Analysis¶
Measuring an ansatz rather than asserting things about it: expressibility, entanglement, spectra, geometry.
qmlkit.diagnostics¶
diagnostics ¶
Whether the model you just built is quietly broken.
In most libraries a mistake raises. In quantum machine learning it returns a number — right shape, right range, entirely plausible, and wrong:
- A re-uploading model whose trainable block commutes with its encoding trains,
converges, and reaches one Fourier frequency instead of the
Lit was designed for. Every weight in it is a phase shift. - An ansatz with a parameter the circuit cannot feel fits exactly as well without it. The optimiser reports no difficulty, because there is none.
- A kernel that has concentrated gives every pair of inputs the same similarity, and still produces a Gram matrix, an SVM, and an accuracy.
None of these are exceptions to catch. They are properties to measure, and this module measures them.
The checks are deliberately decisive rather than exhaustive. Each one has a threshold that separates "wrong" from "unusual" with room to spare, because a diagnostic that cries wolf is one nobody runs twice. Where the check is exact — a parameter that cannot change the state is dead, full stop — the threshold is machine epsilon. Where it is statistical, the finding says what was measured, so the number can be argued with.
>>> import qmlkit as qk
>>> report = qk.diagnose(qk.hardware_efficient(3, 2))
>>> bool(report)
False
A report is falsy when it found nothing, so if qk.diagnose(model): ... reads
the way it should. Findings carry a stable code to branch on, a message
saying what is wrong, and a fix naming the edit that resolves it.
Finding
dataclass
¶
One thing that is wrong, why it matters, and the edit that fixes it.
Report
dataclass
¶
Report(subject: str, findings: tuple[Finding, ...] = ())
Everything :func:diagnose found, worst first.
Falsy when empty, so it can be tested directly. Iterating yields
:class:Finding objects; codes is the flat list to assert against.
diagnose ¶
diagnose(
subject: object,
X: Any = None,
y: Any = None,
*,
obs: Observable | None = None,
n_samples: int = 30,
probes: int = 3,
seed: int | None = 0,
backend: BackendLike = None,
shots: int | None = None,
n_qubits: int | None = None,
) -> Report
Check a model or a Gram matrix for the failures that do not raise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subject
|
object
|
An :class: |
required |
X
|
Any
|
Optional. Given both, and a trained model holding a |
None
|
y
|
Any
|
Optional. Given both, and a trained model holding a |
None
|
obs
|
Observable | None
|
Observable for the trainability probe. Defaults to |
None
|
n_samples
|
int
|
Sample count for the statistical checks — entanglement and gradient variance. The exact checks ignore it. |
30
|
probes
|
int
|
Random points at which to test whether a parameter can move the state. A parameter is dead if it moves nothing at any of them; three is already conclusive, since the points are random and the test is exact. |
3
|
shots
|
int | None
|
Gram matrices only. |
None
|
n_qubits
|
int | None
|
Gram matrices only. |
None
|
Returns:
| Type | Description |
|---|---|
Report
|
Falsy when nothing was found. Sorted worst first. |
Examples:
>>> import qmlkit as qk
>>> healthy = qk.diagnose(qk.hardware_efficient(3, 2))
>>> bool(healthy)
False
A model whose weights share the encoding's generator is the trap the re-uploading literature warns about, and it is silent without this:
>>> from qmlkit.ansatz import Ansatz, EncodingLayer, RotationLayer, repeat
>>> fmap = qk.AngleFeatureMap(1, rotation="ry")
>>> block = EncodingLayer(fmap) + RotationLayer("ry")
>>> broken = qk.diagnose(Ansatz(1, repeat(3, block), n_inputs=1))
>>> "ENCODING_COMMUTES" in broken.codes
True
Source code in src/qmlkit/diagnostics.py
833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 | |
qmlkit.metrics¶
metrics ¶
Does this ansatz stand a chance? — expressibility, entanglement, trainability.
Choosing an ansatz by eye is guesswork. These are the four numbers the literature actually uses, and they pull against each other:
- Expressibility — how close the ansatz's state distribution gets to Haar-random.
Measured as
KL(fidelities || Haar); smaller is more expressive. - Entangling capability — the mean Meyer–Wallach
Q; 0 is a product state, 1 is maximally entangled. - Trainability — the variance of the gradient. It collapses roughly as
2^-nfor deep circuits, which is the barren plateau: gradients vanish faster than any shot budget can resolve them. - Generalization — how much data the model needs, growing with trainable gates.
More expressibility costs trainability. That trade is the whole design problem, and
:class:AnsatzReport puts both numbers side by side so the choice is informed.
AnsatzReport
dataclass
¶
AnsatzReport(
ansatz: Ansatz,
n_samples: int = 300,
seed: int | None = 0,
backend: BackendLike = None,
results: dict[str, object] = dict(),
)
Expressibility, entanglement, depth, cost and trainability in one call.
print(AnsatzReport(qk.hardware_efficient(4, 2)))
haar_fidelity_pdf ¶
Haar-random fidelity density: (N-1)(1-F)^(N-2) for N = 2^n.
Source code in src/qmlkit/metrics.py
fidelity_samples ¶
fidelity_samples(
ansatz: Ansatz,
n_samples: int = 2000,
seed: int | None = None,
backend: BackendLike = None,
) -> NDArray[Any]
Fidelities between pairs of states from independently sampled parameters.
Source code in src/qmlkit/metrics.py
expressibility ¶
expressibility(
ansatz: Ansatz,
n_samples: int = 2000,
n_bins: int = 75,
seed: int | None = None,
backend: BackendLike = None,
) -> float
KL(ansatz fidelities || Haar). Smaller is more expressive; 0 is Haar.
Note the direction — it is a divergence from Haar, so a low number means the ansatz reaches as much of state space as a random circuit would.
Source code in src/qmlkit/metrics.py
meyer_wallach ¶
Meyer–Wallach Q = 2(1 - (1/n) sum_k Tr rho_k^2).
0 for any product state, 1 for a maximally entangled one.
Source code in src/qmlkit/metrics.py
entangling_capability ¶
entangling_capability(
ansatz: Ansatz,
n_samples: int = 200,
seed: int | None = None,
backend: BackendLike = None,
) -> float
Mean Meyer–Wallach Q over randomly sampled parameters.
Source code in src/qmlkit/metrics.py
gradient_variance ¶
gradient_variance(
ansatz: Ansatz,
obs: Observable | None = None,
n_samples: int = 100,
param_index: int = 0,
seed: int | None = None,
backend: BackendLike = None,
) -> float
Variance of one parameter's gradient over random initialisations.
This is the barren-plateau probe: if it falls exponentially with width, no realistic shot budget will resolve the gradient.
It probes one parameter, and which one matters. The default param_index=0
is a leading rotation on several stock ansaetze, and a leading Rz on |0>
has a gradient of exactly zero against Z - not because the ansatz is
untrainable but because that parameter does nothing. A variance at machine zero
is reported with a warning saying so, because the number alone is
indistinguishable from a genuine plateau. :func:~qmlkit.diagnostics.diagnose
finds dead parameters directly and names their indices.
Source code in src/qmlkit/metrics.py
barren_plateau_scan ¶
barren_plateau_scan(
ansatz_factory: Callable[[int], Ansatz],
qubit_range: Sequence[int],
obs_factory: Callable[[int], Observable] | None = None,
n_samples: int = 100,
seed: int | None = None,
backend: BackendLike = None,
) -> dict[str, Any]
Gradient variance against qubit count.
obs_factory decides the cost locality, which matters at fixed shallow
depth: measured on a 2-layer hardware-efficient ansatz from 2 to 6 qubits, a
local Z(0) holds its gradient variance flat (decay 0.98 per qubit) while a
global Z^n collapses exponentially (0.56). Depth eventually wins regardless
— at L = 2n both decay exponentially — so this reports the measurement
rather than asserting a rule.
Source code in src/qmlkit/metrics.py
fisher_information ¶
fisher_information(
ansatz: Ansatz,
X: NDArray[Any],
theta: NDArray[Any],
obs: Observable | None = None,
backend: BackendLike = None,
) -> NDArray[Any]
Classical Fisher information of the model output, averaged over inputs.
This is the classical FIM of the output distribution — the object effective
dimension is built on. Not to be confused with the quantum Fisher information,
which is 4 x the Fubini–Study metric and is what natural gradient uses.
Source code in src/qmlkit/metrics.py
effective_dimension ¶
Normalised effective dimension of a model, from its Fisher information.
How many parameters are usefully independent, which is generally far fewer than the raw count. Follows the normalised-Fisher construction rather than the "count eigenvalues above a threshold" shortcut, which is a teaching stand-in.
Source code in src/qmlkit/metrics.py
generalization_bound ¶
Expected generalization gap, O(sqrt(T log T / N)) (Caro et al. 2022).
with_log=False drops the log factor for the simplified sqrt(T/N) form
often quoted — easier to reason about, but not the actual bound.
Source code in src/qmlkit/metrics.py
samples_for_gap ¶
Invert the bound: how many examples to reach a target generalization gap.
Source code in src/qmlkit/metrics.py
noise_survival ¶
qmlkit.fourier¶
fourier ¶
What function does this model actually represent?
A variational model with data re-uploading is a truncated Fourier series in its inputs. The encoding fixes which frequencies are reachable; the ansatz only sets their coefficients. That is the central claim of the re-uploading literature, and this module turns it from a claim into a measurement:
coeffs = fourier_coefficients(f, degree=4)
spectrum(f) # which frequencies actually carry weight
Useful for two things. Checking that L uploads really did buy frequencies
0..L — and diagnosing a model that will not fit its target, because if the
target's frequency is not in the spectrum, no amount of training will reach it.
fourier_coefficients ¶
Complex Fourier coefficients c_-d .. c_d of a 2*pi-periodic function.
Sampled on a uniform grid and transformed exactly — no fitting, no optimiser.
The grid must be at least 2*degree + 1 points to avoid aliasing, which is
the default.
Source code in src/qmlkit/fourier.py
spectrum ¶
spectrum(
f: ScalarFn,
degree: int = 5,
n_samples: int | None = None,
tol: float = 1e-08,
) -> dict[int, float]
Frequency -> amplitude, keeping only what is actually present.
Amplitudes are |c_k| + |c_-k| for k > 0, so a real-valued model reports
one number per frequency rather than a conjugate pair.
Source code in src/qmlkit/fourier.py
reconstruct ¶
Evaluate the series these coefficients describe.
Source code in src/qmlkit/fourier.py
reachable_frequencies ¶
L uploads of a Pauli-rotation encoding reach frequencies 0..L.
dominant_frequency ¶
The non-zero frequency carrying the most weight — 0 if the model is constant.
Source code in src/qmlkit/fourier.py
model_spectrum ¶
model_spectrum(
encoder: Any,
theta: ArrayLike,
obs: Observable | None = None,
degree: int | None = None,
backend: BackendLike = None,
) -> dict[int, float]
Spectrum of a one-feature re-uploading model, as a function of its input.
The direct check that an encoder buys the frequencies it claims to.
Source code in src/qmlkit/fourier.py
qmlkit.info¶
info ¶
Quantum information quantities — the qml.qinfo equivalent.
Reduced density matrices, purity, entropies and state fidelity. These are the building blocks the trainability metrics and the projected kernel are made of, and they are useful on their own for looking at what a circuit is actually doing.
density_matrix ¶
density_matrix(
state: CircuitSpec | NDArray[Any],
backend: BackendLike = None,
) -> NDArray[Any]
reduced_dm ¶
reduced_dm(
state: CircuitSpec | NDArray[Any],
wires: Sequence[int],
n_qubits: int | None = None,
backend: BackendLike = None,
) -> NDArray[Any]
Trace out everything except wires, kept in the order given.
Qubit 0 is the most significant bit, matching the rest of the library, and the
returned matrix carries its own bit order: qubit wires[0] is its most
significant. reduced_dm(psi, [1, 0]) is therefore reduced_dm(psi, [0, 1])
with its two subsystems exchanged — SWAP @ rho @ SWAP — and not the same
matrix. Everything basis-independent (trace, eigenvalues, purity, entropy) is
blind to the difference, which is why the contract has to be stated here rather
than left to be read off a test.
Source code in src/qmlkit/info.py
purity ¶
purity(
state: CircuitSpec | NDArray[Any],
wires: Sequence[int] | None = None,
n_qubits: int | None = None,
backend: BackendLike = None,
) -> float
Tr(rho^2) — 1 for a pure state, 1/d for the maximally mixed one.
With no wires the whole register is meant. That is 1 for a statevector by
construction — but not when backend evolves a density matrix, where the whole
point of asking is that the state may be mixed. Returning 1.0 there answered a
question nobody asked, and was the one path in this module that neither refused
nor computed.
Source code in src/qmlkit/info.py
vn_entropy ¶
vn_entropy(
state: CircuitSpec | NDArray[Any],
wires: Sequence[int],
n_qubits: int | None = None,
base: float | None = None,
backend: BackendLike = None,
) -> float
Von Neumann entropy of a subsystem — how entangled it is with the rest.
Source code in src/qmlkit/info.py
mutual_info ¶
mutual_info(
state: CircuitSpec | NDArray[Any],
wires_a: Sequence[int],
wires_b: Sequence[int],
n_qubits: int | None = None,
backend: BackendLike = None,
) -> float
S(A) + S(B) - S(AB) — total correlation between two subsystems.
Source code in src/qmlkit/info.py
state_fidelity ¶
state_fidelity(
state_a: CircuitSpec | NDArray[Any],
state_b: CircuitSpec | NDArray[Any],
backend: BackendLike = None,
) -> float
|<a|b>|^2 — the quantity a fidelity kernel estimates.
Source code in src/qmlkit/info.py
concurrence ¶
concurrence(
state: CircuitSpec | NDArray[Any],
backend: BackendLike = None,
) -> float
Two-qubit concurrence — 0 for a product state, 1 for a Bell state.
Source code in src/qmlkit/info.py
bloch_vector ¶
bloch_vector(
state: CircuitSpec | NDArray[Any],
wire: int = 0,
n_qubits: int | None = None,
backend: BackendLike = None,
) -> NDArray[Any]
(<X>, <Y>, <Z>) for one qubit — its point on (or in) the Bloch sphere.
Source code in src/qmlkit/info.py
qmlkit.optim¶
optim ¶
Optimisers that only make sense for quantum circuits.
Adam and SGD come from torch. These three do not exist there, because they exploit structure a general optimiser cannot see:
- Rotosolve — a circuit expectation is a sinusoid in any single Pauli-rotation angle. Three evaluations pin that sinusoid down exactly, so you can jump straight to its minimum instead of stepping toward it. No learning rate, no tuning.
- Quantum natural gradient — parameter space is curved. Following the Fubini–Study geometry rather than the Euclidean one usually converges in far fewer steps.
- SPSA — lives in :mod:
qmlkit.gradients.spsa; two evaluations per step at any parameter count.
AdamState
dataclass
¶
The two running averages Adam carries between steps, and the step count.
Exposed because a circuit-level optimiser is often driven one step at a time from a loop the caller owns, and losing this between steps silently turns Adam back into plain gradient descent with a decaying learning rate.
supports_rotosolve ¶
supports_rotosolve(spec: CircuitSpec) -> bool
Whether Rotosolve's closed form is actually valid for this circuit.
Rotosolve works because a circuit expectation is a single sinusoid
:math:A\sin(\theta + B) + C in any one Pauli-rotation angle — three samples then
determine it exactly. That holds when a parameter drives one rotation, or several
that compose into one (same qubit, same generator).
It does not hold when one parameter is shared across gates that do not compose
— QAOA's cost angle drives one rz per graph edge, so E(gamma) carries one
frequency per edge. Rotosolve then solves for the wrong minimum, converges
immediately, and reports a number that looks like a result. Measured on a 5-edge
MaxCut: frequencies 1 through 4 are all present, and Rotosolve sticks at the
uniform-state energy no matter how many sweeps it is given.
Source code in src/qmlkit/optim.py
rotosolve_step ¶
rotosolve_step(
f: LossFn,
theta: ArrayLike,
indices: Sequence[int] | None = None,
) -> NDArray[Any]
One sweep: set every coordinate to its exact optimum, in turn.
Three evaluations per parameter, and each one lands on that coordinate's minimum rather than moving toward it.
Source code in src/qmlkit/optim.py
minimize_rotosolve ¶
minimize_rotosolve(
f: LossFn,
theta0: Sequence[float],
n_sweeps: int = 20,
tol: float = 1e-09,
callback: Callable[[int, NDArray[Any], float], None]
| None = None,
) -> tuple[NDArray[Any], list[float]]
Minimise by repeated Rotosolve sweeps. No learning rate to choose.
Precondition. f must be a single sinusoid in each angle — true for a plain
expectation value <O> of a circuit where each parameter drives one Pauli
rotation. It is not true when an angle is shared across gates that do not
compose (QAOA's cost angle drives one rz per edge), nor when the loss is
non-linear in the state (purity is Tr(rho^2), so it carries double
frequencies). In those cases Rotosolve converges immediately on the wrong point
and reports it as a result. :func:supports_rotosolve checks the first case;
the second is a property of your loss, not of the circuit.
Source code in src/qmlkit/optim.py
adam_step ¶
adam_step(
theta: ArrayLike,
gradient: ArrayLike,
state: AdamState,
lr: float = 0.05,
beta1: float = 0.9,
beta2: float = 0.999,
eps: float = 1e-08,
) -> tuple[NDArray[Any], AdamState]
One Adam update, given a gradient you already have.
Returns the new parameters and the new state; state is not mutated, so a
caller can keep a trajectory without copying by hand.
Adam earns its place on variational circuits for a specific reason: parameter gradients in a deep ansatz differ in scale by orders of magnitude — a rotation near the readout moves the expectation far more than one behind a wall of entanglers — and a single learning rate either crawls on the small ones or diverges on the large. Dividing by the running gradient magnitude makes the step size per-parameter, which is exactly that problem.
Source code in src/qmlkit/optim.py
minimize_adam ¶
minimize_adam(
f: LossFn,
theta0: Sequence[float],
grad: Callable[[NDArray[Any]], NDArray[Any]],
n_steps: int = 100,
lr: float = 0.05,
beta1: float = 0.9,
beta2: float = 0.999,
eps: float = 1e-08,
tol: float = 0.0,
callback: Callable[[int, NDArray[Any], float], None]
| None = None,
) -> tuple[NDArray[Any], list[float]]
Minimise f by Adam, using the gradient grad supplies.
grad is explicit rather than inferred because the right way to differentiate a
circuit depends on the circuit and the backend: pass
lambda t: qk.grad(spec, t, obs) for an exact gradient, or a shot-based one
when the point is to see what a device would do. :func:~qmlkit.grad chooses the
method; this chooses the step.
tol stops early when the loss improves by less than that between steps;
the default of 0.0 runs the full budget, since a variational loss plateaus and
then escapes often enough that stopping on the first flat step is usually wrong.
Source code in src/qmlkit/optim.py
metric_tensor ¶
metric_tensor(
spec: CircuitSpec,
theta: ArrayLike,
approx: str | None = "block-diag",
backend: BackendLike = None,
eps: float = 0.0001,
) -> NDArray[Any]
Fubini–Study metric — the curvature of parameter space.
approx="diag" keeps only the diagonal (cheapest). "block-diag" and
None compute the full tensor from state overlaps; on a simulator that is
affordable and exact, so they currently coincide. Note that PennyLane's
approx="block-diag" means something narrower — it blocks by circuit layer and
zeroes every cross-layer entry — so the same keyword does not port between the
two libraries. qmlkit follows the true geometry; PennyLane follows an
approximation to it.
.. math:: g_{ij} = \mathrm{Re}\langle \partial_i\psi | \partial_j\psi \rangle - \langle \partial_i\psi|\psi\rangle\langle\psi|\partial_j\psi\rangle
The derivative states are exact whenever every parameterised gate declares a
closed-form derivative, which every built-in gate does. eps is only consulted
on the fallback path, for a custom gate registered without a dmatrix.
Source code in src/qmlkit/optim.py
quantum_fisher_information ¶
quantum_fisher_information(
spec: CircuitSpec,
theta: Sequence[float],
backend: BackendLike = None,
) -> NDArray[Any]
QFIM — exactly 4 x the Fubini–Study metric.
Distinct from the classical Fisher information in :mod:qmlkit.metrics, which
describes the output distribution and is what effective dimension uses.
Source code in src/qmlkit/optim.py
qng_step ¶
qng_step(
spec: CircuitSpec,
theta: ArrayLike,
obs: Observable | None = None,
lr: float = 0.1,
approx: str = "block-diag",
regularization: float = 1e-06,
backend: BackendLike = None,
) -> NDArray[Any]
One natural-gradient step: theta <- theta - lr * g^+ grad.
The pseudo-inverse of the metric rescales each direction by how much the state actually moves, rather than how much the parameter does.
Source code in src/qmlkit/optim.py
minimize_qng ¶
minimize_qng(
spec: CircuitSpec,
theta0: Sequence[float],
obs: Observable | None = None,
n_steps: int = 50,
lr: float = 0.1,
approx: str = "block-diag",
backend: BackendLike = None,
callback: Callable[[int, NDArray[Any], float], None]
| None = None,
) -> tuple[NDArray[Any], list[float]]
Minimise <obs> by quantum natural gradient descent.
Source code in src/qmlkit/optim.py
shots_for_precision ¶
Shots needed to reach standard error eps — the 1/eps**2 price.
qmlkit.datasets¶
datasets ¶
Datasets for benchmarking quantum models.
Small, self-contained, no downloads, no sklearn. The important one is
:func:ad_hoc_data, which is constructed to be separable by a specific quantum
feature map and not by a classical kernel — so it distinguishes a working
implementation from one that only appears to work.
ad_hoc_data ¶
ad_hoc_data(
n_samples: int = 40,
n_features: int = 2,
gap: float = 0.3,
seed: int | None = None,
scale: float = 2 * pi,
) -> tuple[NDArray[Any], NDArray[Any]]
The Havlíček-style separable-by-construction dataset.
Labels come from the sign of a hidden observable measured on a ZZ-feature-mapped
state, so the ZZ kernel separates it by construction while classical kernels
struggle. gap discards points near the boundary, which makes the separation
clean enough to be a real check.
Source code in src/qmlkit/datasets.py
bars_and_stripes ¶
Every bars-and-stripes pattern on a size x size grid, flattened.
The standard target distribution for a quantum circuit Born machine: a sparse, highly structured subset of all bitstrings.
Source code in src/qmlkit/datasets.py
make_moons ¶
make_moons(
n_samples: int = 100,
noise: float = 0.1,
seed: int | None = None,
to_angles: bool = True,
) -> tuple[NDArray[Any], NDArray[Any]]
Two interleaving half-circles — not linearly separable.
Source code in src/qmlkit/datasets.py
make_circles ¶
make_circles(
n_samples: int = 100,
noise: float = 0.08,
factor: float = 0.5,
seed: int | None = None,
to_angles: bool = True,
) -> tuple[NDArray[Any], NDArray[Any]]
One circle inside another — needs a nonlinear boundary.
Source code in src/qmlkit/datasets.py
make_blobs ¶
make_blobs(
n_samples: int = 100,
centers: int = 2,
spread: float = 0.4,
n_features: int = 2,
seed: int | None = None,
to_angles: bool = True,
) -> tuple[NDArray[Any], NDArray[Any]]
Gaussian clusters — the easy baseline every model should pass.
Source code in src/qmlkit/datasets.py
make_parity ¶
make_parity(
n_samples: int = 100,
n_features: int = 4,
seed: int | None = None,
) -> tuple[NDArray[Any], NDArray[Any]]
Label is the parity of the bits — the classic hard case for shallow models.
Source code in src/qmlkit/datasets.py
train_test_split ¶
train_test_split(
X: NDArray[Any],
y: NDArray[Any],
test_size: float = 0.3,
seed: int | None = None,
) -> tuple[
NDArray[Any], NDArray[Any], NDArray[Any], NDArray[Any]
]
Shuffle and split. Here so a quickstart needs no extra dependency.
Source code in src/qmlkit/datasets.py
qmlkit.draw¶
draw ¶
Look at the circuit — the qml.draw / qml.specs equivalent.
Plain text, no matplotlib, no optional dependency. Reads the IR, so it works for any circuit the library can build and shows exactly what a backend will run.
draw ¶
draw(
spec: CircuitSpec,
max_width: int = 160,
ascii: bool | None = None,
) -> str
A text diagram of the circuit.
print(qk.draw(qk.hardware_efficient(3, 1).build()))
The diagram uses box-drawing glyphs. ascii=None (the default) checks whether
sys.stdout can encode them and degrades to -, | and t when it
cannot: a Windows console defaults to cp1252, which encodes none of them, and
printing the result would otherwise raise UnicodeEncodeError from inside the
caller, with a traceback naming the codec rather than this function. Pass
ascii=True or ascii=False to decide for yourself. Column widths are
identical either way, so the fallback lines up exactly like the Unicode form.
Source code in src/qmlkit/draw.py
specs ¶
specs(spec: CircuitSpec) -> dict[str, object]
Everything worth knowing about a circuit's cost, in one dict.
Source code in src/qmlkit/draw.py
draw_ansatz ¶
Convenience: draw an :class:~qmlkit.ansatz.library.Ansatz unbound.
probabilities_bar ¶
probabilities_bar(
probs: NDArray[Any],
n_qubits: int,
top: int = 8,
width: int = 30,
ascii: bool | None = None,
) -> str
A text histogram of outcome probabilities — the most likely bitstrings.
Source code in src/qmlkit/draw.py
qmlkit.generative¶
generative ¶
Generative models — learning a distribution rather than a mapping.
Two families, and the split matters:
- Born machines (QCBM, qGAN) are implicit. Measuring the circuit samples
p(x) = |<x|psi>|^2directly, so sampling is free and scoring is not: you cannot ask such a model forp(x)of an arbitraryxwithout estimating it. Training therefore uses a sample-based loss — MMD, or a discriminator. - Energy models (QBM, quantum Hopfield) are explicit. They define
p(x) ∝ exp(-E(x)), so scoring is easy and sampling is hard, because the partition function sums over2^nstates.
That is the whole taxonomy, and it decides which loss you can even write down.
QCBM ¶
QCBM(
n_qubits: int,
ansatz: Ansatz | None = None,
n_layers: int = 3,
backend: BackendLike = None,
shots: int | None = None,
seed: int | None = None,
)
Quantum circuit Born machine.
The circuit is the distribution: measuring it samples |<x|psi>|^2. There is
no likelihood to maximise, so training minimises MMD between its samples and the
data — a distance you can compute from samples alone.
Source code in src/qmlkit/generative.py
sample ¶
sample(
n_samples: int = 512,
params: ArrayLike | None = None,
seed: int | None = None,
) -> NDArray[Any]
Draw bitstrings as a (n_samples, n_qubits) array of 0/1.
Source code in src/qmlkit/generative.py
fit ¶
fit(
data: NDArray[Any],
n_iterations: int = 100,
gamma: float | Sequence[float] = (0.25, 1.0, 4.0),
n_samples: int = 512,
seed: int | None = None,
callback: Callable[[int, NDArray[Any], float], None]
| None = None,
) -> QCBM
Train by minimising MMD against data, using SPSA.
SPSA because the loss is a sample statistic: two circuit evaluations per step whatever the parameter count, and it tolerates the sampling noise.
Source code in src/qmlkit/generative.py
score ¶
score(
data: NDArray[Any],
gamma: float | Sequence[float] = (0.25, 1.0, 4.0),
n_samples: int = 1024,
seed: int | None = None,
) -> float
MMD² against the data — zero means the distributions match.
This is a sampled estimate, so pass seed if you need it reproducible;
without one it draws on the backend's own RNG. For a deterministic comparison
on a simulator use :meth:exact_distance, which needs no samples at all.
Source code in src/qmlkit/generative.py
exact_distance ¶
exact_distance(
data: NDArray[Any],
metric: str = "tv",
params: Sequence[float] | None = None,
) -> float
Distance to the target distribution with no sampling.
On a simulator the model's distribution is available exactly, so progress can be measured without shot noise — which is what makes a before/after comparison trustworthy rather than a coin flip.
Source code in src/qmlkit/generative.py
QGAN ¶
QGAN(
generator: QCBM,
discriminator: Callable[[NDArray[Any]], NDArray[Any]],
seed: int | None = None,
)
Quantum generator, classical discriminator.
The generator is a Born machine; the discriminator is any callable scoring a
batch as real. They are trained against each other, and at equilibrium the
discriminator should be at chance — which is what :meth:equilibrium_gap reports.
Source code in src/qmlkit/generative.py
generator_loss ¶
Generator wants the discriminator to call its samples real.
Source code in src/qmlkit/generative.py
fit_generator ¶
fit_generator(
n_iterations: int = 50,
n_samples: int = 256,
seed: int | None = None,
) -> QGAN
Train the generator against a fixed discriminator.
Source code in src/qmlkit/generative.py
equilibrium_gap ¶
|accuracy - 0.5| — zero when the discriminator is guessing.
Source code in src/qmlkit/generative.py
QuantumBoltzmannMachine ¶
QuantumBoltzmannMachine(
n_visible: int,
n_hidden: int = 0,
gamma: float = 0.7,
beta: float = 1.0,
seed: int | None = None,
edges: Sequence[tuple[int, int]] | None = None,
pattern: str = "chain",
)
A transverse-field Ising model as a generative model.
The classical part is diagonal (Z fields and ZZ couplings); the transverse
field Gamma * X is off-diagonal, which is what makes it quantum — and what
makes the log-likelihood gradient intractable, since the model term no longer has
a spins-in-number-out form. Training therefore optimises a lower bound, and
grad here is the bound's clamped - model difference.
Source code in src/qmlkit/generative.py
QuantumHopfield ¶
Associative memory: store patterns, recall the nearest by state overlap.
Recall is a fidelity comparison against each stored pattern — the same overlap a swap test estimates, which is why this belongs beside the kernel methods.
Source code in src/qmlkit/generative.py
store ¶
store(
patterns: dict[Any, Sequence[float]],
) -> QuantumHopfield
Store named patterns, normalised to unit vectors.
Source code in src/qmlkit/generative.py
overlaps ¶
|<pattern|cue>|^2 for every stored pattern.
Source code in src/qmlkit/generative.py
recall ¶
swap_probability
staticmethod
¶
gaussian_kernel ¶
exp(-gamma |a - b|^2) over all pairs.
Source code in src/qmlkit/generative.py
mmd_squared ¶
Maximum mean discrepancy between two sample sets.
Zero exactly when the distributions match. A sample statistic — it never needs
p(x), which is why an implicit model can be trained on it at all. Passing
several gamma values averages kernels of different widths, which stops the
loss going blind at one scale.
Source code in src/qmlkit/generative.py
kl_divergence ¶
KL(p || q) over two discrete distributions.
Source code in src/qmlkit/generative.py
total_variation ¶
0.5 * sum |p - q| — bounded in [0, 1], unlike KL.
Source code in src/qmlkit/generative.py
boltzmann ¶
(p, Z) for p(x) = exp(-beta E(x)) / Z.
Source code in src/qmlkit/generative.py
partition_function ¶
Z = sum exp(-beta E) — the sum over 2^n states that makes sampling hard.
ising_energy ¶
ising_energy(
spins: Sequence[int],
fields: NDArray[Any],
couplings: dict[tuple[int, int], float],
) -> float
-sum b_i s_i - sum w_ij s_i s_j for spins in {+1, -1}.
Source code in src/qmlkit/generative.py
qmlkit.shadows¶
Classical shadows: many observables from few measurements.
shadows ¶
Classical shadows — estimate many observables from few measurements.
Huang, Kueng & Preskill (2020). Measure in a randomly chosen basis each shot, invert
the resulting depolarising channel, and the collection of snapshots predicts
:math:M observables to fixed accuracy from :math:O(\log M) measurements — rather
than measuring each one separately.
On an exact simulator this buys nothing: shots=None returns every observable
exactly. It earns its place because measurement cost is what binds on hardware,
and because it makes that cost visible: :func:shadow_shot_cost against
:func:qmlkit.kernels.kernel_shot_cost is the comparison worth looking at before
committing to a device run.
shadow = ClassicalShadow(spec, n_snapshots=2000, seed=0)
shadow.expectation(qk.Z(0) + 0.5 * qk.ZZ(0, 2))
ClassicalShadow ¶
ClassicalShadow(
spec: CircuitSpec,
n_snapshots: int = 1000,
seed: int | None = None,
backend: BackendLike = None,
)
A set of randomised single-qubit measurements, and what they predict.
Each snapshot picks a random Pauli basis per qubit, measures once, and stores
(basis, outcome). An observable's estimate then averages over only the
snapshots whose bases happen to match its support — which is why the cost grows
with the observable's locality, not with how many observables you ask for.
Source code in src/qmlkit/shadows.py
expectation ¶
Estimate <O> from the stored snapshots.
shadow_shot_cost ¶
Snapshots for :math:M observables of given locality to accuracy epsilon.
The headline scaling: :math:O(3^k \log M / \epsilon^2) — logarithmic in how
many observables you want, exponential only in their locality. Measuring each one
separately is instead linear in M, which is the trade this whole method makes.
Source code in src/qmlkit/shadows.py
qmlkit.utils.shots¶
shots ¶
Shot-budget arithmetic.
Simulator-only means shots are opt-in, not mandatory. When they are on, every sampled number should be reportable with its uncertainty — that is what makes "would this survive on a real device?" an answerable question rather than a guess.
variance ¶
standard_error ¶
Standard error of a single Pauli term's expectation over shots samples.
scale is the term's coefficient: (cP)^2 = c^2 I, so the variance is
c^2 - z^2 and the error scales with |c|.
This formula is only correct for one term. A sum needs <O^2>, which is a
different measurement and not recoverable from <O> — feeding a sum in here
gives an error bar that is too tight, too loose, or exactly zero once |<O>|
reaches |c|. :func:~qmlkit.core.execute.expectation checks the observable
before calling this.
Source code in src/qmlkit/utils/shots.py
shots_for_precision ¶
Shots needed to reach standard error eps — the 1/eps**2 price.
p0_from_z ¶
z_from_p0 ¶
runtime_estimate ¶
Wall-clock seconds for a shot budget at a given sampling rate.