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 |
required |
layer
|
Module
|
An encoding layer. |
required |
differentiable
|
bool
|
Keep the graph to the layer's trainable parameters (its weights and
any |
False
|
batch_size
|
int | None
|
Replay the circuit on this many rows at a time and join the states.
|
None
|
Returns:
| Type | Description |
|---|---|
Tensor
|
Complex tensor of shape |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
RuntimeError
|
If the layer's QNode carries a transform other than
|
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 |
required |
states_y
|
Tensor | None
|
Optional second set of states, shape |
None
|
Returns:
| Type | Description |
|---|---|
Tensor
|
|
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 |
required |
layer
|
Module
|
The encoding layer whose circuit defines |
required |
Y
|
Tensor | None
|
Optional second set of inputs, shape |
None
|
differentiable
|
bool
|
As for |
False
|
batch_size
|
int | None
|
As for |
None
|
noise_level
|
float
|
With |
0.0
|
noise_position
|
float
|
With |
0.0
|
Returns:
| Type | Description |
|---|---|
Tensor
|
|
Raises:
| Type | Description |
|---|---|
(TypeError, ValueError, RuntimeError)
|
As |
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
|
required |
y
|
Tensor
|
Binary labels, |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
0-d |
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
|
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
|
As for |
required |
layer
|
Tensor
|
As for |
required |
Y
|
Tensor
|
As for |
required |
shots
|
int | None
|
Samples per circuit. |
None
|
seed
|
int | None
|
Seed of the sampling on the default device; ignored with |
None
|
device
|
Device | None
|
A PennyLane device to run the circuits on, e.g. a noisy simulator.
Default: |
None
|
project_psd
|
bool
|
With finite shots or a noisy device the estimate need not be positive
semi-definite, which |
False
|
Returns:
| Type | Description |
|---|---|
Tensor
|
|
Raises:
| Type | Description |
|---|---|
(TypeError, ValueError, RuntimeError)
|
As |
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 |
required |
layer
|
Tensor
|
As for |
required |
differentiable
|
Tensor
|
As for |
required |
batch_size
|
Tensor
|
As for |
required |
noise_level
|
float
|
Depolarising probability per channel, in |
0.0
|
noise_position
|
Position
|
|
'all'
|
Returns:
| Type | Description |
|---|---|
Tensor
|
|
Raises:
| Type | Description |
|---|---|
(TypeError, ValueError, RuntimeError)
|
As |
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 |
required |
rho_y
|
Tensor | None
|
Optional second set, shape |
None
|