Skip to content

PyTorch

Circuits as nn.Modules, with gradients flowing to the inputs as well as the weights.

qmlkit.nn.layer

layer

The PyTorch bridge.

Above this boundary everything is ordinary torch — Adam, .backward(), DataLoader, nn.Sequential. Below it, circuits and shots. Nobody has to write an autograd.Function themselves.

Inputs get gradients. backward returns df/dx as well as df/dtheta, so a classical layer placed before the quantum one actually trains. Returning None there is the common shortcut, and it silently freezes any pre-net — which is invisible in the loss curve and fatal to transfer learning, where the layer feeding the circuit is the one you meant to train.

Getting df/dx through a nonlinear feature map takes two steps: the circuit is differentiated with respect to its encoding angles, then the chain rule down to the features is finished classically by the map's angle_jacobian. No circuits are spent on the classical half.

QuantumFunction

Bases: Function

Autograd boundary: forward runs circuits, backward differentiates them.

Combined

Bases: Protocol

A model that carries its own encoding, interleaved with a trainable block.

Re-uploading is a pattern, not a class, so there is no base to test against - what makes a model combined is that it both encodes data and holds weights. That is four attributes, and this protocol is where they are written down. Anything satisfying it can be handed to :class:QuantumLayer in place of a feature map.

QuantumLayer

QuantumLayer(
    feature_map: FeatureMap,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    shots: int | None = None,
    backend: Any = None,
    grad_method: str = "auto",
    seed: int | None = None,
    init: str = "small",
    init_seed: int | None = None,
)

Bases: Module

A circuit as an nn.Module.

Maps (batch, n_features) to (batch, n_observables), each output an expectation value in [-1, 1].

Defaults are chosen for a simulator: exact expectations and adjoint gradients. Pass shots=N to model a device, and grad_method="parameter-shift" to compute the way hardware would have to.

Source code in src/qmlkit/nn/layer.py
def __init__(
    self,
    feature_map: FeatureMap,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    shots: int | None = None,
    backend: Any = None,
    grad_method: str = "auto",
    seed: int | None = None,
    init: str = "small",
    init_seed: int | None = None,
) -> None:
    super().__init__()
    self.feature_map = feature_map
    if _is_combined(feature_map):
        if ansatz is not None:
            raise ValueError(
                "a re-uploading model already contains its trainable block; pass it "
                "alone, without a separate ansatz"
            )
        self.ansatz: Ansatz | Combined | None = feature_map
    else:
        self.ansatz = ansatz
    n = feature_map.n_qubits
    self.observables = (
        list(observables) if observables is not None else [Z(i) for i in range(n)]
    )

    if ansatz is not None and ansatz.n_qubits != n:
        raise ValueError(f"feature map uses {n} qubits but the ansatz uses {ansatz.n_qubits}")

    weights = self.ansatz
    self.theta = nn.Parameter(
        torch.as_tensor(weights.init(init, init_seed), dtype=torch.get_default_dtype())
        if weights is not None
        else torch.zeros(0)
    )
    self._runner = _Runner(
        feature_map, ansatz, self.observables, backend, shots, grad_method, seed
    )
    if grad_method == "auto":
        self._runner.grad_method = choose_method(self._runner.spec, backend, shots)
configure
configure(
    shots: int | None = _UNCHANGED,
    grad_method: str | None = None,
) -> QuantumLayer

Switch to device-realism mode (or back) without rebuilding the layer.

Source code in src/qmlkit/nn/layer.py
def configure(
    self, shots: int | None = _UNCHANGED, grad_method: str | None = None
) -> QuantumLayer:
    """Switch to device-realism mode (or back) without rebuilding the layer."""
    if shots is not _UNCHANGED:
        self._runner.shots = shots
    if grad_method is not None:
        self._runner.grad_method = (
            choose_method(self._runner.spec, self._runner.backend, self._runner.shots)
            if grad_method == "auto"
            else grad_method
        )
    return self
resources
resources() -> dict[str, object]

Circuit cost, and what a batch costs under each gradient method.

Source code in src/qmlkit/nn/layer.py
def resources(self) -> dict[str, object]:
    """Circuit cost, and what a batch costs under each gradient method."""
    from qmlkit.gradients.parameter_shift import grad_circuit_cost

    spec = self._runner.spec
    out = dict(spec.resources())
    out["grad_method"] = self._runner.grad_method
    out["shots"] = self._runner.shots
    out["n_outputs"] = self.n_outputs
    out["circuits_per_sample_parameter_shift"] = 1 + grad_circuit_cost(spec)
    out["passes_per_sample_adjoint"] = 1
    return out

qmlkit.nn.models

models

Ready-made models — the two-line path.

model = qk.VQC(n_features=4, n_classes=2)
model.fit(X, y)
model.score(X, y)

Every default here is a choice you can override, and each override is one keyword. Pass your own feature_map or ansatz, and the rest still works — nothing in these models knows anything about the specific ones they default to.

If you want the layer without the training loop, use :class:~qmlkit.nn.layer.QuantumLayer directly and treat it as any other nn.Module.

HybridModel

HybridModel(
    n_features: int,
    n_outputs: int,
    n_qubits: int | None = None,
    n_layers: int = 2,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    shots: int | None = None,
    backend: Any = None,
    grad_method: str = "auto",
    scale_inputs: bool = True,
    seed: int | None = None,
)

Bases: Module

Shared machinery: a training loop, and sensible construction defaults.

Source code in src/qmlkit/nn/models.py
def __init__(
    self,
    n_features: int,
    n_outputs: int,
    n_qubits: int | None = None,
    n_layers: int = 2,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    shots: int | None = None,
    backend: Any = None,
    grad_method: str = "auto",
    scale_inputs: bool = True,
    seed: int | None = None,
) -> None:
    super().__init__()
    self.n_features = n_features
    self.n_outputs = n_outputs
    n_qubits = n_qubits or (feature_map.n_qubits if feature_map else n_features)

    self.feature_map = feature_map or AngleFeatureMap(n_qubits, entangle=n_qubits > 1)
    # A re-uploading model is its own encoding *and* its own trainable block, so
    # the default ansatz must not be supplied on top of it. Doing that
    # unconditionally is what made re-uploading - the pattern this library
    # recommends most - unreachable from the class most people start with.
    if _is_combined(self.feature_map):
        if ansatz is not None:
            raise ValueError(
                "a re-uploading feature map already contains its trainable block; "
                "pass it alone, without a separate ansatz"
            )
        self.ansatz: Ansatz | None = None
    else:
        self.ansatz = ansatz or hardware_efficient(n_qubits, n_layers)
    obs = list(observables) if observables is not None else [Z(i) for i in range(n_qubits)]

    # The classical layers initialise from torch's *global* RNG, so a seeded model
    # was still not reproducible: the quantum weights honoured `seed` and the
    # Linear ones did not. Seeding around their construction and putting the
    # global state back leaves no side effect on the caller's own RNG - which
    # `torch.manual_seed(seed)` here would not.
    rng_state = torch.get_rng_state() if seed is not None else None
    if seed is not None:
        torch.manual_seed(seed)

    # a classical projection only when the widths genuinely differ
    self.pre: nn.Module = (
        nn.Sequential(nn.Linear(n_features, n_qubits), nn.Tanh())
        if n_features != n_qubits
        else nn.Identity()
    )
    self.quantum = QuantumLayer(
        self.feature_map,
        self.ansatz,
        obs,
        shots=shots,
        backend=backend,
        grad_method=grad_method,
        init_seed=seed,
    )
    self.head = nn.Linear(len(obs), n_outputs)
    if rng_state is not None:
        torch.set_rng_state(rng_state)
    self.scaler = AngleScaler() if scale_inputs else None
    self.history_: list[float] = []
    #: The seed shuffling uses, kept so that `fit` is reproducible. Without it the
    #: batch order came off torch's *global* RNG, so two runs of the same seeded
    #: model disagreed - and `qk.search(seed=0)` was not reproducible either,
    #: which is a worse thing for a library that asks to be trusted with a number.
    self.seed = seed
fit
fit(
    X: NDArray[Any],
    y: NDArray[Any],
    epochs: int = 30,
    lr: float = 0.05,
    batch_size: int | None = None,
    optimizer: Optimizer | None = None,
    verbose: bool = False,
) -> HybridModel

Train. Returns self, so it chains.

Source code in src/qmlkit/nn/models.py
def fit(
    self,
    X: npt.NDArray[Any],
    y: npt.NDArray[Any],
    epochs: int = 30,
    lr: float = 0.05,
    batch_size: int | None = None,
    optimizer: torch.optim.Optimizer | None = None,
    verbose: bool = False,
) -> HybridModel:
    """Train. Returns ``self``, so it chains."""
    xt = self._prepare(X, fit_scaler=True)
    yt = self._targets(np.asarray(y))
    opt = optimizer or torch.optim.Adam(self.parameters(), lr=lr)
    loss_fn = self._loss_fn(np.asarray(y))
    n = xt.shape[0]
    bs = batch_size or n
    self.history_ = []

    batches_per_epoch = (n + bs - 1) // bs
    with progress_task(f"fit {type(self).__name__}", epochs * batches_per_epoch) as tracked:
        # A local generator rather than the global RNG: shuffling off the global
        # one makes a seeded model unreproducible, and makes any two fits running
        # at once perturb each other.
        shuffle = torch.Generator()
        shuffle.manual_seed(self.seed if self.seed is not None else torch.seed() % (2**31))
        for epoch in range(epochs):
            perm = torch.randperm(n, generator=shuffle)
            total = 0.0
            # the extra norms cost a pass over the parameters, so they are only
            # computed when something is actually going to show them
            watching = progress_current() is not None
            grad_sq = 0.0
            for start in range(0, n, bs):
                idx = perm[start : start + bs]
                opt.zero_grad()
                loss = loss_fn(self(xt[idx]), yt[idx])
                loss.backward()
                if watching:
                    grad_sq += sum(
                        float(p.grad.pow(2).sum())
                        for p in self.parameters()
                        if p.grad is not None
                    )
                opt.step()
                total += float(loss.detach()) * len(idx)
                tracked.advance()
            self.history_.append(total / n)
            if watching:
                # loss says whether it is learning; the gradient norm says whether
                # it *can* -- a plateau and a solved problem look identical in loss
                progress_log("loss", self.history_[-1], epoch)
                progress_log("gradient norm", (grad_sq / batches_per_epoch) ** 0.5, epoch)
                progress_log(
                    "parameter norm",
                    float(torch.cat([p.detach().flatten() for p in self.parameters()]).norm()),
                    epoch,
                )
            if verbose:
                print(f"epoch {epoch + 1:3d}/{epochs}  loss {self.history_[-1]:.5f}")
    return self
resources
resources() -> dict[str, object]

What one training step costs, so nobody discovers it an hour in.

Source code in src/qmlkit/nn/models.py
def resources(self) -> dict[str, object]:
    """What one training step costs, so nobody discovers it an hour in."""
    out = dict(self.quantum.resources())
    out["trainable_parameters"] = sum(p.numel() for p in self.parameters() if p.requires_grad)
    return out

VQC

VQC(
    n_features: int,
    n_classes: int = 2,
    class_weight: str | None = None,
    focal_gamma: float = 0.0,
    **kwargs: Any,
)

Bases: HybridModel

Variational quantum classifier.

model = VQC(n_features=4, n_classes=3).fit(X, y) model.score(X, y)

class_weight="balanced" reweights the loss by class frequency, which is what stops a skewed training set training the circuit to a constant. The weights are computed from the y passed to :meth:fit, so they describe the data actually trained on rather than an assumption made at construction. focal_gamma additionally down-weights examples the model already gets right; 0.0 disables it, 2.0 is the published default.

Source code in src/qmlkit/nn/models.py
def __init__(
    self,
    n_features: int,
    n_classes: int = 2,
    class_weight: str | None = None,
    focal_gamma: float = 0.0,
    **kwargs: Any,
) -> None:
    super().__init__(n_features, n_classes, **kwargs)
    self.n_classes = n_classes
    self.class_weight = class_weight
    self.focal_gamma = focal_gamma
score
score(X: NDArray[Any], y: NDArray[Any]) -> float

Mean accuracy.

Source code in src/qmlkit/nn/models.py
def score(self, X: npt.NDArray[Any], y: npt.NDArray[Any]) -> float:
    """Mean accuracy."""
    return float((self.predict(X) == np.asarray(y).ravel()).mean())

VQRegressor

VQRegressor(
    n_features: int, n_outputs: int = 1, **kwargs: Any
)

Bases: HybridModel

Variational quantum regressor.

model = VQRegressor(n_features=3).fit(X, y) model.predict(X)

Source code in src/qmlkit/nn/models.py
def __init__(self, n_features: int, n_outputs: int = 1, **kwargs: Any) -> None:
    super().__init__(n_features, n_outputs, **kwargs)
score
score(X: NDArray[Any], y: NDArray[Any]) -> float

R² — 1.0 is perfect, 0.0 is no better than predicting the mean.

Source code in src/qmlkit/nn/models.py
def score(self, X: npt.NDArray[Any], y: npt.NDArray[Any]) -> float:
    """R² — 1.0 is perfect, 0.0 is no better than predicting the mean."""
    pred = np.asarray(self.predict(X)).ravel()
    truth = np.asarray(y, dtype=float).ravel()
    ss_res = float(((truth - pred) ** 2).sum())
    ss_tot = float(((truth - truth.mean()) ** 2).sum())
    return 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0

qmlkit.nn.advanced

advanced

QCNN, QLSTM and MPS layers — architectures with structure, not just depth.

Each is an ordinary nn.Module built from a :class:QuantumLayer, so they train the same way and compose with anything else in torch. What distinguishes them is where the structure lives:

  • QCNN — a convolution filter shared across every pair, then pooling that halves the register. Log-depth in the qubit count, and few parameters because the filter is tied. Provably free of the exponential barren plateau.
  • QLSTM — four small circuits standing in for the gates of an LSTM cell. The recurrence and nonlinearities stay classical; only the gates are quantum.
  • MPS — a staircase of two-qubit blocks, matching a bond-dimension-2 matrix product state. Linear depth, and classically simulable at small bond dimension, which is worth knowing before claiming an advantage.

QCNNLayer

QCNNLayer(
    n_qubits: int,
    feature_map: FeatureMap | None = None,
    tie_weights: bool = True,
    observables: Sequence[Observable] | None = None,
    filter: str | tuple[Any, int] = "ry_cx",
    pattern: str = "chain",
    pool: str = "discard",
    ansatz: Ansatz | None = None,
    **kwargs: Any,
)

Bases: Module

Quantum convolutional layer: shared filter, then pooling.

The filter is tied across every pair it slides over, so an 8-qubit QCNN carries 6 parameters where an untied version needs 22 — at the same gradient cost.

Source code in src/qmlkit/nn/advanced.py
def __init__(
    self,
    n_qubits: int,
    feature_map: FeatureMap | None = None,
    tie_weights: bool = True,
    observables: Sequence[Observable] | None = None,
    filter: str | tuple[Any, int] = "ry_cx",  # noqa: A002 - the domain word
    pattern: str = "chain",
    pool: str = "discard",
    ansatz: Ansatz | None = None,
    **kwargs: Any,
) -> None:
    super().__init__()
    self.n_qubits = n_qubits
    # The QCNN's structural choices are arguments, not a subclass per paper:
    # `filter` and `pool` reach qcnn_ansatz, or hand in a whole `ansatz` instead.
    self.quantum = QuantumLayer(
        feature_map or _default_map(n_qubits),
        ansatz
        or qcnn_ansatz(
            n_qubits, tie_weights=tie_weights, filter=filter, pattern=pattern, pool=pool
        ),
        observables or [Z(n_qubits - 1)],  # the surviving wire after pooling
        **kwargs,
    )

MPSLayer

MPSLayer(
    n_qubits: int,
    feature_map: FeatureMap | None = None,
    observables: Sequence[Observable] | None = None,
    filter: str | tuple[Any, int] = "ry_cx",
    tied: bool = False,
    ansatz: Ansatz | None = None,
    **kwargs: Any,
)

Bases: Module

Matrix-product-state layer — a staircase of two-qubit blocks.

Source code in src/qmlkit/nn/advanced.py
def __init__(
    self,
    n_qubits: int,
    feature_map: FeatureMap | None = None,
    observables: Sequence[Observable] | None = None,
    filter: str | tuple[Any, int] = "ry_cx",  # noqa: A002 - the domain word
    tied: bool = False,
    ansatz: Ansatz | None = None,
    **kwargs: Any,
) -> None:
    super().__init__()
    self.n_qubits = n_qubits
    # `filter="su4"` gives genuinely arbitrary bond tensors; `tied=True` makes the
    # chain translation-invariant. Same registry the QCNN convolves with.
    self.quantum = QuantumLayer(
        feature_map or _default_map(n_qubits),
        ansatz or mps_ansatz(n_qubits, filter=filter, tied=tied),
        observables or [Z(n_qubits - 1)],  # readout on the last wire of the chain
        **kwargs,
    )

QLSTMCell

QLSTMCell(
    n_inputs: int,
    hidden_size: int,
    n_qubits: int = 4,
    ansatz: Ansatz | None = None,
    n_layers: int = 2,
    **kwargs: Any,
)

Bases: Module

One LSTM cell with its four gates replaced by small circuits.

forget, input, candidate and output each become a :class:QuantumLayer; the recurrence, the sigmoids and the tanh stay classical. A classical projection maps [x, h] down to the qubit count first, which is what keeps the circuits small enough to be worth running.

Source code in src/qmlkit/nn/advanced.py
def __init__(
    self,
    n_inputs: int,
    hidden_size: int,
    n_qubits: int = 4,
    ansatz: Ansatz | None = None,
    n_layers: int = 2,
    **kwargs: Any,
) -> None:
    super().__init__()
    from qmlkit.ansatz.library import hardware_efficient

    self.n_inputs = n_inputs
    self.hidden_size = hidden_size
    self.n_qubits = n_qubits
    self.project = nn.Linear(n_inputs + hidden_size, n_qubits)
    self.gates = nn.ModuleDict(
        {
            name: QuantumLayer(
                _default_map(n_qubits),
                ansatz or hardware_efficient(n_qubits, n_layers),
                [Z(i) for i in range(n_qubits)],
                init_seed=i,
                **kwargs,
            )
            for i, name in enumerate(self.GATES)
        }
    )
    self.readout = nn.ModuleDict(
        {name: nn.Linear(n_qubits, hidden_size) for name in self.GATES}
    )

QLSTM

QLSTM(
    n_inputs: int,
    hidden_size: int,
    n_qubits: int = 4,
    **kwargs: Any,
)

Bases: Module

A QLSTM over a sequence. Returns (outputs, (h, c)).

Source code in src/qmlkit/nn/advanced.py
def __init__(self, n_inputs: int, hidden_size: int, n_qubits: int = 4, **kwargs: Any) -> None:
    super().__init__()
    self.cell = QLSTMCell(n_inputs, hidden_size, n_qubits, **kwargs)
    self.hidden_size = hidden_size

DressedQuantumNet

DressedQuantumNet(
    backbone: Module | None,
    in_features: int,
    n_qubits: int,
    n_outputs: int,
    n_layers: int = 2,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    freeze_backbone: bool = True,
    **kwargs: Any,
)

Bases: Module

The dressed circuit: a frozen backbone, then Linear -> quantum -> Linear.

Transfer learning with a quantum head. The backbone is frozen, so only the dressed block trains — and because the layer returns input gradients, the Linear that feeds the circuit trains too. An implementation that returns None for the input gradient silently freezes exactly that layer, which is the one doing the adapting.

Source code in src/qmlkit/nn/advanced.py
def __init__(
    self,
    backbone: nn.Module | None,
    in_features: int,
    n_qubits: int,
    n_outputs: int,
    n_layers: int = 2,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    freeze_backbone: bool = True,
    **kwargs: Any,
) -> None:
    super().__init__()
    from qmlkit.ansatz.library import hardware_efficient

    # `None` means "no pretrained backbone" — the dressed block on its own. Without
    # this the class is unusable except in a transfer-learning setting, which is a
    # narrower thing than it needs to be.
    self.backbone = backbone if backbone is not None else nn.Identity()
    if freeze_backbone:
        self.backbone.requires_grad_(False)
    self.pre = nn.Linear(in_features, n_qubits)
    self.quantum = QuantumLayer(
        feature_map or _default_map(n_qubits),
        ansatz or hardware_efficient(n_qubits, n_layers),
        [Z(i) for i in range(n_qubits)],
        **kwargs,
    )
    self.post = nn.Linear(n_qubits, n_outputs)