# qmlkit > A quantum machine learning library built to refuse a plausible wrong number. In > this field a mistake usually does not raise — a collapsed re-uploading model, a > concentrated kernel and a lead inside the fold spread all run and all return > numbers in the right range — so `diagnose`, `baseline`, `plan`, `selfcheck` and > `progress` are the point of the library rather than an add-on. > > Underneath: reusable feature maps, a composable ansatz vocabulary, quantum > kernels, PyTorch layers, and one `grad()` that points at any circuit and > observable. The same circuit runs on the built-in NumPy reference, SpinQit, > Qiskit, Cirq or Torch, and on two mixed-state backends under noise. Install with `pip install qmlkit`. The core depends on NumPy and nothing else; every SDK is an optional extra (`qmlkit[torch]`, `[qiskit]`, `[cirq]`, `[spinqit]`, `[sklearn]`). What is worth knowing before writing any of it: - **Simulator-only for the whole 0.x line.** No hardware. Expectations and gradients are exact unless you pass `shots=N`, and `adjoint` is therefore the default gradient method — parameter-shift costs `2P` circuit evaluations for the same answer, so it is the teaching subject and the test-suite reference, not the performance default. - **Three layers, and nothing at a higher one hides a lower one.** `VQC(...).fit(X, y)` for a ready-made model; `QuantumLayer(...)` as an `nn.Module` inside any `nn.Sequential`; or circuits and `grad()` directly. Drop a level without giving up what the level above was doing. - **Estimators are scikit-learn clonable** and models are `nn.Module`s, so `Pipeline`, `GridSearchCV`, `cross_val_score` and ordinary torch training loops all work. - **Run `qk.diagnose(model)` before trusting a result.** In this field a mistake usually returns a plausible number rather than raising: a re-uploading model whose trainable block commutes with its encoding reaches one Fourier frequency instead of the `L` it was designed for, and it trains and converges anyway. `diagnose` returns findings with a code, what was measured, and the edit that fixes it. - **The names are qmlkit's, not PennyLane's or Qiskit's**, and a wrong one tells you the right one: `qk.AngleEmbedding` reports that it is `qk.AngleFeatureMap` here. Unknown registry names suggest the nearest valid one, so guessing is cheap. - **Every extension point is a registry:** `register_ansatz`, `register_gate`, `register_gradient`, `register_backend`, `register_conv_filter`. Registering makes your thing a first-class citizen everywhere that kind of argument is taken. - **Data re-uploading is a pattern, not a class.** It is `EncodingLayer` composed into the block vocabulary, with `reupload()` as a convenience over it. ============================================================================== # Home (source: docs/index.md) ============================================================================== # qmlkit A quantum machine learning library built to **refuse a plausible wrong number**. What makes this field difficult is not writing the circuit. It is that a mistake here usually does not raise: a re-uploading model whose trainable rotations commute with its encoding trains happily and reaches one Fourier frequency instead of eight; a kernel concentrates until every pair of points looks alike and still returns a Gram matrix; a quantum model beats its classical baseline by less than the spread between folds and gets written up as a result. All three run. All three return numbers in the right range. All three are wrong. So the tools that catch those are not an afterthought — they are the library: ```python # docs: requires torch import qmlkit as qk print(qk.plan(qk.hardware_efficient(3, 2))) # what the run will cost, first print(qk.diagnose(qk.hardware_efficient(3, 2))) # what does not raise ``` [`diagnose`](guides/agents.md) names the failure and the edit that fixes it, [`baseline`](guides/evaluation.md) puts the classical bar on identical folds and refuses to call a lead inside the fold spread a result, [`progress`](guides/watching-a-run.md) says how much of a run is left, and `selfcheck` compares every exact gradient route against every other. The same instinct runs through the rest: `adjoint` [refuses on a noisy backend](guides/noise.md) rather than quietly differentiating a noiseless one, and an unknown gate is refused by name rather than approximated. ## Why one `diagnose` can read any model Because **a circuit is data, not a backend object**. That one decision is what makes the layer above possible: `diagnose` walks `spec.ops`, so it works on any ansatz, on any backend, whether or not the library has ever seen the gates in it. The same structure serves a single gradient implementation across every backend, lets inventing an ansatz take one line and inherit correct gradients for free, and makes weight tying expressible at all — a parameter that fills several slots, differentiated one occurrence at a time, which is the thing a shift rule gets silently wrong when circuits are opaque objects. ```python import numpy as np import qmlkit as qk ansatz = qk.hardware_efficient(3, n_layers=2) theta = ansatz.init(seed=0) spec = ansatz.build() observable = qk.Z(0) + 0.5 * qk.ZZ(0, 2) print(f" = {qk.expval(spec, observable, theta=theta):+.6f}") print(f"gradient = {np.round(qk.grad(spec, theta, observable)[:4], 4)} ...") print(f"cost = 1 pass (adjoint) vs {qk.gradient_cost(spec, 'parameter-shift')} circuits (parameter-shift)") ``` ## Where to start
- :material-play: **[Tutorials](tutorials/index.md)** Eight pages, start to finish. Every snippet is executed by the test suite, so none of them can quietly stop working. - :material-book-open-variant: **[Guides](guides/index.md)** Why parameter-shift is subtler than it looks, which gradient to reach for, and how to add your own gate, ansatz, backend or estimator. - :material-api: **[Reference](reference/index.md)** Generated from the docstrings, so it cannot drift from the code. - :material-check-decagram: **[Validation](about/validation.md)** 301 cross-validation cases against PennyLane, and the four genuine convention differences that surfaced. - :material-book-open-page-variant: **[Case studies](studies/index.md)** Eight whole problems, raw data to defensible number. In most of them the number is that the quantum model lost.
## What is actually here | | | |---|---| | **Backends** | NumPy (reference), SpinQit, Qiskit, Cirq, Torch — one circuit, one answer, checked against each other | | **Gradients** | adjoint, backprop, Hadamard-test, parameter-shift, SPSA, finite differences, behind one `grad()` | | **Encoding** | angle, amplitude, basis, Hamiltonian, and Pauli feature maps, with input gradients | | **Ansätze** | a composable block vocabulary plus ten templates written in it | | **Kernels** | three overlap estimators, PSD repair, `QSVC`/`QSVR`, trainable and projected kernels | | **PyTorch** | `QuantumLayer`, `VQC`, `VQRegressor`, QCNN/QLSTM/MPS, dressed networks | | **Analysis** | expressibility, Meyer–Wallach entanglement, barren-plateau scans, Fourier spectra, Fubini–Study geometry | | **Noise** | `cirq-density` and `qiskit-aer` evolve a density matrix; shot noise stays separable from decoherence, and state-based gradients refuse | | **Interop** | `from_qasm`, `from_qiskit`, `from_pennylane`, `from_cirq` — circuits come back in as well as out | | **Honesty** | `diagnose`, `selfcheck`, `baseline`, `evaluate`, `plan`, `fingerprint` — the layer this library is actually for | ## Three layers, and you pick where to stand Nothing at a higher layer hides a lower one. ```python # docs: requires torch import numpy as np import qmlkit as qk rng = np.random.default_rng(0) X = rng.normal(size=(40, 4)) y = (X[:, 0] * X[:, 1] > 0).astype(int) # 1. a ready-made model model = qk.VQC(n_features=4, n_classes=2, seed=0).fit(X, y, epochs=5) # 2. a torch layer, in any nn.Module you like layer = qk.QuantumLayer(qk.ZZFeatureMap(3), qk.hardware_efficient(3, 2), [qk.Z(0)]) # 3. circuits and gradients directly g = qk.grad(qk.hardware_efficient(3, 2).build(), qk.hardware_efficient(3, 2).init(seed=0)) ``` ## Scope **Simulator-only for the whole 0.x line.** That is a design constraint, not a missing feature: it makes `adjoint` the right default gradient, makes shot noise opt-in rather than unavoidable, and keeps the focus on properties of the *model* — trainability, expressibility, concentration — rather than properties of a device. Parameter-shift is still first-class, because it is the rule that stays valid when you do move to hardware. Apache-2.0. [Source on GitHub](https://github.com/Ziadt160/qmlkit). ============================================================================== # Install (source: docs/install.md) ============================================================================== # Install ```bash pip install qmlkit ``` That is the whole install. qmlkit depends on **NumPy and nothing else** — the NumPy backend is the reference implementation, and every core feature works with no optional package present. Python 3.10 or newer. 3.9 reached end of life in October 2025, and 3.10 is also the highest version SpinQit supports, so it is the overlap that matters. ## Extras Each extra adds one capability. None of them is required, and `import qmlkit` never imports any of them — a missing SDK produces an install command, not a traceback. === "PyTorch" ```bash pip install "qmlkit[torch]" ``` Unlocks `QuantumLayer`, `VQC`, `VQRegressor`, the structured architectures, and `method="backprop"`. === "Qiskit" ```bash pip install "qmlkit[qiskit]" ``` Adds the Qiskit backend and `to_qiskit()`, so a circuit can be handed to Qiskit's own transpiler and visualisation. === "Cirq" ```bash pip install "qmlkit[cirq]" ``` === "SpinQit" ```bash pip install "qmlkit[spinqit]" ``` SpinQit ships wheels for Python 3.8–3.10 only and pins `numpy<2`, so the extra is gated behind an environment marker: on 3.11+ it resolves cleanly to nothing rather than failing. Use a dedicated 3.10 environment for it. === "Everything" ```bash pip install "qmlkit[torch,qiskit,cirq,sklearn,viz]" ``` `sklearn` enables `QSVC`/`QSVR` (which wrap scikit-learn's precomputed-kernel solver) and `viz` enables matplotlib plotting helpers. ## Check what you have ```python import qmlkit as qk print(qk.__version__) print(qk.available_backends()) print(qk.backend_report()) ``` `backend_report()` prints one line per backend with an install command for the ones you are missing: ```text qmlkit backends: [ok] numpy [ok] torch [missing] cirq -> pip install 'qmlkit[cirq]' [missing] qiskit -> pip install 'qmlkit[qiskit]' [missing] spinqit -> pip install 'qmlkit[spinqit]' ``` Set the default with the `QMLKIT_BACKEND` environment variable, or in code: ```python import qmlkit as qk qk.set_default_backend("numpy") print(qk.default_backend().name) ``` ## From source ```bash git clone https://github.com/Ziadt160/qmlkit cd qmlkit pip install -e ".[dev]" pytest ``` ## Verifying an install you did not build If you want to confirm a release is intact — that nothing is missing from the wheel and no optional dependency is secretly required — the repository ships the check it runs in CI: ```bash python -m venv /tmp/clean && /tmp/clean/bin/pip install qmlkit && /tmp/clean/bin/python scripts/verify_install.py ``` ============================================================================== # Tutorials / Tutorials (source: docs/tutorials/index.md) ============================================================================== # Tutorials Eight pages, in order. Each one builds on the last, and each is short enough to read in a sitting. **Every Python snippet on these pages is executed by the test suite.** They are not illustrations of the API — they are tests of it, so a rename that breaks a tutorial breaks the build. The outputs shown were produced by running the code, not written by hand.
- **[1. Circuits are data](01-first-circuit.md)** Build, draw, run, measure. The `CircuitSpec`/slot idea that everything else depends on, and why shots come with an error bar. - **[2. Getting data in](02-encoding-data.md)** Angle, basis, amplitude and Pauli feature maps — the choice that fixes what your model can represent, before training starts. - **[3. Gradients](03-gradients.md)** Six methods, one function. Plus the two ways a hand-rolled parameter-shift returns a smooth, plausible, wrong answer. - **[4. Designing an ansatz](04-ansatz-design.md)** A block vocabulary where a new ansatz is one line and inherits gradients, resource counting and a torch layer for free. - **[5. Training with PyTorch](05-training-torch.md)** `VQC` in two lines, `QuantumLayer` in any `nn.Module`, and the input gradient that decides whether your classical pre-net trains at all. - **[6. Quantum kernels](06-quantum-kernels.md)** No variational training, a convex solver — and a quadratic circuit cost plus exponential concentration waiting for you. - **[7. Re-uploading and Fourier](07-reuploading.md)** Why depth buys frequencies, measured rather than asserted, and the commuting block that silently collapses the whole model. - **[8. Trainability](08-trainability.md)** Barren plateaus, cost locality, and three optimisers that exploit structure a general-purpose one cannot see.
## Running them yourself Everything except tutorials 5 and 6 needs only a bare install: ```bash pip install qmlkit ``` Tutorial 5 needs `qmlkit[torch]`, and the classification section of tutorial 6 needs `qmlkit[sklearn]`. Both pages say so where it matters. If you would rather have one script than eight pages, the repository ships `examples/quickstart.py`, which walks the same ground end to end. ============================================================================== # Tutorials / 1. Circuits are data (source: docs/tutorials/01-first-circuit.md) ============================================================================== # 1. Circuits are data Most quantum SDKs give you a circuit *object* that belongs to a particular simulator. qmlkit gives you a `CircuitSpec`: an immutable description of operations and parameter slots that belongs to nobody. Backends compile it; gradients, resource counting and drawing all read it. That is not a stylistic preference. It is the reason one gradient implementation serves five backends, and the reason the later tutorials work at all. ## Build one ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() print(qk.draw(bell)) ``` ```text q0: ─H──@── q1: ────X── ``` `QCircuit` is a builder — mutable, chainable, convenient. `to_spec()` freezes it into the immutable thing everything else consumes. ## Run it three ways Exactly, as a statevector: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() print(qk.statevector(bell)) print(qk.probabilities(bell)) ``` ```text [0.70710678+0.j 0. +0.j 0. +0.j 0.70710678+0.j] [0.5 0. 0. 0.5] ``` Or by sampling, which is opt-in: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() print(qk.run_counts(bell, shots=1000, seed=0)) ``` ```text {'00': 521, '11': 479} ``` !!! note "Qubit 0 is the most significant bit" `'01'` means qubit 0 is `|0⟩` and qubit 1 is `|1⟩`. This is big-endian, matching SpinQit and PennyLane. Qiskit is little-endian, and the Qiskit backend handles the reversal at build time so indices mean the same thing everywhere — see [Backends and conventions](../guides/backends.md). ## Inspect it A spec knows its own cost, because it is just data: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() print(bell) print(f"qubits {bell.n_qubits} · depth {bell.depth()} · gates {bell.gate_counts()}") ``` ```text CircuitSpec(n_qubits=2, n_ops=2, n_params=0, depth=2) qubits 2 · depth 2 · gates {'cx': 1, 'h': 1} ``` ## Parameters, and the slot idea A parameter is a *reference*, not a number. `ParamRef(0)` means "whatever ends up at index 0 of the parameter vector". ```python import numpy as np import qmlkit as qk qc = qk.QCircuit(1) qc.ry(0, qk.ParamRef(0)) spec = qc.to_spec() print(f"parameters: {spec.n_params}") print(f" at θ=0.7: {qk.expval(spec, qk.Z(0), theta=[0.7]):.15f}") print(f"cos(0.7): {np.cos(0.7):.15f}") ``` ```text parameters: 1 at θ=0.7: 0.764842187284488 cos(0.7): 0.764842187284489 ``` The piece that matters later is the **slot**. A slot is one angle site — one (operation, parameter position) pair. Several slots may point at the *same* logical parameter, which is how weight tying is expressed: ```python import qmlkit as qk a = qk.Ansatz(1, qk.share(3, qk.RotationLayer("ry"))) spec = a.build() print(f"logical parameters: {spec.n_params}") print(f"slots: {len(spec.slots())}") print(f"occurrences of θ0: {len(spec.occurrences_of(0))}") ``` ```text logical parameters: 1 slots: 3 occurrences of θ0: 3 ``` Three `Ry(θ₀)` on one qubit is `Ry(3θ₀)`, so the derivative is three times what a single rotation would give. Keeping slots and parameters distinct is what lets the gradient code get that right — [tutorial 3](03-gradients.md) shows what happens when a library conflates them. ## Observables Observables are Pauli sums, built with `+` and `*`: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() observable = qk.Z(0) + 0.5 * qk.ZZ(0, 1) + 0.3 * qk.X(1) print(observable) print(f" = {qk.expval(bell, qk.Z(0)):.6f}") print(f" = {qk.expval(bell, qk.ZZ(0, 1)):.6f}") print(f" = {qk.expval(bell, observable):.6f}") ``` ```text Z0 + 0.5*Z0 Z1 + 0.3*X1 = 0.000000 = 1.000000 = 0.500000 ``` ` = 1` for a Bell state — the qubits always agree — while ` = 0`, because each on its own is a fair coin. That is entanglement in two numbers. ## Shots are opt-in, and come with an error bar `shots=None` is the default and returns the exact value. When you do sample, ask for the standard error too — a sampled number without one is not a measurement, it is a guess: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) bell = qc.to_spec() for shots in (100, 10_000, 1_000_000): value, err = qk.expectation(bell, qk.Z(0), shots=shots, seed=0, return_std=True) print(f"{shots:>9,} shots: {value:+.5f} ± {err:.5f}") print(f"exact: {qk.expval(bell, qk.Z(0)):+.5f}") print(f"shots for ±0.01: {qk.shots_for_precision(0.01):,}") ``` ```text 100 shots: +0.02000 ± 0.09998 10,000 shots: +0.01240 ± 0.01000 1,000,000 shots: +0.00126 ± 0.00100 exact: +0.00000 shots for ±0.01: 10,000 ``` The error falls as `1/√N`: a hundred times more shots buys ten times the precision. That is the whole economics of measurement, and it is why the [gradient tutorial](03-gradients.md) cares so much about how many circuits a method needs. ## Composing Specs compose and invert, which is all a fidelity kernel really needs: ```python import qmlkit as qk first = qk.QCircuit(1) first.ry(0, 0.4) second = qk.QCircuit(1) second.rz(0, 1.1) both = first.to_spec().compose(second.to_spec()) identity = both.compose(both.adjoint()) print(qk.draw(both)) print(f"U U† is the identity: {qk.expval(identity, qk.Z(0)):.12f}") ``` ```text q0: ─RY(0.40)──RZ(1.10)── U U† is the identity: 1.000000000000 ``` --- **Next:** [Getting data in](02-encoding-data.md) — the choice that decides what your model can represent, before any training happens. ============================================================================== # Tutorials / 2. Getting data in (source: docs/tutorials/02-encoding-data.md) ============================================================================== # 2. Getting data in A quantum model can only learn functions its encoding can express. That makes this the most consequential choice in the pipeline, and the one most often made by accident — before any training happens, the encoding has already fixed the hypothesis class. ## Angle encoding: one number per qubit The default, and usually the right first move. ```python import qmlkit as qk spec = qk.angle_encode([0.5, 1.2, 2.0]) print(qk.draw(spec)) ``` ```text q0: ─RY(0.50)── q1: ─RY(1.20)── q2: ─RY(2.00)── ``` `n` features cost `n` qubits and depth 1. Cheap, shallow, and the amplitudes are smooth in the data — which is what makes gradients well behaved. ## Basis encoding: bits to qubits ```python import qmlkit as qk print(qk.run_counts(qk.basis_encode([1, 0, 1]), 256, seed=0)) ``` ```text {'101': 256} ``` Exact, trivial, and no superposition — a single computational basis state. Useful for combinatorial problems, useless as a feature map for continuous data. ## Amplitude encoding: `2ⁿ` numbers in `n` qubits Exponentially compact in qubits, and you pay for it in gates. ```python import numpy as np import qmlkit as qk vector = [1, 2, 3, 4] spec = qk.amplitude_encode(vector) print(qk.draw(spec)) print("prepared:", np.round(np.abs(qk.statevector(spec)), 4)) print("target: ", np.round(np.abs(np.array(vector) / np.linalg.norm(vector)), 4)) ``` ```text q0: ─RY(2.30)──@────────────@── q1: ─RY(2.03)──X──RY(0.18)──X── prepared: [0.1826 0.3651 0.5477 0.7303] target: [0.1826 0.3651 0.5477 0.7303] ``` qmlkit builds this from **uniformly-controlled rotations**, not a backend state-preparation primitive. The circuit is made of ordinary registered gates, so it runs identically on every backend, can be drawn and transpiled — and its cost is visible rather than hidden inside an SDK call: ```python import numpy as np import qmlkit as qk for n_qubits in (2, 4, 8): spec = qk.amplitude_encode(np.arange(1, 2 ** n_qubits + 1)) gates = sum(spec.gate_counts().values()) print(f"{2 ** n_qubits:>4} numbers -> {n_qubits} qubits, {gates:>4} gates, depth {spec.depth()}") ``` ```text 4 numbers -> 2 qubits, 5 gates, depth 4 16 numbers -> 4 qubits, 37 gates, depth 34 256 numbers -> 8 qubits, 749 gates, depth 742 ``` The qubit count grows logarithmically and the gate count grows linearly in the data size. "Exponentially compact" is true and, on its own, misleading. !!! warning "One global phase is dropped" The phase cascade reproduces every *relative* phase exactly and drops one overall factor, which is unobservable — until you put the block inside a larger **controlled** circuit, where it stops being global. Pass `check=True` to re-simulate and assert the prepared state is right. ## Pauli feature maps: the ones designed to be hard to simulate `ZFeatureMap` is a product of single-qubit rotations, so it factorises and a classical kernel can reproduce it. `ZZFeatureMap` adds entangling terms, and that is the point. ```python import qmlkit as qk print(qk.draw(qk.ZZFeatureMap(2, reps=1).build([0.4, 1.3]))) ``` ```text q0: ─H──RZ(0.80)──@─────────────@── q1: ─H──RZ(2.60)──X──RZ(10.10)──X── ``` The `Rz(10.10)` is the two-body term: the default data map sends a pair `(x₀, x₁)` to `(π − x₀)(π − x₁)`, and the emitted angle is twice that. That factor of two is a convention — PennyLane's `IQPEmbedding` uses the other one, which is documented in [Validation](../about/validation.md). ```python import qmlkit as qk for name, fmap in [ ("ZFeatureMap(3)", qk.ZFeatureMap(3)), ("ZZFeatureMap(3, reps=2)", qk.ZZFeatureMap(3, reps=2)), ("AngleFeatureMap(3)", qk.AngleFeatureMap(3)), ]: r = fmap.resources() print(f"{name:<26} depth {r['depth']:>3} 1q {r['n_1q']:>3} 2q {r['n_2q']:>3}") ``` ```text ZFeatureMap(3) depth 4 1q 12 2q 0 ZZFeatureMap(3, reps=2) depth 16 1q 16 2q 8 AngleFeatureMap(3) depth 3 1q 3 2q 2 ``` ## Scale before you encode An angle is periodic. Feed it raw features spanning `[0, 300]` and distinct inputs collapse onto the same rotation — the model cannot tell them apart, and no amount of training fixes it. ```python import numpy as np import qmlkit as qk X = np.array([[0.0, 100.0], [50.0, 200.0], [100.0, 300.0]]) scaler = qk.AngleScaler().fit(X) print(np.round(scaler.transform(X), 4)) ``` ```text [[0. 0. ] [3.1416 3.1416] [6.2832 6.2832]] ``` When you have more features than qubits, reduce first — `PCAReducer` is a plain SVD with no scikit-learn dependency, and `reduce_to_qubits` wires the two together. ## Gradients flow through the encoding too This is what makes a classical layer placed *before* the circuit trainable. Ask for it with `trainable=True`: ```python import numpy as np import qmlkit as qk x = np.array([0.3, 0.9]) spec = qk.angle_encode(x, trainable=True) print("df/dx =", np.round(qk.grad(spec, x, qk.Z(0) + qk.Z(1)), 6)) print("analytic =", np.round([-np.sin(0.3), -np.sin(0.9)], 6)) ``` ```text df/dx = [-0.29552 -0.783327] analytic = [-0.29552 -0.783327] ``` Without this, a hybrid network silently freezes everything upstream of the circuit: the loss still falls, because the quantum weights still train, so the bug looks like slow convergence rather than a broken model. [Tutorial 5](05-training-torch.md) returns to this. ## Choosing | | Qubits | Depth | Use when | |---|---|---|---| | **Basis** | `n` bits | 1 | data is already binary | | **Angle** | `n` features | 1 | the default — cheap, smooth, well-conditioned | | **Amplitude** | `log₂ n` | `O(n)` | qubits are scarce and depth is not | | **ZZ / Pauli** | `n` features | `O(reps·n)` | you want a kernel that is hard to reproduce classically | --- **Next:** [Gradients](03-gradients.md) — six ways to get the same number, and two ways to get a plausible wrong one. ============================================================================== # Tutorials / 3. Gradients (source: docs/tutorials/03-gradients.md) ============================================================================== # 3. Gradients Six methods, one function. They differ in cost, in whether they are exact, and in whether they could run on real hardware — not in the answer. ```python import numpy as np import qmlkit as qk ansatz = qk.hardware_efficient(3, n_layers=2) spec, theta = ansatz.build(), ansatz.init(seed=0) obs = qk.Z(0) + 0.5 * qk.ZZ(0, 2) reference = qk.grad(spec, theta, obs, method="adjoint") for method in qk.list_gradient_methods(): kwargs = {"seed": 0, "n_avg": 50} if method == "spsa" else {} try: g = qk.grad(spec, theta, obs, method=method, **kwargs) except qk.BackendNotAvailable as exc: # every method is registered, but backprop needs the torch extra print(f"{method:<17}{'-':>4} {exc.args[0].splitlines()[0]}") continue cost = qk.gradient_cost(spec, method) print(f"{method:<17}{str(cost):>4} circuits max error {np.abs(g - reference).max():.2e}") ``` ```text adjoint 1 circuits max error 0.00e+00 backprop 1 circuits max error 6.94e-17 finite-diff 24 circuits max error 5.14e-10 hadamard 12 circuits max error 1.11e-16 parameter-shift 24 circuits max error 7.67e-16 spsa 2 circuits max error 4.29e-02 ``` Every method is registered whether or not its extra is installed, so `list_gradient_methods()` always lists all six and `backprop` explains itself if PyTorch is missing rather than raising `ModuleNotFoundError`. `qk.grad(spec, theta, obs)` with no `method` picks for you: adjoint when every gate has a closed-form derivative and the backend can produce a statevector, parameter-shift otherwise. Asking for `shots` forces parameter-shift, because sampling rules out reading the statevector by definition. Which to reach for is its own page: [Choosing a gradient method](../guides/choosing-a-gradient.md). ## Two ways to get a plausible wrong number Parameter-shift is the method everyone implements themselves, and there are two places where a natural implementation returns a smooth, believable, wrong answer. Neither raises. Both are why this library derives shift rules instead of transcribing one. ### The rule is per gate, not per library The famous two-term rule `[E(θ+π/2) − E(θ−π/2)] / 2` is correct for a gate whose generator has a single frequency. It is *not* correct for a controlled rotation. ```python import qmlkit as qk for gate in ("ry", "rz", "crz", "phase"): rule = qk.rule_for_gate(gate) print(f"{gate:<7} frequencies {qk.get_gate(gate).frequencies} -> {len(rule.shifts)}-term rule") ``` ```text ry frequencies (1.0,) -> 2-term rule rz frequencies (1.0,) -> 2-term rule crz frequencies (0.5, 1.0) -> 4-term rule phase frequencies (1.0,) -> 2-term rule ``` Here is a circuit where it bites. `H⊗H` then `CRZ(θ)`, measuring `X₀X₁`, gives exactly `cos(θ/2)` — pure frequency ½: ```python import numpy as np import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).h(1).crz(0, 1, qk.ParamRef(0)) spec = qc.to_spec() XX = qk.PauliString(((0, "X"), (1, "X")), 1.0) theta = 1.1 correct = qk.grad(spec, np.array([theta]), XX, method="parameter-shift")[0] naive = ( qk.expval(spec, XX, theta=[theta + np.pi / 2]) - qk.expval(spec, XX, theta=[theta - np.pi / 2]) ) / 2 print(f"E(θ) = cos(θ/2), checked: {qk.expval(spec, XX, theta=[theta]):.10f} vs {np.cos(theta / 2):.10f}") print(f"correct (4-term) {correct:+.10f} = -sin(θ/2)/2") print(f"naive (2-term) {naive:+.10f} = -sin(θ/2)/√2") print(f"ratio {naive / correct:.10f} (that is √2)") ``` ```text E(θ) = cos(θ/2), checked: 0.8525245221 vs 0.8525245221 correct (4-term) -0.2613436145 = -sin(θ/2)/2 naive (2-term) -0.3695956840 = -sin(θ/2)/√2 ratio 1.4142135624 (that is √2) ``` The naive answer is exactly **√2 times** the right one. Same sign, same shape, so training still descends — with a step size that is silently wrong by 41%. Nothing about the loss curve would tell you. !!! note "Why measuring the target qubit hides it" Swap `X₀X₁` for `Z₁` and both rules agree perfectly, because `Z` commutes with `CRZ` and the expectation does not depend on `θ` at all. A test built on that circuit passes against a broken implementation. This is not hypothetical — an early version of this library's own test suite made exactly that mistake. ### Tied parameters shift one at a time When one logical parameter fills several slots, its derivative is the **sum** over occurrences. Shifting them all together computes something else entirely: ```python import numpy as np import qmlkit as qk ansatz = qk.Ansatz(1, qk.share(3, qk.RotationLayer("ry"))) spec, theta = ansatz.build(), np.array([0.4]) correct = qk.grad(spec, theta, qk.Z(0), method="parameter-shift")[0] naive = ( qk.expval(spec, qk.Z(0), theta=theta + np.pi / 2) - qk.expval(spec, qk.Z(0), theta=theta - np.pi / 2) ) / 2 print(f"three tied Ry(θ) on one qubit is Ry(3θ), so E(θ) = cos(3θ)") print(f"analytic -3·sin(1.2) = {-3 * np.sin(1.2):+.10f}") print(f"correct = {correct:+.10f}") print(f"naive (shifted as one)= {naive:+.10f}") ``` ```text three tied Ry(θ) on one qubit is Ry(3θ), so E(θ) = cos(3θ) analytic -3·sin(1.2) = -2.7961172579 correct = -2.7961172579 naive (shifted as one)= +0.9320390860 ``` Wrong by a factor of −3 here, and it is the case that matters for QCNNs and any convolutional ansatz, where weight tying is the whole point. ## Cost, and why adjoint is the default Parameter-shift costs `2P` circuits — more when a gate needs a four-term rule. Adjoint costs one pass regardless of `P`: ```python import qmlkit as qk for n_layers in (2, 6, 12): a = qk.hardware_efficient(5, n_layers) spec = a.build() print( f"P={a.n_params:>3} adjoint 1 pass " f"hadamard {qk.gradient_cost(spec, 'hadamard'):>4} " f"parameter-shift {qk.gradient_cost(spec, 'parameter-shift'):>4}" ) ``` ```text P= 20 adjoint 1 pass hadamard 20 parameter-shift 40 P= 60 adjoint 1 pass hadamard 60 parameter-shift 120 P=120 adjoint 1 pass hadamard 120 parameter-shift 240 ``` Measured on a 5-qubit ansatz with a two-term observable, that is **12.6 ms for adjoint against 823 ms for parameter-shift** at `P=120`. Adjoint is not more accurate — both are exact — it is just cheaper on a simulator, where reading the statevector is allowed. `hadamard` sits between them: one circuit per parameter instead of two, using an ancilla and controlled generators, and unlike adjoint it is a real measurement, so it stays valid on hardware. ## Sampling Ask for `shots` and you get parameter-shift with shot noise, which is what a device would give you: ```python import numpy as np import qmlkit as qk ansatz = qk.hardware_efficient(3, n_layers=2) spec, theta = ansatz.build(), ansatz.init(seed=0) obs = qk.Z(0) + 0.5 * qk.ZZ(0, 2) exact = qk.grad(spec, theta, obs, method="parameter-shift") sampled = qk.grad(spec, theta, obs, method="parameter-shift", shots=4096, seed=0) print(f"4096 shots per circuit: max deviation {np.abs(sampled - exact).max():.4f}") ``` ```text 4096 shots per circuit: max deviation 0.0211 ``` ## Second derivatives `hessian` differences the *exact* gradient, so only the outer derivative is approximate: ```python import numpy as np import qmlkit as qk qc = qk.QCircuit(1) qc.ry(0, qk.ParamRef(0)) h = qk.hessian(qc.to_spec(), np.array([0.8]), qk.Z(0)) print(f"hessian {h[0, 0]:+.8f}") print(f"-cos(0.8) {-np.cos(0.8):+.8f}") ``` ```text hessian -0.69670671 -cos(0.8) -0.69670671 ``` ## Bringing your own The gradient dispatcher is a registry, so a new estimator becomes a keyword everywhere the library takes `method=`: ```python import numpy as np import qmlkit as qk @qk.register_gradient("central_diff_demo") def central_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-5, **kw): out = np.zeros(spec.n_params) for k in range(spec.n_params): step = np.zeros_like(theta) step[k] = eps plus = qk.expval(spec, obs, theta=theta + step, backend=backend, shots=shots) minus = qk.expval(spec, obs, theta=theta - step, backend=backend, shots=shots) out[k] = (plus - minus) / (2 * eps) return out ansatz = qk.hardware_efficient(2, n_layers=1) spec, theta = ansatz.build(), ansatz.init(seed=0) mine = qk.grad(spec, theta, qk.Z(0), method="central_diff_demo") exact = qk.grad(spec, theta, qk.Z(0), method="adjoint") print(f"agrees with adjoint to {np.abs(mine - exact).max():.2e}") ``` --- **Next:** [Designing an ansatz](04-ansatz-design.md) — one line, and it inherits all of the above for free. ============================================================================== # Tutorials / 4. Designing an ansatz (source: docs/tutorials/04-ansatz-design.md) ============================================================================== # 4. Designing an ansatz Most libraries give you a list of templates and a note saying "or write your own", where writing your own means implementing parameter counting, gradients and resource estimation from scratch. Here an ansatz is an expression in a small vocabulary, and anything you build in it inherits correct gradients, resource counting and a PyTorch layer without opting into any of them. ## The vocabulary Four block types, composed with `+`, `repeat` and `share`. | | | |---|---| | `RotationLayer("ry", "rz")` | one rotation per qubit, per named axis | | `EntanglerLayer("cz", "ring")` | a fixed two-qubit gate over a named pattern | | `ParametricEntangler("crz", "chain")` | the same, but the entangler carries an angle | | `PoolLayer(...)` | measure-and-discard, for QCNN-shaped circuits | Patterns are `chain`, `ring`, `full`, `alternating`. ## One line ```python import qmlkit as qk brick = qk.Ansatz( 4, qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")), "brick_wall", ) print(qk.draw(brick.build())) print(brick) ``` ```text q0: ─RY(θ0)──@──RY(θ4)──────────@───── q1: ─RY(θ1)──Z────@─────RY(θ5)──Z──@── q2: ─RY(θ2)──@────Z─────RY(θ6)──@──Z── q3: ─RY(θ3)──Z──RY(θ7)──────────Z───── Ansatz('brick_wall', n_qubits=4, n_params=8) ``` Nothing declared the parameter count. It is **inferred** from a dry build, so miscounting is not a failure mode — a whole category of bug that simply cannot occur. ## It is already a first-class citizen ```python import numpy as np import qmlkit as qk brick = qk.Ansatz( 4, qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")), "brick_wall", ) spec, theta = brick.build(), brick.init(seed=0) print("gradient:", np.round(qk.grad(spec, theta, qk.Z(0)), 5)) print("resources:", brick.resources()["depth"], "depth,", brick.resources()["n_2q"], "two-qubit gates") ``` ## Measure it, do not assert about it `AnsatzReport` runs the diagnostics that actually distinguish ansätze: ```python import qmlkit as qk print(qk.metrics.AnsatzReport(qk.hardware_efficient(4, 2), n_samples=300)) ``` ```text hardware_efficient on 4 qubits parameters 16 depth 9 two-qubit gates 6 gradient circuits 32 expressibility 0.0736 (KL from Haar, lower is more expressive) entangling capability 0.7254 (Meyer-Wallach Q) gradient variance 3.096e-01 (higher = more trainable) ``` **Expressibility** is the KL divergence between the distribution of fidelities the ansatz produces and the Haar distribution — lower means closer to covering the space uniformly. **Entangling capability** is the mean Meyer–Wallach measure. Neither is "good" on its own; they trade against each other and against trainability. ## Comparing candidates ```python import qmlkit as qk brick = qk.Ansatz( 4, qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")), "brick_wall", ) print(f"{'ansatz':<24}{'params':>7}{'depth':>7}{'2q':>5}{'expr':>10}{'entang':>9}") candidates = ["hardware_efficient", "strongly_entangling", "tree_tensor_network", "mps", "qcnn"] for name in candidates: r = qk.metrics.AnsatzReport(qk.get_ansatz(name, n_qubits=4), n_samples=300).results print(f"{name:<24}{r['n_params']:>7}{r['depth']:>7}{r['n_2q']:>5}" f"{r['expressibility']:>10.4f}{r['entangling_capability']:>9.4f}") r = qk.metrics.AnsatzReport(brick, n_samples=300).results print(f"{'brick_wall (ours)':<24}{r['n_params']:>7}{r['depth']:>7}{r['n_2q']:>5}" f"{r['expressibility']:>10.4f}{r['entangling_capability']:>9.4f}") ``` ```text ansatz params depth 2q expr entang hardware_efficient 16 9 6 0.0736 0.7254 strongly_entangling 24 14 8 0.0602 0.8495 tree_tensor_network 6 4 3 0.7550 0.3581 mps 6 6 3 0.4550 0.4029 qcnn 4 8 4 0.6075 0.4259 brick_wall (ours) 8 6 6 0.4165 0.4504 ``` Read the trade honestly: `strongly_entangling` is the most expressive and the most entangling, and it costs 24 parameters and depth 14. `tree_tensor_network` is the least expressive by a wide margin and costs 6 parameters at depth 4. Expressibility is not free, and — as [tutorial 8](08-trainability.md) shows — it is not always what you want. ## Weight tying, and why it is a first-class idea `share` makes several applications of a block use the **same** parameters. That is what makes a QCNN convolutional rather than merely deep: ```python import qmlkit as qk qcnn = qk.get_ansatz("qcnn", n_qubits=8) spec = qcnn.build() print(f"logical parameters: {spec.n_params}") print(f"angle slots: {len(spec.slots())}") print(f"gradient circuits: {qk.gradient_cost(spec, 'parameter-shift')}") ``` ```text logical parameters: 6 angle slots: 22 gradient circuits: 44 ``` Six free parameters filling twenty-two slots. The gradient cost scales with the *logical* parameter count, not the slot count — which is the real advantage of a convolutional ansatz, and the reason [tutorial 3](03-gradients.md) made such a fuss about summing over occurrences. ## Initialisation matters more than it looks ```python import qmlkit as qk ansatz = qk.hardware_efficient(3, 2) for strategy in ("small", "uniform", "zeros"): theta = ansatz.init(strategy, seed=0) print(f"{strategy:<9} mean {theta.mean():+.4f} std {theta.std():.4f}") ``` ```text small mean +0.0022 std 0.0704 uniform mean +0.1695 std 2.1294 zeros mean +0.0000 std 0.0000 ``` `small` is the default and it is not arbitrary: near-identity initialisation keeps the circuit shallow in effect at the start, which is one of the few reliable defences against barren plateaus. `zeros` is worse than it looks — a symmetric starting point can leave whole parameter groups with identical gradients forever. ## Registering it ```python import qmlkit as qk @qk.register_ansatz("brick_wall_demo") def brick_wall(n_qubits, n_layers=2): return qk.Ansatz( n_qubits, qk.repeat(n_layers, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")), "brick_wall_demo", ) print(qk.get_ansatz("brick_wall_demo", n_qubits=3, n_layers=1)) ``` ```text Ansatz('brick_wall_demo', n_qubits=3, n_params=3) ``` It is now reachable by name anywhere the library takes one, including `AnsatzReport`, `QuantumLayer` and `compare_ansatze`. --- **Next:** [Training with PyTorch](05-training-torch.md) — putting the ansatz in a network that actually learns. ============================================================================== # Tutorials / 5. Training with PyTorch (source: docs/tutorials/05-training-torch.md) ============================================================================== # 5. Training with PyTorch Needs the extra: ```bash pip install "qmlkit[torch]" ``` A circuit becomes an `nn.Module` and everything torch already knows how to do — optimisers, schedulers, batching, autograd — applies unchanged. ## The two-line path ```python # docs: requires torch import numpy as np import qmlkit as qk rng = np.random.default_rng(0) X = rng.normal(size=(120, 4)) y = (X[:, 0] * X[:, 1] > 0).astype(int) # XOR-like: not linearly separable model = qk.VQC(n_features=4, n_classes=2, seed=0).fit(X, y, epochs=30) print(f"accuracy {model.score(X, y):.1%}") print(f"loss {model.history_[0]:.4f} -> {model.history_[-1]:.4f}") ``` ```text accuracy 78.3% loss 0.7836 -> 0.4640 ``` `VQC` is a convenience, not a wall — every default is one keyword away from being something else, and the feature map, ansatz, observables and optimiser are all arguments. ## The layer underneath ```python # docs: requires torch import torch from torch import nn import qmlkit as qk torch.manual_seed(0) layer = qk.QuantumLayer( qk.ZZFeatureMap(3, reps=1), qk.hardware_efficient(3, 2), [qk.Z(0), qk.Z(1)], init_seed=0, ).double() net = nn.Sequential(nn.Linear(6, 3), nn.Tanh(), layer, nn.Linear(2, 2)).double() xb = torch.randn(8, 6, dtype=torch.float64) loss = nn.CrossEntropyLoss()(net(xb), torch.randint(0, 2, (8,))) loss.backward() print(f"output shape {tuple(net(xb).shape)}") print(f"pre-net grad norm {float(net[0].weight.grad.norm()):.6f}") print(f"quantum grad norm {float(layer.theta.grad.norm()):.6f}") print(f"post-net grad norm {float(net[3].weight.grad.norm()):.6f}") ``` ```text output shape (8, 2) pre-net grad norm 0.330885 quantum grad norm 0.114621 post-net grad norm 0.012605 ``` ## The line that matters **`pre-net grad norm 0.330885`.** The `nn.Linear(6, 3)` sitting *before* the quantum layer receives a real gradient, so it trains. This is not automatic. It requires `∂f/∂x` — the derivative of the circuit with respect to its *encoding angles*, not just its weights — and many hand-rolled implementations return `None` there. When they do, everything upstream of the circuit silently freezes. The loss still falls, because the quantum weights still train, so the failure presents as slow convergence rather than as a bug. A dressed network whose classical pre-net never moves is doing far less than it appears to. qmlkit computes it by differentiating the circuit with respect to its encoding angles and finishing the chain rule classically, so a *nonlinear* feature map costs no extra circuits. ```python # docs: requires torch import torch from torch.autograd import gradcheck import qmlkit as qk layer = qk.QuantumLayer( qk.AngleFeatureMap(2, entangle=False), qk.hardware_efficient(2, 1), [qk.Z(0)], init_seed=0, ).double() x = torch.randn(1, 2, dtype=torch.float64, requires_grad=True) print("gradcheck on the inputs:", gradcheck(lambda v: layer(v), (x,), eps=1e-6, atol=1e-6)) ``` ```text gradcheck on the inputs: True ``` `torch.autograd.gradcheck` compares the analytic backward pass against numerical differentiation of the forward pass. Passing it for the *inputs* is the assertion that the pre-net gradient is real rather than merely non-`None`. ## Regression ```python # docs: requires torch import numpy as np import qmlkit as qk xs = np.linspace(-1, 1, 60).reshape(-1, 1) ys = np.sin(3 * xs).ravel() model = qk.VQRegressor(n_features=1, seed=0).fit(xs, ys, epochs=60) print(f"R² {model.score(xs, ys):.4f}") print(f"loss {model.history_[0]:.4f} -> {model.history_[-1]:.4f}") ``` ```text R² 0.9917 loss 0.6780 -> 0.0042 ``` A one-qubit re-uploading model fits `sin(3x)` because three uploads reach frequency 3 — which is exactly the claim [tutorial 7](07-reuploading.md) turns into a measurement rather than an assertion. ## Choosing the gradient method `QuantumLayer` takes `grad_method`. On a simulator, leave it alone: the default resolves to adjoint, which costs one pass regardless of the parameter count. Set it to `"parameter-shift"` when you want to see what the model would do on hardware, optionally with `shots`. ```python # docs: requires torch import qmlkit as qk hardware_like = qk.QuantumLayer( qk.AngleFeatureMap(2, entangle=False), qk.hardware_efficient(2, 1), [qk.Z(0)], grad_method="parameter-shift", shots=2048, init_seed=0, ) print(hardware_like) ``` ## Structured architectures The same layer machinery, arranged into the shapes the literature names: | | | |---|---| | `QCNNLayer` | convolution and pooling with tied weights | | `MPSLayer` | matrix-product-state contraction order | | `QLSTM` | a recurrent cell with quantum gates | | `DressedQuantumNet` | classical → quantum → classical, the transfer-learning shape | --- **Next:** [Quantum kernels](06-quantum-kernels.md) — the other way to use a feature map, with no variational training at all. ============================================================================== # Tutorials / 6. Quantum kernels (source: docs/tutorials/06-quantum-kernels.md) ============================================================================== # 6. Quantum kernels The other way to use a feature map. No variational parameters, no training loop, no barren plateaus — just an inner product between encoded states, handed to a classical solver that is convex and has a unique optimum. The price is that you pay it in circuits: `m(m−1)/2` of them for an `m`-sample training set, and that quadratic is what limits the approach long before qubit counts do. ## The kernel is an overlap ```python import numpy as np import qmlkit as qk fmap = qk.ZZFeatureMap(2, reps=2) x1, x2 = np.array([0.4, 1.3]), np.array([1.9, 0.6]) print(f"k(x, x) = {qk.fidelity_kernel(fmap, x1, x1):.10f}") print(f"k(x, x') = {qk.fidelity_kernel(fmap, x1, x2):.10f}") ``` ```text k(x, x) = 1.0000000000 k(x, x') = 0.7055431570 ``` `fidelity_kernel` is compute-uncompute: run `U(x)`, then `U(x')†`, and read the probability of measuring all zeros. That probability *is* `|⟨φ(x')|φ(x)⟩|²`. It falls straight out of `spec.adjoint()` and needs no ancilla. Three estimators, same quantity: ```python import numpy as np import qmlkit as qk fmap = qk.ZZFeatureMap(2, reps=2) x1, x2 = np.array([0.4, 1.3]), np.array([1.9, 0.6]) print(f"fidelity (compute-uncompute) {qk.fidelity_kernel(fmap, x1, x2):+.10f}") print(f"swap test {qk.swap_test_kernel(fmap, x1, x2):+.10f}") print(f"hadamard test (signed) {qk.hadamard_test(fmap, x1, x2):+.10f}") ``` ```text fidelity (compute-uncompute) +0.7055431570 swap test +0.7055431570 hadamard test (signed) -0.2029143060 ``` The Hadamard test is the odd one out on purpose: it estimates the **signed** inner product `Re⟨φ(x')|φ(x)⟩`, not its square. Use it when the sign carries information; use `fidelity_kernel` otherwise, since it needs the fewest qubits and no ancilla. As a *kernel*, `QuantumKernel(fmap, estimator="hadamard")` runs it **twice** — once for the real part, once (with an `Sdg` on the ancilla) for the imaginary one — and returns `Re² + Im²`, which is the same `|⟨φ(x')|φ(x)⟩|²` the other two estimate. One Hadamard test measures one *component* of a complex overlap, so the modulus costs two circuits, and `kernel.n_evaluations` counts both. ## A Gram matrix ```python import numpy as np import qmlkit as qk X, y = qk.datasets.ad_hoc_data(n_samples=40, n_features=2, gap=0.4, seed=0) X_train, X_test, y_train, y_test = qk.datasets.train_test_split(X, y, 0.3, seed=0) kernel = qk.QuantumKernel(qk.ZZFeatureMap(2, reps=2)) K = kernel(X_train) print(f"shape {K.shape} symmetric {np.allclose(K, K.T)} unit diagonal {np.allclose(np.diag(K), 1)}") print(f"positive semi-definite: {qk.is_psd(K)}") print(f"circuits run: {kernel.n_evaluations} (m(m-1)/2 = {len(X_train) * (len(X_train) - 1) // 2})") print(f"target alignment: {qk.target_alignment(K, y_train):+.4f}") ``` ```text shape (28, 28) symmetric True unit diagonal True positive semi-definite: True circuits run: 378 (m(m-1)/2 = 378) target alignment: +0.3360 ``` `QuantumKernel` caches symmetrically, so `k(a,b)` and `k(b,a)` share one entry and a training Gram matrix costs exactly `m(m−1)/2` circuits — not `m²`. **Target alignment** measures how well the kernel's geometry matches the labels, before fitting anything. It is the cheapest signal you have about whether a feature map suits a dataset. ## Classification ```python # docs: requires sklearn import qmlkit as qk from sklearn.svm import SVC X, y = qk.datasets.ad_hoc_data(n_samples=40, n_features=2, gap=0.4, seed=0) X_train, X_test, y_train, y_test = qk.datasets.train_test_split(X, y, 0.3, seed=0) clf = qk.QSVC(qk.ZZFeatureMap(2, reps=2)).fit(X_train, y_train) print(f"QSVC train {clf.score(X_train, y_train):.0%} test {clf.score(X_test, y_test):.0%}") for kind in ("rbf", "linear"): s = SVC(kernel=kind).fit(X_train, y_train) print(f"SVC {kind:<7} train {s.score(X_train, y_train):.0%} test {s.score(X_test, y_test):.0%}") ``` ```text QSVC train 100% test 100% SVC rbf train 68% test 67% SVC linear train 57% test 67% ``` !!! warning "Read that honestly" `ad_hoc_data` is **constructed** to be separable by a ZZ kernel and not by a classical one. It demonstrates that the machinery works; it is not evidence of quantum advantage on real data. A dataset built to favour your method is a sanity check, not a result. ## Shot noise breaks positive semi-definiteness A Gram matrix estimated from finite samples can leave the PSD cone, and an SVM solver handed a non-PSD kernel does not necessarily fail loudly — it can just return something wrong. ```python import numpy as np import qmlkit as qk X, y = qk.datasets.ad_hoc_data(n_samples=40, n_features=2, gap=0.4, seed=0) X_train, *_ = qk.datasets.train_test_split(X, y, 0.3, seed=0) K = qk.QuantumKernel(qk.ZZFeatureMap(2, reps=2), shots=512, seed=0)(X_train[:10]) print(f"sampled Gram is PSD: {qk.is_psd(K)} min eigenvalue {qk.min_eigenvalue(K):+.5f}") for name, repair in ( ("threshold", qk.threshold_matrix), ("displace", qk.displace_matrix), ("flip", qk.flip_matrix), ): R = repair(K) print(f" {name:<10} PSD {qk.is_psd(R)} min eig {qk.min_eigenvalue(R):+.5f}" f" ‖K−R‖_F {np.linalg.norm(K - R):.5f}") ``` ```text sampled Gram is PSD: False min eigenvalue -0.06497 threshold PSD True min eig +0.00000 ‖K−R‖_F 0.06497 displace PSD True min eig +0.00000 ‖K−R‖_F 0.20546 flip PSD True min eig +0.06497 ‖K−R‖_F 0.12994 ``` `threshold` clips negative eigenvalues to zero and is the closest PSD matrix in Frobenius norm — usually the right default. `displace` shifts the whole spectrum, which preserves eigenvectors but distorts more. `flip` takes absolute values, keeping the spectral magnitude at the cost of moving further. ## Concentration is the real limit As the register widens, fidelities between distinct points all collapse toward the same tiny number. The kernel stops distinguishing anything, and no amount of shots recovers it — the signal is gone, not merely noisy. ```python import numpy as np import qmlkit as qk rng = np.random.default_rng(0) def spread(M): return float(M[~np.eye(len(M), dtype=bool)].std()) for n in (2, 4, 6, 8): Xn = rng.uniform(0, np.pi, (8, n)) fmap = qk.ZZFeatureMap(n, reps=2) print(f"n={n}: fidelity spread {spread(qk.QuantumKernel(fmap)(Xn)):.5f}" f" projected {spread(qk.projected_kernel_matrix(fmap, Xn)):.5f}") ``` ```text n=2: fidelity spread 0.17154 projected 0.14718 n=4: fidelity spread 0.07615 projected 0.10260 n=6: fidelity spread 0.02117 projected 0.06558 n=8: fidelity spread 0.01654 projected 0.06393 ``` The fidelity kernel's off-diagonal spread falls by a factor of ten from 2 to 8 qubits. The **projected** kernel — which compares one-qubit reduced density matrices instead of the global overlap — falls by only about half, and by 6 qubits it carries three times more signal than the fidelity kernel does. It is not immune to concentration; it just degrades far more slowly. `concentration_report` measures this for your own feature map. ## Training the embedding You can optimise the *feature map* before fitting any classifier, by maximising target alignment: ```python import numpy as np import qmlkit as qk X, y = qk.datasets.ad_hoc_data(n_samples=24, n_features=2, gap=0.4, seed=0) def factory(w): """A ZZ map whose data scaling is the thing being learned.""" return qk.PauliFeatureMap( 2, paulis=("Z", "ZZ"), reps=2, data_map=lambda x, idx: float(np.prod([x[i] for i in idx])) * float(w[0]), ) trainable = qk.TrainableKernel(factory, n_params=1) trainable.fit(X, y, n_iterations=60, theta0=np.array([0.4]), seed=0) print(f"alignment {trainable.history_[0]:+.4f} -> {trainable.history_[-1]:+.4f}") print(f"scale 0.4 -> {trainable.params_[0]:.3f}") ``` ```text alignment +0.0460 -> +0.1406 scale 0.4 -> 0.549 ``` Three times the alignment, before any classifier is fitted. Two honest caveats: the landscape is bumpy — a scale of 1.0 scores 0.3664 on this data, so SPSA has found a local optimum, not the best one — and `fit` returns the **final** iterate rather than the best seen, so a short run can end below where it started. `history_` is the full alignment trajectory, and its last entry always corresponds to `params_`. --- **Next:** [Re-uploading and Fourier](07-reuploading.md) — why depth buys you frequencies, measured rather than asserted. ============================================================================== # Tutorials / 7. Re-uploading and Fourier (source: docs/tutorials/07-reuploading.md) ============================================================================== # 7. Re-uploading and the Fourier picture Encode the data once and a circuit is a fairly limited function of it. Encode it again between trainable blocks and the model becomes a **truncated Fourier series**, where each upload buys one more frequency. That is the single clearest theoretical statement in variational QML, and it is directly measurable. ## Re-uploading is a pattern, not a structure There is no single "the re-uploading ansatz". It is *any* interleaving of *any* encoding with *any* trainable block, so qmlkit treats it as a composition rather than a class: ```python import qmlkit as qk variants = [ ("default (S then W)", qk.reupload(qk.AngleFeatureMap(2), n_layers=3)), ("order='WS'", qk.reupload(qk.AngleFeatureMap(2), n_layers=3, order="WS")), ("shared weights", qk.reupload(qk.AngleFeatureMap(2), n_layers=3, share_weights=True)), ("ZZ feature map", qk.reupload(qk.ZZFeatureMap(2, reps=1), n_layers=2)), ] for label, model in variants: r = model.resources() print(f"{label:<20} inputs {model.n_inputs} weights {model.n_weights:>3} depth {r['depth']:>3}") ``` ```text default (S then W) inputs 2 weights 18 depth 18 order='WS' inputs 2 weights 18 depth 18 shared weights inputs 2 weights 6 depth 18 ZZ feature map inputs 3 weights 12 depth 18 ``` `reupload()` covers the common shapes. Anything else composes directly from the block vocabulary — including **two different feature maps in one model**, which a fixed class cannot express at all. Notice `share_weights=True` gives 6 weights instead of 18, at identical depth: the same trainable block reused at every upload. ## The circuit ```python import qmlkit as qk model = qk.reupload( qk.AngleFeatureMap(1, entangle=False), n_layers=2, rotations=("rz", "ry", "rz"), entangler=None, ) print(qk.draw(model.build())) ``` ```text q0: ─RY(θ0)──RZ(θ1)──RY(θ2)──RZ(θ3)──RY(θ0)──RZ(θ4)──RY(θ5)──RZ(θ6)── ``` `θ0` appears twice — that is the data, uploaded twice. The rest are weights. Data and weights occupy separate ranges of one flat vector, which is what keeps `∂f/∂x` and `∂f/∂θ` separable while the model still drops straight into a `QuantumLayer`. ## Measuring the claim `L` uploads should reach frequencies `0…L`. Do not take that on faith — extract the spectrum: ```python import numpy as np import qmlkit as qk for n_layers in (1, 2, 3, 4): model = qk.reupload( qk.AngleFeatureMap(1, entangle=False), n_layers=n_layers, rotations=("rz", "ry", "rz"), entangler=None, ) weights = model.init("uniform", seed=1) bound = model.build() def f(x, bound=bound, model=model, weights=weights): return qk.expval(bound, qk.Z(0), theta=np.concatenate([model.angles([x]), weights])) spectrum = qk.fourier.spectrum(f, n_layers + 3) print(f"L={n_layers}: frequencies {sorted(spectrum)}" f" amplitudes {[round(spectrum[k], 4) for k in sorted(spectrum)]}") ``` ```text L=1: frequencies [1] amplitudes [0.9997] L=2: frequencies [0, 1, 2] amplitudes [0.0853, 0.2176, 0.1573] L=3: frequencies [0, 1, 2, 3] amplitudes [0.4162, 0.5004, 0.1654, 0.0156] L=4: frequencies [0, 1, 2, 3, 4] amplitudes [0.4088, 0.5291, 0.2017, 0.0506, 0.0028] ``` Exactly `0…L`, and nothing above it. Frequencies the architecture cannot reach are not "hard to learn" — they are unreachable, and no training run will find them. That makes this a design question, decided before the first gradient step. Note the amplitudes fall off sharply at the top end. Reaching a frequency is not the same as having much of it: `L=4` reaches frequency 4 with amplitude 0.0028. Depth buys bandwidth, not power. ## The trap: a trainable block that commutes If the trainable block commutes with the encoding, the uploads collapse. `Ry(x)Ry(θ₁)Ry(x)Ry(θ₂)` is just `Ry(2x + θ₁ + θ₂)` — one frequency, and the weights do nothing but shift a phase. ```python import numpy as np import qmlkit as qk for label, rotations in (("Ry only (commutes)", ("ry",)), ("Rz Ry Rz (does not)", ("rz", "ry", "rz"))): model = qk.reupload( qk.AngleFeatureMap(1, entangle=False), n_layers=3, rotations=rotations, entangler=None ) weights = model.init("uniform", seed=1) bound = model.build() def f(x, bound=bound, model=model, weights=weights): return qk.expval(bound, qk.Z(0), theta=np.concatenate([model.angles([x]), weights])) spectrum = qk.fourier.spectrum(f, 6) print(f"{label:<22} frequencies {sorted(spectrum)}" f" amplitudes {[round(spectrum[k], 4) for k in sorted(spectrum)]}") ``` ```text Ry only (commutes) frequencies [3] amplitudes [1.0] Rz Ry Rz (does not) frequencies [0, 1, 2, 3] amplitudes [0.4162, 0.5004, 0.1654, 0.0156] ``` A three-upload model that reaches **one** frequency, with weights that cannot change the function's shape. It trains, it produces a loss curve, and it is architecturally incapable of the thing it was built for. !!! warning "The library warns about this one" `DataReuploadEncoder` raises a `UserWarning` when the trainable block commutes with the encoding rotation, because the failure is otherwise invisible — the model looks fine and simply cannot represent anything. ## Using it A re-uploading model is an `Ansatz`, so it drops into everything else. Bind data and weights separately: ```python import numpy as np import qmlkit as qk model = qk.reupload(qk.AngleFeatureMap(2), n_layers=2) weights = model.init(seed=0) x = np.array([0.3, 0.8]) spec = model.bind(x, weights) print(f" = {qk.expval(spec, qk.Z(0)):+.6f}") print(f"inputs {model.n_inputs}, weights {model.n_weights}") ``` `build(theta)` still takes the full concatenated vector, and says so clearly when the sizes disagree — the two calling conventions were a real source of confusion, so the error message names both sizes. ## Training one A re-uploading model is its own encoding *and* its own trainable block, so it goes in as the **feature map**, with no separate ansatz: ```python # docs: requires torch model = qk.VQC( n_features=2, n_classes=2, feature_map=qk.reupload(qk.AngleFeatureMap(2), n_layers=3), ) print(model.ansatz) # None -- the re-uploading model already carries the weights ``` Passing one as `ansatz=` instead, or alongside a separate ansatz, is refused: ```python # docs: requires torch try: qk.VQC( n_features=2, n_classes=2, feature_map=qk.reupload(qk.AngleFeatureMap(2), n_layers=2), ansatz=qk.hardware_efficient(2, 1), ) except ValueError as exc: print(exc) ``` The same applies one layer down, where `QuantumLayer` takes it in the feature-map position and `ansatz=None`: ```python # docs: requires torch layer = qk.QuantumLayer( qk.reupload(qk.AngleFeatureMap(2), n_layers=2), None, [qk.Z(0), qk.Z(1)] ) print(layer.n_features, layer.n_outputs) ``` Both were unreachable before `0.1.0`: `VQC` supplied a default ansatz unconditionally, so a re-uploading feature map always collided with it and the only way through was a hand-written training loop. Worth stating because the fix came from someone trying to use the pattern this page recommends and finding they could not. --- **Next:** [Trainability](08-trainability.md) — what happens when the gradient is there but vanishingly small. ============================================================================== # Tutorials / 8. Trainability (source: docs/tutorials/08-trainability.md) ============================================================================== # 8. Trainability A correct gradient is not the same as a useful one. Variational circuits have a failure mode where the gradient is exactly right and exponentially small — the **barren plateau** — and no optimiser recovers from it, because there is nothing to follow. The good news is that it is measurable before you spend a training run finding out. ## Cost locality is the lever you actually control At fixed shallow depth, *what you measure* matters more than how wide the register is: ```python import qmlkit as qk local = qk.barren_plateau_scan( lambda n: qk.hardware_efficient(n, 2), [2, 4, 6, 8], lambda n: qk.Z(0), n_samples=200, seed=0, ) global_ = qk.barren_plateau_scan( lambda n: qk.hardware_efficient(n, 2), [2, 4, 6, 8], lambda n: qk.PauliString(tuple((q, "Z") for q in range(n)), 1.0), n_samples=200, seed=0, ) print(f"{'n':>3}{'local Z0':>14}{'global Z^n':>14}") for i, n in enumerate(local["n_qubits"]): print(f"{n:>3}{local['variance'][i]:>14.3e}{global_['variance'][i]:>14.3e}") print(f"\ndecay per qubit: local {local['decay_per_qubit']:.4f} global {global_['decay_per_qubit']:.4f}") print(f"looks exponential: local {local['looks_exponential']} global {global_['looks_exponential']}") ``` ```text n local Z0 global Z^n 2 3.381e-01 1.250e-01 4 2.684e-01 1.847e-02 6 2.793e-01 8.684e-03 8 2.740e-01 1.437e-03 decay per qubit: local 0.9322 global 0.2257 looks exponential: local False global True ``` A local `Z(0)` holds its gradient variance essentially flat from 2 to 8 qubits. A global `Z^⊗n` on the *same circuits* collapses by a factor of 87 — decaying to 22% of its value per added qubit. Same ansatz, same depth, same initialisation. The only difference is the observable. ## Depth eventually wins anyway Cost locality is not a cure, and claiming otherwise would be the comfortable mistake. Let the depth grow with the width and the local cost collapses too: ```python import qmlkit as qk deep = qk.barren_plateau_scan( lambda n: qk.hardware_efficient(n, 2 * n), [2, 4, 6], lambda n: qk.Z(0), n_samples=200, seed=0, ) for i, n in enumerate(deep["n_qubits"]): print(f"n={n}, L=2n: variance {deep['variance'][i]:.3e}") print(f"decay per qubit {deep['decay_per_qubit']:.4f}, looks exponential {deep['looks_exponential']}") ``` ```text n=2, L=2n: variance 1.496e-01 n=4, L=2n: variance 3.540e-02 n=6, L=2n: variance 6.744e-03 decay per qubit 0.2123, looks exponential True ``` A **local** cost at depth `2n` decays at 0.2123 per qubit — indistinguishable from the global cost's 0.2257 at shallow depth. So the honest statement is: a local cost buys you room at shallow depth, and depth takes it back. The practical defences are the unglamorous ones — shallow circuits, local costs, small (near-identity) initialisation, and structured ansätze like QCNN or MPS whose tied weights keep the effective parameter count low. ## Optimisers built for circuits Adam and SGD come from torch. These three exploit structure a general optimiser cannot see. ```python import numpy as np import qmlkit as qk ansatz = qk.hardware_efficient(3, 2) spec, start = ansatz.build(), ansatz.init("uniform", seed=1) cost = qk.Z(0) + qk.Z(1) + qk.Z(2) # minimum is -3 def loss(theta): return qk.expval(spec, cost, theta=theta) _, roto = qk.minimize_rotosolve(loss, start, n_sweeps=12) _, qng = qk.minimize_qng(spec, start, cost, n_steps=25, lr=0.15) _, spsa = qk.minimize_spsa(loss, start, n_iterations=200, seed=0) plain = start.copy() for _ in range(25): plain = plain - 0.15 * qk.grad(spec, plain, cost) print(f"start {roto[0]:+.6f}") print(f"plain GD (25 steps) {loss(plain):+.6f}") print(f"QNG (25 steps) {qng[-1]:+.6f}") print(f"Rotosolve (12 sweeps) {roto[-1]:+.6f}") print(f"SPSA (200 iters) {spsa[-1]:+.6f}") print(f"minimum -3.000000") ``` ```text start -0.085469 plain GD (25 steps) -2.995834 QNG (25 steps) -3.000000 Rotosolve (12 sweeps) -2.997598 SPSA (200 iters) -2.998708 minimum -3.000000 ``` **Rotosolve** exploits the fact that a circuit expectation is a *sinusoid* in any single Pauli-rotation angle. Three evaluations pin that sinusoid down exactly, so you jump to its minimum instead of stepping toward it — no learning rate at all. It is coordinate descent, so it converges slowly near the optimum on correlated parameters. **Quantum natural gradient** follows the Fubini–Study geometry rather than the Euclidean one. Same 25 steps and same step size as plain gradient descent, and it reaches the minimum where plain GD does not. **SPSA** uses two circuit evaluations per step regardless of the parameter count. Worth it when `P` is large or the evaluations are noisy. ## The geometry underneath ```python import numpy as np import qmlkit as qk ansatz = qk.hardware_efficient(3, 1, rotations=("ry",), pattern="chain") spec, theta = ansatz.build(), ansatz.init(seed=0) g = qk.metric_tensor(spec, theta, approx=None) print(f"metric shape {g.shape}, symmetric {np.allclose(g, g.T)}") print(f"QFIM = 4·g: {np.allclose(qk.quantum_fisher_information(spec, theta), 4 * g)}") ``` The metric is computed by differentiating the state in closed form — exact, and with no ancilla. That matters because QNG is only as good as the metric it follows. !!! note "`approx=\"block-diag\"` means something different in PennyLane" PennyLane blocks the metric by circuit *layer* and zeroes cross-layer entries. qmlkit computes the exact metric, which costs no more on a simulator. The consequence is measurable, and it is in [Validation](../about/validation.md). ## What to check before a long run ```python import qmlkit as qk report = qk.metrics.AnsatzReport(qk.hardware_efficient(4, 2), n_samples=300) print(report) ``` Look at **gradient variance** first. If it is already at `1e-4` on four qubits, widening the register will not help and the architecture needs changing, not more epochs. --- That is the tour. From here: the [guides](../guides/index.md) go deeper on the parameter-shift rule and on extending the library, and [Validation](../about/validation.md) covers how any of this is known to be correct. ============================================================================== # Case studies / Case studies (source: docs/studies/index.md) ============================================================================== # Case studies The tutorials show how each piece works. These show a whole problem worked through, from the raw data to a number you could defend — and in most of them the number is that the quantum model **lost**. That is deliberate. A library that only publishes its wins teaches you nothing about when to trust it, and every case study here ends with the verdict the library itself prints rather than the one the author would have preferred. | Study | Task | What it is really about | |---|---|---| | [1. Imbalanced classification](01-imbalanced-classification.md) | 32,581 loan applications, 21.8% default | The metric that lies, and the one keyword that fixes the model | | [2. Is a quantum kernel worth it?](02-quantum-kernels.md) | Kernel methods on small data | Two diagnostics that point opposite ways, answered before fitting | | [3. Regression](03-regression.md) | A smooth non-linear target | Why `r2` and `rmse` disagree, and what re-uploading buys | | [4. Chemistry: H₂ ground state](04-chemistry.md) | VQE to chemical accuracy | An ansatz that converges confidently to the wrong energy | | [5. Clustering and generative models](05-beyond-classification.md) | Unsupervised, and a Born machine | The metrics that exist because accuracy does not apply | | [6. Clinical decisions](06-clinical.md) | 569 breast-cancer biopsies, 30 features | When a false negative is not the same error as a false positive | | [7. Images and structure](07-images-and-structure.md) | Handwritten digits, a QCNN | Putting the structure of the data into the circuit | | [8. A classifier that declines to answer](08-selective-classification.md) | Abstention on breast-cancer biopsies | The reported number rises while the comparable one falls | Every code block on these pages runs in CI (`tests/test_docs.py`), so the numbers are produced by the code beside them and cannot drift from it. ## The shape they all share Each study follows the same five questions, because they are the questions that decide whether a result means anything: 1. **What will the data break?** — `imbalance_report`, and the split it implies 2. **What is the bar?** — `baseline`, run *before* any quantum code 3. **Is the model quietly broken?** — `diagnose`, before training rather than after 4. **What does the metric actually say?** — `evaluate`, with its notes 5. **Is the number right, and reproducible?** — `selfcheck` and `fingerprint` Four of them have a heavier version in `examples/`, run at full size rather than trimmed to fit a page: | Study | Full-size version | |---|---| | 1 | [`credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py) — twelve steps on the real 32,581-row Kaggle table | | 4 | [`experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py) experiment 1 — H₂ across a range of bond lengths, checked against dense diagonalisation at each | | 6 | `experiments.py` experiment 3 — all 30 clinical features, compared across qubit counts | | 7 | `experiments.py` experiment 2 — real MNIST at 784 pixels and eight qubits, with three convolution filters compared | `experiments.py` takes about twenty minutes; the QCNN is most of it. ============================================================================== # Case studies / 1. Imbalanced classification (source: docs/studies/01-imbalanced-classification.md) ============================================================================== # Study 1 — Imbalanced classification, and the metric that lies The full version of this runs on the [Kaggle credit-risk table](https://www.kaggle.com/datasets/laotse/credit-risk-dataset) — 32,581 loan applications, 21.8% of which defaulted — in [`examples/credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py). This page is the same twelve steps compressed onto data the page can generate, so every number below is produced by the code beside it. ## The data, and what its labels will break ```python import numpy as np import qmlkit as qk rng = np.random.default_rng(0) X = rng.normal(size=(400, 4)) score = 1.6 * X[:, 0] - 1.1 * X[:, 1] + 0.5 * X[:, 2] + 0.6 * rng.normal(size=400) y = (score > np.quantile(score, 0.78)).astype(int) # 22% positive, like the real thing print(qk.imbalance.imbalance_report(y)) ``` Two findings, and both name the call that fixes them. Everything this study does about the skew comes from that report and nowhere else: ```python train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0) print(f"{train.size} train / {test.size} test, {int(y[test].sum())} positives held out") ``` A random split of a 78/22 problem leaves the minority class thin or absent in test often enough to make a single test score meaningless. Stratified splitting removes that as a source of variance before it becomes one. ## The bar, before any quantum code ```python table = qk.baseline(X, y, cv=3, seed=0, include=["majority", "logistic", "random-forest", "rbf-kernel-ridge"]) print(table) ``` Read the *failures* as well as the winner. On the real credit table, `svc-rbf` and `mlp` sit at the 0.500 floor because the raw columns span six orders of magnitude — the table diagnosed the preprocessing before any model was tuned. ## The naive model, and the metric that admits it ```python # docs: requires torch import torch pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train]) Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test]) torch.manual_seed(0) naive = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=2, seed=0) naive.fit(Xtr, y[train], epochs=20, lr=0.08, batch_size=256) naive_scores = qk.evaluate.classification(y[test], naive.predict(Xte)) print(naive_scores) ``` `Scores` returns every metric at once precisely so the disagreement between them stays visible, and prints a note when accuracy is overstating the model. `primary` is `balanced_accuracy` rather than `accuracy` here, chosen from the class distribution rather than by the author. ## One keyword, taken from step one ```python # docs: requires torch torch.manual_seed(0) weighted = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=2, class_weight="balanced", seed=0) weighted.fit(Xtr, y[train], epochs=20, lr=0.08, batch_size=256) weighted_scores = qk.evaluate.classification(y[test], weighted.predict(Xte)) for name, s in (("naive", naive_scores), ("class_weight='balanced'", weighted_scores)): print(f" {name:24} balanced_accuracy {s['balanced_accuracy']:.3f}" f" mcc {s['mcc']:+.3f} accuracy {s['accuracy']:.3f}") ``` On the real table accuracy *falls* — 0.811 to 0.714 — while balanced accuracy rises 0.692 to 0.730. The weighted model finds more of the defaults and pays in false alarms. MCC can move the other way, and the library hands back both rather than picking the one that flatters the change. Which trade you want is a lending decision, not a modelling one. ## Is the circuit quietly broken? ```python # docs: requires torch print(qk.diagnose(weighted)) ``` `hardware_efficient` ends each layer in `rz`, which commutes with the `cx` entanglers *and* with any Z-basis observable — so a quarter of its weights cannot move the readout. The optimiser carries them every step and the loss curve never shows it. ## The verdict ```python # docs: requires torch def build(): torch.manual_seed(0) model = qk.VQC(n_features=4, n_classes=2, n_qubits=4, class_weight="balanced", seed=0) fit = model.fit model.fit = lambda a, b: fit(a, b, epochs=15, lr=0.08, batch_size=256) return model Xq = qk.FeaturePipeline(n_qubits=4).fit(X).transform(X) final = qk.baseline(Xq, y, model=build, cv=3, seed=0, include=["majority", "logistic", "random-forest"]) print(final.verdict) ``` The classical rows are scored on the **same four principal components** the quantum model sees. Comparing a 4-component model against a 21-feature one would be a claim about the input rather than the model — the full example reports both bars and clears neither, and says so in as many words. A negative result you can defend is worth more than a positive one you cannot. ============================================================================== # Case studies / 2. Is a quantum kernel worth it? (source: docs/studies/02-quantum-kernels.md) ============================================================================== # Study 2 — Is a quantum kernel worth trying at all? A quantum kernel is the most appealing thing in this field: no training loop, no barren plateau, a Gram matrix you hand to any kernel method. It is also the easiest place to spend a month on a matrix that could never have separated anything. Two numbers decide it, and both are available **before** the kernel is fitted to anything. ## The data and the bar ```python import numpy as np import qmlkit as qk X, y = qk.datasets.make_circles(n_samples=80, seed=0) table = qk.baseline(X, y, cv=3, seed=0, include=["majority", "rbf-kernel-ridge", "nearest-centroid"]) print(table) ``` `rbf-kernel-ridge` is the row that matters. It is the *identical algorithm* to a quantum kernel method — a closed-form kernel ridge solve — differing only in which kernel fills the Gram matrix. Any gap between it and a quantum kernel is attributable to the kernel and to nothing else, which is what makes it the honest foil. ## Question one: has the kernel concentrated? A fidelity kernel's off-diagonal entries shrink like `2^-n`. Once the spread is at that scale, every pair of inputs looks equally similar and no model built on the matrix can separate them — the Gram matrix still exists, the SVM still fits, and the accuracy is chance. ```python feature_map = qk.ZZFeatureMap(2, reps=2) kernel = qk.QuantumKernel(feature_map) gram = kernel(X[:60]) report = qk.concentration_report(gram, n_qubits=2) print(f"off-diagonal spread {report['off_diagonal_std']:.4f}") print(f"predicted at 2 qubits {report['predicted_spread']:.4f}") print(f"positive semi-definite: {report['is_psd']}") ``` At two qubits there is plenty of spread. The check earns its place as the width grows: `shots_to_resolve` says roughly `4^n` shots are needed to see a `2^-n` signal above sampling noise, so a 10-qubit fidelity kernel needs about a million shots *per entry* before the number means anything. ## Question two: is the geometry even different? Concentration says the kernel can still distinguish things. It does not say it distinguishes anything the classical kernel could not. That is the geometric difference: ```python classical = np.exp(-0.5 * ((X[:60, None, :] - X[None, :60, :]) ** 2).sum(-1)) g = qk.geometric_difference(classical, gram) bar = np.sqrt(len(gram)) # Huang et al.'s threshold, not a number chosen here print(f"g(K_classical, K_quantum) = {g:.3g} (bar: sqrt(N) = {bar:.2f})") print("above the bar -> a geometry the RBF kernel cannot reach" if g > bar else "below the bar -> the classical kernel already spans this") ``` Huang et al.'s statistic, and their bar: `g` above `sqrt(N)` means the two kernels induce genuinely different geometries, so a separation is at least *possible*. Below it means the classical kernel already sees everything the quantum one does, whatever the accuracy table later says. Identical kernels give exactly 1, which is what makes the scale readable — the threshold is the caller's to apply rather than folded into the number. ## Both at once `diagnose` takes a Gram matrix directly: ```python print(qk.diagnose(gram, n_qubits=2)) ``` ## The verdict ```python # docs: requires sklearn K_train = kernel(X[:60]) svc = qk.QSVC(feature_map).fit(X[:60], y[:60]) scores = qk.evaluate.classification(y[60:], svc.predict(X[60:])) print(f"QSVC balanced accuracy {scores['balanced_accuracy']:.3f}") print(f"best classical {table.best_classical.mean:.3f} ({table.best_classical.name})") print(f"circuits run: {kernel.n_evaluations:,}") ``` The two checks are worth running in that order because they can disagree, and the disagreement is the finding. On the credit-risk data in [`examples/credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py) they do exactly that: geometric difference **66.9** — a geometry the RBF kernel cannot reach — against a concentration report saying the spread is *already* at the `2^-n` scale at four qubits. The reachable geometry is being squeezed out as fast as it appears, and widening the register improves the first number while making the second worse. That tension is publishable, and it cost two function calls rather than a fortnight of fitting SVMs. ============================================================================== # Case studies / 3. Regression (source: docs/studies/03-regression.md) ============================================================================== # Study 3 — Regression, and two metrics that disagree Classification hides a bad model behind accuracy. Regression hides one behind `r2`, which is scaled by the variance of whatever you happened to sample — so the same model scores differently on a narrow test set and a wide one, and neither number is wrong. `qk.evaluate.regression` returns all of them, and says when `r2` has stopped meaning anything. ## A target with structure a linear model cannot reach ```python import numpy as np import qmlkit as qk rng = np.random.default_rng(0) X = rng.uniform(-1.0, 1.0, size=(160, 3)) y = np.sin(2.0 * X[:, 0]) + 0.4 * X[:, 1] ** 2 + 0.1 * rng.normal(size=160) train, test = np.arange(120), np.arange(120, 160) print(f"target range [{y.min():.2f}, {y.max():.2f}], variance {y.var():.3f}") ``` ## The bar first ```python table = qk.baseline(X, y, cv=3, seed=0, include=["mean", "linear", "rbf-kernel-ridge"]) print(table) ``` `mean` scores `r2 = 0` by construction — it is the definition of the zero point, not a model. `linear` is the one that matters here: the target is deliberately non-linear, so the gap between `linear` and `rbf-kernel-ridge` is how much non-linear structure is actually available to be captured. If that gap is small, no model of any kind is going to look impressive, and it is better to know before training one. ## The quantum regressor ```python # docs: requires torch import torch pipeline = qk.FeaturePipeline(n_qubits=3).fit(X[train]) Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test]) torch.manual_seed(0) model = qk.VQRegressor(n_features=3, n_qubits=3, n_layers=2, seed=0) model.fit(Xtr, y[train], epochs=30, lr=0.08, batch_size=256) scores = qk.evaluate.regression(y[test], model.predict(Xte)) print(scores) ``` Seven metrics, and they answer different questions: - **`r2`** — how much of the variance is explained, relative to predicting the mean. Comparable across models on *this* data and not across datasets. - **`rmse`** and **`mae`** — error in the target's own units. `rmse` punishes large misses quadratically; `mae` does not. When they disagree, the residuals are skewed. - **`median_absolute_error`** — the typical miss, immune to a handful of outliers. - **`max_error`** — the worst single case, which is the only one that matters if the prediction feeds a decision with a floor under it. - **`explained_variance`** — `r2` without the bias term, so the gap between them is exactly the model's systematic offset. `mape` is omitted here rather than returned as infinity, and the note says why: it is undefined wherever the target is zero, and this target crosses zero. ## Where the quantum model's ceiling comes from A variational circuit with angle encoding is a **Fourier series in the input**, and its reachable frequencies are set by how many times the data is uploaded — not by how many weights it has: ```python print(qk.fourier.reachable_frequencies(1)) # one upload print(qk.fourier.reachable_frequencies(3)) # three uploads ``` The target contains `sin(2x)`, so a single-upload model has no frequency-2 component to fit it with, however many layers are stacked on top. That is a representational limit, not an optimisation one, and no learning rate will move it. Re-uploading is the fix — with the trap the library warns about: ```python fmap = qk.AngleFeatureMap(3, entangle=False) good = qk.reupload(fmap, n_layers=3, block=qk.RotationLayer(("rz", "ry", "rz"))) print(f"{good.n_params} weights, reaching frequencies 0..3") ``` If the trainable block used `ry` — the same generator the encoding uses — the uploads would merge into a single rotation, the model would reach one frequency, and every weight would become a phase. It would still train, still converge, and still report a loss. `reupload()` warns at construction, and `qk.diagnose` catches it on a model composed by hand: ```python collapsed = qk.Ansatz(3, qk.repeat(3, qk.EncodingLayer(fmap) + qk.RotationLayer("ry")), name="ry-reupload", n_inputs=3) print(qk.diagnose(collapsed)) ``` ## The verdict ```python # docs: requires torch print(f"quantum r2 {scores['r2']:.3f} vs best classical " f"{table.best_classical.mean:.3f} ({table.best_classical.name})") ``` Verify the spectrum of any re-uploading model you build with `qmlkit.fourier.spectrum` rather than trusting the upload count — the frequencies are reachable, not guaranteed. ============================================================================== # Case studies / 4. Chemistry - H2 ground state (source: docs/studies/04-chemistry.md) ============================================================================== # Study 4 — H₂, and an ansatz that converges to the wrong energy Chemistry is the one place quantum computing has a target it can be scored against exactly: diagonalise the Hamiltonian and compare. That makes it the best available test of whether a variational method is working, and the worst place to be approximately right without knowing it. Chemical accuracy is 1 kcal/mol — **1.594 mHa**. Anything outside that is not a chemistry result. ## The molecule ```python import qmlkit as qk from qmlkit.algorithms import VQE, exact_ground_energy, h2_hamiltonian hamiltonian, info = h2_hamiltonian() exact = exact_ground_energy(hamiltonian, 4) print(f"{info['n_terms']} Pauli terms on {info['n_qubits']} qubits") print(f"exact ground state {exact:.6f} Ha") ``` The Hartree–Fock reference is the occupation `[1, 1, 0, 0]` — the two electrons in the two lowest spin-orbitals. An `Ansatz` starts from `|0000>`, so that state has to be prepared first: ```python occupation = info["hartree_fock_occupation"] def hartree_fock(inner): """Prepend the HF occupation to any ansatz.""" def build(circuit, context): for wire, occupied in enumerate(occupation): if occupied: circuit.x(wire) inner.block.emit(circuit, context) return qk.Ansatz(4, qk.Custom(build, "hf"), f"hf_{inner.name}") reference = qk.QCircuit(4) reference.x(0) reference.x(1) print(f"Hartree-Fock energy {qk.expectation(reference.to_spec(), hamiltonian):.6f} Ha") ``` That prints `-1.116999`. So HF already has most of the answer, and the whole job of the VQE is the **20.3 mHa of correlation energy** between it and the exact value. That framing matters: a method that returns something near `-1.1` has not necessarily done anything at all. ## The failure ```python shallow = hartree_fock(qk.hardware_efficient(4, 2)) result = VQE(hamiltonian, ansatz=shallow).run(seed=0) print(f"VQE {result.energy:.6f} Ha error {abs(result.energy - exact) * 1000:.1f} mHa") ``` `-0.536370 Ha`, which is **601 mHa** out — off by thirty times the entire correlation energy, and *worse than doing nothing*, since Hartree–Fock alone was 20 mHa away. Nothing raised. The optimiser converged. `-0.53` is a plausible-looking number in Hartree, and it is the energy of the `|1000>` state — the circuit found a comfortable minimum in the wrong particle-number sector and settled there. ## What the library says about it Depth is the problem, and it is visible before the energy is: ```python for layers in (2, 4): ansatz = hartree_fock(qk.hardware_efficient(4, layers)) energy = VQE(hamiltonian, ansatz=ansatz).run(seed=0).energy error = abs(energy - exact) * 1000 verdict = "chemical accuracy" if error < 1.594 else "NOT chemical accuracy" print(f" {layers} layers, {ansatz.n_params:2d} params: {energy:+.6f} Ha" f" ({error:7.2f} mHa) {verdict}") ``` Four layers reaches `-1.137306` — **0.00 mHa**, exact to the printed precision. Two layers cannot represent the state at all, and says so only through an energy you have to already know the answer to recognise as wrong. `strongly_entangling(4, 3)` also reaches it, so this is about expressive capacity rather than about one particular template. ## Two traps this study sits on top of **Rotosolve is not always valid.** `VQE` defaults to it, and its three-point fit assumes the loss is a single sinusoid in each angle. That fails when one angle drives several gates that do not compose — QAOA's cost angle drives one `rz` per edge, five frequencies — and it then converges instantly to the wrong point and reports it. Check before trusting it: ```python from qmlkit.optim import supports_rotosolve print(supports_rotosolve(hartree_fock(qk.hardware_efficient(4, 4)).build())) ``` **Particle number is conserved.** A molecular Hamiltonian commutes with the number operator, so any excitation that does not conserve it has *exactly zero* gradient at Hartree–Fock. An ADAPT run with a generic operator pool grows an empty circuit and reports convergence. Use `chemistry_operator_pool`; this is physics, not a bug, and a test pins it. ## The verdict The quantum method wins this one outright — `-1.137306` against an exact `-1.137306` — which is worth stating plainly given how the other studies end. H₂ in a minimal basis is four qubits and sixteen amplitudes, so a laptop diagonalises it instantly and no advantage is claimed. What the study demonstrates is narrower and more useful: **the same code, one layer count apart, produces an answer that is exact and an answer that is 601 mHa wrong, and only one of them looks different from the outside.** ============================================================================== # Case studies / 5. Clustering and generative (source: docs/studies/05-beyond-classification.md) ============================================================================== # Study 5 — Clustering and generative models Accuracy needs labels and a decision. Two large parts of machine learning have neither, and they fail in ways a classification metric cannot describe: a clustering that is beautifully separated and answers the wrong question, and a generative model that assigns zero probability to something that actually happens. ## Clustering: two kinds of "good" that routinely disagree ```python import numpy as np import qmlkit as qk X, y = qk.datasets.make_blobs(n_samples=90, centers=3, seed=0) labels = qk.algorithms.QMeans(n_clusters=3, seed=0).fit_predict(X) scores = qk.evaluate.clustering(X, labels, y_true=y) print(scores) ``` `qk.evaluate.clustering` returns both families, because they answer different questions and you usually only have one of them: **Internal** — no ground truth needed. `silhouette` asks whether points sit closer to their own cluster than to the next one; `davies_bouldin` compares within-cluster spread to between-cluster distance (lower is better). These say the partition is *clean*. **External** — needs labels. `adjusted_rand` corrects for chance agreement, so zero means "no better than a random partition of the same shape"; `normalized_mutual_info` measures shared information; `purity` is the honest-but-flattering one, since it rises automatically as clusters get smaller. They disagree constantly. A silhouette of 0.7 with an ARI of 0.05 is a well-separated partition of something other than what you were looking for — and that is a finding, not a failure, but only if you looked at both. ```python print(f"cluster sizes {scores.extras['cluster_sizes']}") ``` The primary metric is `adjusted_rand` when labels are given and `silhouette` when they are not, so the number you quote does not silently change meaning with the arguments. ## Generative: the model that assigns zero to something real A quantum circuit Born machine samples bitstrings from `|psi|^2`. The standard target is bars-and-stripes — six valid patterns out of sixteen four-bit strings: ```python patterns = qk.datasets.bars_and_stripes(2) target = np.zeros(16) for row in patterns: target[int("".join(map(str, row.astype(int))), 2)] += 1 target /= target.sum() uniform = np.full(16, 1 / 16) print(f"a uniform guess scores TV = " f"{qk.evaluate.generative(uniform, target)['total_variation']:.3f}") ``` That is the number to beat. Reporting a total variation of 0.5 means nothing until you know that guessing scores 0.625. ```python model = qk.generative.QCBM(n_qubits=4, n_layers=3, seed=0) model.fit(patterns, n_iterations=60) scores = qk.evaluate.generative(model.probabilities(), target) print(scores) ``` ### Why total variation is the primary, and KL is not ```python missing = qk.evaluate.generative(np.eye(16)[0], target) print(f"KL(target || model) = {missing['kl_target_model']}") print(f"total variation = {missing['total_variation']:.3f}") ``` A model that puts zero mass where the target has some gives an **infinite** KL. That is mathematically correct and useless as a training signal — it is infinite for a model that misses one rare pattern and equally infinite for one that has learned nothing. Total variation stays finite, is bounded in `[0, 1]`, and is a metric. `hellinger` and `js_distance` share those properties. `support_coverage` reports the fraction of the target's support the model reaches at all, and a note fires whenever mass is missing, so the infinity is explained rather than just printed. All divergences here are in **nats**, stated because half the literature uses bits and the factor of `ln 2` is exactly the kind of silent discrepancy this library exists to avoid. ## The verdict A four-qubit QCBM at this depth and iteration budget beats a uniform guess and does not master the distribution. Depth helps — eight layers reaches roughly `TV = 0.49` against the uniform `0.625`, with about half its mass on valid patterns against the `0.375` a random distribution puts there — but that is a modest win on a sixteen-outcome problem a lookup table solves exactly. Which is the point of measuring it against the uniform baseline rather than against zero. `total_variation = 0.49` sounds like a result; `0.49 against a 0.625 floor` is one. ============================================================================== # Case studies / 6. Clinical decisions (source: docs/studies/06-clinical.md) ============================================================================== # Study 6 — Clinical data, where the two errors are not the same error Credit risk in [study 1](01-imbalanced-classification.md) was about a skewed dataset. This one is about something else that no amount of balancing fixes: **a false negative and a false positive are not equivalent mistakes**, and no single-number metric knows that. Telling a woman with a malignant tumour that she is fine is not the same kind of error as calling her back for a second scan. Accuracy scores them identically. So does balanced accuracy, and so does F1. ## The data ```python # docs: requires sklearn import numpy as np from sklearn.datasets import load_breast_cancer import qmlkit as qk data = load_breast_cancer() X, y = data.data, data.target # 0 = malignant, 1 = benign print(f"{X.shape[0]} samples, {X.shape[1]} clinical features") print(f"classes {list(data.target_names)}, malignant rate {(y == 0).mean():.3f}") ``` At 37% malignant this is only mildly imbalanced — `imbalance_report` says as much, and correctly does not recommend the remedies study 1 needed: ```python # docs: requires sklearn report = qk.imbalance.imbalance_report(y) print(report if report else "nothing to report: the classes are close enough to even") ``` That is the point of the report being falsy when it finds nothing. The skew is not the problem here; the **asymmetry of the costs** is, and no diagnostic can infer that from the labels. It comes from the domain. ## Train it ```python # docs: requires torch # docs: requires sklearn import torch train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0) pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train]) Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test]) print(f"30 features -> 4 angles, {pipeline.explained_variance_:.0%} of the variance") torch.manual_seed(0) model = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=3, seed=0) model.fit(Xtr, y[train], epochs=25, lr=0.08, batch_size=256) scores = qk.evaluate.classification(y[test], model.predict(Xte)) print(f"accuracy {scores['accuracy']:.3f} balanced {scores['balanced_accuracy']:.3f}") ``` Around 0.93 either way. A good-looking result, and on its own it does not tell you whether the model is safe to use. ## The number that actually matters ```python # docs: requires torch # docs: requires sklearn per_class = scores.extras["per_class"] malignant = per_class["0"] print(f"malignant recall {malignant['recall']:.3f} " f"on {malignant['support']} malignant cases") print(f"malignant precision {malignant['precision']:.3f}") print("\nconfusion matrix (rows = truth, columns = prediction):") print(scores.extras["confusion_matrix"]) ``` The confusion matrix is where the model stops being a score. The top-right entry — malignant cases predicted benign — is roughly **five patients** out of sixty-four. Accuracy 0.93 and *five missed cancers* are the same model. `qk.evaluate.classification` hands back `per_class`, `support` and the confusion matrix alongside the summary metrics for exactly this reason. The summary is what you report; the breakdown is what you decide on. ## Moving the operating point Nothing above is fixed. The model outputs probabilities, and the threshold is a policy choice rather than a modelling one: ```python # docs: requires torch # docs: requires sklearn probabilities = model.predict_proba(Xte)[:, 0] # P(malignant) print(f"{'threshold':>10}{'malignant recall':>19}{'false alarms':>15}") for threshold in (0.5, 0.35, 0.2): predicted = np.where(probabilities > threshold, 0, 1) s = qk.evaluate.classification(y[test], predicted) recall = s.extras["per_class"]["0"]["recall"] false_alarms = int(((predicted == 0) & (y[test] == 1)).sum()) print(f"{threshold:>10.2f}{recall:>19.3f}{false_alarms:>15}") ``` Lowering the threshold catches more cancers and calls back more healthy patients. There is no setting that does both, and the library will not pick for you — that is a clinical decision with a cost ratio attached, and a library that chose silently would be making it on your behalf. `average_precision` is the summary to quote when the threshold is not fixed, because it integrates over all of them: ```python # docs: requires torch # docs: requires sklearn full = qk.evaluate.classification(y[test], model.predict(Xte), model.predict_proba(Xte)) print(f"average_precision {full['average_precision']:.3f} roc_auc {full['roc_auc']:.3f}") ``` ## The verdict ```python # docs: requires torch # docs: requires sklearn Xq = qk.FeaturePipeline(n_qubits=4).fit(X).transform(X) table = qk.baseline(Xq, y, cv=3, seed=0, include=["majority", "logistic", "random-forest"]) print(table.verdict) ``` A logistic regression on the same four components is hard to beat here, which is the expected outcome and worth stating. The study's contribution is not the model. It is that **the metric was chosen from the decision the model feeds**, not from the shape of the data — and that the library gives you the confusion matrix and the threshold sweep without being asked twice. The full-size version, on all 30 features with a per-qubit comparison, is experiment 3 in [`examples/experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py). ============================================================================== # Case studies / 7. Images and structure (source: docs/studies/07-images-and-structure.md) ============================================================================== # Study 7 — Image data, and putting the structure in the circuit Every other study on this page flattens its data and hands it to a general-purpose ansatz. Images are the case where that is obviously wasteful: neighbouring pixels are related, and a circuit that treats all wires as interchangeable has to learn that from scratch with parameters it did not need to spend. A quantum convolutional network builds the assumption in — a two-qubit filter slid across the register, then a pooling layer halving the width, repeated until one wire carries the answer. ## The data ```python # docs: requires sklearn import numpy as np from sklearn.datasets import load_digits import qmlkit as qk digits = load_digits() keep = (digits.target == 0) | (digits.target == 1) X, y = digits.data[keep], digits.target[keep] print(f"{X.shape[0]} images, {X.shape[1]} pixels (8x8), classes 0 and 1") train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0) pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train]) Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test]) print(f"64 pixels -> 4 angles, {pipeline.explained_variance_:.0%} of the variance") ``` The full-size version uses real MNIST at 784 pixels and eight qubits — experiment 2 in [`examples/experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py). It needs a download, so this page uses the bundled 8×8 digits instead. ## The architecture `QCNNLayer` is a torch module, so it composes with ordinary layers: ```python # docs: requires torch # docs: requires sklearn import torch from torch import nn from qmlkit.nn.advanced import QCNNLayer torch.manual_seed(0) layer = QCNNLayer(4, filter="su4", tie_weights=True, init_seed=0).double() model = nn.Sequential(layer, nn.Linear(1, 2).double()) print(f"{sum(p.numel() for p in model.parameters())} parameters in total") ``` **`tie_weights=True` is the convolution.** One filter block is reused at every position rather than learning an independent block per pair, which is what makes it a convolution and not just a sparse ansatz. It is also the case the gradient code has to get right: one logical parameter drives several gates, so the derivative is the sum over occurrences, each shifted on its own. Shifting them together computes something else entirely. ```python # docs: requires torch # docs: requires sklearn spec = layer.ansatz.build() if hasattr(layer, "ansatz") else None print("weight tying means one parameter, several gates — see the parameter-shift guide") ``` ## The filter is a choice, and it is measurable ```python # docs: requires torch print(qk.list_conv_filters()) ``` Four are registered, and `register_conv_filter` adds your own. They are shared with `mps_ansatz` and `tree_tensor_network`, since all three slide the same two-qubit block. A name that is not one of them is refused with the right one rather than accepted: ```python try: QCNNLayer(4, filter="ry_cz") except Exception as error: print(error) ``` ## Train it ```python # docs: requires torch # docs: requires sklearn inputs = torch.tensor(Xtr[:150]) targets = torch.tensor(y[train][:150], dtype=torch.long) optimiser = torch.optim.Adam(model.parameters(), lr=0.15) criterion = nn.CrossEntropyLoss() for _ in range(12): optimiser.zero_grad() criterion(model(inputs), targets).backward() optimiser.step() with torch.no_grad(): predicted = model(torch.tensor(Xte)).argmax(1).numpy() scores = qk.evaluate.classification(y[test], predicted) print(f"balanced accuracy {scores['balanced_accuracy']:.3f} " f"on {scores.n_samples} held-out images") ``` Around 0.93 from roughly thirty parameters — the parameter count is the interesting number, not the accuracy. A dense ansatz on four qubits with comparable depth carries several times as many, and 0 vs 1 is a problem a linear model solves perfectly: ```python # docs: requires sklearn table = qk.baseline(np.vstack([Xtr, Xte]), np.concatenate([y[train], y[test]]), cv=3, seed=0, include=["majority", "logistic"]) print(table) ``` ## What this study is actually for Not the accuracy. It is that **structure in the data can be structure in the circuit**, and that the library makes that a one-line choice — `filter=`, `tie_weights=` — rather than a rewrite. The same block vocabulary builds `mps_ansatz` and `tree_tensor_network`, and `qk.compare_ansatze` will score them side by side on expressibility, entanglement and gradient variance before any of them is trained. The honest caveat is the one every study here shares: MNIST 0 vs 1 is separable by a linear model, so nothing below is evidence of advantage. It is evidence that the architecture can be expressed, trained, and measured without leaving the library. ============================================================================== # Case studies / 8. A classifier that declines (source: docs/studies/08-selective-classification.md) ============================================================================== # Study 8 — A classifier that declines to answer Letting a model abstain is an old and good idea: answer where you are confident, refuse where you are not, and hand the rest to a human. In quantum machine learning it arrives with an extra motive — a quantum model's output is a *distribution*, so reading it costs repeated circuit executions, and a rule that accepts a prediction early saves shots as well as errors. It also produces the most flattering number in classification, and the flattery is free. This study builds an abstaining classifier, reports the number that looks good, and then shows why it is the wrong one to quote. ## The trap, stated first Selective accuracy — accuracy on the samples the model chose to answer — rises monotonically as you abstain more. Push the threshold far enough and it reaches 1.000 on the single sample you are surest about. So a selective accuracy quoted against a model that answered *everything* is not a comparison between two models; it is a comparison between two different questions. ```python # docs: requires sklearn import numpy as np import qmlkit as qk truth = np.array([0, 1, 0, 1, 0, 1, 0, 1]) answered_only_when_certain = np.array([0, 1, None, None, None, None, None, None], dtype=object) scores = qk.evaluate.selective(truth, answered_only_when_certain) print(f"selective_accuracy {scores['selective_accuracy']:.3f} " f"at coverage {scores['coverage']:.0%}") print(f"accuracy {scores['accuracy']:.3f} <- comparable to a model that cannot abstain") for note in scores.notes: print("note:", note) ``` A perfect selective score, bought with coverage. `qk.evaluate.selective` reports both halves and makes the comparable one the **primary** metric, so `scores.score` cannot quietly become the flattering number. ## On a real model Breast cancer, three PCA components, a two-layer `VQC`. The classical bar first, as always: ```python # docs: requires sklearn from sklearn.datasets import load_breast_cancer from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler X, y = load_breast_cancer(return_X_y=True) X = PCA(n_components=3, random_state=0).fit_transform(StandardScaler().fit_transform(X)) X = qk.AngleScaler().fit_transform(X) print(f"{len(X)} samples, {X.shape[1]} features") ``` Training a `VQC` on 455 rows and evaluating on 114 held out, the model's own confidence — the larger of its two class probabilities — becomes the abstention signal. Sweeping the threshold gives this, measured: | threshold | selective accuracy | coverage | **comparable accuracy** | |---|---|---|---| | answer everything | 0.8947 | 100% | **0.8947** | | 0.60 | 0.9182 | 96.5% | 0.8860 | | 0.70 | 0.9320 | 90.4% | 0.8421 | | 0.80 | **0.9479** | 84.2% | **0.7982** | Read the two shaded columns together. As the threshold rises, selective accuracy climbs from 0.895 to **0.948** — a 5.3-point gain that would headline well. Over the same sweep the comparable accuracy *falls* from 0.895 to **0.798**. Abstention did not make this model better. It made the reported number better and the model worse, and only one of the two columns says so. For reference, `qk.baseline` puts `svc-linear` at **0.9507** on the same three features — above every row in the table, including the flattering one. ## The curve, because the point was chosen A single `(coverage, accuracy)` pair is a threshold someone picked. The whole trade is a curve, and it is what makes two abstaining models comparable: ```python # docs: requires sklearn rng = np.random.default_rng(0) labels = rng.integers(0, 2, 200) confidence = rng.uniform(0, 1, 200) predictions = np.where(confidence > 0.35, labels, 1 - labels) # right when confident curve = qk.evaluate.risk_coverage(labels, predictions, confidence, n_points=5) for c, r in zip(curve["coverage"], curve["risk"]): print(f" coverage {c:.2f} risk {r:.3f}") print(f"AURC {curve['aurc']:.4f}") ``` **AURC** — area under the risk–coverage curve — is the summary that abstaining more cannot inflate. A model that buys a high selective accuracy by refusing most of the data pays for it in the low-coverage region, where its risk is measured against the handful of samples it kept. On the `VQC` above the curve is `(0.25, 0.000)`, `(0.50, 0.000)`, `(0.75, 0.024)`, `(1.00, 0.105)` with **AURC 0.0198**: the confidence really is informative — errors concentrate where the model is unsure — which is what justifies abstaining at all, and is a separate question from whether abstaining helped. ## Which number to quote - Comparing against a model that **cannot** abstain — quote `accuracy`. It counts an abstention as an error, which is what an abstention costs you when something downstream needs an answer. - Comparing two abstaining models — quote **AURC**, or fix a coverage and compare selective accuracy *there*. Comparing at two different coverages compares nothing. - Reporting the saving in circuit executions — say what it bought and what it cost, in the same sentence. `qk.plan()` prices the run; the execution saving at prediction time is usually small beside the training budget that produced the model. ## Where this comes from The construction is classical — Chow's reject option, 1970 — and recurs in quantum machine learning because a quantum prediction is already a repeated measurement. For a recent treatment in this setting, and the measurement rule this study's shape is drawn from, see Ptáček, Lewandowska and Kukulski, *Resource-Efficient Variational Quantum Classifier*, [arXiv:2511.09204](https://arxiv.org/abs/2511.09204). Their construction discards shots whose qubits disagree and votes on the survivors, which is abstention implemented in the readout rather than in post-processing. Quoting any selective result, theirs or your own, needs the coverage beside it. That is the whole argument for `qk.evaluate.selective` reporting both. --- **Next:** back to the [study index](index.md), or the [evaluation guide](../guides/evaluation.md) for the metrics this one builds on. ============================================================================== # Guides / Guides (source: docs/guides/index.md) ============================================================================== # Guides The tutorials show you how. These explain why, and are meant to be read out of order when a particular question comes up. | | | |---|---| | **[Coming from PennyLane](from-pennylane.md)** | Borrow the three loops worth borrowing without migrating anything, the name map, and the four places the two libraries genuinely differ | | **[The parameter-shift rule](parameter-shift.md)** | Why the famous two-term formula is not the whole story, and how qmlkit derives a rule per gate instead of transcribing one | | **[Choosing a gradient method](choosing-a-gradient.md)** | Six options, a decision table, and measured costs | | **[Backends and conventions](backends.md)** | Endianness, controlled-gate ordering, precision floors, and three upstream discrepancies worth knowing about | | **[Running under noise](noise.md)** | Mixed-state backends, why noise never picks a simulator for you, and what a noise model does to a gradient | | **[Extending qmlkit](extending.md)** | Adding a gate, an ansatz, a gradient estimator or a backend — every extension point is a registry | | **[Evaluating a model honestly](evaluation.md)** | Metrics that say when they mislead, skewed classes, the classical bar, and whether a number can be trusted or reproduced | | **[Watching a run](watching-a-run.md)** | A live line with an honest ETA, what the run spent its time on, and the whole thing written out as one self-contained page | | **[Working with a coding agent](agents.md)** | Why a wrong name answers with the right one, and what `diagnose()` catches that nothing raises for | For how any of this is known to be correct, see [Validation](../about/validation.md). ============================================================================== # Guides / Coming from PennyLane (source: docs/guides/from-pennylane.md) ============================================================================== # Coming from PennyLane You do not have to migrate anything to use this library, and the cheapest way to start is not to. ## Don't migrate. Borrow the loops that are worth borrowing `qk.from_pennylane` reads a tape, a QNode or a plain quantum function — templates decompose on the way in — so a PennyLane circuit can be handed to qmlkit for the parts where qmlkit is faster and left exactly where it is for everything else. ```python # docs: skip import pennylane as qml import qmlkit as qk @qml.qnode(qml.device("default.qubit", wires=3)) def circuit(x): qml.AngleEmbedding(x, wires=range(3)) qml.StronglyEntanglingLayers(weights, wires=range(3)) return qml.expval(qml.PauliZ(0)) spec = qk.from_pennylane(circuit) # nothing above this line changes ``` Three inner loops are worth stealing, and `examples/accelerate_pennylane.py` runs all three against PennyLane's own answer before quoting a number: | Loop | Why it moves | Measured | |---|---|---| | **Kernel Gram matrix** | every entry is the *same circuit* at different angles, so the whole matrix is one batched evaluation rather than one QNode call per pair | **69×** | | **Batched gradients** | a training batch is the same circuit at one parameter vector per sample | **19.8×** on a full training step at 4 qubits | | **Fubini–Study metric** | closed-form differentiation of the state against `O(P)` adjoint or `O(P²)` Hadamard tests | **105×** at `P=24`, and it *widens* with parameter count | Per-call overhead dominates the Gram matrix so completely that `lightning.qubit` is *slower* than `default.qubit` there. The metric tensor is the one that is algorithmic rather than overhead, agrees with PennyLane's own routes to `1.7e-16`, and is the only row that gets better the bigger the problem is. Everywhere else the gap is 1.0–1.2×. See [Validation](../about/validation.md) for the whole table, including the row where PennyLane wins. ## When you guess the wrong name, the error tells you the right one Most code written against this library is written by a model, and the training data holds far more PennyLane than qmlkit — so the first guess is usually PennyLane's. Sixty foreign names are answered with the qmlkit one: ```pycon >>> qk.AngleEmbedding AttributeError: module 'qmlkit' has no attribute 'AngleEmbedding'. 'AngleEmbedding' is PennyLane's name for qmlkit.AngleFeatureMap (or angle_encode(x) for a one-shot circuit). ``` **It is a translation, not an alias — the foreign name still raises.** An alias would become API, and it would hide the drift underneath: `qml.expval` takes a QNode, `qk.expectation` takes a `CircuitSpec` and an observable. A name that silently resolved would fail later, further from its cause, with a worse message. A rough map of the ones people reach for first: | PennyLane | qmlkit | |---|---| | `qml.device` | `qk.get_backend` | | `qml.probs` / `qml.state` / `qml.sample` | `qk.probabilities` / `qk.statevector` / `qk.run_counts` | | `qml.jacobian` / `qml.grad` | `qk.grad` | | `AngleEmbedding` / `AmplitudeEmbedding` / `BasisEmbedding` | `AngleFeatureMap` / `amplitude_encode` / `basis_encode` | | `StronglyEntanglingLayers` / `BasicEntanglerLayers` | `strongly_entangling` / `basic_entangler` | | `SimplifiedTwoDesign` / `RandomLayers` / `MPS` / `TTN` | `simplified_two_design` / `random_layers` / `mps_ansatz` / `tree_tensor_network` | | `qml.math.reduced_dm` | `qk.reduced_dm` | | `qml.about()` | `qk.backend_report()` | ## Four places the two libraries genuinely differ None is a bug in either, each is pinned by its own parity test so it stays deliberate, and every one of them would be very hard to spot by eye. The full account is in [Validation](../about/validation.md#four-convention-differences); the short version: **IQP angle convention.** PennyLane's `IQPEmbedding` emits `RZ(xᵢ)`; qmlkit follows Qiskit and emits `Rz(2φ)`. A kernel differing by exactly this factor looks fine. **Amplitude-encoding phase.** qmlkit builds it from uniformly controlled rotations and drops one overall factor — unobservable alone, observable inside a *controlled* block. **Two-qubit "ring".** A ring on two qubits would revisit the same pair, so qmlkit emits one `CX` where PennyLane's templates emit both directions. On two qubits these are genuinely different circuits. **`approx="block-diag"` does not port.** PennyLane blocks the metric tensor by circuit layer and zeroes every cross-layer entry; qmlkit computes the exact metric, which costs nothing on a simulator. This one is not cosmetic: on a 3-qubit, 2-layer problem at equal step count and step size, qmlkit's QNG reaches `−2.9999999` where PennyLane's default `block-diag` QNG stalls at `−2.22`. Pointed at the exact metric (`approx=None`), PennyLane traces qmlkit's trajectory to `1e-8`. ## What PennyLane does that qmlkit does not Worth knowing before you commit to anything. - **Hardware.** qmlkit is **simulator-only for the whole `0.x` line**. If you need to run on a device, you need PennyLane. - **Gradients through a noise channel.** `default.mixed` backpropagates through the channel; qmlkit's density-matrix backends offer parameter-shift only. This is the clearest place PennyLane is ahead. - **JAX and `jit`.** Untested here and unclaimed — the benchmark machine has no JAX. - **Catalyst, pulse-level control, plugin breadth, quantum chemistry depth.** Not contested. ## What you get in exchange The layer that answers *is this result real*, which is the reason the library exists: ```python # docs: skip qk.plan(model) # what the run costs in circuits, before you pay for it qk.baseline(X, y) # the classical bar, on identical folds qk.diagnose(model) # the failures that return a number instead of raising qk.diagnose(model, X, y) # whether the quantum layer earned its place qk.selfcheck(spec, theta) # every exact gradient route against every other qk.progress() # how much of the run is left ``` ## If you do want to move a whole project ```python # docs: skip qk.from_pennylane(qnode) # a tape, QNode or quantum function; templates decompose qk.from_qasm(text) # standard library only, no extras needed qk.from_qiskit(circuit) # unbound Parameters carried onto ParamRef qk.from_cirq(circuit) # sympy symbols carried onto ParamRef ``` Qubit order is where importers actually break, so it is what the tests check: `from_qiskit(to_qiskit(spec))` reproduces the **statevector** to `1e-12` over randomly generated circuits, not merely the same list of gates. Qiskit and QASM are little-endian and get flipped; **PennyLane is big-endian like qmlkit and does not** — and that claim is asserted against PennyLane's own simulator rather than assumed. A gate qmlkit has no definition for is refused by name, never approximated. The one exception is the `u`/`u3` family, decomposed into rotations with a warning that an overall phase was dropped. ============================================================================== # Guides / The parameter-shift rule (source: docs/guides/parameter-shift.md) ============================================================================== # The parameter-shift rule Every QML course teaches this formula: $$\frac{\partial E}{\partial \theta} = \frac{E(\theta + \pi/2) - E(\theta - \pi/2)}{2}$$ It is correct — for a gate whose generator has a single frequency, differentiated one occurrence at a time. Both of those conditions are easy to violate without noticing, and violating either returns a smooth, finite, believable number rather than an error. qmlkit therefore **derives** each rule from the gate's declared generator frequencies. Nothing is transcribed. ## Where the rule comes from For a gate $U(\theta) = e^{-i\theta G/2}$, the expectation $E(\theta)$ is a trigonometric polynomial whose frequencies are the gaps between eigenvalues of $G$. A gate that declares frequencies $\{\Omega_1, \dots, \Omega_k\}$ has $$E(\theta) = a_0 + \sum_{j=1}^{k} \left[ a_j \cos(\Omega_j \theta) + b_j \sin(\Omega_j \theta) \right]$$ Reconstructing $E'(\theta)$ exactly from finitely many evaluations of $E$ is then a linear algebra problem: choose $2k$ shifts, write down what each evaluation contributes, and solve for the coefficients. `general_shift_rule` does exactly that. ```python import qmlkit as qk rule = qk.general_shift_rule((0.5, 1.0)) for shift, coeff in zip(rule.shifts, rule.coeffs): print(f"shift {shift:+.6f} coefficient {coeff:+.6f}") ``` ```text shift +1.570796 coefficient +0.426777 shift -1.570796 coefficient -0.426777 shift +4.712389 coefficient -0.073223 shift -4.712389 coefficient +0.073223 ``` Solving a linear system rather than looking up a formula means a gate you register yourself gets a correct rule automatically, as long as you declare its frequencies. ## Rules are per gate ```python import qmlkit as qk for gate in ("rx", "ry", "rz", "phase", "crx", "cry", "crz"): rule = qk.rule_for_gate(gate) print(f"{gate:<7} frequencies {str(qk.get_gate(gate).frequencies):<14} {len(rule.shifts)}-term rule") ``` ```text rx frequencies (1.0,) 2-term rule ry frequencies (1.0,) 2-term rule rz frequencies (1.0,) 2-term rule phase frequencies (1.0,) 2-term rule crx frequencies (0.5, 1.0) 4-term rule cry frequencies (0.5, 1.0) 4-term rule crz frequencies (0.5, 1.0) 4-term rule ``` A controlled rotation's generator is a projector times a Pauli. Its eigenvalue gaps include both ½ and 1, so a two-term rule cannot reconstruct the derivative. [Tutorial 3](../tutorials/03-gradients.md) has a worked case where the naive rule returns exactly √2 times the right answer — same sign, same shape, wrong magnitude. ## Occurrences are shifted one at a time When one logical parameter fills several slots, the chain rule gives a **sum** over occurrences: $$\frac{\partial E}{\partial \theta_k} = \sum_{\text{slots } s \text{ using } \theta_k} \frac{\partial E}{\partial \phi_s}$$ Shifting every occurrence simultaneously computes a directional derivative along a different direction entirely. For three tied `Ry(θ)` on one qubit — which is `Ry(3θ)` — it is wrong by a factor of −3. This is not an exotic case. It is exactly what a QCNN does, and weight tying is the entire point of a convolutional ansatz. ## Cost is not `2P` Because rules differ per gate, the real cost is a sum over slots, not a multiple of the parameter count: ```python import qmlkit as qk qc = qk.QCircuit(2) qc.ry(0, qk.ParamRef(0)).crz(0, 1, qk.ParamRef(1)) spec = qc.to_spec() print(f"P = {spec.n_params}") print(f"naive 2P = {2 * spec.n_params}") print(f"actual cost = {qk.grad_circuit_cost(spec)} (2 for the ry, 4 for the crz)") ``` ```text P = 2 naive 2P = 4 actual cost = 6 (2 for the ry, 4 for the crz) ``` `grad_circuit_cost` sums the real per-slot rule cost. On hardware, where each circuit carries fixed latency, that difference is the difference between a job that fits in your queue allocation and one that does not. ## Scaling and offsets A `ParamRef` may carry a scale and an offset — `φ = scale·θ + offset`. The chain rule then multiplies the slot's contribution by `scale`: ```python import numpy as np import qmlkit as qk qc = qk.QCircuit(1) qc.ry(0, qk.ParamRef(0, scale=2.0)) spec = qc.to_spec() g = qk.grad(spec, np.array([0.3]), qk.Z(0), method="parameter-shift") print(f"gradient {g[0]:+.10f}") print(f"2 · -sin(2·0.3) {-2 * np.sin(0.6):+.10f}") print(f"without the scale {-np.sin(0.6):+.10f}") ``` ```text gradient -1.1292849468 2 · -sin(2·0.3) -1.1292849468 without the scale -0.5646424734 ``` Dropping the `scale` factor halves the gradient here. Like the other two failure modes on this page, the result stays smooth and finite — training still descends, just at the wrong rate. ## When it still does not apply A generator with a continuum of frequencies — a general time-evolution operator, say — has no finite shift rule. The literature's answer is the **stochastic** parameter-shift rule (Banchi & Crooks 2020), which samples an integral instead. It is not implemented here; `method="adjoint"` covers those cases on a simulator, and `register_gradient` is the hook if you need the hardware-valid version. ============================================================================== # Guides / Choosing a gradient method (source: docs/guides/choosing-a-gradient.md) ============================================================================== # Choosing a gradient method Six methods, and `qk.grad(spec, theta, obs)` with no `method` picks a sensible one. This page is for when you want to override it. ## The short answer | Situation | Use | |---|---| | Simulating, and you just want the gradient | **`adjoint`** (the default) | | The circuit lives inside a torch autograd graph | **`backprop`** | | You are modelling what hardware would do | **`parameter-shift`**, with `shots` | | Hardware, and circuit count is the binding constraint | **`hadamard`**, if the ancilla can reach every wire | | Very many parameters, or very noisy evaluations | **`spsa`** | | Checking another method | **`finite-diff`** | ## The full picture | Method | Cost | Exact | Runs on hardware | |---|---|---|---| | `adjoint` | one backward pass | yes | no — needs the statevector | | `backprop` | one autograd pass | yes | no — needs the statevector | | `hadamard` | `P` circuits + one ancilla | yes | yes, given the connectivity | | `parameter-shift` | `2P` circuits, more for four-term gates | yes | yes | | `spsa` | 2 evaluations, any `P` | no — unbiased estimate | yes | | `finite-diff` | `2P` | no — `O(h²)` bias | technically, but don't | Measured on a 5-qubit hardware-efficient ansatz with a two-term observable: | `P` | `adjoint` | `backprop` | `hadamard` | `parameter-shift` | `finite-diff` | |---|---|---|---|---|---| | 20 | **2.2 ms** | 8.5 ms | 15 ms | 28 ms | 29 ms | | 60 | **6.2 ms** | 24 ms | 109 ms | 213 ms | 226 ms | | 120 | **12.6 ms** | 50 ms | 404 ms | 823 ms | 870 ms | ## Choosing a gradient is not choosing an optimiser `grad()` decides how the derivative is computed; what you do with it is separate. Four optimisers are registered, reachable by name wherever a `VQE`-style algorithm takes one: | name | needs a gradient | when | |---|---|---| | `adam` | yes | the default choice for a variational circuit of any depth | | `gradient-descent` | yes | when you want the plainest possible baseline to compare against | | `rotosolve` | no | shallow circuits where each angle drives one Pauli rotation — no learning rate to pick | | `spsa` | no | two evaluations per step whatever `P` is; the shot-budget option | `adam` is the one to reach for first. 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 — so a single learning rate either crawls on the small ones or diverges on the large. Adam divides by the running gradient magnitude, which makes the step size per-parameter. ```python from qmlkit.optim import minimize_adam import qmlkit as qk ansatz = qk.hardware_efficient(3, 2) spec, obs = ansatz.build(), qk.Z(0) theta, history = minimize_adam( lambda t: qk.expval(spec, obs, theta=t), ansatz.init("small", seed=0), lambda t: qk.grad(spec, t, obs), n_steps=40, ) print(f"{history[0]:+.4f} -> {history[-1]:+.4f}") ``` `adam_step` and `AdamState` are public for the case where the loop is yours. Keep the state between steps: dropping it turns Adam back into gradient descent with a decaying learning rate, which trains, converges, and is not what you asked for. ## What `method="auto"` decides ```python import qmlkit as qk ansatz = qk.hardware_efficient(3, 2) spec = ansatz.build() print("no shots: ", qk.choose_method(spec)) print("with shots:", qk.choose_method(spec, shots=1000)) ``` ```text no shots: adjoint with shots: parameter-shift ``` Adjoint when every gate has a closed-form derivative and the backend can produce a statevector; parameter-shift otherwise. Asking for `shots` rules out adjoint by definition — you cannot sample a statevector you are not allowed to read. Methods that need the statevector **refuse** a shot budget rather than silently ignoring it: ```python import qmlkit as qk ansatz = qk.hardware_efficient(2, 1) spec, theta = ansatz.build(), ansatz.init(seed=0) try: qk.grad(spec, theta, qk.Z(0), method="adjoint", shots=1000) except ValueError as exc: print(exc) ``` ## Notes on each **`adjoint`** — one forward pass and one backward pass, whatever `P` is. Exact. The right default on a simulator, and the reason this library exists in a simulator-only 0.x: it makes the cost of a gradient independent of the parameter count. **`backprop`** — differentiates a torch statevector simulator directly. Exact, and slower than adjoint for a standalone gradient because of per-gate tensor overhead. Its reason to exist is that the circuit sits *inside* an autograd graph, which is what `QuantumLayer` needs. It is also the least physical method here: it reads intermediate states no device will expose, and its memory grows with depth. How much slower, measured on a hardware-efficient ansatz: | qubits | `P` | `adjoint` | `backprop` | `parameter-shift` | |---|---|---|---|---| | 3 | 12 | **1.4 ms** | 4.7 ms | 8.2 ms | | 4 | 24 | **2.3 ms** | 9.0 ms | 31.5 ms | | 6 | 36 | **3.8 ms** | 15.9 ms | 79.2 ms | | 8 | 32 | **3.6 ms** | 16.3 ms | 93.5 ms | Batched over a training batch of 128, the gap widens and `backprop` drops out entirely - it has no batched form, and `grad_batch` says so rather than falling back: | qubits | `P` | `adjoint` | `parameter-shift` | |---|---|---|---| | 4 | 16 | **7.6 ms** | 58.7 ms | | 6 | 24 | **28.3 ms** | 430.9 ms | | 8 | 32 | **118 ms** | 2552 ms | This is worth stating because the intuition travels badly. In a framework where every circuit evaluation goes through a dispatch layer, `backprop` can be dramatically *faster* than `adjoint` - the dispatch dominates, and backprop pays it once instead of once per parameter. qmlkit's adjoint is a direct NumPy sweep with no dispatch to amortise, so the ranking inverts. If you arrive expecting backprop to win, measure before switching; `method="auto"` already picks the fast one here. **`hadamard`** — one circuit per parameter instead of two, using an ancilla in `|+⟩` and a controlled generator. Unlike adjoint it is a real measurement, so it stays valid on hardware. The trade is an ancilla that must couple to every wire the generator touches; on real devices that routing cost usually eats the saving, which is why parameter-shift stays the hardware default. It refuses controlled rotations rather than guessing, because their generators are not Paulis. **`parameter-shift`** — exact, hardware-valid, and the one worth understanding in detail: see [The parameter-shift rule](parameter-shift.md). **`spsa`** — two evaluations per gradient regardless of `P`. Stochastic but unbiased, so averaging converges on the true gradient. Use `n_avg` to trade evaluations for variance. **`finite-diff`** — biased by construction at `O(h²)`, and noisy at `O(1/h)` when sampling. It exists to check other methods. It should never be the method you train with, and the fact that it sometimes lands within `1e-9` is luck, not accuracy. ## Bringing your own ```python import numpy as np import qmlkit as qk @qk.register_gradient("my_estimator") def my_estimator(spec, theta, obs, *, backend=None, shots=None, **kwargs): return np.zeros(spec.n_params) print("my_estimator" in qk.list_gradient_methods()) ``` Once registered it is a keyword everywhere the library takes `method=`, including `QuantumLayer`. See [Extending qmlkit](extending.md). ============================================================================== # Guides / Backends and conventions (source: docs/guides/backends.md) ============================================================================== # Backends and conventions One circuit, eight backends, one answer. `tests/test_cross_backend.py` runs the same circuit zoo through every installed backend and asserts agreement with the NumPy reference on statevectors, probabilities, expectations over X/Y/Z and two-body terms, seeded sampling, and parameter-shift gradients. | | simulator | when | |---|---|---| | `numpy` | the built-in reference | the default. Exact, vectorised, fastest up to ~13 qubits | | `aer` | `AerSimulator(method="statevector")` | **above ~13 qubits** — C++, and it does not degrade the way a Python gate loop does | | `qiskit` | `quantum_info.Statevector` | Qiskit's reference. Exact, no extra install, and the slowest of the three | | `cirq` | `cirq.Simulator` | Cirq's reference | | `spinqit` | SpinQit's simulator | the diploma's own SDK; Python 3.10 only | | `torch` | a differentiable simulator | `backprop` only | | `cirq-density` · `qiskit-aer` | density matrices | noise, when you ask for it by name | ### Which one, in practice The NumPy reference wins at the widths quantum machine learning actually runs at, because it carries the batch as a leading axis and contracts gate by gate rather than sample by sample. That advantage is a Python-level one, and it stops paying once the statevector gets big: | qubits | `numpy` | `qiskit` | `aer` | |---|---|---|---| | 10 | **0.46s** | 0.98s | 0.59s | | 12 | **0.92s** | 1.60s | 0.97s | | 14 | 1.52s | 2.16s | **1.27s** | | 16 | 9.87s | 13.03s | **0.60s** | | 18 | 25.91s | 35.99s | **0.66s** | *(a Gram matrix on a `ZZFeatureMap`; single machine, exact throughout.)* The crossover is around **13 qubits** and it is abrupt, because it is the same one that turns off NumPy's batching — `NumpyBackend.batch_max_qubits` is 10, and above it the reference falls back to a loop while Aer stays in C++. If you are working wider than a dozen qubits, `backend="aer"` is the answer; below it, the default already is. `aer` is deliberately a separate name rather than a silent upgrade to `qiskit`, so the simulator that produced a number stays visible in the code that produced it. ```python import qmlkit as qk print(qk.available_backends()) print(qk.backend_report()) ``` Every SDK import is lazy, so `import qmlkit` requires none of them and a missing one produces an install command rather than an `ImportError`. Set the default with `QMLKIT_BACKEND` or `qk.set_default_backend(...)`, or pass `backend=` per call. ## Qubit ordering **qmlkit is big-endian: qubit 0 is the most significant bit.** A count key `'011'` means qubit 0 measured `|0⟩`, qubit 1 `|1⟩`, qubit 2 `|1⟩`. This matches SpinQit and PennyLane. Qiskit is little-endian. Rather than reversing statevectors after the fact, the Qiskit backend maps qmlkit qubit `i` to Qiskit qubit `n−1−i` **at build time**, so the index conventions coincide and no reversal is needed anywhere downstream. ## Three upstream discrepancies Building the cross-backend suite turned up three real differences. All are handled; all are worth knowing about if you go looking at the native circuits. | Finding | Handling | |---|---| | **SpinQit's `CY` applies `−iY`**, not `Y`, to the control-1 subspace | Emitted as `Sd·CX·S` instead. This is a *relative* phase between control branches, so it is physically observable — a control qubit in superposition gives different measurement statistics. SpinQit's single-qubit `Y` is correct; only the controlled form is affected | | **Cirq silently drops qubits a circuit never touches** | An explicit `qubit_order` is always passed, so an idle qubit still occupies its place in the statevector | | **Qiskit is little-endian** | Index remapping at build time, as above | `verify_conventions()` re-checks bit order and gate definitions against a live install in one call — worth running after an SDK upgrade. ## Precision SpinQit's simulator carries a floor near `1e-10` rather than machine precision: a single-qubit `Ry(0.7)` expectation lands about `5.6e-11` from the analytic `cos(0.7)`. Cross-backend comparisons use a per-backend tolerance so this is not mistaken for a translation error, and it is worth knowing before anyone reports a "gradient mismatch" that is really accumulated simulator noise. ```text TOLERANCE = {"spinqit": 1e-7, "qiskit": 1e-9, "cirq": 1e-9} ``` ## Native circuits Each backend exposes its own object, so you can hand a circuit to that SDK's transpiler or drawing tools: ```python # docs: requires qiskit import qmlkit as qk qc = qk.QCircuit(2) qc.h(0).cx(0, 1) print(type(qk.get_backend("qiskit").to_qiskit(qc.to_spec())).__name__) ``` `to_qiskit`, `to_cirq` and `to_spinqit` are the three. ## Reading circuits in The translations run both ways. `to_qiskit`/`to_cirq`/`to_spinqit` hand a circuit to another SDK; `from_qasm`/`from_qiskit`/`from_pennylane`/`from_cirq` bring one back. ```python import qmlkit as qk spec = qk.from_qasm(""" OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; h q[0]; cx q[0],q[1]; """) print(spec.n_qubits, len(spec.ops)) ``` `from_qasm` uses the standard library alone, so it works in a bare `pip install qmlkit`. Every major SDK exports OpenQASM 2.0, which makes it the widest import path the library has. `from_qiskit` exists next to it for the one thing QASM cannot represent — an unbound `Parameter`, which becomes a `ParamRef` indexed in Qiskit's own parameter order: ```python # docs: requires qiskit from qiskit import QuantumCircuit from qiskit.circuit import Parameter qc = QuantumCircuit(2) qc.ry(Parameter("theta"), 0) spec = qk.from_qiskit(qc) print(spec.n_params) ``` `from_cirq` is the interesting one, because Cirq has no gate *names* to look up. `cirq.S`, `cirq.T` and `cirq.rz` are all a `ZPowGate`; what separates them is the exponent and the `global_shift`. So the importer classifies rather than reads a table, and it reads `global_shift` rather than ignoring it — `cirq.X` and `cirq.rx(pi)` differ by a global phase, which is unobservable alone and *relative* inside a controlled block. ```python # docs: requires cirq import cirq import sympy t = sympy.Symbol("t") qubits = cirq.LineQubit.range(2) spec = qk.from_cirq(cirq.Circuit([cirq.rx(2 * t).on(qubits[0]), cirq.CZ(*qubits)])) print(spec.n_params, spec.ops[0].params[0]) ``` `cirq.rx(2 * t)` carries the exponent `2*t/pi`; multiplied back by pi that is `2*t`, and `ParamRef` models exactly `scale * theta + offset`, so it survives as `ParamRef(0, scale=2.0)`. Symbols are indexed by sorted name, matching `sorted(cirq.parameter_names(circuit))`. Anything nonlinear is refused. One asymmetry to know about: Cirq has no declared register, so a qubit that no operation touches is not in the circuit at all. The same logical circuit imports two qubits wide from Qiskit and one from Cirq. ```python # docs: requires cirq print(qk.from_cirq(cirq.Circuit([cirq.X(cirq.LineQubit(0))])).n_qubits) # 1 print(qk.from_cirq(cirq.Circuit([cirq.X(cirq.LineQubit(0)), cirq.I(cirq.LineQubit(1))])).n_qubits) # 2 ``` ### The convention that matters Importing is where qubit order goes wrong quietly. Qiskit and QASM are little-endian, so their qubit `j` becomes qmlkit's `n-1-j` — the exact inverse of what `to_qiskit` does on the way out. PennyLane and Cirq are big-endian like qmlkit, so their wires pass through. Neither claim is taken on trust. `tests/test_import.py` asserts the *statevector* after `from_qiskit(to_qiskit(spec))` over randomly generated circuits at `1e-12`, and checks the PennyLane and Cirq importers against those libraries' own simulators. It also asserts that every gate `to_cirq` emits comes back through `from_cirq`, since a gate qmlkit can write but not read is a one-way door. A circuit whose gates are symmetric across the register cannot tell a correct mapping from a reversed one, so the test zoo is deliberately asymmetric. ### What is refused A gate with no qmlkit definition raises `UnsupportedGate` naming it, rather than being dropped or approximated. So do `measure` and `reset`, which the 0.x line does not model. The single exception is the `u`/`u3` family: it is decomposed into `rz·ry·rz` and warns that an overall phase was dropped — unobservable for the circuit alone, and a *relative* phase if that circuit is later used inside a controlled block. ## SpinQit needs its own environment SpinQit ships wheels for Python 3.8–3.10 only and pins `numpy<2`, so the extra is gated behind an environment marker and resolves to nothing on 3.11+. Use a dedicated 3.10 environment: ```bash conda create -n spinq python=3.10 && conda activate spinq && pip install "qmlkit[spinqit]" ``` A practical consequence: **nothing in the library may use a NumPy-2-only API** (`np.trapezoid`, `np.in1d`, …), because the test suite has to pass under `numpy<2` as well. CI runs both. ## The torch backend `TorchBackend` is a differentiable statevector simulator, and it is what makes `method="backprop"` possible. It is the least physical backend here — deliberately — and exists because a circuit inside an autograd graph is genuinely useful, not because it could ever run anywhere but a simulator. ============================================================================== # Guides / Running under noise (source: docs/guides/noise.md) ============================================================================== # Running under noise qmlkit is simulator-only, but a simulator does not have to be a *perfect* one. Two backends evolve a density matrix and take a noise model, so you can ask what a circuit does on a device that makes mistakes. | Backend | Simulator | Noise model | Install | |---|---|---|---| | `cirq-density` | `cirq.DensityMatrixSimulator` | any `cirq` channel or `cirq.NoiseModel` | `pip install 'qmlkit[cirq]'` | | `qiskit-aer` | `AerSimulator(method="density_matrix")` | `qiskit_aer.noise.NoiseModel`, including `NoiseModel.from_backend(...)` | `pip install 'qmlkit[aer]'` | `qiskit-aer` is a separate distribution from `qiskit`: installing the Qiskit backend does not give you this one. SpinQit has no noisy simulator — its noisy path is the real NMR hardware, which `0.x` does not target. ## You always name the backend Noise never selects a simulator for you. ```python # docs: requires cirq import cirq import qmlkit as qk backend = qk.get_backend("cirq-density", noise=cirq.depolarize(0.01)) ``` Asking for noise without naming a mixed-state backend is an error, not a guess: ```python # docs: requires cirq try: qk.get_backend(noise=cirq.depolarize(0.01)) except ValueError as exc: print(str(exc).splitlines()[0]) # noise was given, but the default backend evolves a pure state and cannot carry it. ``` This is deliberate. A mixed-state run costs more, refuses two of the gradient methods, and answers a *different question* — so which simulator produced a number should be visible in the code that produced it, not inferred from a global default. ## Noise and shot noise are separate, and stay separate `shots=None` on a noisy backend is not a contradiction. The density matrix is evolved exactly; what is exact is the answer *given the noise model*. ```python # docs: requires cirq import numpy as np spec = qk.angle_encode([0.7]) noisy = qk.get_backend("cirq-density", noise=cirq.depolarize(0.05)) exact_pure = qk.expectation(spec, qk.Z(0), backend="numpy") exact_noisy = qk.expectation(spec, qk.Z(0), backend=noisy) sampled = qk.expectation(spec, qk.Z(0), backend=noisy, shots=10_000, seed=0) print(f"no noise, no shots {exact_pure:+.4f}") # cos(0.7) print(f"noise, no shots {exact_noisy:+.4f}") # cos(0.7) * (1 - 4p/3) print(f"noise and shots {sampled:+.4f}") ``` Being able to turn one off is the point. Decoherence and sampling error both pull a number around, and studying either one with the other layered on top means never knowing which you are looking at. Ask for `shots=N` when you want both — that is what a device would give you. With no noise model at all, these backends reproduce the pure-state ones to machine precision. That is not a trivial case; it is what makes the noisy numbers trustworthy, and `tests/test_noisy_backends.py` asserts it across circuits, observables and both SDKs. ## What is refused There is no statevector, so the two gradient methods that differentiate one decline: ```python # docs: requires cirq ansatz = qk.hardware_efficient(2, 2) theta = np.linspace(0.1, 1.2, ansatz.n_params) try: qk.grad(ansatz.build(), theta, qk.Z(0), method="adjoint", backend=noisy) except ValueError as exc: print(str(exc)[:60]) ``` Refusing is the whole point. `adjoint` and `backprop` would have quietly differentiated a *noiseless* circuit and handed back a machine-precision gradient to someone who asked about a noisy one — a number that is wrong in a way no assertion would catch. `parameter-shift` works, because a shift rule never inspects a state — it evaluates the same circuit at shifted angles, which is exactly what a device does: ```python # docs: requires cirq g = qk.grad(ansatz.build(), theta, qk.Z(0), method="parameter-shift", backend=noisy) print(g.shape) ``` `qk.grad_batch` routes through the same path, so a batched training step works under noise too. ## What noise does to trainability This is the number worth knowing before you spend a week on an experiment. A 3-qubit, 3-layer hardware-efficient ansatz, gradient norm averaged over 8 random parameter vectors, against depolarizing strength: | `p` | mean ‖∇‖ | vs noiseless | purity | |---|---|---|---| | 0 | 0.9145 | 1.000 | 1.000 | | 0.005 | 0.8257 | 0.903 | 0.753 | | 0.01 | 0.7425 | 0.812 | 0.585 | | 0.02 | 0.5052 | 0.552 | 0.334 | | 0.05 | 0.3243 | 0.355 | 0.151 | | 0.1 | 0.1270 | 0.139 | 0.126 | The gradient direction survives — it correlates above 0.99 with the noiseless one at `p = 0.05` — but the *magnitude* collapses. That is the failure mode: the cost landscape flattens toward the maximally mixed state, and it flattens faster with depth. Past some point the gradient is smaller than the standard error of the shot budget you can afford, and training stops working for a reason no amount of tuning fixes. `qk.plan()` will tell you the standard error your shot budget buys. Comparing it to the gradient norm above is the arithmetic that decides whether an experiment is feasible at all. ```python # docs: requires cirq print(f"purity under 5% depolarizing: {noisy.purity(ansatz.build(theta)):.4f}") ``` `purity()` is the cheapest single number that says how much the noise model actually did. `1.0` is a pure state; `1/2**n` is maximally mixed. ## Traps **A fidelity kernel loses its unit diagonal.** `k(x, x) = 1` is a fact about a noiseless compute-uncompute circuit. Under noise the circuit does not return to `|0⟩`, so the diagonal drops below one — and anything that fills the diagonal in by assumption is writing down a number it did not measure. ```python # docs: requires cirq x = np.array([0.4, 0.5]) kernel = qk.QuantumKernel(qk.ZZFeatureMap(2), backend=noisy) print(f"k(x, x) under noise: {float(np.atleast_2d(kernel(x, x))[0, 0]):.4f}") # < 1 ``` **The diagnostic thresholds were calibrated on exact gradients.** `qk.diagnose()` reports `FLAT_GRADIENTS` against a cutoff chosen for shot-free, noiseless runs. Under noise, gradients are genuinely smaller, so that finding fires more readily than on an exact backend — the deep ansatz below is clean at `p = 0` and flagged at `p = 0.1`. The finding says which of the two it is measuring, but it cannot separate them for you: read it as "the gradient is small here", which is true, rather than "the ansatz is badly designed", which may not be. Rerun on `numpy` to tell them apart. Two of `diagnose()`'s probes need a statevector — whether a parameter is dead, and whether the circuit entangles — and a mixed-state backend has none. Both are questions about the *ansatz* rather than the device, so they run on the exact reference, and the report's subject line says so rather than substituting quietly: ```python # docs: requires cirq report = qk.diagnose(qk.hardware_efficient(3, 2), backend="cirq-density", n_samples=6) print(report.subject) # hardware_efficient on 3 qubits [structure checked on the numpy reference: ...] ``` That notice is not a finding, so `if qk.diagnose(model):` keeps meaning "something is wrong". **Some measures do not exist on a mixed state, and say so.** Expressibility, Meyer-Wallach entangling capability and the Fubini-Study metric tensor are defined between *state vectors*. Asking for one on a density-matrix backend is refused by name rather than approximated: ```python # docs: requires cirq try: qk.metrics.entangling_capability(qk.hardware_efficient(2, 1), n_samples=4, backend=noisy) except ValueError as exc: print(str(exc)[:96]) # entangling_capability is defined on a pure state, and the 'cirq-density' backend ... ``` Note the difference from `diagnose()`, which substitutes the reference instead. That is not an inconsistency: `diagnose()` was asked to check a *model*, and whether a parameter is dead is a property of the ansatz. Here you named a backend and asked for a measure on it, so the honest answer is that the measure is not defined there. To ask what the noise did to a particular circuit, use `purity()`. **Cirq applies a bare channel after every moment.** `cirq.depolarize(p)` passed as a noise model becomes a `ConstantQubitNoiseModel` — every qubit, every moment, including idle ones. That is a reasonable first model and a poor imitation of a real device. Pass a `cirq.NoiseModel`, or lift one off hardware with Qiskit's `NoiseModel.from_backend(...)`, when the answer needs to mean something. ## What qmlkit does not do **Error mitigation.** No ZNE, PEC or CDR here. [Mitiq](https://mitiq.readthedocs.io) is backend-agnostic and already good at this, and a qmlkit backend is an executor function away from it. Reimplementing it would be a worse copy of a solved problem. **Error correction.** Simulating a code with a QML circuit inside it is not a library gap — an encoded VQC is non-Clifford, so the stabilizer simulators that make QEC tractable at scale do not apply, and dense simulation of the encoded register is out of reach. [Stim](https://github.com/quantumlib/Stim) is the tool for codes. What is missing and worth having is the honest comparison: whether mitigation actually *improved* an estimate, or only traded bias for variance, measured on identical seeds with the shot cost stated. That is the shape of `qk.baseline`, and it is where this will go next. ============================================================================== # Guides / Evaluating a model honestly (source: docs/guides/evaluation.md) ============================================================================== # Evaluating a quantum model honestly Three things go wrong between a trained model and a reported result, and none of them raises an exception. 1. **The metric flatters the model.** Accuracy on a skewed dataset is high for a model that has learned to predict the majority class. 2. **The comparison is missing.** "Compared to what?" is the first question a reviewer asks, and the RBF-kernel SVM usually never got run. 3. **The number cannot be reproduced.** Library version, SDK version, backend and seed all move the answer, and none of them is recorded. This guide covers the three modules that close those gaps: [`qmlkit.evaluate`](../reference/evaluation.md), [`qmlkit.imbalance`](../reference/evaluation.md) and [`qk.baseline`](../reference/evaluation.md). ## Every metric for the task, in one call `qk.evaluate` groups metrics by task rather than making you assemble them: ```python import numpy as np import qmlkit as qk y_true = np.array([0] * 95 + [1] * 5) y_pred = np.zeros(100, dtype=int) # a model that always says "class 0" scores = qk.evaluate.classification(y_true, y_pred) print(round(scores["accuracy"], 3), round(scores["balanced_accuracy"], 3)) ``` That prints `0.95 0.5`. The model has learned nothing, and accuracy says 95%. The point of returning every metric at once is that the disagreement between them stays visible. `Scores` also says so directly: ```python print(scores.primary) print(scores.notes[0][:60]) ``` `primary` is `balanced_accuracy` here rather than `accuracy`, because the class distribution makes accuracy unusable — and `notes` explains why in a sentence. There are four tasks: `classification`, `regression`, `clustering` and `generative`. Each returns the same `Scores` object, which indexes like a dict: ```python reg = qk.evaluate.regression([1.0, 2.0, 3.0, 4.0], [1.1, 1.9, 3.2, 3.8]) sorted(reg.keys())[:4] ``` Nothing in `qmlkit.evaluate` needs scikit-learn. Everything in it is asserted equal to scikit-learn in `tests/test_evaluate.py`, on randomly generated inputs — the same cross-validation-against-a-second-implementation approach used for the [PennyLane parity suite](../about/validation.md). ## Skewed classes Imbalance breaks the loss and the split, not only the score. `qk.imbalance.imbalance_report` says which: ```python report = qk.imbalance.imbalance_report(y_true) print(report.codes) ``` Each finding carries the call that fixes it. The three that matter: ```python qk.imbalance.class_weights(y_true) # {0: 0.526..., 1: 10.0} - for a weighted loss qk.imbalance.pos_weight(y_true) # 19.0 - for BCEWithLogitsLoss train, test = qk.imbalance.stratified_split(y_true, test_size=0.2, seed=0) int(y_true[test].sum()) # 1, never 0 ``` A random 80/20 split of this data leaves the minority class out of the test set entirely about a third of the time, which makes the test score noise. `stratified_split` and `stratified_folds` guarantee it cannot happen. `VQC` takes the weighting directly, computed from the `y` passed to `fit`: ```python # docs: requires torch model = qk.VQC(n_features=2, n_classes=2, class_weight="balanced") ``` `focal_gamma=2.0` additionally down-weights examples the model already gets right — worth reaching for when the majority class is not merely abundant but trivially separable. On a variational circuit that matters more than it does classically, because gradient signal spent on easy examples is a budget measured in circuits. ## The classical bar `qk.baseline` runs every classical baseline on the same folds, the same metric and the same preprocessing as the model under test: ```python X, y = qk.datasets.make_moons(n_samples=60, seed=0) table = qk.baseline(X, y, cv=3, seed=0, include=["majority", "rbf-kernel-ridge"]) print(table.best_classical.name) ``` `rbf-kernel-ridge` is the one to watch for a quantum kernel method: it is the *identical* algorithm — a closed-form kernel ridge solve — differing only in which kernel fills the Gram matrix. Any gap between it and a quantum kernel is attributable to the kernel and nothing else. Pass `model=` and the model joins the table with a verdict: ```python table = qk.baseline(X, y, model=qk.baselines.NearestCentroid(), cv=3, seed=0, include=["majority"]) print(table.beats_classical) ``` The verdict does not call a lead a result when the lead is smaller than the fold-to-fold spread: > `quantum (0.810) leads svc-rbf (0.800) by 0.010, which is inside the fold-to-fold > spread (0.071) — not yet a result` Baselines that need scikit-learn are listed as **skipped** when it is absent rather than dropped, because a table that quietly omits the strong baseline is the problem this module exists to solve. The companion check for kernel methods is `qk.geometric_difference`, which asks whether the quantum kernel reaches a geometry the classical one cannot. A large geometric difference with no accuracy gain is a real finding; a small one says the classical kernel was always going to be enough. ## Sweeping everything tunable `qk.search` takes any axis as a list and leaves the rest at its default: ```python # docs: requires torch import qmlkit as qk X, y = qk.datasets.make_moons(n_samples=60, seed=0) result = qk.search(X, y, n_layers=[1, 2], lr=[0.05, 0.15], epochs=8, cv=2, verbose=False) print(result.verdict) ``` Ansätze and feature maps are named through the registries, so anything you registered with `register_ansatz` or `register_feature_map` joins the grid with no special handling. `qk.AXES` lists every axis; a name outside it is an error with a suggestion, because a typo'd axis is a sweep that silently varies nothing. ### It skips what the diagnostics already condemn This is the part a grid search cannot normally do. Before fitting anything, each assembled model goes through [`qk.diagnose`](#after-the-run-was-it-right-and-can-it-be-repeated), and configurations that are already broken are reported with their reason instead of costing a full fit and then sitting in the table looking merely unlucky: ```python # docs: requires torch import numpy as np rng = np.random.default_rng(0) Xw = rng.uniform(0, np.pi, (60, 4)) yw = (Xw[:, 0] + Xw[:, 1] > np.pi).astype(int) plan = qk.search(Xw, yw, ansatz=["hardware_efficient", "basic_entangler"], n_layers=[2, 3], cv=2, dry_run=True, prune="untrainable") print(len(plan.pruned), "of", len(plan.rows), "skipped before fitting") ``` Pruning is by finding *code*, not severity — `DEAD_WEIGHTS` is a warning meaning "wasteful" while `FLAT_GRADIENTS` is a warning meaning "cannot learn". `prune="error"` is the default and skips only what cannot work at all; `"untrainable"` adds flat gradients; `"warning"` is aggressive enough to empty a grid, since `hardware_efficient` carries an `UNMEASURABLE_WEIGHTS` finding by construction. Every configuration is diagnosed either way, and its findings print beside its score. `dry_run=True` builds and prunes the grid without fitting anything, so a sweep that would take a week is something you learn in a second. ## Before the run: what it will cost `qk.plan` computes the circuit budget from the ansatz, the training set size and the gradient method, and lists the cheaper routes with what each gives up: ```python budget = qk.plan(qk.hardware_efficient(4, 3), n_samples=100, steps=50, shots=1024) print(f"{budget.circuits:,} circuits, {budget.hours(0.5):.1f} hours at 0.5 s each") print([r.name for r in budget.reductions]) ``` Qubit-wise-commuting grouping is counted rather than assumed, so a four-term observable that shares one measurement setting is costed as one circuit, not four. ## After the run: was it right, and can it be repeated `qk.selfcheck` computes the gradient by every exact route available — adjoint, backprop, Hadamard-test, parameter-shift — and compares them. They share the circuit IR and almost nothing else, so agreement is evidence and disagreement localises the method that is wrong: ```python ansatz = qk.hardware_efficient(3, 2) check = qk.selfcheck(ansatz.build(), np.full(ansatz.n_params, 0.3), qk.Z(0)) bool(check) # False: every route agrees, nothing to report ``` This is what to run when a number looks wrong and nothing raised. It catches a custom gate with wrong declared `frequencies`, which produces a plausible gradient rather than an error. `qk.fingerprint` records the stack that decided the number: ```python stamp = qk.fingerprint(seed=0, shots=1024) sorted(stamp.as_dict())[:4] ``` It is JSON-serialisable, so it is cheap enough to attach to every result file. ## Putting it together The order these run in is the order the questions arise: | When | What to call | |---|---| | Before training | `qk.imbalance.imbalance_report(y)` · `qk.plan(model, ...)` · `qk.diagnose(ansatz)` | | During | `class_weight="balanced"` · `stratified_folds(y)` | | After | `qk.evaluate.classification(...)` · `qk.baseline(X, y, model=...)` | | Before publishing | `qk.selfcheck(...)` · `qk.fingerprint(...)` | ============================================================================== # Guides / Watching a run (source: docs/guides/watching-a-run.md) ============================================================================== # Watching a run `qk.plan` says what a run will cost before it starts. `qk.diagnose` says what went wrong after it finishes. In between there was nothing, and in between is where the hours go. A quantum kernel on a few hundred points is tens of thousands of circuits. Every library in this field runs them behind a single call that returns when it returns, and *how much is left* is one of the most common questions asked about all of them. ```python # docs: run import numpy as np import qmlkit as qk rng = np.random.default_rng(0) X = rng.uniform(0, np.pi, (24, 2)) kernel = qk.QuantumKernel(qk.ZZFeatureMap(2), shots=64, seed=0) with qk.progress(live=False) as run: # live=True draws the line below gram = kernel(X) print(run.report()) ``` ```text kernel gram 3,412/12,720 27% 14.2s elapsed ~37s left ``` Nothing is printed unless you enter `qk.progress()`, and the live line goes to stderr, so piping a script's stdout to a file does not collect redraws. ## Where the time actually went ```python # docs: skip print(run.report()) ``` ```text Run finished in 51.4s kernel gram 12,720 items 47.9s 3.77 ms/item fit VQC 600 items 3.2s 5.33 ms/item (untracked) 0.3s - setup, data handling, and anything outside a task ``` The `(untracked)` row is there because the first thing a timing breakdown usually gets wrong is implying that the parts it measured are the whole run. On a short run it is often the largest row, and that is worth seeing. ## Three properties, in this order **It does not change any number.** A seeded kernel and a seeded circuit produce bit-identical results watched and unwatched, and a test asserts exactly that. A reporter that perturbed a result would be a worse defect than the silence it replaces. **It is free when nobody is watching.** With no active reporter, the tracking calls inside the library cost one comparison against `None`, and the live line redraws at most ten times a second however fast the loop runs. **It does not claim to know what it does not.** An estimate from four items in half a second says more about scheduling noise than about the run, so it prints `estimating` until there is evidence — the same rule the rest of the library follows about reporting a measurement. ## The trajectory, not just the timings `HybridModel.fit` — so `VQC` and `VQRegressor` — logs three series per epoch without being asked: | | | |---|---| | **loss** | whether it is learning | | **gradient norm** | whether it *can*. A plateau and a solved problem look identical in loss | | **parameter norm** | whether the weights are running away, which looks like nothing at all in a loss curve | Computing the two norms costs a pass over the parameters, so it happens only when something is watching. Anything else can log its own: ```python # docs: skip with qk.progress() as run: run.note(dataset="breast-cancer", seed=0) # facts about the run for step, value in enumerate(history): run.log("validation auc", value, step) ``` ## One file, no server ```python # docs: skip run.save_html("run.html") ``` Timings, every logged series as a chart, and the versions and settings the run was made with — in a single self-contained page with no assets directory and nothing to fetch. It is deliberately a *file* rather than a dashboard. A dashboard you have to start, connect to and keep alive is a dependency, a port and a process; a page you can open, email, and leave next to the result in a directory is none of those and outlives all of them. The charts are inline SVG for the same reason: nothing to pin, nothing to break in two years. A flat series is labelled `flat — never moved` rather than drawn as a straight line at an arbitrary height, because a loss curve that never moved is the run you most want to look at, and a picture that hides it is worse than no picture. ## Counting your own loop ```python # docs: skip for row in qk.track(rows, "rows"): ... ``` `qk.track` counts an iterable as it is consumed and takes its total from `len` when there is one. Inside the library, `qmlkit.progress.task` is the equivalent — it hands back a silent stand-in when nothing is watching, so a loop never has to branch on whether anyone is there. Already instrumented: the pair-at-a-time kernel Gram matrix — the path that takes hours, used for sampled kernels, non-inversion estimators, and any backend without a statevector — and `HybridModel.fit`. ============================================================================== # Guides / Extending qmlkit (source: docs/guides/extending.md) ============================================================================== # Extending qmlkit Almost every extension point is a registry. Register something and it becomes reachable by name everywhere the library takes one — no subclassing, no plugin manifest, no coordination with anything else, and no fork. | I want to change | Use | And it becomes reachable from | |---|---|---| | The circuit shape | `register_ansatz` — or build an `Ansatz` inline | `get_ansatz`, `qk.search`'s `ansatz=` axis, `compare_ansatze` | | A gate the library lacks | `register_gate` | every circuit, every backend, every gradient method | | The two-qubit block inside a QCNN | `register_conv_filter` | `conv_block`, `qcnn_ansatz`, `mps_ansatz`, `tree_tensor_network` | | How data becomes angles | `register_feature_map` | `qk.search`'s `feature_map=` axis | | How gradients are estimated | `register_gradient` | `method=` anywhere, including `QuantumLayer` | | Where circuits run | `register_backend` | `backend=`, `QMLKIT_BACKEND`, `backend_report()` | | What the classical bar is | `register_baseline` | `qk.baseline`'s comparison table | | Reading circuits in from elsewhere | `register_importer` | `get_importer`, `list_importers` | Two things are extended **without** a registry, and that is deliberate — see [a new optimiser](#a-new-optimiser) and [a new algorithm](#a-new-algorithm). ## A new ansatz ```python import qmlkit as qk @qk.register_ansatz("my_ladder") def my_ladder(n_qubits, n_layers=2): block = qk.RotationLayer(("ry", "rz")) + qk.EntanglerLayer("cx", "chain") return qk.Ansatz(n_qubits, qk.repeat(n_layers, block), "my_ladder") ansatz = qk.get_ansatz("my_ladder", n_qubits=3, n_layers=2) print(ansatz) print(qk.draw(ansatz.build())) ``` It now has correct gradients, resource counting, drawing, an `AnsatzReport`, and a `QuantumLayer` — none of which you wrote. The parameter count is inferred from a dry build, so there is nothing to miscount. ## A new gate A gate needs a matrix. Declare its **generator frequencies** and parameter-shift works on it; add a **derivative matrix** and adjoint differentiation works too. ```python import numpy as np import qmlkit as qk def _sqrt_x(_=None): return 0.5 * np.array([[1 + 1j, 1 - 1j], [1 - 1j, 1 + 1j]], dtype=complex) qk.register_gate(qk.GateDef("sx", n_qubits=1, n_params=0, matrix=_sqrt_x)) qc = qk.QCircuit(1) qc.apply("sx", 0) print(np.round(qk.statevector(qc.to_spec()), 4)) ``` For a *parameterised* gate, the two optional fields are what unlock differentiation: ```python import numpy as np import qmlkit as qk def _rzz(theta): return np.diag([np.exp(-0.5j * theta), np.exp(0.5j * theta), np.exp(0.5j * theta), np.exp(-0.5j * theta)]) def _d_rzz(theta): return np.diag([-0.5j * np.exp(-0.5j * theta), 0.5j * np.exp(0.5j * theta), 0.5j * np.exp(0.5j * theta), -0.5j * np.exp(-0.5j * theta)]) qk.register_gate(qk.GateDef( "rzz", n_qubits=2, n_params=1, matrix=_rzz, frequencies=(1.0,), # -> a correct 2-term shift rule, derived not transcribed dmatrix=_d_rzz, # -> adjoint differentiation )) qc = qk.QCircuit(2) qc.h(0).apply("rzz", (0, 1), qk.ParamRef(0)) spec, theta = qc.to_spec(), np.array([0.7]) shift = qk.grad(spec, theta, qk.X(0), method="parameter-shift") adj = qk.grad(spec, theta, qk.X(0), method="adjoint") print(f"parameter-shift {shift[0]:+.10f}") print(f"adjoint {adj[0]:+.10f}") print(f"agree to {abs(shift[0] - adj[0]):.2e}") ``` Getting the same number from two independent routes is the check worth making on any gate you add — see [The parameter-shift rule](parameter-shift.md) for why the frequencies matter. !!! warning "Gate registration is global" The registry is process-wide, so a gate registered in a test is visible to every later test. If you register throwaway gates, snapshot the registry rather than reading it live — the parity suite learned this the hard way. ### How far does your gate travel? The obvious worry: Qiskit and Cirq have their own gate tables, and your gate is not in either. So what happens when you ask for `backend="qiskit"`? It works. Neither SDK has heard of your gate, so it is emitted **as its matrix** — `UnitaryGate` on Qiskit, `MatrixGate` on Cirq — built by calling the `matrix=` you registered. Nothing else about your circuit changes, and the result agrees with the NumPy reference to machine precision. ```python # docs: skip qk.register_gate(qk.GateDef("xy", n_qubits=2, n_params=1, matrix=xy, frequencies=(1.0,))) qk.statevector(spec, backend="qiskit") # UnitaryGate, exact qk.statevector(spec, backend="cirq") # MatrixGate, exact qk.grad(spec, theta, qk.Z(1), method="parameter-shift", backend="qiskit") ``` The subtlety this hides is qubit order, and it is the kind that does not raise. qmlkit is big-endian and Qiskit is little-endian, so `to_qiskit` already maps qubit `i` to `n-1-i` — but a raw matrix carries its qubit order in its *basis* rather than in its wire list, so the basis has to be reversed as well or a two-qubit gate on `(a, b)` quietly acts as though it were on `(b, a)`. Cirq needs no reversal at all, being big-endian like qmlkit. Rather than reason about that, the cross-backend suite asserts it against the NumPy reference over ascending, descending and non-adjacent wire orders, and over a three-qubit custom gate. | Backend | A registered gate | |---|---| | `numpy` · `qiskit` · `cirq` · `aer` | **works**, to machine precision | | `cirq-density` · `qiskit-aer` | **works** — they inherit the same translation | | `spinqit` | refuses. Its builder takes named gates, not an arbitrary matrix | | `torch` | refuses for `backprop`. It differentiates *through* the gate, which needs a torch-native form; your `matrix=` is NumPy and no gradient flows through it. `parameter-shift` works, because a shift rule never inspects a state | Both refusals say that, and what to do instead, rather than telling you to edit the library. ## A new gradient estimator ```python import numpy as np import qmlkit as qk @qk.register_gradient("forward_diff") def forward_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-6, **kwargs): base = qk.expval(spec, obs, theta=theta, backend=backend, shots=shots) out = np.zeros(spec.n_params) for k in range(spec.n_params): step = np.zeros_like(theta) step[k] = eps out[k] = (qk.expval(spec, obs, theta=theta + step, backend=backend, shots=shots) - base) / eps return out ansatz = qk.hardware_efficient(2, 1) spec, theta = ansatz.build(), ansatz.init(seed=0) mine = qk.grad(spec, theta, qk.Z(0), method="forward_diff") exact = qk.grad(spec, theta, qk.Z(0), method="adjoint") print(f"max deviation from adjoint: {np.abs(mine - exact).max():.2e}") ``` The signature is fixed: `(spec, theta, obs, *, backend, shots, **kwargs)`, returning an array of length `spec.n_params`. Anything else you need arrives through `kwargs`, and callers pass it straight through `qk.grad(..., your_kwarg=...)`. ## A new backend Subclass `Backend` and implement **one** method — `statevector`. The base class supplies the measurement *semantics*: sampling, basis rotation, expectation values, seeded counts. That is deliberate: if every backend re-implemented those, agreement between them would be a coincidence rather than a property. ```python import numpy as np import qmlkit as qk from qmlkit.core.backends.base import Backend class MirrorBackend(Backend): """The NumPy reference, but proving the extension point works.""" name = "mirror" supports_statevector = True supports_exact = True def statevector(self, spec): self._check_bound(spec) return qk.get_backend("numpy").statevector(spec) qk.register_backend("mirror", MirrorBackend) qc = qk.QCircuit(2) qc.h(0).cx(0, 1) spec = qc.to_spec() print(np.round(qk.statevector(spec, backend="mirror"), 4)) print("agrees with numpy:", np.allclose( qk.statevector(spec, backend="mirror"), qk.statevector(spec, backend="numpy"))) ``` If you add a real backend, the thing to run is `tests/test_cross_backend.py` — it parametrises over every installed backend automatically, so yours is covered the moment it is registered. ### A device, which cannot hand you a state A real QPU has no statevector and no shot-free expectation. It can run a circuit and report bitstrings, and that is *also* one method: ```python import numpy as np import qmlkit as qk from qmlkit.core.backends.base import Backend class Device(Backend): """Everything a QPU is, and nothing it is not.""" name = "example_device" supports_statevector = False # no amplitudes supports_exact = False # no shot-free expectation def counts(self, spec, shots, seed=None): self._check_bound(spec) # a real provider would submit the circuit here probabilities = np.abs(qk.get_backend("numpy").statevector(spec)) ** 2 rng = np.random.default_rng(0 if seed is None else seed) drawn = rng.multinomial(shots, probabilities / probabilities.sum()) return {format(i, f"0{spec.n_qubits}b"): int(n) for i, n in enumerate(drawn) if n} device = Device() ansatz = qk.hardware_efficient(3, 2) spec, theta = ansatz.build(), ansatz.init(seed=0) observable = qk.Z(0) + 0.5 * qk.ZZ(0, 2) print(f"sampled expectation {qk.expectation(spec, observable, theta, shots=4096, backend=device):+.3f}") ``` ### What that one method gets you Everything above it, derived once in the base class: ```python thetas = np.random.default_rng(0).uniform(-np.pi, np.pi, (5, ansatz.n_params)) values = qk.expectation_over(spec, thetas, observable, shots=4096, backend=device) print(f"batched expectations {values.shape}") gradients = qk.grad_batch(spec, thetas, observable, method="parameter-shift", shots=4096, backend=device) print(f"batched gradients {gradients.shape}") kernel = qk.QuantumKernel(qk.AngleFeatureMap(3), shots=4096, backend=device, seed=0) print(f"Gram matrix {kernel(np.random.default_rng(1).uniform(0, np.pi, (4, 3))).shape}") ``` Qubit-wise-commuting grouping comes with it, so a four-term observable diagonal in `Z` costs one circuit rather than four — on a device, where circuit count is the binding constraint, that is the difference between a feasible run and an infeasible one. `param_shift_grad_batch` matters most here. A shift rule only ever needs the circuit *run* at shifted angles, so a whole batch's gradient is one set of evaluations with no state inspection anywhere — which on hardware is a single job submission instead of `batch x 2P` blocking calls. ### And what it refuses ```python for method in ("adjoint", "backprop"): try: qk.grad(spec, theta, observable, method=method, backend=device) except ValueError as error: print(f"{method}: {str(error)[:70]}...") ``` Both need the statevector, so both refuse and name `parameter-shift` instead. So does exact mode: ```python try: qk.expectation(spec, observable, theta, backend=device) except ValueError as error: print(error) ``` That is the half of "backend-agnostic" that matters. Anything can *run* everywhere; the useful property is that what cannot work is refused by name rather than silently computed on a simulator and handed back looking perfect. `backprop` was doing exactly that until it was caught by writing this section. ### What is still missing for real hardware The 0.x line ships no device backend, and the protocol supporting one is a different claim from having one. `examples/toward_hardware.py` runs a mock QPU end to end and states the four gaps in the order they would bite: **batched submission** (now largely in place — `expectation_over_slots` is the call a provider would turn into a job), **transpilation and routing**, **error mitigation**, and **asynchronous jobs**. The last is the one that would still change the `Backend` protocol. ## A new optimiser There is no `register_optimizer`, because there is nothing to look up — `OPTIMIZERS` is an open dict and an optimiser is just a function: ```python # docs: skip from qmlkit.algorithms.vqe import OPTIMIZERS def my_optimiser(loss, theta0, *, grad, n_steps=200, lr=0.1): theta = np.asarray(theta0, dtype=float).copy() history = [float(loss(theta))] for _ in range(n_steps): theta = theta - lr * grad(theta) # qk.grad, exact, not a difference history.append(float(loss(theta))) return theta, history # the contract: (theta, history) OPTIMIZERS["mine"] = my_optimiser VQE(hamiltonian, n_qubits=3, optimizer="mine").run(n_steps=50, lr=0.05) ``` The contract is `fn(loss, theta0, **kw) -> (theta, history)`. `VQE`, `QAOA` and `AdaptVQE` all take `optimizer=` as either a name from that dict **or the function itself**, so you do not have to register anything to use one. !!! warning "If your optimiser needs the gradient, the caller has to inject it" Each solver decides which optimisers get `grad=` passed in. Naming only some of them there is how `optimizer="adam"` came to raise `TypeError: _adam() missing 1 required keyword-only argument: 'grad'` from all three algorithms that advertised it — a documented option that had never run. `tests/test_optimizer_wiring.py` now parametrises over `OPTIMIZERS` itself, so a new entry cannot be added without being covered. Each optimiser also spells its own iteration count (`n_sweeps`, `n_iterations`, `n_steps`), because `run(**optimizer_kwargs)` passes them straight through. ## A new algorithm There is no `register_algorithm` either, and this one is worth explaining because it looks like an omission. A registry exists so the library can look something up **on your behalf** — a gate name inside a circuit, a `method=` string, a `backend=` string. An algorithm is the outermost layer. Nothing inside qmlkit needs to find your VQE by name; you call it. So an algorithm is not a plugin, it is a loop: > Each one is a *loop* over machinery that already exists — ansatz, gradients, > optimisers, observables — so each is thin, and every structural choice it makes is an > argument rather than something baked in. > > — `qmlkit/algorithms/__init__.py` `VQE` is 200 lines and the shape is worth copying: ```python # docs: skip @dataclass(frozen=True) class MyResult: value: float theta: np.ndarray history: list[float] exact: float | None = None # what it was checked against class MyAlgorithm: def __init__(self, hamiltonian, n_qubits, *, ansatz=None, optimizer="rotosolve", backend=None, shots=None): self.ansatz = ansatz or qk.hardware_efficient(n_qubits, 2) # default, not hardcode def cost(self, theta): ... # one qk.expectation call def gradient_of_cost(self, theta): ... # one qk.grad call def run(self, seed=None, compare_exact=None, **optimizer_kwargs) -> MyResult: ... ``` Four things make it belong here rather than merely work: **Take the structure as an argument, and actually use it.** `tests/test_injection.py` injects two ansätze of *different parameter counts* and asserts the model's count follows — because a constructor that accepts `ansatz=` and quietly ignores it looks identical from the outside. **Check yourself against something exact while you still can.** `VQE`, `QAOA` and `AdaptVQE` diagonalise densely when it is affordable (12 qubits or fewer by default) and report `error_vs_exact`. A variational algorithm converging confidently on the wrong answer is the ordinary failure here, not the exotic one. **Refuse, or warn, when a component's assumptions do not hold.** `QAOA` consults `supports_rotosolve(spec)` and warns when the optimiser is invalid for the circuit it just built: Rotosolve assumes each angle drives a single sinusoid, and QAOA's cost angle drives one `rz` per edge — five frequencies on a five-edge problem, measured. It converges immediately, on the wrong point, and reports it as a result. **Use the exact gradient.** `qk.grad` already gives you one on any circuit and observable. Finite differences are for debugging and tests, and say so in their own docstring. ### Where to put it If it is your own research, **it does not need to be in the library at all** — import qmlkit, write your loop, keep it in your project. That is what the three layers are for, and nothing is gained by vendoring it in. If it is genuinely general, it goes in `src/qmlkit/algorithms/`, exported from that package's `__init__`, with a reference page entry and a changelog entry. And register the *pieces* it introduces — a gate, an ansatz shape, a filter, an estimator — because those are the parts other people's code will want to reach by name. ============================================================================== # Guides / Working with a coding agent (source: docs/guides/agents.md) ============================================================================== # Working with a coding agent Most code written against a library now is written by a model, and a model does not read this page before it types. It guesses a name from what it has read elsewhere, runs it, reads the traceback, and tries again. That loop is the real interface. Three things follow from taking it seriously, and all three help a human equally — none of this is a special mode. ## A wrong name answers with the right one The training data for any model contains far more PennyLane and Qiskit than qmlkit, so the first guess at a name is usually theirs. That guess now costs one line instead of a search: ```python import qmlkit as qk try: qk.AngleEmbedding except AttributeError as exc: print(exc) ``` ``` module 'qmlkit' has no attribute 'AngleEmbedding'. 'AngleEmbedding' is PennyLane's name for qmlkit.AngleFeatureMap (or angle_encode(x) for a one-shot circuit). ``` It is a **translation, not an alias** — the foreign name still raises. Two reasons. Anything importable is something somebody will depend on, and a shadow vocabulary of thirty PennyLane spellings is a second public surface to keep working forever. More importantly, an alias would hide semantic drift: `qml.expval` takes a QNode where `qk.expectation` takes a circuit and an observable, so a name that silently resolved would fail later and further from its cause. Names that are merely misspelled get the same treatment from every registry, and case or separator drift resolves to a single certain suggestion rather than a fuzzy one — `parameter_shift` for `parameter-shift` is what half-remembering another library looks like, not a typo: ```python try: qk.get_ansatz("hardware-efficient") except KeyError as exc: print(exc) ``` ``` unknown ansatz 'hardware-efficient'. Did you mean 'hardware_efficient'? Valid: basic_entangler, hardware_efficient, mps, qaoa, qcnn, random_layers, simplified_two_design, strongly_entangling, tree_tensor_network, two_local. Add your own with register_ansatz(name, factory). ``` Every gate, backend, gradient method, ansatz and conv filter behaves this way. So does asking for a backend you have not installed — but that is a *different* answer, because the name was right and the environment was wrong, and it names the install command instead. ## `diagnose()` catches what does not raise This is the part that has nothing to do with names, and it is the one that matters most. In this field a mistake usually returns a number of the right shape and the right range, and the model trains, converges and reports an accuracy. Here is a re-uploading model composed by hand out of blocks. It has three uploads, six weights, and one frequency: ```python from qmlkit.ansatz import Ansatz, EncodingLayer, RotationLayer, repeat fmap = qk.AngleFeatureMap(2, rotation="ry", entangle=False) # entangle=False: an entangler defeats the collapse model = Ansatz(2, repeat(3, EncodingLayer(fmap) + RotationLayer("ry")), n_inputs=2) print(qk.diagnose(model)) ``` ``` ansatz on 2 qubits: 1 finding(s) [error] ENCODING_COMMUTES: 3 uploads, but every trainable rotation is 'ry', the same generator the encoding uses. RY(x) RY(t) composes into one rotation, so the model reaches 1 frequency rather than 0..3, and its weights do nothing beyond a phase. Fix: Use a non-commuting block, e.g. RotationLayer(('rz', 'ry', 'rz')). ``` Nothing about that model raises. It builds, binds, differentiates and trains; it is simply not the model that was intended. `reupload()` warns about this case at construction, but a model composed directly out of blocks has no constructor to warn from — and composing directly is the whole point of the block vocabulary. Take the fix and the report goes quiet: ```python fixed = Ansatz(2, repeat(3, EncodingLayer(fmap) + RotationLayer(("rz", "ry", "rz"))), n_inputs=2) report = qk.diagnose(fixed) print(bool(report), report.codes) ``` ``` False () ``` A report is falsy when it found nothing, so `if qk.diagnose(model): ...` reads the way it should. Each finding carries a stable `code` to branch on, the number that was measured, and the edit that resolves it: ```python finding = qk.diagnose(model)[0] print(finding.code, finding.severity, finding.value) ``` ``` ENCODING_COMMUTES error 3.0 ``` It takes an `Ansatz`, anything holding one — a `QuantumLayer`, a `VQC`, an `nn.Sequential` with a quantum layer somewhere inside it — or a Gram matrix, where it checks for concentration, for a signal below the shot noise, and for a matrix that has stopped being positive semi-definite: ```python import numpy as np flat = np.full((8, 8), 0.5) np.fill_diagonal(flat, 1.0) print(qk.diagnose(flat).codes) ``` ``` ('KERNEL_CONCENTRATED',) ``` What it checks, and on what evidence, is in [the API reference](../reference/analysis.md#qmlkit.diagnostics.diagnose). Checks that can be exact are exact: a parameter is dead if shifting it cannot change the state at all. Checks that are statistical report the number they measured, so the threshold can be argued with rather than trusted. ## The whole library in one fetch A model that has to click through a documentation site mostly does not. Two files are generated from these pages and from the package itself, and served at the root of the site: - **[`/llms.txt`](https://ziadt160.github.io/qmlkit/llms.txt)** — what is here, where it is, and the handful of constraints that are not inferable from the API. - **[`/llms-full.txt`](https://ziadt160.github.io/qmlkit/llms-full.txt)** — every tutorial and guide in full, then the entire public API with signatures and summary lines. One fetch, no navigation. Both are generated by `scripts/generate_llms_txt.py`, committed, and checked in CI, for the same reason `tests/test_docs.py` executes every snippet on every page: a summary of an API written by hand is a second copy of the truth, and second copies rot silently. Change a page or a public signature without regenerating and the build goes red. For working *on* qmlkit rather than with it, [`AGENTS.md`](https://github.com/Ziadt160/qmlkit/blob/main/AGENTS.md) carries the commands, the conventions the tests enforce, and the traps that have already cost time. ============================================================================== # Reference / Reference (source: docs/reference/index.md) ============================================================================== # Reference Generated from the docstrings in the source, so it cannot drift from the code. | Section | What lives there | |---|---| | [Core](core.md) | The circuit IR, gates, observables, execution, backends | | [Gradients](gradients.md) | Six estimators behind one `grad()` | | [Encoding](encoding.md) | Angle, amplitude, basis and Pauli feature maps | | [Ansatz](ansatz.md) | The block vocabulary, the template zoo, re-uploading | | [Kernels](kernels.md) | Overlap estimators, Gram matrices, `QSVC`/`QSVR` | | [PyTorch](nn.md) | `QuantumLayer`, `VQC`, structured architectures | | [Algorithms](algorithms.md) | VQE, QAOA, ADAPT, chemistry, autoencoders, clustering, RL | | [Analysis](analysis.md) | Metrics, Fourier spectra, quantum information, optimisers | | [Evaluation](evaluation.md) | Scores, class imbalance, classical baselines, budget, provenance | If you are looking for the shape of the library rather than a particular function, the [tutorials](../tutorials/index.md) are the faster route. ============================================================================== # Reference / Core (source: docs/reference/core.md) ============================================================================== # Core The circuit IR, gates, observables, execution and backends. Everything else in the library reads or writes these types. ## `qmlkit.core.ir` ## `qmlkit.core.gates` ## `qmlkit.core.observables` ## `qmlkit.core.builder` ## `qmlkit.core.execute` ## `qmlkit.core.backends.base` The protocol every backend implements. A simulator supplies `statevector`; a device supplies `counts`. Everything else — sampling, basis rotation, qubit-wise-commuting grouping, expectation values, batched execution — is derived here once, which is what makes agreement between backends a property rather than a coincidence. ## `qmlkit.core.backends.registry` ## `qmlkit.interop` ============================================================================== # Reference / Gradients (source: docs/reference/gradients.md) ============================================================================== # Gradients Six estimators behind one `grad()`, plus the shift-rule machinery they share. ## `qmlkit.gradients.batch` Gradients for a whole training batch in one pass. `param_shift_grad_batch` never inspects a state, so it works on every backend including a sampling-only device, and is the batched submission real hardware wants. ## `qmlkit.gradients.dispatch` ## `qmlkit.gradients.rules` ## `qmlkit.gradients.parameter_shift` ## `qmlkit.gradients.adjoint` ## `qmlkit.gradients.hadamard` ## `qmlkit.gradients.spsa` ============================================================================== # Reference / Encoding (source: docs/reference/encoding.md) ============================================================================== # Encoding Getting classical data into a circuit, and the scaling decisions that come first. ## `qmlkit.encoding.angle` ## `qmlkit.encoding.amplitude` ## `qmlkit.encoding.feature_maps` ## `qmlkit.encoding.hamiltonian` ## `qmlkit.encoding.pipeline` Standardise, reduce to `n_qubits` columns, scale into rotation angles — one scikit-learn-clonable object, used in every case study. ## `qmlkit.encoding.scaling` ============================================================================== # Reference / Ansatz (source: docs/reference/ansatz.md) ============================================================================== # Ansatz A block vocabulary, the templates written in it, and re-uploading as a composition. ## `qmlkit.ansatz.blocks` ## `qmlkit.ansatz.library` ## `qmlkit.ansatz.reupload` ============================================================================== # Reference / Kernels (source: docs/reference/kernels.md) ============================================================================== # Kernels Three overlap estimators, Gram matrices that stay positive semi-definite, and the models on top. ## `qmlkit.kernels.estimators` ## `qmlkit.kernels.matrix` ## `qmlkit.kernels.models` ============================================================================== # Reference / PyTorch (source: docs/reference/nn.md) ============================================================================== # PyTorch Circuits as `nn.Module`s, with gradients flowing to the inputs as well as the weights. ## `qmlkit.nn.layer` ## `qmlkit.nn.models` ## `qmlkit.nn.advanced` ============================================================================== # Reference / Algorithms (source: docs/reference/algorithms.md) ============================================================================== # Algorithms Variational algorithms built on the same IR, ansatz vocabulary and gradients as everything else — so an ansatz you registered, a gate you defined, or a backend you wrote works in all of them without any of them knowing about it. Every one of these takes its ansatz, feature map or operator pool as an argument and must actually use it. `tests/test_injection.py` injects two of different sizes and asserts the parameter count follows, because a constructor that accepts `ansatz=` and silently ignores it looks identical from the outside. ## `qmlkit.algorithms.vqe` Ground-state energy by variational minimisation. Worked end to end in [study 4](../studies/04-chemistry.md), including the case where a too-shallow ansatz converges confidently to an energy 601 mHa wrong. ## `qmlkit.algorithms.adapt` ADAPT-VQE: grow the ansatz one operator at a time, chosen by gradient magnitude. **The trap worth knowing.** A molecular Hamiltonian conserves particle number, so any operator that does not has *exactly zero* gradient at Hartree–Fock — the generic pool grows an empty circuit and reports convergence. Use `chemistry_operator_pool`. This is physics, not a bug, and a test pins it. ## `qmlkit.algorithms.qaoa` Quantum approximate optimisation. **Rotosolve is not valid here.** QAOA's cost angle drives one `rz` per edge, and those do not compose into a single sinusoid — measured: five frequencies. Rotosolve's three-point fit then converges instantly to the wrong point and reports it as a result. Check with `qmlkit.optim.supports_rotosolve` before trusting it. ## `qmlkit.algorithms.molecule` and `qmlkit.algorithms.chemistry` Molecular Hamiltonians. Two routes, deliberately: `from_integrals` is the general one and takes PySCF or OpenFermion output for any molecule, while the built-in SCF handles s-orbital elements only. qmlkit is not a quantum chemistry package and does not try to become one. ## `qmlkit.algorithms.hamiltonians` Standard model Hamiltonians — Ising, Heisenberg, and the rest — as `PauliSum`s. ## `qmlkit.algorithms.autoencoder` Quantum autoencoders: compress a state onto fewer qubits and measure what the discarded "trash" qubits retain. ## `qmlkit.algorithms.clustering` `QMeans`. Scored with `qmlkit.evaluate.clustering`, which reports internal *and* external quality because they routinely disagree — see [study 5](../studies/05-beyond-classification.md). ## `qmlkit.algorithms.rl` Variational policies for reinforcement learning. ============================================================================== # Reference / Analysis (source: docs/reference/analysis.md) ============================================================================== # Analysis Measuring an ansatz rather than asserting things about it: expressibility, entanglement, spectra, geometry. ## `qmlkit.diagnostics` ## `qmlkit.metrics` ## `qmlkit.fourier` ## `qmlkit.info` ## `qmlkit.optim` ## `qmlkit.datasets` ## `qmlkit.draw` ## `qmlkit.generative` ## `qmlkit.shadows` Classical shadows: many observables from few measurements. ## `qmlkit.utils.shots` ============================================================================== # Reference / Evaluation (source: docs/reference/evaluation.md) ============================================================================== # Evaluation Scoring predictions, handling skewed classes, comparing against classical baselines, costing a run before it starts, and recording what produced a number. The guide is [Evaluating a quantum model honestly](../guides/evaluation.md). ## `qmlkit.evaluate` ## `qmlkit.imbalance` ## `qmlkit.search` ## `qmlkit.baselines` ## `qmlkit.budget` ## `qmlkit.provenance` ## `qmlkit.nn.losses` ## `qmlkit.progress` See [Watching a run](../guides/watching-a-run.md) for the guide. ## `qmlkit.report` ## `qmlkit.utils.errors` How every "unknown gate / backend / ansatz / method" message in the library is built. Worth reading before adding one, since the shape of the message is the contract: what was wrong, what was probably meant, and what is allowed. ============================================================================== # About / Validation (source: docs/about/validation.md) ============================================================================== # Validation How any of this is known to be correct. A library's own test suite can only catch the bugs its author thought of. Worse, a suite of *agreeing* implementations can only catch the bugs they do not share — five backends and a second library once agreed, unanimously, with a wrong number. So qmlkit leans on five checks, ordered here by how much each one can prove: | | | | |---|---|---| | 1 | **An independent reference** | `tests/densesim.py` — 206 lines sharing no code and no conventions with the library it checks | | 2 | **Cross-library parity** | 301 executed cases against PennyLane, and every metric in `qk.evaluate` against scikit-learn | | 3 | **Property-based testing** | 17 mathematical invariants over Hypothesis-generated circuits — 1,825 per default run, ~19,500 before a release | | 4 | **Cross-backend equivalence** | One circuit zoo through every installed SDK, compared to the NumPy reference | | 5 | **Executable documentation** | Every snippet on this site is run by the test suite | Plus the ordinary suite: **1,434 tests passing of 1,563 collected**, 0 failures, 94% combined coverage measured in CI, `ruff` and `mypy --strict` clean over the whole package. Every one of the 115 skips is accounted for and none of them is silent: 65 constant gates tested at a single angle rather than six, 27 needing SpinQit's Python 3.10, 17 documentation pages with no runnable Python, 4 SpinQit-only, and 2 where the observable is wider than the circuit. CI asserts each optional SDK actually imported before running its jobs, because a suite guarded by `importorskip` and never given its dependency turns the job green by skipping. ## The reference that shares nothing This is the check that matters most, and it is the newest. Everything else here compares qmlkit against something that inherited its conventions — five backends against each other, or against PennyLane, which qmlkit's gate table was written by reading. Those agree when a convention is wrong *everywhere*. `densesim.py` inherited none of them: hand-written gate matrices, hand-derived derivative matrices, an explicit bit-index loop where the library uses `tensordot`, its own product rule. It reads `spec.ops` and nothing else — no backend, no gradient routine, no execution code. It earned its place immediately. The torch backend had reimplemented `np.moveaxis` without numpy's `sorted(zip(destination, source))`, so a two-qubit gate on descending wires permuted the wires it was not acting on. `Z(3)` read `-0.1288` against `-0.7374`. Nothing raised, `method="backprop"` differentiated through it, and it was reachable from a built-in `conv_block(filter="su4")`. Four other references agreed with it, because not one of them was independent of the convention that caused it. The lesson generalises past this project: **when correctness actually matters, at least one reference must share no code and no conventions with the thing being checked.** Four properties in `tests/test_torture.py` run it against randomly generated circuits — statevectors, expectations, gradients, and every installed backend at once. ## Property-based testing `tests/test_torture.py` states 17 invariants that hold by mathematics rather than by example, and lets Hypothesis look for circuits that break them: the four exact gradient routes agree; a tied weight's gradient sums over its occurrences; batched equals looped; `adjoint()` undoes; expectation lies inside the observable's spectrum; Qiskit and Cirq round-trips reproduce the statevector; sampling lands within five standard errors. A default run generates **1,825 cases**. Before a release: ```bash QMLKIT_TORTURE_EXAMPLES=1500 pytest tests/test_torture.py ``` which is about 19,500 circuits and ten minutes. When something fails, Hypothesis shrinks it to the smallest circuit that still shows it and replays that case thereafter. No CI job runs the deep campaign — it is a release step, done by hand. ## Parity with PennyLane ```bash pip install pennylane pytest tests/test_pennylane_parity.py ``` | Layer | Compared | Agreement | |---|---|---| | Gates | 55 matrix comparisons over all 20 gates — the 7 parametric ones at 6 angles each, the 13 constant ones once — plus 21 closed-form `dU/dθ` against a differenced PennyLane matrix | `1e-12` | | Circuits | 40 **randomly generated** circuits over the full gate set, 1–5 qubits — statevectors, probabilities, random multi-term observables | `1e-12` | | Gradients | 5 ansätze × 4 observables; all four exact methods; PennyLane's own four back against ours; fuzzed circuits | `1e-10` | | Encodings | angle (X/Y/Z), amplitude, basis, IQP | `1e-12` | | Templates | `BasicEntanglerLayers`, `StronglyEntanglingLayers` | `1e-12` | | Kernels | full Gram matrices, fidelity and swap-test estimators | `1e-10` | | Quantum info | reduced density matrices, von Neumann entropy, purity, mutual information, fidelity, over random states | `1e-10` | | Fourier | re-uploading spectra at depths 1–4 | `1e-10` | | Geometry | Fubini–Study metric (full and diagonal), QFIM | `1e-12` | | Optimisers | Rotosolve and QNG trajectories, step by step | `1e-10` | The **randomised** tests are the ones that matter. Hand-picked cases confirm what the author already believed; a fuzzer explores the space. Every bug found in this project so far has been of the plausible-wrong-number kind that only a second opinion catches — including two in this library's own parameter-shift implementation, and two in its own tests. ## Four convention differences None is a bug in either library. Each is pinned by its own test so it stays deliberate rather than drifting. **IQP angle convention.** PennyLane's `IQPEmbedding` emits `RZ(xᵢ)` and `MultiRZ(xᵢxⱼ)`; qmlkit's `PauliFeatureMap` follows the Qiskit convention and emits `Rz(2φ)`. Halving the data map lines them up exactly. A kernel differing by precisely this factor would be very hard to spot. **Amplitude encoding phase.** qmlkit builds amplitude encoding from uniformly controlled rotations rather than a state-preparation primitive, and the phase cascade drops one overall factor. Unobservable in isolation — every probability and expectation is identical — but it stops being global inside a *controlled* block. The docstring warns about it, and `check=True` re-simulates and asserts. **Two-qubit "ring".** A ring on two qubits would revisit the same pair, so `entangler_pairs` collapses it to a single `CX`. PennyLane's templates run their loop uniformly and emit both `CNOT(0,1)` and `CNOT(1,0)`. A two-qubit strongly-entangling layer is genuinely a different circuit in the two libraries; adding the second `CNOT` by hand reconciles them exactly, which is what the test asserts. **`approx="block-diag"`.** PennyLane blocks the metric tensor by circuit *layer* and zeroes every cross-layer entry. qmlkit computes the exact metric, which costs no more on a simulator. The same keyword does not port between the two libraries. That last one is not cosmetic. On a 3-qubit, 2-layer problem at equal step count and step size: | | reaches | |---|---| | qmlkit QNG (exact metric) | **−2.9999999** | | PennyLane QNG, default `approx="block-diag"` | −2.22 | | PennyLane QNG, `approx=None` | traces qmlkit's trajectory to `1e-8` | ## Where qmlkit is more accurate `state_fidelity` hits the analytic `|⟨a|b⟩|²` to `1e-16`. `qml.math.fidelity` takes matrix square roots of rank-1 density matrices, which is ill-conditioned, and loses about eight digits. Recorded as a test so a future tolerance change there is a decision rather than an accident. ## Cross-backend equivalence `tests/test_cross_backend.py` runs one circuit zoo through Qiskit, Cirq and — on Python 3.10 — SpinQit, asserting agreement with the NumPy reference on statevectors, probabilities, expectations over X/Y/Z and two-body terms, seeded sampling, and parameter-shift gradients. The zoo deliberately targets where SDKs differ: endianness, controlled-gate qubit order, idle qubits, basis rotations. **It does not cover the torch backend, and that omission has already cost something.** Torch was excluded on the reasoning that a differentiable simulator is a different kind of backend — and torch is exactly where the worst defect in 0.1.1 lived. An agreement test that skips a participant tests nothing about that participant. Torch agreement is now asserted instead by `tests/test_grad_batch.py` and by the property suite, which iterates `available_backends()` and explicitly declines to skip it. The zoo found three real upstream discrepancies, all handled — including **SpinQit's `CY` applying `−iY` instead of `Y`** to the control-1 subspace, which is a relative phase and therefore physically observable. Details in [Backends and conventions](../guides/backends.md). ## Executable documentation Every Python block on this site is executed by `tests/test_docs.py`. The snippets are not illustrations of the API — they are tests of it, so a rename that breaks a tutorial breaks the build, and the outputs shown were produced by running the code. This caught two errors while the docs were being written: a wrong `RotationLayer` call signature, and a hand-typed number that did not match what the code printed. ## Speed `examples/benchmark_pennylane.py` times identical work on both libraries, against PennyLane's **fastest** configuration rather than its reference one. That choice is the point, and it costs us most of the headline. `pennylane-lightning` is a dependency of PennyLane, so the C++ `lightning.qubit` is in every install whether the user asked for it or not; and `qml.adjoint_metric_tensor` is an `O(P)` statevector algorithm sitting right next to the `O(P²)` Hadamard-test `qml.metric_tensor`. Timing against `default.qubit` and the `O(P²)` route would be timing an opponent nobody runs. This section used to do exactly that, and reported a median of 6.1×. The real figure is **1.7×**, it is the one below, and the flattering column is printed beside it so the difference is visible rather than taken on trust. | Operation | qmlkit | PennyLane (best) | | vs `default.qubit` | |---|---|---|---|---| | Expectation, 12 qubits | 3.9 ms | 4.6 ms `lightning` | 1.2× | 2.9× | | Gradient, 8 qubits, `P=96` | 11.0 ms | 11.3 ms `lightning-adjoint` | 1.02× | 6.1× | | Parameter-shift, 6 qubits, `P=72` | 300 ms | 347 ms `lightning` | 1.2× | 3.9× | | 20×20 kernel Gram matrix | 3.2 ms | 219 ms `default` | **69×** | 69× | | Exact metric tensor, `P=24` | 6.8 ms | 715 ms `adjoint_metric` | **105×** | 276× | qmlkit is ahead on 13 of 14 cases, median **1.7×**. Re-run on a second machine it comes out 14 of 14 at 1.78×, because the 8-qubit gradient row is a dead tie that falls either way — the table above quotes the worse of the two runs. `examples/benchmark_pennylane.py` checks that both libraries produce the *same number* before it reports a speedup, and prints the agreement alongside. An acceleration that changes the answer is not an acceleration. Read that in three parts. The expectation, gradient and parameter-shift rows are dispatch and interpreter overhead rather than arithmetic — qmlkit does less per call, leads at small registers, and ties by 8 qubits. The kernel Gram matrix is ~69× because the whole matrix is one batched evaluation against one QNode call per pair; per-call overhead dominates there so completely that `lightning` is actually slower than `default.qubit`. The metric tensor is different in kind: closed-form differentiation of the state, agreeing with PennyLane's own routes to `1.7e-16`, and *widening* with parameter count (49× at `P=12`, 105× at `P=24`) rather than narrowing. Single machine, single thread, small registers, exact simulation throughout. JAX is not installed on the benchmark machine, so jit-compiled PennyLane is untested and unclaimed. Nothing here says anything about running on hardware. ============================================================================== # About / API stability (source: docs/about/stability.md) ============================================================================== # API stability Research code outlives the version it was written against. A script from last year that no longer runs is not a small inconvenience — it is a result nobody can reproduce, and it is the most common complaint about every library in this field. So this page is a promise, not a description. ## What is promised Everything exported from the top-level `qmlkit` namespace — `qk.expectation`, `qk.grad`, `qk.VQC`, `qk.QuantumKernel`, the registries, and everything else in `qmlkit.__all__` — is **public API**. Within a major version: - A public name will not be **removed** without a deprecation period. - A public function will not **change what it returns**, or the meaning of an argument it already accepts. - A **default will not change silently.** If a default changes, the old behaviour stays reachable by passing the old value explicitly, and the change is in the changelog under `Changed`. - **Numerical conventions are frozen**: qubit 0 stays the most significant bit, angles stay in radians, divergences stay in nats, `shots=None` stays exact. Names beginning with an underscore, and anything under `qmlkit.core.*` not re-exported at the top level, are internal. They may change at any time. ## The deprecation period A public name that is going away: 1. keeps working, and emits a `DeprecationWarning` naming its replacement; 2. stays that way for **two minor releases**; 3. is removed only in the release after that, and only with a changelog entry. A `DeprecationWarning` from qmlkit always names what to use instead. If one does not, that is a bug worth reporting. ## What is explicitly *not* promised Being honest about the edges is what makes the rest of the promise worth having. | | | |---|---| | **The `0.x` line** | Semantic versioning allows breaking changes in `0.x`, and this project uses that latitude — with the deprecation period above applied anyway wherever it is practical. The guarantees tighten at `1.0`. | | **Exact floating-point output** | Results are exact to the tolerances the test suite asserts, not bit-for-bit across versions, platforms, or NumPy releases. An optimisation that changes the last two digits is not a breaking change. | | **Performance** | Speed may change in either direction. Where a change makes something *slower* for a plausible workload, it is in the changelog. | | **Simulator-only scope** | The whole `0.x` line is simulator-only. That is a scope decision, stated in the README, not something to be inferred from what happens to work. | | **Third-party conventions** | If Qiskit, Cirq, PennyLane or SpinQit change a convention qmlkit maps onto, the mapping follows theirs. `tests/test_cross_backend.py` and `tests/test_pennylane_parity.py` exist to catch that when it happens. | ## How you can check The promise is only as good as the evidence for it, so: - **Executable documentation.** Every Python block on this site runs in CI (`tests/test_docs.py`). An API change that breaks a documented example breaks the build before it reaches you. - **Cross-library parity.** 301 cases against PennyLane and every metric against scikit-learn, so a convention cannot drift unnoticed. - **[`qk.fingerprint()`](../reference/evaluation.md)** records the versions that produced a number, so a result that stops reproducing can be traced to the layer that moved. If you find something that broke without a deprecation, that is a bug — please [open an issue](https://github.com/Ziadt160/qmlkit/issues). ============================================================================== # API reference (generated from the package) ============================================================================== AXES dict() -> new empty dictionary class AngleFeatureMap(n_features: 'int', rotation: 'str' = 'ry', entangle: 'bool' = True, entanglement: 'str' = 'chain', reps: 'int' = 1) -> 'None' One rotation per feature, optionally followed by an entangling layer. The plainest map there is, and the one whose kernel has a closed form: ``cos^2((x - x')/2)`` per feature when ``entangle=False``. class AngleScaler(lo: 'float' = 0.0, hi: 'float' = 6.283185307179586, data_min: 'npt.NDArray[Any] | None' = None, data_max: 'npt.NDArray[Any] | None' = None) -> None Fit-then-transform angle scaling, so train and test share one range. class Ansatz(n_qubits: 'int', block: 'Block', name: 'str' = 'ansatz', n_inputs: 'int | None' = None) -> 'None' A trainable circuit: a qubit count and a composable block. ``n_inputs`` is **inferred** from the encoding layers in the block — each feature map owns a disjoint range of input slots, and the same map re-used shares its own. Pass it only to assert a total you already know; a value that disagrees raises. class AnsatzReport(ansatz: 'Ansatz', n_samples: 'int' = 300, seed: 'int | None' = 0, backend: 'BackendLike' = None, results: 'dict[str, object]' = ) -> None Expressibility, entanglement, depth, cost and trainability in one call. print(AnsatzReport(qk.hardware_efficient(4, 2))) class Backend(seed: 'int | None' = None) -> 'None' A device or simulator that can run a :class:`CircuitSpec`. class BackendNotAvailable Raised when a backend's underlying SDK is not installed or not importable. class BaselineRow(name: 'str', mean: 'float', std: 'float', fold_scores: 'tuple[float, ...]' = (), is_model: 'bool' = False, skipped: 'str' = '') -> None One model's score across the folds. class BaselineSpec(name: 'str', task: 'str', factory: 'Callable[[], Any]', requires: 'str | None' = None, note: 'str' = '') -> None One classical model that can stand next to a quantum one. class BaselineTable(task: 'str', metric: 'str', n_samples: 'int', n_folds: 'int', rows: 'tuple[BaselineRow, ...]' = (), notes: 'tuple[str, ...]' = (), extras: 'dict[str, Any]' = ) -> None Every model on the same folds, sorted best first. ``verdict`` is the sentence to quote: whether the model under test cleared the strongest classical baseline, and by how much relative to the fold-to-fold spread — a gap smaller than the noise is not a gap. class CircuitSpec(n_qubits: 'int', ops: 'tuple[Op, ...]' = (), n_params: 'int' = 0) -> None An immutable circuit description. class Custom(fn: 'Callable[[QCircuit, BuildContext], None]', name: 'str' = 'Custom') Wrap an arbitrary ``fn(qc, ctx)`` as a block. The escape hatch: anything the vocabulary cannot express, written directly against the builder, still composes with everything else. class DataReuploadEncoder(n_features: 'int', n_uploads: 'int' = 3, rotations: 'Sequence[str]' = ('rz', 'ry', 'rz'), encoding_rotation: 'str' = 'ry', entanglement: 'str | None' = 'chain', trainable_input: 'bool' = False) -> 'None' One convenient re-uploading shape: angle encoding, rotations, entangler. .. note:: Re-uploading is a **pattern, not a structure** — any feature map, any trainable block, any interleaving. This class fixes one convenient choice. For anything else use :func:`qmlkit.reupload`, or compose :class:`~qmlkit.ansatz.blocks.EncodingLayer` directly with the block vocabulary. This remains for the plain angle-encoding case. The circuit alternates ``S(x)`` — an angle encoding — with ``W(theta)``, a trainable rotation block, ``n_uploads`` times. Data enters as *literals* by default; pass ``trainable_input=True`` to make the features circuit parameters too, which is what yields ``df/dx`` for a classical pre-net. The parameter vector is laid out as ``(n_uploads, n_qubits, len(rotations))``, flattened, with the input parameters (if trainable) appended after it. class DressedQuantumNet(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' 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. class EncodingLayer(feature_map: 'object') -> 'None' Insert a feature map as a block, so re-uploading is just composition. Data re-uploading is not one structure. It is *any* interleaving of an encoding with a trainable block, and which encoding, which block, and in what order are all design choices: repeat(3, EncodingLayer(fmap) + RotationLayer(("rz", "ry", "rz")) + EntanglerLayer()) repeat(2, RotationLayer("ry") + EncodingLayer(fmap)) # W before S EncodingLayer(fmap) + repeat(4, RotationLayer("ry")) # encode once, vary often EncodingLayer(zz) + RotationLayer("ry") + EncodingLayer(angle) # two different maps **Which slots a layer reads.** Every repeat of *the same feature map* references the same input angles: re-uploading means feeding the same data in again, not consuming new features. Two *different* maps get disjoint ranges, allocated in the order they first appear, because a slot holds an **angle** and each map derives its angles its own way — ``ZZFeatureMap(2).angles([0.3, 0.7])`` is ``[0.6, 1.4, 13.876]``, since a Z term follows the ``Rz(2 phi)`` convention, while ``AngleFeatureMap(2).angles([0.3, 0.7])`` is ``[0.3, 0.7]``. Angle 0 of one is not angle 0 of the other, so sharing the slot would encode the wrong number without raising. So the composition above reserves ``3 + 2 = 5`` input slots, and :meth:`~qmlkit.ansatz.library.Ansatz.angles` concatenates the maps' angles in the same order. ``n_inputs`` is inferred from the block; pass it only to assert a total you already know. Slots cannot instead be keyed by *feature*, so that both maps read the raw ``x``: a slot is referenced by a :class:`~qmlkit.core.ir.ParamRef`, which is affine in one parameter (``scale * theta[i] + offset``), and a Pauli map's higher-order angle is ``2 * prod_j (pi - x_j)`` — nonlinear, in several features at once. The map from features to angles has to stay classical, which is what :meth:`~qmlkit.encoding.feature_maps.FeatureMap.angle_jacobian` is for. To learn the *frequencies* rather than inherit them from the encoding, put a classical layer in front — ``nn.Sequential(nn.Linear(d, d), QuantumLayer(...))``. That is a scaling of the inputs, which torch already differentiates; it does not need to live inside the circuit. class EntanglerLayer(gate: 'str' = 'cx', pattern: 'str' = 'chain') A layer of fixed two-qubit gates following a named pattern. class FeatureMap() Turns a feature vector into a circuit. Subclasses implement :meth:`build`. ``adjoint`` comes free from the IR, which is what the fidelity kernel's compute-uncompute test needs. class FeaturePipeline(n_qubits: 'int', method: 'str' = 'pca', standardize: 'bool' = True, angle_range: 'tuple[float, float]' = (0.0, 6.283185307179586)) -> 'None' Standardise, reduce to ``n_qubits`` columns, and scale into rotation angles. Parameters ---------- n_qubits How many columns to come out with — one rotation angle per qubit. method ``"pca"`` keeps the leading principal components. ``"truncate"`` keeps the first ``n_qubits`` columns, which is only honest when the features are already ordered by importance. standardize Centre and scale to unit variance first. PCA without this is dominated by whichever feature happens to be measured in the largest units. angle_range Where the output lands. The default ``(0, 2pi)`` uses the full period of a rotation; a narrower range trades expressiveness for a gentler landscape. class Finding(code: 'str', severity: 'str', message: 'str', fix: 'str' = '', value: 'float | None' = None) -> None One thing that is wrong, why it matters, and the edit that fixes it. class Fingerprint(qmlkit: 'str', python: 'str', platform: 'str', numpy: 'str', default_backend: 'str', backends: 'dict[str, str | None]' = , optional: 'dict[str, str | None]' = , seed: 'int | None' = None, extra: 'dict[str, Any]' = ) -> None Everything that could change a number, recorded in one object. Paste :meth:`as_dict` into a results file, or :func:`str` into a paper appendix. The point is that it is cheap enough to attach to every run. class GateDef(name: 'str', n_qubits: 'int', n_params: 'int', matrix: 'Callable[..., Matrix]', frequencies: 'tuple[float, ...]' = (), dmatrix: 'Callable[..., Matrix] | None' = None, adjoint_name: 'str | None' = None, aliases: 'tuple[str, ...]' = ()) -> None Everything the library needs to know about one gate. class 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) -> 'None' Shared machinery: a training loop, and sensible construction defaults. def I() -> 'PauliString' The identity observable. class 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') -> 'None' Matrix-product-state layer — a staircase of two-qubit blocks. class NearestFidelityClassifier(feature_map: 'FeatureMap', shots: 'int | None' = None, backend: 'BackendLike' = None) -> 'None' Classify by fidelity to each class centroid — no solver, no sklearn. The simplest quantum classifier there is: encode every training point, average within each class, and predict whichever class anchor a new point overlaps most. class NumpyBackend(seed: 'int | None' = None, max_qubits: 'int' = 24) -> 'None' Exact statevector simulation in NumPy. class Op(gate: 'str', qubits: 'tuple[int, ...]', params: 'tuple[ParamLike, ...]' = ()) -> None One gate application. class PCAReducer(n_components: 'int', mean_: 'npt.NDArray[Any] | None' = None, components_: 'npt.NDArray[Any] | None' = None, explained_variance_ratio_: 'npt.NDArray[Any] | None' = None) -> None Project features onto their leading principal components, via SVD. Angle encoding needs one qubit per feature, so a 64-feature dataset needs 64 qubits — usually out of reach. Reducing first is the ordinary way through, and ``explained_variance_ratio_`` says how much you gave up doing it. class ParamRef(index: 'int', scale: 'float' = 1.0, offset: 'float' = 0.0) -> None A reference to logical parameter ``index``, optionally linearly rescaled. ``scale`` and ``offset`` let one logical parameter drive a gate angle of ``scale * theta[index] + offset`` without introducing a new parameter. The chain rule for that is handled in the gradient code. class ParametricEntangler(gate: 'str' = 'crz', pattern: 'str' = 'ring') A layer of *trainable* two-qubit gates — exercises the four-term shift rule. class PauliFeatureMap(n_features: 'int', paulis: 'Sequence[str]' = ('Z', 'ZZ'), reps: 'int' = 2, entanglement: 'str' = 'linear', data_map: 'DataMap | None' = None) -> 'None' The general Pauli feature map, for any set of Pauli strings. Parameters ---------- n_features One qubit per feature. paulis Pauli strings to include, e.g. ``("Z", "ZZ")``. reps How many times to repeat the whole block. More reps means higher reachable frequencies, at proportional depth. entanglement Pattern for two-body terms: ``linear``/``chain``, ``ring``, ``full``, or ``alternating``. data_map Override the default :func:`default_data_map`. class PauliString(paulis: 'tuple[tuple[int, str], ...]' = (), coeff: 'complex' = 1.0) -> None A weighted tensor product of Paulis, e.g. ``0.5 * Z0 X2``. Qubits not named act as identity. class PauliSum(terms: 'tuple[PauliString, ...]' = ()) -> None A linear combination of Pauli strings. class Plan(circuits: 'int', shots_total: 'int | None', method: 'str', n_params: 'int', n_samples: 'int', steps: 'int', shots: 'int | None', measurement_settings: 'int', observable_terms: 'int', reductions: 'tuple[Reduction, ...]' = (), notes: 'tuple[str, ...]' = ()) -> None The circuit budget for a training run, and the ways to shrink it. class PoolLayer(keep: 'str' = 'odd', mode: 'str' = 'discard', tied: 'bool' = True) Halve the active register — the pooling half of a QCNN. ``keep="odd"`` retains every second wire starting from the second, matching the convention where the surviving qubit is the target of the preceding entangler. Two pooling modes, because the literature uses both: ``mode="discard"`` Simply stop using the wire. Cheap, adds no parameters, and the information it held survives only through whatever the convolution already moved. ``mode="controlled"`` Before dropping a wire, apply a trainable ``crz`` from it onto its surviving partner, so pooling *learns* what to carry forward. This is the simulator's stand-in for the measure-and-conditionally-rotate pooling of Cong, Choi & Lukin, which needs mid-circuit measurement and feed-forward. ``tied`` shares one pooling angle across the whole layer, matching the way a tied convolution shares one filter. class Progress(stream: 'IO[str] | None' = None, live: 'bool' = True) -> 'None' Collects what the run is doing, and optionally shows it live. class 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') -> 'None' 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. class QCircuit(n_qubits: 'int', n_params: 'int' = 0) -> 'None' Builds a :class:`CircuitSpec` step by step. class QLSTM(n_inputs: 'int', hidden_size: 'int', n_qubits: 'int' = 4, **kwargs: 'Any') -> 'None' A QLSTM over a sequence. Returns ``(outputs, (h, c))``. class QLSTMCell(n_inputs: 'int', hidden_size: 'int', n_qubits: 'int' = 4, ansatz: 'Ansatz | None' = None, n_layers: 'int' = 2, **kwargs: 'Any') -> 'None' 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. class QSVC(feature_map: 'FeatureMap', C: 'float' = 1.0, **kwargs: 'Any') -> 'None' Shared plumbing: fill the Gram matrix, hand it to a precomputed-kernel solver. class QSVR(feature_map: 'FeatureMap', C: 'float' = 1.0, epsilon: 'float' = 0.1, **kwargs: 'Any') -> 'None' Quantum-kernel support vector regressor. class QuantumFunction(*args, **kwargs) Autograd boundary: forward runs circuits, backward differentiates them. class QuantumKernel(feature_map: 'FeatureMap', estimator: 'str' = 'inversion', shots: 'int | None' = None, backend: 'BackendLike' = None, bandwidth: 'float' = 1.0, seed: 'int | None' = None, cache: 'bool' = True) -> 'None' A feature map, as a kernel you can hand to any kernel method. kernel = QuantumKernel(qk.ZZFeatureMap(2)) K = kernel(X) # training Gram matrix K_test = kernel(X_test, X) # rectangular, test against train Arguments --------- estimator How the overlap is measured. ``"inversion"`` (the default) runs the compute-uncompute circuit and reads the all-zeros probability; ``"swap"`` uses a swap test; ``"hadamard"`` runs **two** Hadamard tests and adds the squares of the real and imaginary parts, because one Hadamard test measures one component of a complex overlap and the kernel is its modulus. All three agree on a simulator — :attr:`n_evaluations` is what differs, and on a device so do the width and the connectivity each one needs. shots ``None`` reads the exact probability. A budget samples it, which is what a device does — and a sampled kernel is not positive semi-definite by construction, so pair it with :func:`threshold_matrix`. bandwidth **The first thing to try when a kernel has concentrated.** Every feature vector is scaled by this before encoding, so it sets how far apart two points are in the feature map rather than in the data. At the default ``1.0`` a fidelity kernel over a wide register drives every off-diagonal entry toward the same small number — every pair of points looks equally dissimilar, the Gram matrix approaches the identity, and no amount of training recovers what the encoding threw away. Shrinking the bandwidth (``0.1``-``0.5`` is the usual range) compresses the data into a smaller region of state space and pulls the off-diagonals back apart. :func:`concentration_report` measures whether you have the problem, and ``qk.diagnose(K)`` names it as ``KERNEL_CONCENTRATED``. The alternative fix is a projected kernel, which survives width by measuring local reduced states instead — see the kernels tutorial for when each applies. cache Memoises pair evaluations, which matters because a Gram matrix asks for the same circuit many times. ``n_evaluations`` counts the circuits actually run. class 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) -> 'None' 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. class Recommendation(n_qubits: 'int', n_gates: 'int', statevector_bytes: 'int', backend: 'str', reason: 'str', fusion: 'bool', batching: 'bool', alternatives: 'tuple[tuple[str, str], ...]' = (), notes: 'tuple[str, ...]' = ()) -> None What to run this on, and what that turns on. class Reduction(name: 'str', circuits: 'int', factor: 'float', trade: 'str') -> None One way to make the run cheaper, and what it costs to take it. class Report(subject: 'str', findings: 'tuple[Finding, ...]' = ()) -> None 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. class RotationLayer(gates: 'str | Sequence[str]' = ('ry',), wires: 'Sequence[int] | None' = None) One trainable rotation per gate per active wire. class SPSASchedule(a: 'float' = 0.2, c: 'float' = 0.1, A: 'float | None' = None, alpha: 'float' = 0.602, gamma: 'float' = 0.101, n_iterations: 'int' = 100) -> 'None' Spall's decay schedules for the step size and the perturbation size. class SearchResult(task: 'str', metric: 'str', n_samples: 'int', n_folds: 'int', varied: 'tuple[str, ...]', rows: 'tuple[SearchRow, ...]' = (), notes: 'tuple[str, ...]' = (), extras: 'dict[str, Any]' = ) -> None Every configuration on identical folds, best first. class SearchRow(config: 'dict[str, Any]', mean: 'float' = nan, std: 'float' = nan, fold_scores: 'tuple[float, ...]' = (), pruned: 'str' = '', seconds: 'float' = 0.0, findings: 'tuple[str, ...]' = (), fitted: 'bool' = True) -> None One configuration's outcome: a score, or the reason it was never fitted. class ShiftRule(shifts: 'tuple[float, ...]', coeffs: 'tuple[float, ...]', unshifted_coeff: 'float' = 0.0) -> None ``f'(theta) = sum_i coeffs[i] * f(theta + shifts[i])`` (+ an unshifted term). class SklearnCompatible() ``get_params`` / ``set_params``, read off the constructor signature. scikit-learn duck-types: ``clone``, ``Pipeline`` and ``GridSearchCV`` need these two methods, not a base class. Implementing them directly is what lets a qmlkit estimator sit in a scikit-learn workflow while scikit-learn stays an *optional* dependency — which matters, because the NumPy backend is meant to work alone. The one rule this imposes: an ``__init__`` parameter must be stored on an attribute of the same name, unchanged. class Slot(op_index: 'int', param_pos: 'int', ref: 'ParamRef', gate: 'str') -> None One concrete (operation, parameter-position) angle site. class TaskRecord(label: 'str', items: 'int', seconds: 'float', depth: 'int' = 0) -> None One finished unit of work, kept for the report. class TrainableKernel(feature_map_factory: 'Any', n_params: 'int', shots: 'int | None' = None, backend: 'BackendLike' = None) -> 'None' Train the *feature map itself* by maximising kernel-target alignment. A fixed feature map is a guess. Alignment gives a differentiable score for how well a kernel matches the labels, so the embedding's own parameters can be optimised before any classifier is fitted — usually a bigger win than tuning the classifier afterwards. class UnsupportedGate A source circuit used a gate qmlkit has no definition for. class VQC(n_features: 'int', n_classes: 'int' = 2, class_weight: 'str | None' = None, focal_gamma: 'float' = 0.0, **kwargs: 'Any') -> 'None' 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. class VQRegressor(n_features: 'int', n_outputs: 'int' = 1, **kwargs: 'Any') -> 'None' Variational quantum regressor. model = VQRegressor(n_features=3).fit(X, y) model.predict(X) def X(q: 'int') -> 'PauliString' def Y(q: 'int') -> 'PauliString' def Z(q: 'int') -> 'PauliString' class ZFeatureMap(n_features: 'int', reps: 'int' = 2) -> 'None' First-order, no entanglement — so its kernel factorises over features. def ZZ(a: 'int', b: 'int') -> 'PauliString' class ZZFeatureMap(n_features: 'int', reps: 'int' = 2, entanglement: 'str' = 'linear') -> 'None' First order plus entangling ZZ couplings — the kernel stops factorising. __version__ str(object='') -> str def adjoint_grad(spec: 'CircuitSpec', theta: 'npt.NDArray[Any]', obs: 'Observable | None' = None, backend: 'Backend | str | None' = None) -> 'npt.NDArray[Any]' Exact gradient of ```` with respect to the logical parameter vector. def adjoint_grad_batch(spec: 'CircuitSpec', thetas: 'ArrayLike', obs: 'Observable | None' = None, backend: 'Backend | str | None' = None) -> 'npt.NDArray[Any]' Adjoint gradients for a batch in one forward and one backward sweep. algorithms Algorithms built on the rest of the library. def amplitude_encode(vec: 'Sequence[float] | npt.NDArray[Any]', normalize: 'bool' = True, pad: 'bool' = True, check: 'bool' = False) -> 'CircuitSpec' Encode a vector into the amplitudes of ``ceil(log2 len(vec))`` qubits. def angle_encode(x: 'Sequence[float]', rotation: 'str' = 'ry', trainable: 'bool' = False) -> 'CircuitSpec' One feature per qubit, written into a rotation angle. def available_backends() -> 'tuple[str, ...]' Only the backends whose SDK is actually importable right now. def backend_report() -> 'str' A human-readable summary of which backends this interpreter can run. def 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. def baseline(X: 'Any', y: 'Any', model: 'Any' = None, task: 'str' = 'auto', cv: 'int' = 5, metric: 'str | None' = None, seed: 'int | None' = 0, include: 'Sequence[str] | None' = None, max_samples: 'int | None' = None, fit_kwargs: 'dict[str, Any] | None' = None) -> 'BaselineTable' Score every classical baseline — and optionally ``model`` — on shared folds. def basic_entangler(n_qubits: 'int', n_layers: 'int' = 2, rotation: 'str' = 'rx') -> 'Ansatz' One rotation per wire plus a ring of CNOTs — the minimal useful template. def basis_encode(bits: 'Sequence[int]') -> 'CircuitSpec' Computational-basis encoding: flip a qubit wherever the bit is 1. def basis_index(bits: 'Sequence[int]') -> 'int' ``[1, 0, 1] -> 5``. Qubit 0 is the most significant bit. def bloch_vector(state: 'CircuitSpec | npt.NDArray[Any]', wire: 'int' = 0, n_qubits: 'int | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' ``(, , )`` for one qubit — its point on (or in) the Bloch sphere. def choose_method(spec: 'CircuitSpec', backend: 'Backend | str | None' = None, shots: 'int | None' = None) -> 'str' What ``method="auto"`` resolves to, and why. def closest_psd_matrix(K: 'npt.NDArray[Any]', method: 'str' = 'threshold') -> 'npt.NDArray[Any]' Nearest PSD matrix by the named method. def compare_ansatze(ansatze: 'Sequence[Ansatz]', n_samples: 'int' = 300, seed: 'int | None' = 0) -> 'list[dict[str, object]]' The same report across candidates — the table a paper would want. def concentration_report(K: 'npt.NDArray[Any]', n_qubits: 'int', shots: 'int | None' = None) -> 'dict[str, Any]' Is this Gram matrix telling you anything, or has it concentrated? def concurrence(state: 'CircuitSpec | npt.NDArray[Any]', backend: 'BackendLike' = None) -> 'float' Two-qubit concurrence — 0 for a product state, 1 for a Bell state. def conv_block(pattern: 'str' = 'chain', tied: 'bool' = True, filter: 'str | tuple[ConvFilter, int]' = 'ry_cx') -> 'Block' A QCNN convolution layer: slide one two-qubit ``filter`` across ``pattern``. datasets Datasets for benchmarking quantum models. def default_backend() -> 'Backend' The process-wide default. def 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. def displace_matrix(K: 'npt.NDArray[Any]') -> 'npt.NDArray[Any]' Shift the whole spectrum up until it is non-negative. def draw(spec: 'CircuitSpec', max_width: 'int' = 160, ascii: 'bool | None' = None) -> 'str' A text diagram of the circuit. def effective_dimension(fisher: 'npt.NDArray[Any]', n_samples: 'int' = 1000, gamma: 'float' = 1.0) -> 'float' Normalised effective dimension of a model, from its Fisher information. def entangler_pairs(n_qubits: 'int', pattern: 'str' = 'chain') -> 'tuple[tuple[int, int], ...]' Qubit pairs for a named entanglement pattern. def entangling_capability(ansatz: 'Ansatz', n_samples: 'int' = 200, seed: 'int | None' = None, backend: 'BackendLike' = None) -> 'float' Mean Meyer–Wallach ``Q`` over randomly sampled parameters. evaluate Every score a task needs, in one call — and a note when a score is lying. def expectation(spec: 'CircuitSpec', obs: 'Observable | None' = None, theta: 'ArrayLike | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None, return_std: 'bool' = False) -> 'float | tuple[float, float]' ```` for a circuit. def expectation_batch(specs: 'Sequence[CircuitSpec]', obs: 'Observable | None' = None, thetas: 'Sequence[ArrayLike] | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' ```` for several circuits, resolving the backend once. def expectation_over(spec: 'CircuitSpec', thetas: 'ArrayLike', obs: 'Observable | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' ```` for **one** circuit at many parameter vectors — the batched path. def 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. def expval(spec: 'CircuitSpec', obs: 'Observable | None' = None, theta: 'ArrayLike | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'float' ```` as a plain float -- :func:`expectation` without the optional error bar. def fidelity_kernel(fmap: 'FeatureMap', x: 'Sequence[float]', xp: 'Sequence[float]', shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'float' ``k(x, x')`` by the compute-uncompute test — the default estimator. def fingerprint(seed: 'int | None' = None, **extra: 'Any') -> 'Fingerprint' The versions and settings that decide what a number comes out as. def finite_diff_grad(f: 'Callable[[npt.NDArray[Any]], float]', theta: 'Sequence[float]', eps: 'float' = 1e-06, mode: 'str' = 'central') -> 'npt.NDArray[Any]' Finite differences. For debugging and tests only — never for training. def flip_matrix(K: 'npt.NDArray[Any]') -> 'npt.NDArray[Any]' Take the absolute value of each eigenvalue. def four_term_rule() -> 'ShiftRule' Controlled rotations: generator eigenvalues {0, 0, +-1/2} => frequencies {1/2, 1}. fourier What function does this model actually represent? def from_cirq(circuit: 'Any') -> 'CircuitSpec' Convert a ``cirq.Circuit``, bound or carrying ``sympy`` symbols. def from_pennylane(source: 'Any', *args: 'Any', **kwargs: 'Any') -> 'CircuitSpec' Convert a PennyLane tape, QNode or quantum function. def from_qasm(text: 'str', little_endian: 'bool' = True) -> 'CircuitSpec' Parse OpenQASM 2.0 into a :class:`~qmlkit.core.ir.CircuitSpec`. def from_qiskit(circuit: 'Any') -> 'CircuitSpec' Convert a Qiskit ``QuantumCircuit``, bound or parameterised. def general_shift_rule(frequencies: 'Sequence[float]', shifts: 'Sequence[float] | None' = None) -> 'ShiftRule' Build the exact shift rule for a generator with these frequencies. def generalization_bound(n_trainable_gates: 'int', n_samples: 'int', with_log: 'bool' = True) -> 'float' Expected generalization gap, ``O(sqrt(T log T / N))`` (Caro et al. 2022). generative Generative models — learning a *distribution* rather than a mapping. def geometric_difference(k_quantum: 'npt.NDArray[Any]', k_classical: 'npt.NDArray[Any]') -> 'float' ``g(K_C || K_Q)`` — the statistic that says whether quantum *could* help. def get_ansatz(name: 'str', **kwargs: 'object') -> 'Ansatz' Build a registered ansatz by name. def get_backend(backend: 'str | Backend | None' = None, **kwargs: 'object') -> 'Backend' Resolve a backend name, instance, or ``None`` (the default) to an instance. def get_baseline(name: 'str', task: 'str' = 'classification') -> 'BaselineSpec' One registered baseline. Names are unique within a task, not across tasks. def get_gate(name: 'str') -> 'GateDef' def get_importer(name: 'str') -> 'Callable[..., CircuitSpec]' def grad(spec: 'CircuitSpec', theta: 'ArrayLike', obs: 'Observable | None' = None, method: 'str' = 'auto', backend: 'Backend | str | None' = None, shots: 'int | None' = None, **kwargs: 'object') -> 'npt.NDArray[Any]' Gradient of ```` with respect to ``theta``. def grad_batch(spec: 'CircuitSpec', thetas: 'ArrayLike', obs: 'Observable | None' = None, method: 'str' = 'auto', shots: 'int | None' = None, backend: 'Backend | str | None' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' ``(batch, n_params)`` gradients, by whichever route fits the backend. def grad_circuit_cost(spec: 'CircuitSpec') -> 'int' Circuit evaluations for one full parameter-shift gradient. def gradient_cost(spec: 'CircuitSpec', method: 'str' = 'parameter-shift') -> 'int | str' Circuit evaluations one gradient needs under a given method. def 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. def hadamard_grad(spec: 'CircuitSpec', theta: 'npt.NDArray[Any]', obs: 'Observable | None' = None, backend: 'BackendLike' = None, shots: 'int | None' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' Exact gradient using one extra qubit and one circuit per parameter. def hadamard_test(fmap: 'FeatureMap', x: 'Sequence[float]', xp: 'Sequence[float]', part: 'str' = 'real', shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'float' ``Re`` (or the imaginary part) — the **signed** inner product. def hamiltonian_encode(x: 'Sequence[float]', t: 'float' = 1.0, steps: 'int' = 3, entanglement: 'str' = 'chain', initial_hadamard: 'bool' = True) -> 'CircuitSpec' Evolve under a data-dependent Ising Hamiltonian. def hardware_efficient(n_qubits: 'int', n_layers: 'int' = 2, rotations: 'Sequence[str]' = ('ry', 'rz'), entangler: 'str' = 'cx', pattern: 'str' = 'chain') -> 'Ansatz' Rotations then entanglers, repeated. General-purpose, barren-plateau prone. def hessian(spec: 'CircuitSpec', theta: 'Sequence[float]', obs: 'Observable | None' = None, backend: 'Backend | str | None' = None, eps: 'float' = 0.0001) -> 'npt.NDArray[Any]' Second derivatives, by differencing the exact gradient. imbalance Skewed classes: measuring the skew, weighting the loss, and splitting safely. info Quantum information quantities — the ``qml.qinfo`` equivalent. def is_available(name: 'str') -> 'bool' True if this backend's SDK can be imported in the current interpreter. def is_psd(K: 'npt.NDArray[Any]', tol: 'float' = 1e-09) -> 'bool' True if every eigenvalue is non-negative to within ``tol``. def kernel_matrix(X: 'npt.NDArray[Any]', Y: 'npt.NDArray[Any] | None' = None, kernel: 'KernelFn | None' = None) -> 'npt.NDArray[Any]' Gram matrix of ``X`` against ``Y`` (or itself, exploiting symmetry). kernels Quantum kernels: estimators, Gram matrices, PSD repair, and kernel models. def list_ansatze() -> 'tuple[str, ...]' def list_backends() -> 'tuple[str, ...]' Every registered backend name, installed or not. def list_baselines(task: 'str | None' = None) -> 'tuple[str, ...]' Registered baseline names, optionally filtered to one task. def list_conv_filters() -> 'tuple[str, ...]' def list_feature_maps() -> 'tuple[str, ...]' def list_gates() -> 'tuple[str, ...]' def list_gradient_methods() -> 'tuple[str, ...]' def list_importers() -> 'tuple[str, ...]' def metric_tensor(spec: 'CircuitSpec', theta: 'ArrayLike', approx: 'str | None' = 'block-diag', backend: 'BackendLike' = None, eps: 'float' = 0.0001) -> 'npt.NDArray[Any]' Fubini–Study metric — the curvature of parameter space. metrics Does this ansatz stand a chance? — expressibility, entanglement, trainability. def meyer_wallach(state: 'npt.NDArray[Any]', n_qubits: 'int | None' = None) -> 'float' Meyer–Wallach ``Q = 2(1 - (1/n) sum_k Tr rho_k^2)``. def min_eigenvalue(K: 'npt.NDArray[Any]') -> 'float' def 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, npt.NDArray[Any], float], None] | None' = None) -> 'tuple[npt.NDArray[Any], list[float]]' Minimise ```` by quantum natural gradient descent. def minimize_rotosolve(f: 'LossFn', theta0: 'Sequence[float]', n_sweeps: 'int' = 20, tol: 'float' = 1e-09, callback: 'Callable[[int, npt.NDArray[Any], float], None] | None' = None) -> 'tuple[npt.NDArray[Any], list[float]]' Minimise by repeated Rotosolve sweeps. No learning rate to choose. def minimize_spsa(f: 'LossFn', theta0: 'ArrayLike', n_iterations: 'int' = 100, schedule: 'SPSASchedule | None' = None, seed: 'int | None' = None, callback: 'Callable[[int, npt.NDArray[Any], float], None] | None' = None) -> 'tuple[npt.NDArray[Any], list[float]]' Minimise ``f`` with SPSA. Returns the final parameters and the loss history. def mps_ansatz(n_qubits: 'int', filter: 'str | tuple[ConvFilter, int]' = 'ry_cx', tied: 'bool' = False) -> 'Ansatz' A staircase of two-qubit blocks — a bond-dimension-2 matrix product state. def mutual_info(state: 'CircuitSpec | npt.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. def n_qubits_for(n_values: 'int') -> 'int' Qubits needed to hold ``n_values`` amplitudes: ``ceil(log2 N)``. optim Optimisers that only make sense for quantum circuits. def p0_from_z(z: 'float') -> 'float' P(0) from . def parallel_map(fn: 'Callable[[T], R]', items: 'Iterable[T]', n_jobs: 'int | None' = None) -> 'list[R]' Apply ``fn`` to each item, on ``n_jobs`` threads, preserving order. def param_shift_grad(f_slots: 'SlotFn', spec: 'CircuitSpec', theta: 'Sequence[float]', rules: 'dict[int, ShiftRule] | None' = None, f0: 'float | None' = None) -> 'npt.NDArray[Any]' Exact gradient of ``f`` with respect to the logical parameter vector. def param_shift_grad_batch(spec: 'CircuitSpec', thetas: 'ArrayLike', obs: 'Observable | None' = None, shots: 'int | None' = None, backend: 'Backend | str | None' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' Parameter-shift gradients for a batch, as **one** set of evaluations. def param_shift_grad_circuit(spec: 'CircuitSpec', theta: 'Sequence[float]', obs: 'Observable | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]' Convenience wrapper: parameter-shift gradient of ```` for a circuit. def plan(model: 'Any', n_samples: 'int' = 1, steps: 'int' = 1, method: 'str' = 'parameter-shift', obs: 'Observable | None' = None, shots: 'int | None' = None) -> 'Plan' Circuits, shots and wall-clock for a training run, plus the ways to shrink it. def probabilities(spec: 'CircuitSpec', theta: 'ArrayLike | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' Exact outcome probabilities over the ``2**n`` basis states. def progress(live: 'bool' = True, stream: 'IO[str] | None' = None) -> 'Iterator[Progress]' Watch a run, and keep what it did. def projected_kernel_matrix(feature_map: 'FeatureMap', X: 'npt.NDArray[Any]', gamma: 'float' = 1.0, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' Projected quantum kernel — the standard answer to exponential concentration. def purity(state: 'CircuitSpec | npt.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. def qaoa_ansatz(n_qubits: 'int', edges: 'Sequence[tuple[int, int]] | None' = None, p: 'int' = 1, mixer: 'str' = 'x') -> 'Ansatz' Cost and mixer layers — only ``2p`` parameters, whatever the width. def qcnn_ansatz(n_qubits: 'int', tie_weights: 'bool' = True, filter: 'str | tuple[ConvFilter, int]' = 'ry_cx', pattern: 'str' = 'chain', pool: 'str' = 'discard', keep: 'str' = 'odd') -> 'Ansatz' Convolution + pooling, halving the register until one qubit is left. def quantum_fisher_information(spec: 'CircuitSpec', theta: 'Sequence[float]', backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' QFIM — exactly ``4 x`` the Fubini–Study metric. def random_layers(n_qubits: 'int', n_layers: 'int' = 2, ratio_imprimitive: 'float' = 0.3, seed: 'int | None' = None) -> 'Ansatz' Randomly placed rotations and CNOTs — the baseline a new ansatz must beat. def recommend(model: 'Any', *, shots: 'int | None' = None, batch: 'int | None' = None) -> 'Recommendation' Which backend to run ``model`` on, and what that switches on. def reduce_to_qubits(x: 'npt.NDArray[Any]', n_qubits: 'int', method: 'str' = 'pca', to_angles: 'bool' = True, lo: 'float' = 0.0, hi: 'float' = 6.283185307179586) -> 'npt.NDArray[Any]' Reduce a feature matrix to ``n_qubits`` columns, ready for angle encoding. def reduced_dm(state: 'CircuitSpec | npt.NDArray[Any]', wires: 'Sequence[int]', n_qubits: 'int | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' Trace out everything except ``wires``, kept **in the order given**. def register_ansatz(name: 'str', factory: 'AnsatzFactory | None' = None) -> 'Callable[[AnsatzFactory], AnsatzFactory] | AnsatzFactory' Register an ansatz factory. Usable as a decorator or a direct call. def register_backend(name: 'str', factory: 'Callable[..., Backend]', requires: 'str | None' = None, extra: 'str | None' = None) -> 'None' Register a backend factory. def register_baseline(name: 'str', task: 'str', factory: 'Callable[[], Any]', requires: 'str | None' = None, note: 'str' = '') -> 'None' Add a baseline, so it appears in every table for that task from now on. def register_conv_filter(name: 'str', fn: 'ConvFilter', n_params: 'int') -> 'None' Make a two-qubit filter reachable by name from :func:`conv_block`. def register_feature_map(name: 'str', factory: 'Callable[..., FeatureMap]') -> 'None' Make a feature map reachable by name, here and in every later search. def register_gate(gate: 'GateDef') -> 'GateDef' Add a gate to the registry. Re-registering the same name is an error. def register_gradient(name: 'str', fn: 'GradFn | None' = None) -> 'Callable[[GradFn], GradFn] | GradFn' Register a gradient estimator under ``name``. Usable as a decorator. def register_importer(name: 'str', fn: 'Callable[..., CircuitSpec]') -> 'None' Add an importer, so a new source format is reachable by name. def repeat(times: 'int', block: 'Block') -> 'Repeat' ``repeat(3, RotationLayer("ry"))`` — three layers, fresh weights each. def reupload(feature_map: 'object', n_layers: 'int' = 3, block: 'Block | None' = None, order: 'str' = 'SW', entangler: 'str | None' = 'cx', pattern: 'str' = 'chain', rotations: 'Sequence[str]' = ('rz', 'ry', 'rz'), share_weights: 'bool' = False, name: 'str' = 'reupload') -> 'Ansatz' Build a re-uploading ansatz from any feature map and any trainable block. def rotosolve_step(f: 'LossFn', theta: 'ArrayLike', indices: 'Sequence[int] | None' = None) -> 'npt.NDArray[Any]' One sweep: set every coordinate to its exact optimum, in turn. def rule_for_gate(gate: 'str') -> 'ShiftRule' Look the rule up from the gate's declared generator frequencies. def run_counts(spec: 'CircuitSpec', shots: 'int' = 8192, theta: 'ArrayLike | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'dict[str, int]' Sample the computational basis. Keys are ``n_qubits``-wide bitstrings. def search(X: 'Any', y: 'Any', task: 'str' = 'auto', cv: 'int' = 3, metric: 'str | None' = None, seed: 'int | None' = 0, prune: 'str | Sequence[str]' = 'error', max_configs: 'int | None' = None, dry_run: 'bool' = False, verbose: 'bool' = True, n_jobs: 'int | None' = None, **axes: 'Any') -> 'SearchResult' Sweep every tunable axis, skipping the configurations that cannot work. def selfcheck(spec: 'CircuitSpec', theta: 'ArrayLike', obs: 'Observable', backend: 'Any' = None, cross_backend: 'bool' = True) -> 'Any' Compute this circuit's value and gradient every available way, and compare. def set_default_backend(backend: 'str | Backend', **kwargs: 'object') -> 'Backend' Set the process-wide default backend and return it. shadows Classical shadows — estimate many observables from few measurements. def share(times: 'int', block: 'Block') -> 'Share' ``share(3, conv_block)`` — three applications of one tied weight set. def shots_for_precision(eps: 'float', z: 'float' = 0.0) -> 'int' Shots needed to reach standard error ``eps`` — the ``1/eps**2`` price. def simplified_two_design(n_qubits: 'int', n_layers: 'int' = 2) -> 'Ansatz' The standard reference ansatz in barren-plateau studies. def specs(spec: 'CircuitSpec') -> 'dict[str, object]' Everything worth knowing about a circuit's cost, in one dict. def spsa_grad(f: 'LossFn', theta: 'ArrayLike', c: 'float' = 0.1, n_avg: 'int' = 1, seed: 'int | None' = None, rng: 'np.random.Generator | None' = None) -> 'npt.NDArray[Any]' A stochastic gradient estimate from ``2 * n_avg`` evaluations. def standard_error(z: 'float', shots: 'int', scale: 'float' = 1.0) -> 'float' Standard error of a **single** Pauli term's expectation over ``shots`` samples. def state_fidelity(state_a: 'CircuitSpec | npt.NDArray[Any]', state_b: 'CircuitSpec | npt.NDArray[Any]', backend: 'BackendLike' = None) -> 'float' ``||^2`` — the quantity a fidelity kernel estimates. def statevector(spec: 'CircuitSpec', theta: 'ArrayLike | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]' Final state as a flat ``2**n`` complex vector. def strongly_entangling(n_qubits: 'int', n_layers: 'int' = 2) -> 'Ansatz' Three rotations per wire, plus a ring of CX per layer. def supports_adjoint(spec: 'CircuitSpec', backend: 'Backend | str | None' = None) -> 'bool' True if every parameterised gate in ``spec`` has a closed-form derivative. def swap_test_kernel(fmap: 'FeatureMap', x: 'Sequence[float]', xp: 'Sequence[float]', shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'float' ``k(x, x')`` by the swap test — two registers plus one ancilla. def target_alignment(K: 'npt.NDArray[Any]', y: 'npt.NDArray[Any]', rescale: 'bool' = True) -> 'float' Kernel-target alignment: how much the Gram matrix looks like the labels. def threshold_matrix(K: 'npt.NDArray[Any]') -> 'npt.NDArray[Any]' Clip negative eigenvalues to zero — the standard projection onto the cone. def to_angle_range(x: 'npt.NDArray[Any]', lo: 'float' = 0.0, hi: 'float' = 6.283185307179586, data_min: 'npt.NDArray[Any] | None' = None, data_max: 'npt.NDArray[Any] | None' = None) -> 'npt.NDArray[Any]' Rescale features into an angle window, per column. def track(iterable: 'Iterable[T]', label: 'str', total: 'int | None' = None) -> 'Iterator[T]' Count an iterable's items as they are consumed, if anything is watching. def tree_tensor_network(n_qubits: 'int', filter: 'str | tuple[ConvFilter, int]' = 'ry_cx', tied: 'bool' = False) -> 'Ansatz' Log-depth merge tree — shallow, and resistant to barren plateaus. def two_local(n_qubits: 'int', n_layers: 'int' = 2, rotations: 'Sequence[str]' = ('ry',), entangler: 'str' = 'cx', pattern: 'str' = 'full') -> 'Ansatz' A configurable rotation/entangler alternation, ending on a rotation layer. def two_term_rule() -> 'ShiftRule' The familiar Pauli-rotation rule: shifts +-pi/2, coefficients +-1/2. def variance(z: 'float') -> 'float' Single-shot variance of a +-1 observable with mean ``z``: ``1 - z**2``. def vn_entropy(state: 'CircuitSpec | npt.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. def z_from_p0(p0: 'float') -> 'float' from P(0).