Quantum Neural Networks

QNN is Divi’s first-class primitive for quantum machine learning. It trains the weights of a parameterized circuit over a batch of classical feature vectors. By default it minimizes the expectation value of a chosen observable averaged across the batch (unsupervised); pass labels to train a supervised loss instead (see Supervised training).

Unlike PennyLane & Qiskit Integration (where you bring your own circuit via CustomVQA), QNN builds the circuit for you from two composable pieces:

  • a FeatureMap — the data layer. It encodes each feature vector into circuit parameters that are bound from data, never optimized.

  • an Ansatz — the trainable layer. Any Divi ansatz works; its parameters are the weights the optimizer updates.

Data parameters and weight parameters are kept disjoint. At every optimization step, the cost observable is evaluated on the composed circuit once per sample, the per-sample expectation values are reduced along the sample axis (mean by default), and a single scalar loss per weight candidate is handed to the optimizer. The optimizer never sees the data axis — the fan-out and reduction happen inside the pipeline’s data-binding stage.

Quick Start

Train a tiny 2-qubit QNN on a four-sample toy dataset (unsupervised — it minimizes the observable; the Supervised training section adds labels):

import numpy as np
from qiskit.circuit.library import CXGate, RYGate, RZGate

from divi.qprog import QNN, AngleEmbedding, GenericLayerAnsatz
from divi.qprog.optimizers import ScipyMethod, ScipyOptimizer
from divi.backends import MaestroSimulator

n_qubits = 2

# Two loose clusters: (n_samples, n_features). n_features must match the
# feature map's parameter count — AngleEmbedding uses one feature per qubit.
X_train = np.array([[0.1, 0.2], [0.3, 0.5], [2.0, 2.1], [2.3, 2.4]])

program = QNN(
    n_qubits=n_qubits,
    feature_map=AngleEmbedding(rotation="Y"),
    ansatz=GenericLayerAnsatz(
        gate_sequence=[RYGate, RZGate],
        entangler=CXGate,
        entangling_layout="linear",
    ),
    feature_batch=X_train,
    n_layers=2,
    loss_reduction="mean",
    optimizer=ScipyOptimizer(method=ScipyMethod.COBYLA),
    max_iterations=5,
    backend=MaestroSimulator(),
    seed=1997,
)

# QNNs return their answer as the trained weights, so the solution-sampling
# "final computation" step that combinatorial VQAs use is unnecessary.
program.run(perform_final_computation=False)

print(f"Best loss: {program.best_loss:.4f}")
best_weights = program.best_params

The optimizer view (best_params) contains only the weightsansatz.n_params_per_layer(n_qubits) * n_layers of them. The feature columns never appear in the parameter vector.

Inference

After run(), score a fresh feature batch with predict(). It binds each row’s features with the trained weights, estimates the cost observable per sample (the same shot-based readout the loss optimizes), and returns the sign as a class label in {-1, +1}:

X_new = np.array([[0.15, 0.25], [2.1, 2.2]])

labels = program.predict(X_new, params=trained_weights)   # {-1, +1} per row
scores = program.predict(X_new, params=trained_weights, return_scores=True)  # raw ⟨H⟩ per row

When called after training, params defaults to best_params. Pass return_scores=True to predict() for the continuous scores if you want to apply your own decision threshold. The same method is available on CustomVQA when it has a data axis.

Feature Maps

A feature map encodes a feature vector into a parametric circuit. Built-in maps:

  • AngleEmbedding — one single-qubit rotation per feature, R(x_i) on qubit i. Choose the axis with rotation="X" | "Y" | "Z" (default "Y").

  • ZZFeatureMap — the ZZ entangling encoding of Havlíček et al. (Hadamards, RZ(2·x_i) per qubit, then RZZ(2·(π−x_i)(π−x_j)) over an entangling layout). Select the pair pattern with entangling_layout="linear" | "circular" | "all-to-all". Requires at least two qubits.

Both built-in maps consume one feature per qubit, so feature_batch must have n_qubits columns. More generally, its column count must equal feature_map.n_params(n_qubits); a mismatch raises ValueError at construction. Feature maps are not layered — a single application encodes the vector once. For data re-uploading (interleaving encoding with variational layers), subclass FeatureMap and implement n_params and build.

The Observable and the Loss

observable is the operator whose expectation value is minimized, as a SparsePauliOp acting on n_qubits qubits. It defaults to the all-qubit parity Z Z Z, which gives a single readout in [-1, 1] informed by every qubit. Pass your own to change the readout, e.g. SparsePauliOp.from_list([("ZI", 1.0)]) to read a single qubit.

loss_reduction controls how the per-sample expectation values collapse into the scalar the optimizer sees:

  • "mean" (default) — average over the batch.

  • "sum" — total over the batch.

  • a callable np.ndarray (n_samples,) -> float — any custom aggregation.

Supervised training

Pass labels (shape (n_samples,), aligned with feature_batch’s rows) to train a supervised loss. Each sample’s prediction — the cost observable’s expectation value, in [-1, 1] for the default parity observable — is compared to its label by loss_fn, and those per-sample losses are then aggregated by loss_reduction. The default loss_fn="squared_error" with the default "mean" reduction is mean-squared error; encode labels to match the readout range (e.g. -1 / +1 for the parity observable):

import numpy as np
from qiskit.circuit.library import CXGate, RYGate, RZGate

from divi.qprog import QNN, AngleEmbedding, GenericLayerAnsatz
from divi.qprog.optimizers import ScipyMethod, ScipyOptimizer
from divi.backends import MaestroSimulator

X_train = np.array([[0.1, 0.2], [0.3, 0.5], [2.0, 2.1], [2.3, 2.4]])
y_train = np.array([-1.0, -1.0, 1.0, 1.0])  # one label per sample

clf = QNN(
    n_qubits=2,
    feature_map=AngleEmbedding(rotation="Y"),
    ansatz=GenericLayerAnsatz(
        gate_sequence=[RYGate, RZGate],
        entangler=CXGate,
        entangling_layout="linear",
    ),
    feature_batch=X_train,
    labels=y_train,
    loss_fn="squared_error",  # default; or a callable (pred, label) -> float
    n_layers=2,
    optimizer=ScipyOptimizer(method=ScipyMethod.COBYLA),
    max_iterations=5,
    backend=MaestroSimulator(),
    seed=1997,
)
clf.run(perform_final_computation=False)

A supervised loss requires a single-readout observable (one prediction per sample), so keep the default parity observable or another single-qubit/parity operator. The same labels / loss_fn pair is available on CustomVQA’s data-binding path (PennyLane & Qiskit Integration) when you bring your own circuit.

When to use QNN vs CustomVQA

  • Reach for QNN when you want a curated feature-map + ansatz workflow and Divi to compose and bind the circuit for you.

  • Reach for CustomVQA (see PennyLane & Qiskit Integration) when you already have a PennyLane or Qiskit circuit and want full control over its structure, marking data parameters yourself with data_arg / arg_shapes / data_param_indices.

Next Steps