Skip to content

7. Re-uploading and the Fourier picture

Encode the data once and a circuit is a fairly limited function of it. Encode it again between trainable blocks and the model becomes a truncated Fourier series, where each upload buys one more frequency. That is the single clearest theoretical statement in variational QML, and it is directly measurable.

Re-uploading is a pattern, not a structure

There is no single "the re-uploading ansatz". It is any interleaving of any encoding with any trainable block, so qmlkit treats it as a composition rather than a class:

import qmlkit as qk

variants = [
    ("default (S then W)", qk.reupload(qk.AngleFeatureMap(2), n_layers=3)),
    ("order='WS'", qk.reupload(qk.AngleFeatureMap(2), n_layers=3, order="WS")),
    ("shared weights", qk.reupload(qk.AngleFeatureMap(2), n_layers=3, share_weights=True)),
    ("ZZ feature map", qk.reupload(qk.ZZFeatureMap(2, reps=1), n_layers=2)),
]
for label, model in variants:
    r = model.resources()
    print(f"{label:<20} inputs {model.n_inputs}  weights {model.n_weights:>3}  depth {r['depth']:>3}")
default (S then W)   inputs 2  weights  18  depth  18
order='WS'           inputs 2  weights  18  depth  18
shared weights       inputs 2  weights   6  depth  18
ZZ feature map       inputs 3  weights  12  depth  18

reupload() covers the common shapes. Anything else composes directly from the block vocabulary — including two different feature maps in one model, which a fixed class cannot express at all.

Notice share_weights=True gives 6 weights instead of 18, at identical depth: the same trainable block reused at every upload.

The circuit

import qmlkit as qk

model = qk.reupload(
    qk.AngleFeatureMap(1, entangle=False),
    n_layers=2,
    rotations=("rz", "ry", "rz"),
    entangler=None,
)
print(qk.draw(model.build()))
q0: ─RY(θ0)──RZ(θ1)──RY(θ2)──RZ(θ3)──RY(θ0)──RZ(θ4)──RY(θ5)──RZ(θ6)──

θ0 appears twice — that is the data, uploaded twice. The rest are weights. Data and weights occupy separate ranges of one flat vector, which is what keeps ∂f/∂x and ∂f/∂θ separable while the model still drops straight into a QuantumLayer.

Measuring the claim

L uploads should reach frequencies 0…L. Do not take that on faith — extract the spectrum:

import numpy as np
import qmlkit as qk

for n_layers in (1, 2, 3, 4):
    model = qk.reupload(
        qk.AngleFeatureMap(1, entangle=False),
        n_layers=n_layers,
        rotations=("rz", "ry", "rz"),
        entangler=None,
    )
    weights = model.init("uniform", seed=1)
    bound = model.build()

    def f(x, bound=bound, model=model, weights=weights):
        return qk.expval(bound, qk.Z(0), theta=np.concatenate([model.angles([x]), weights]))

    spectrum = qk.fourier.spectrum(f, n_layers + 3)
    print(f"L={n_layers}: frequencies {sorted(spectrum)}"
          f"  amplitudes {[round(spectrum[k], 4) for k in sorted(spectrum)]}")
L=1: frequencies [1]  amplitudes [0.9997]
L=2: frequencies [0, 1, 2]  amplitudes [0.0853, 0.2176, 0.1573]
L=3: frequencies [0, 1, 2, 3]  amplitudes [0.4162, 0.5004, 0.1654, 0.0156]
L=4: frequencies [0, 1, 2, 3, 4]  amplitudes [0.4088, 0.5291, 0.2017, 0.0506, 0.0028]

Exactly 0…L, and nothing above it. Frequencies the architecture cannot reach are not "hard to learn" — they are unreachable, and no training run will find them. That makes this a design question, decided before the first gradient step.

Note the amplitudes fall off sharply at the top end. Reaching a frequency is not the same as having much of it: L=4 reaches frequency 4 with amplitude 0.0028. Depth buys bandwidth, not power.

The trap: a trainable block that commutes

If the trainable block commutes with the encoding, the uploads collapse. Ry(x)Ry(θ₁)Ry(x)Ry(θ₂) is just Ry(2x + θ₁ + θ₂) — one frequency, and the weights do nothing but shift a phase.

import numpy as np
import qmlkit as qk

for label, rotations in (("Ry only (commutes)", ("ry",)), ("Rz Ry Rz (does not)", ("rz", "ry", "rz"))):
    model = qk.reupload(
        qk.AngleFeatureMap(1, entangle=False), n_layers=3, rotations=rotations, entangler=None
    )
    weights = model.init("uniform", seed=1)
    bound = model.build()

    def f(x, bound=bound, model=model, weights=weights):
        return qk.expval(bound, qk.Z(0), theta=np.concatenate([model.angles([x]), weights]))

    spectrum = qk.fourier.spectrum(f, 6)
    print(f"{label:<22} frequencies {sorted(spectrum)}"
          f"  amplitudes {[round(spectrum[k], 4) for k in sorted(spectrum)]}")
Ry only (commutes)     frequencies [3]  amplitudes [1.0]
Rz Ry Rz (does not)    frequencies [0, 1, 2, 3]  amplitudes [0.4162, 0.5004, 0.1654, 0.0156]

A three-upload model that reaches one frequency, with weights that cannot change the function's shape. It trains, it produces a loss curve, and it is architecturally incapable of the thing it was built for.

The library warns about this one

DataReuploadEncoder raises a UserWarning when the trainable block commutes with the encoding rotation, because the failure is otherwise invisible — the model looks fine and simply cannot represent anything.

Using it

A re-uploading model is an Ansatz, so it drops into everything else. Bind data and weights separately:

import numpy as np
import qmlkit as qk

model = qk.reupload(qk.AngleFeatureMap(2), n_layers=2)
weights = model.init(seed=0)
x = np.array([0.3, 0.8])

spec = model.bind(x, weights)
print(f"<Z0> = {qk.expval(spec, qk.Z(0)):+.6f}")
print(f"inputs {model.n_inputs}, weights {model.n_weights}")

build(theta) still takes the full concatenated vector, and says so clearly when the sizes disagree — the two calling conventions were a real source of confusion, so the error message names both sizes.

Training one

A re-uploading model is its own encoding and its own trainable block, so it goes in as the feature map, with no separate ansatz:

# docs: requires torch
model = qk.VQC(
    n_features=2,
    n_classes=2,
    feature_map=qk.reupload(qk.AngleFeatureMap(2), n_layers=3),
)
print(model.ansatz)   # None -- the re-uploading model already carries the weights

Passing one as ansatz= instead, or alongside a separate ansatz, is refused:

# docs: requires torch
try:
    qk.VQC(
        n_features=2,
        n_classes=2,
        feature_map=qk.reupload(qk.AngleFeatureMap(2), n_layers=2),
        ansatz=qk.hardware_efficient(2, 1),
    )
except ValueError as exc:
    print(exc)

The same applies one layer down, where QuantumLayer takes it in the feature-map position and ansatz=None:

# docs: requires torch
layer = qk.QuantumLayer(
    qk.reupload(qk.AngleFeatureMap(2), n_layers=2), None, [qk.Z(0), qk.Z(1)]
)
print(layer.n_features, layer.n_outputs)

Both were unreachable before 0.1.0: VQC supplied a default ansatz unconditionally, so a re-uploading feature map always collided with it and the only way through was a hand-written training loop. Worth stating because the fix came from someone trying to use the pattern this page recommends and finding they could not.


Next: Trainability — what happens when the gradient is there but vanishingly small.