QCNN line-by-line implementation walkthrough¶
English · 简体中文
This page explains the QCNN (arXiv:1911.01117, ICLR 2020) implementation line by line: first an end-to-end minimal example, then a segment-by-segment reading of the sources of the classical basis (qcnn.py) and the quantum building blocks (qcnn_layer.py). Every code block is runnable (tests/core/test_qcnn.py is built from these snippets). For the algorithm overview see the quantum convolutional neural network (QCNN) page; for the API reference see qcnn and qcnn_layer.
1. End-to-end minimal example¶
import random
from oracq.algorithms.qml.qcnn import ConvSpec, convolution_forward
from oracq.algorithms.qml.qcnn_layer import qcnn_sampled_layer
# 5×5 single-channel input, 2×2 kernel ×2 output channels, capReLU cap 3.0, 2×2 max pooling.
spec = ConvSpec(input_shape=(5, 5, 1), kernel_shape=(2, 2, 1, 2), cap=3.0, pool=2)
random.seed(7)
x = [random.uniform(-1, 1) for _ in range(25)] # input image (H×W×D flattened)
kernel = [random.uniform(-1, 1) for _ in range(2 * 2 * 1 * 2)] # 4-tensor kernel flattened
classical = convolution_forward(x, kernel, spec) # classical mirror (reference for comparison)
pooled, stats = qcnn_sampled_layer(x, kernel, spec, samples=40000, eta=0.0, seed=1)
2. The classical basis line by line (qcnn.py)¶
ConvSpec — the layer specification (dimension rules of paper §3.3–3.4)¶
@dataclass(frozen=True)
class ConvSpec:
input_shape: tuple # (H^l, W^l, D^l): the three dimensions of the input tensor
kernel_shape: tuple # (h, w, D^l, D^{l+1}): the 4-tensor kernel
cap: float = 5.0 # capReLU cap C (§5.1.6: provides a known max f for the conditional rotation)
pool: int = 1 # pooling window P (Eq. 39)
pool_kind: str = "max" # "max" or "average" (the two overwrite rules of §5.2.2)
output_shape follows Eq. (4) of the paper: H^{l+1} = H^l − h + 1 (no padding/stride); pooled_shape divides the output spatial dimensions by the pooling window (__post_init__ enforces divisibility — a 5×5 input with a 2×2 kernel gives a 4×4 output, exactly divisible by 2).
im2col — the matrix form of Eq. (6)¶
def im2col(x, spec):
h, w, _ = spec.input_shape
kh, kw, d, _ = spec.kernel_shape
rows = []
for i in range(h - kh + 1): # each output row corresponds to one receptive field
for j in range(w - kw + 1):
row = []
for dd in range(d): # stack channel by channel (column-major vectorization, paper §3.4)
for ki in range(kh):
for kj in range(kw):
row.append(tensor_get(x, spec.input_shape, i + ki, j + kj, dd))
rows.append(row)
return rows # A^l: row-major, one receptive field per row
Each row A^l_p is the vectorization of the receptive field at position (i,j); kernel_columns expands each kernel into one column of F^l in the same (d, ki, kj) order — the two orders agree, so the inner product is exactly the convolution output Y^{l+1}_{p,q}.
convolution_forward — the classical mirror¶
a = im2col(x, spec)
f = kernel_columns(kernel, spec)
y = [[cap_relu(sum(ar * fc for ar, fc in zip(row, col, strict=True)), spec.cap)
for col in f] for row in a] # convolution + capReLU (§5.1.6)
return pool_tensor(y, spec) # the pooling of Eq. 39
cap_relu = min(max(x, 0), cap) — the paper uses the capped ReLU so that max_pq f(Y_pq) is known a priori (the cap constant), allowing the conditional rotation (Eq. 28) to use a fixed normalization.
QCNNQRAM — the row-tree data structure (§4.3 + §5.2.2)¶
self.leaf_start = 1
while leaf_start < self.width: # complete binary tree, leaves start at leaf_start
leaf_start *= 2
self.tree_size = 2 * leaf_start # 1-based heap: root is 1, internal nodes store squared partial sums
_write(p, r, value) writes one leaf and refreshes the squared partial sums up the ancestors — a single-point update costs logarithmically (the O(log² n) update of Theorem 4.3 in the paper). update_with_pooling implements the online overwrite: max writes only when the new value is higher (paper §5.2.2, “update the leaf if the new sampled value is higher”), and average amortizes with a state count (“replace the actual value by the new averaged value”).
3. The quantum building blocks line by line (qcnn_layer.py)¶
vector_angle_tables — from data to runtime banks¶
angles[(index << width) | node] = round(angle * scale) % (1 << angle_width)
Line by line, node by node: angle = 2·atan2(√S_right, √S_left) (the RY half-angle convention, amplitude ratio √(S_right/S_node)); quantized into an angle_width-bit angle word. Signs go into a separate signs bank (key (index<<width)|leaf). Changing to another layer’s data only recomputes these two tables — the circuit object is completely unchanged (QRAM semantics).
qcnn_vector_prep — the row-loading circuit of Eq. (15)¶
for depth in range(vector_width): # tree level: bit from high to low
for prefix in range(1 << depth):
node = (1 << depth) - 1 + prefix # tree node number (addressing consistent with qram_state_prep)
node_register = _constant_register(b, f"node_{depth}_{prefix}", vector_width, node)
if depth:
with b.control(b["target"][bit + 1:], prefix): # prefix control on the settled high bits
_prep_node_rotation(b, angles, node_register, bit, angle_width)
else:
_prep_node_rotation(b, angles, node_register, bit, angle_width)
# symmetrically restore node_register (X flips it back)
The three steps of _prep_node_rotation: query angles (address fuse(node_register, index) — index in the high bits, consistent with the bank key); synthesize controlled RYs with bitwise weighting (each set angle-word bit k applies ry(2π·2^k/2^aw)); un-query to clean the work. The sign kickback at the end: query signs addressed by the already-prepared target, apply a Z on the flag, then un-query — negative components are left with only a −1 phase.
qcnn_inner_product — Eqs. (16)–(19)¶
for register in (b["p"], b["q"], b["flag"]):
b.h(register) # uniform superposition Σ|p>|q> (1/√2)(|0>+|1>)
with b.control(b["flag"], 0):
invoke(b, row_prep, "row", index=b["p"], target=b["vec"], work=b["work"])
with b.control(b["flag"], 1):
invoke(b, column_prep, "col", index=b["q"], target=b["vec"], work=b["work"])
b.h(b["flag"]) # amplitude √P_pq lands on the flag=0 branch
This is exactly the three-line derivation of Eq. (16) in the paper: branch loading of |A_p⟩/|F_q⟩ → Hadamard interference → measurement probability P_pq=(1+⟨A_p|F_q⟩)/2 on flag=0. Validation against Eq. (20): the joint probability of (p,q,flag=0) after summing out (p,q,vec,work) = (1+⟨A_p|F_q⟩)/(2·rows·columns), with a measured worst-case deviation of 4×10⁻⁵.
qcnn_sampled_layer — the sampling driver of Eqs. (34)–(39)¶
values[(p, q)] = cap_relu(inner, spec.cap) # Y_pq=(2P_pq-1)||A_p||||F_q|| after activation
weight = {key: value * value for key, value in values.items()}
# sampling probability ∝ f(Y)^2 (Eq. 34); each sample yields the triple (p, q, f(Y)):
if value < eta: # Eq. 36: pixels below η are treated as unsampled
continue
i, j = p // ow, p % ow # Eq. 7: recover the row number (i^{l+1}, j^{l+1})
index = (i // spec.pool) * pw + (j // spec.pool) # Eq. 39: pooling-region mapping
pooled[index][q] = max(pooled[index][q], value) # QRAM online overwrite (§5.2.2)
quantized_prepared_state is the classical mirror of the circuit semantics (level-by-level quantized angle rotations + signs); the driver uses it to recover all of Y — the circuit and the mirror are compared amplitude by amplitude in tests (1e-9), guaranteeing that the distribution modeling introduces no extra approximation.
4. Validation matrix (all passing)¶
Experiment |
Scale |
Result |
|---|---|---|
im2col vs naive convolution |
5×5×1, kernel 2×2×1×2 |
element-wise 1e-12 |
forward + pooling vs naive |
same + 2×2 max |
element-wise 1e-12 |
QRAM rows/norms/overwrite |
16 rows |
exact; max keeps the higher value, average gives (2+4)/2=3 |
row-preparation circuit vs normalized row |
with negative components |
worst 3e-4 (= the lower bound of aw=10 angle quantization) |
circuit vs quantized mirror |
same |
1e-9 |
inner-product probability vs Eq. 20 |
2×2 rows/columns |
4e-5 |
max-pooling sampled recovery |
40000 samples |
differs from the classical maximum by ≤5e-3 |
η→∞ |
1000 samples |
all zero (Eq. 36) |
average convergence |
60000 samples |
to the f²-weighted expectation within ≤2e-2 |