Skip to content

Quantum kernels

hqnn_forge.kernels

hqnn_forge.kernels

Quantum kernel estimation from the library's encoding layers.

A quantum kernel is the fidelity between the states two inputs are encoded into::

k(x, x') = |⟨Φ(x) | Φ(x')⟩|²

Feeding the Gram matrix K[i, j] = k(x_i, x_j) to a classical SVM (sklearn.svm.SVC(kernel="precomputed")) gives the quantum-kernel (QSVM) approach to classification: the quantum device only evaluates the feature map, and the optimisation is the SVM's convex problem, with a unique optimum and no barren plateaus. This is the complementary method to the trainable VQCs in hqnn_forge.models.

Which circuit defines the kernel

quantum_kernel_matrix replays the circuit of an encoding layer (QuantumEncodingLayer, IQPEncodingLayer, AmplitudeEncodingLayer, DataReuploadingLayer) up to but not including its measurements, and reads the state vector. The layer's variational block is included as it stands, with the layer's current weights. For the single-upload encoders this makes no difference: the ansatz is a data-independent unitary V and |⟨Φ(x)|V†V|Φ(x')⟩|² = |⟨Φ(x)|Φ(x')⟩|², so the kernel is that of the embedding alone whatever the weights are.

For DataReuploadingLayer the blocks weights[0] to weights[-2] sit between uploads and do shape the kernel; they are then part of the kernel's definition (a "trainable kernel" in the sense of Hubregtsen et al. 2022), and the matrix is that of the layer as currently parametrised. train_kernel_alignment chooses them for the task, by maximising kernel_target_alignment through the differentiable path (differentiable=True). The last block, weights[-1], comes after the last upload and cancels as the single-upload ansatz does, so with n_layers=1 the kernel does not depend on weights at all. A trainable input_scaling multiplies the features inside every scaled upload and always shapes the kernel.

Only the circuit as written is replayed. A transform on the layer's QNode (noise, compilation) would be dropped by the replay, so any transform other than the broadcast_expand the encoders add for non-backprop differentiation makes these functions raise rather than return the kernel of a different circuit. For noise, pass noise_level instead (below).

Under depolarising noise

With noise_level = p > 0 the circuit is replayed on default.mixed with DepolarizingChannel(p) inserted exactly as hqnn_forge.noise inserts it for the models (the same construction, at noise_position), and each input is encoded into a density matrix ρ(x). The fidelity |⟨Φ(x)|Φ(y)⟩|² then generalises to the Hilbert–Schmidt kernel k(x, y) = Tr[ρ(x) ρ(y)]: still symmetric and positive semi-definite (a Gram matrix of the vectorised ρ), and equal to the fidelity kernel for pure states. Its diagonal is the purity Tr[ρ(x)²] < 1, which falls as p grows; that is the measurable effect of the noise, and the matrix is deliberately not renormalised to hide it.

This is not what overlap_kernel_matrix estimates on a noisy device: there the noise acts on the whole compute-uncompute circuit U(x)† U(y), the estimate is ⟨0|N(U(x)†U(y)|0⟩⟨0|U(y)†U(x))|0⟩ for the device's noise N, which is neither Tr[ρ(x)ρ(y)] nor necessarily symmetric. Mixed-state simulation needs 4^n entries per sample instead of 2^n, and default.mixed stops at 23 wires.

Scaling: O(M²) against the VQC

A kernel matrix over M training points has M(M+1)/2 distinct entries, of which the M diagonal ones are 1 by construction. On hardware the other M(M-1)/2, that is O(M²), are circuit evaluations, each an overlap estimate with shot noise, before the SVM even starts, and every prediction costs M more overlaps against the training set.

A VQC with P trainable circuit parameters estimates its gradient on hardware by the parameter-shift rule, about 2P + 1 circuit evaluations per sample, so E epochs cost about E · M · (2P + 1) evaluations, and a prediction costs one. Training the kernel is therefore the cheaper of the two until M(M-1)/2 overtakes E · M · (2P + 1), around M ≈ 2E(2P + 1): for QuantumEncodingLayer(n_qubits=8, n_layers=2) (P = 48) trained for 50 epochs, that is M ≈ 9,700. Past that point, and at every prediction whatever M is, the kernel costs more.

On a state-vector simulator the picture is different: M state vectors of size 2^n and one M × M Gram product, which is what quantum_kernel_matrix does. There the limit is memory for the M × M matrix (see its Notes), not circuit evaluations.

overlap_kernel_matrix estimates the kernel the way hardware has to, one compute-uncompute circuit U(x)† U(y) per entry, reading the probability of the all-zeros outcome, with shots samples per circuit and on any device. That makes the M(M-1)/2 cost and the effect of shot noise (and, on a noisy device, of noise) on the SVM measurable rather than described.

References
  • Havlíček et al. (2019) "Supervised learning with quantum-enhanced feature spaces", Nature 567, 209.
  • Schuld & Killoran (2019) "Quantum machine learning in feature Hilbert spaces", PRL 122, 040504.
  • Hubregtsen et al. (2022) "Training quantum embedding kernels on near-term quantum computers", PRA 106, 042431.
  • Cortes, Mohri & Rostamizadeh (2012) "Algorithms for learning kernels based on centered alignment", JMLR 13, 795–828.
  • Higham (1988) "Computing a nearest symmetric positive semidefinite matrix", Linear Algebra and its Applications 103, 103–118.

encoded_states

encoded_states(X: Tensor, layer: Module, *, differentiable: bool = False, batch_size: int | None = None) -> torch.Tensor

State vectors |Φ(x_i)⟩ the layer prepares for each row of X.

X first goes through the layer's prepare_inputs, the validation and classical transform forward applies before its QNode (a width check, and for the amplitude encoder padding and normalisation). The layer's circuit is then replayed on default.qubit for the whole batch at once (or batch_size rows at a time), with its measurements replaced by qml.state().

Compute the states once and pass them to kernel_from_states to reuse them, for example the training states at every prediction.

Parameters:

Name Type Description Default
X Tensor

Inputs, shape (n_samples, n_features).

required
layer Module

An encoding layer.

required
differentiable bool

Keep the graph to the layer's trainable parameters (its weights and any input_scaling), simulating under backprop, so a loss on the states or the kernel can train them. Default: False, detached.

False
batch_size int | None

Replay the circuit on this many rows at a time and join the states. default.qubit copies the broadcast state at every gate, so one pass over M rows needs a multiple of M · 2**n_qubits · 16 bytes while it runs; batches bound that working memory by batch_size rows. The returned states still take M · 2**n_qubits · 16 bytes. All of X is validated before the first batch runs. None (default): one pass. The result is the same either way. With differentiable=True the bound does not hold: autograd keeps every batch's per-gate intermediates until backward, so the graph takes as much memory as a single pass.

None

Returns:

Type Description
Tensor

Complex tensor of shape (n_samples, 2**n_qubits), one normalised state per row, complex128.

Raises:

Type Description
TypeError

If layer is not an encoding layer or X is not a tensor.

ValueError

If X is not a non-empty 2-D tensor, contains NaN or ±inf, or prepare_inputs rejects it (wrong number of features, an all-zero amplitude vector), or if batch_size is not a positive integer.

RuntimeError

If the layer's QNode carries a transform other than broadcast_expand, for example inside hqnn_forge.noise.apply_depolarizing_noise: the replay would drop it, so it refuses rather than return the untransformed states.

kernel_from_states

kernel_from_states(states_x: Tensor, states_y: Tensor | None = None) -> torch.Tensor

Fidelity kernel K[i, j] = |⟨ψ_i|φ_j⟩|² from precomputed state vectors.

Parameters:

Name Type Description Default
states_x Tensor

States of shape (n_x, dim), as returned by encoded_states. Any real or complex dtype; both sets are promoted to complex128, so states stored as complex64 can be reused as they are.

required
states_y Tensor | None

Optional second set of states, shape (n_y, dim). None (default) gives the square Gram matrix of states_x with itself, made exactly symmetric.

None

Returns:

Type Description
Tensor

float64 tensor of shape (n_x, n_y) (or (n_x, n_x)).

Examples:

Simulate each set once and build the train and test kernels from the same training states:

>>> import torch
>>> from sklearn.svm import SVC
>>> from hqnn_forge.encoding import QuantumEncodingLayer
>>> from hqnn_forge.kernels import encoded_states, kernel_from_states
>>> layer = QuantumEncodingLayer(n_qubits=4, n_layers=1, device_name="default.qubit")
>>> g = torch.Generator().manual_seed(0)
>>> X_train, X_test = torch.rand(10, 4, generator=g), torch.rand(3, 4, generator=g)
>>> y_train = [0, 1] * 5
>>> S_train = encoded_states(X_train, layer)
>>> K_train = kernel_from_states(S_train)
>>> bool(torch.allclose(K_train.diagonal(), torch.ones(10, dtype=torch.float64)))
True
>>> svm = SVC(kernel="precomputed").fit(K_train.numpy(), y_train)
>>> K_test = kernel_from_states(encoded_states(X_test, layer), S_train)
>>> K_test.shape
torch.Size([3, 10])
>>> y_pred = svm.predict(K_test.numpy())

quantum_kernel_matrix

quantum_kernel_matrix(X: Tensor, layer: Module, Y: Tensor | None = None, *, differentiable: bool = False, batch_size: int | None = None, noise_level: float = 0.0, noise_position: Position = 'all') -> torch.Tensor

Pairwise state-fidelity kernel K[i, j] = |⟨Φ(x_i)|Φ(y_j)⟩|².

Parameters:

Name Type Description Default
X Tensor

Inputs, shape (n_samples_x, n_features).

required
layer Module

The encoding layer whose circuit defines Φ. See the module docstring for the role of its variational weights.

required
Y Tensor | None

Optional second set of inputs, shape (n_samples_y, n_features). None (default) computes the square Gram matrix of X with itself. Pass the training inputs here to build the rectangular matrix an SVM needs at prediction time; to avoid simulating the training set again on every call, keep its encoded_states and use kernel_from_states instead.

None
differentiable bool

As for encoded_states: the matrix carries gradients to the layer's trainable parameters.

False
batch_size int | None

As for encoded_states: the states of X and Y are simulated this many rows at a time. Both sets are validated before the first batch.

None
noise_level float

With noise_level > 0, the Hilbert–Schmidt kernel Tr[ρ(x_i) ρ(y_j)] of the states under depolarising noise (see the module docstring and encoded_density_matrices); its diagonal is the purity, below 1. 0 (default) is the noiseless kernel, computed from state vectors as before.

0.0
noise_position float

With noise_level > 0, the Hilbert–Schmidt kernel Tr[ρ(x_i) ρ(y_j)] of the states under depolarising noise (see the module docstring and encoded_density_matrices); its diagonal is the purity, below 1. 0 (default) is the noiseless kernel, computed from state vectors as before.

0.0

Returns:

Type Description
Tensor

float64 tensor of shape (n_samples_x, n_samples_y) (or (n_samples_x, n_samples_x)), entries in [0, 1] up to rounding of order 1e-15. The square matrix is symmetric, positive semi-definite, and has ones on the diagonal.

Raises:

Type Description
(TypeError, ValueError, RuntimeError)

As encoded_states, for X and Y. Both are validated before anything is simulated.

Examples:

>>> import torch
>>> from sklearn.svm import SVC
>>> from hqnn_forge.encoding import QuantumEncodingLayer
>>> from hqnn_forge.kernels import quantum_kernel_matrix
>>> layer = QuantumEncodingLayer(n_qubits=4, n_layers=1, device_name="default.qubit")
>>> g = torch.Generator().manual_seed(0)
>>> X_train, X_test = torch.rand(10, 4, generator=g), torch.rand(3, 4, generator=g)
>>> y_train = [0, 1] * 5
>>> K_train = quantum_kernel_matrix(X_train, layer)
>>> K_train.shape, K_train.dtype
(torch.Size([10, 10]), torch.float64)
>>> svm = SVC(kernel="precomputed").fit(K_train.numpy(), y_train)
>>> K_test = quantum_kernel_matrix(X_test, layer, Y=X_train)
>>> y_pred = svm.predict(K_test.numpy())
Notes

X and Y are simulated together in one batched circuit replay, and the kernel is one (n, 2^q) × (2^q, n) product, so time is O(n² · 2^q). Memory is O(n · 2^q) for the states plus O(n²) for the matrix: at peak the complex128 Gram product and the float64 kernel sit side by side, about 24 n² bytes, which is 9.6 GB at n = 20,000. The states take (n_x + n_y) · 2^q · 16 bytes more, and simulating them in one pass a multiple of that; batch_size bounds the simulation part (not with differentiable=True, see encoded_states). For larger training sets, compute encoded_states once and build the matrix in row blocks with kernel_from_states, so the full matrix is never held as a complex Gram product::

S = encoded_states(X_train, layer, batch_size=256)
K = torch.cat([kernel_from_states(S[i : i + 1000], S) for i in range(0, len(S), 1000)])

|G|² with G = S S† is the Schur product of a positive semi-definite matrix with its conjugate, hence positive semi-definite itself; small negative eigenvalues of order 1e-15 are rounding.

kernel_target_alignment

kernel_target_alignment(K: Tensor, y: Tensor) -> torch.Tensor

Centred kernel-target alignment of K with labels y (Cortes et al. 2012): the cosine between the centred kernel HKH and the centred ideal kernel H yyᵀ H, H = I - 11ᵀ/M, in the Frobenius inner product.

It lies in [-1, 1] and is 1 when the kernel separates the classes exactly as the labels do. Centring removes what every entry shares, so the alignment is not inflated by class imbalance or by a kernel that is large everywhere.

Parameters:

Name Type Description Default
K Tensor

Square kernel matrix; differentiable if it comes from the differentiable=True path.

required
y Tensor

Binary labels, {0, 1} or {-1, +1}, with both classes present.

required

Returns:

Type Description
Tensor

0-d float64 tensor.

train_kernel_alignment

train_kernel_alignment(layer: Module, X: Tensor, y: Tensor, *, steps: int = 50, lr: float = 0.05, subset_size: int | None = None, generator: Generator | None = None) -> list[float]

Train layer's parameters to maximise the kernel-target alignment on (X, y), in place; then fit SVC(kernel="precomputed") on quantum_kernel_matrix(X, layer).

Every step computes the differentiable kernel on X (or, with subset_size, on a random stratified subset, as Hubregtsen et al. 2022 do to keep a step at subset_size² entries instead of M²) and takes one Adam step on -alignment.

Only the weights that sit between uploads and the input_scaling of a DataReuploadingLayer change the kernel. For the single-upload encoders the ansatz cancels in the kernel, so their gradient is zero and this does nothing useful: see the module docstring.

Parameters:

Name Type Description Default
layer Module

An encoding layer; all its trainable parameters are optimised.

required
X Tensor

Training inputs and binary labels.

required
y Tensor

Training inputs and binary labels.

required
steps int

Adam steps and learning rate.

50
lr int

Adam steps and learning rate.

50
subset_size int | None

Samples per step, drawn per class in proportion; None uses all.

None
generator Generator | None

Source of the subsets.

None

Returns:

Type Description
list of float

The alignment at each step, before that step's update.

overlap_kernel_matrix

overlap_kernel_matrix(X: Tensor, layer: Module, Y: Tensor | None = None, *, shots: int | None = None, seed: int | None = None, device: Device | None = None, project_psd: bool = False) -> torch.Tensor

Kernel estimated entry by entry from the compute-uncompute circuit.

Each entry is the probability of measuring all zeros after U(x_i)† U(y_j)|0…0⟩, which equals |⟨Φ(x_i)|Φ(y_j)⟩|²; U is the layer's own circuit, replayed exactly as quantum_kernel_matrix replays it. This is how a device without state-vector access estimates the kernel.

For the square matrix only the M(M-1)/2 pairs above the diagonal are circuits; the diagonal is set to 1 and the lower triangle mirrored. The rectangular matrix against Y takes one circuit per entry.

Parameters:

Name Type Description Default
X Tensor required
layer Tensor required
Y Tensor required
shots int | None

Samples per circuit. None (default) gives the exact probability, which agrees with quantum_kernel_matrix to rounding; with shots each entry is a binomial estimate with standard error sqrt(k(1-k)/shots).

None
seed int | None

Seed of the sampling on the default device; ignored with device.

None
device Device | None

A PennyLane device to run the circuits on, e.g. a noisy simulator. Default: default.qubit.

None
project_psd bool

With finite shots or a noisy device the estimate need not be positive semi-definite, which SVC(kernel="precomputed") assumes. True projects the square matrix onto the nearest PSD matrix (nearest_psd) before returning it.

False

Returns:

Type Description
Tensor

float64, shape (n_x, n_y) or (n_x, n_x).

Raises:

Type Description
(TypeError, ValueError, RuntimeError)

As quantum_kernel_matrix; also ValueError for shots < 1 or project_psd with Y.

nearest_psd

nearest_psd(K: Tensor) -> torch.Tensor

The positive semi-definite matrix nearest to symmetric K in the Frobenius norm: K's eigendecomposition with negative eigenvalues set to zero (Higham 1988).

The diagonal is not restored to 1 afterwards, so entries can move slightly; the projection changes nothing when K is already PSD.

encoded_density_matrices

encoded_density_matrices(X: Tensor, layer: Module, *, noise_level: float = 0.0, noise_position: Position = 'all', differentiable: bool = False, batch_size: int | None = None) -> torch.Tensor

Density matrices ρ(x_i) the layer prepares under depolarising noise.

The circuit is replayed as by encoded_states, on default.mixed with DepolarizingChannel(noise_level) inserted at noise_position as hqnn_forge.noise does for the models; see "Under depolarising noise" in the module docstring.

Parameters:

Name Type Description Default
X Tensor

As for encoded_states.

required
layer Tensor

As for encoded_states.

required
differentiable Tensor

As for encoded_states.

required
batch_size Tensor

As for encoded_states.

required
noise_level float

Depolarising probability per channel, in [0, 0.75]. 0 inserts no channel, so the result is |Φ⟩⟨Φ| for the noiseless states.

0.0
noise_position Position

"all" (after every gate) or "end" (before measurement), as in hqnn_forge.noise.apply_depolarizing_noise.

'all'

Returns:

Type Description
Tensor

complex128 of shape (n_samples, 2**n_qubits, 2**n_qubits), each Hermitian with unit trace.

Raises:

Type Description
(TypeError, ValueError, RuntimeError)

As encoded_states; also ValueError for a noise_level or noise_position out of range.

kernel_from_density_matrices

kernel_from_density_matrices(rho_x: Tensor, rho_y: Tensor | None = None) -> torch.Tensor

Hilbert–Schmidt kernel K[i, j] = Tr[ρ_i σ_j] from density matrices.

Tr[ρσ] = Σ_ab ρ_ab conj(σ_ab) for Hermitian matrices, so K is the real Gram matrix of the vectorised matrices: symmetric and positive semi-definite. The square matrix is made exactly symmetric; its diagonal holds the purities, not ones.

Parameters:

Name Type Description Default
rho_x Tensor

Shape (n_x, d, d), as returned by encoded_density_matrices.

required
rho_y Tensor | None

Optional second set, shape (n_y, d, d). None: the square matrix of rho_x with itself.

None