# qmlkit

> A backend-agnostic quantum machine learning library: 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 or Cirq.

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 where **a circuit is data, not a backend object**.

That one decision is why a single gradient implementation serves five backends, why
inventing an ansatz takes one line and inherits correct gradients for free, and why
weight tying is expressible at all. Everything downstream — differentiation, resource
counting, drawing, translation to SpinQit or Qiskit or Cirq — reads the same structure.

```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"<O>       = {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)")
```

## And where the circuit is not the hard part

Structure is what makes the library composable. It is not what makes quantum machine
learning difficult. What makes it difficult 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.

So a second set of tools sits beside the first, and they are not an afterthought:

```python
# docs: requires torch
print(qk.diagnose(qk.hardware_efficient(3, 2)))   # what does not raise
print(qk.plan(qk.hardware_efficient(3, 2)))       # what the run will cost, first
```

[`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, 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.

## Where to start

<div class="grid cards" markdown>

- :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)**

    Seven whole problems, raw data to defensible number. In most of them the number
    is that the quantum model lost.

</div>

## 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.

<div class="grid cards" markdown>

- **[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.

</div>

## 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"<Z> 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
<Z> 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"<Z0>    = {qk.expval(bell, qk.Z(0)):.6f}")
print(f"<Z0 Z1> = {qk.expval(bell, qk.ZZ(0, 1)):.6f}")
print(f"<O>     = {qk.expval(bell, observable):.6f}")
```

```text
Z0 + 0.5*Z0 Z1 + 0.3*X1
<Z0>    = 0.000000
<Z0 Z1> = 1.000000
<O>     = 0.500000
```

`<Z0 Z1> = 1` for a Bell state — the qubits always agree — while `<Z0> = 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.

## 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"<Z0> = {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 |

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`

The heavier versions live in `examples/`, and every study here has one:

| 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)
print(f"g(K_classical, K_quantum) = {g:.1f}")
print("large -> a geometry the RBF kernel cannot reach" if g > 10
      else "small -> the classical kernel already spans this")
```

Huang et al.'s statistic: large means the two kernels induce genuinely different
geometries, so a separation is at least *possible*. Small means it is not, whatever
the accuracy table later says.

## 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.


==============================================================================
# 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.

| | |
|---|---|
| **[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 |
| **[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 / 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 |

## 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, five 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.

```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:

| 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 / Extending qmlkit    (source: docs/guides/extending.md)
==============================================================================

# Extending qmlkit

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.

| I want to change | Use |
|---|---|
| The circuit shape | `register_ansatz` — or just build an `Ansatz` inline |
| A gate the library lacks | `register_gate` |
| How gradients are estimated | `register_gradient` |
| Where circuits run | `register_backend` |

## 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.

## 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.


==============================================================================
# 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")
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`


==============================================================================
# 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. qmlkit
therefore leans on three independent checks, each of which can fail for reasons the
others cannot.

| | |
|---|---|
| **Cross-backend equivalence** | The same circuit through five backends, compared to the NumPy reference |
| **Cross-library parity** | 301 cases against PennyLane, and every metric in `qk.evaluate` against scikit-learn — both independently written implementations |
| **Executable documentation** | Every snippet on this site runs in CI |

Plus the ordinary suite: **1202 tests, 94% combined coverage** measured in CI,
`ruff` and `mypy --strict` clean.

## Parity with PennyLane

```bash
pip install pennylane
pytest tests/test_pennylane_parity.py
```

| Layer | Compared | Agreement |
|---|---|---|
| Gates | all 20 gate matrices at 6 angles, and every 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 every installed backend,
asserting agreement 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 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.

This section previously reported a median 6.1× against `default.qubit` alone. That was
not a fair comparison: `pennylane-lightning` is a dependency of PennyLane, so the C++
`lightning.qubit` is present in every install, and `qml.adjoint_metric_tensor` is an
`O(P)` statevector algorithm sitting right next to the `O(P²)` Hadamard-test
`qml.metric_tensor`. The numbers below are against both, summarised on the faster.

| 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×**.

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' = 0) -> 'None'
    A trainable circuit: a qubit count and a composable block.
class AnsatzReport(ansatz: 'Ansatz', n_samples: 'int' = 300, seed: 'int | None' = 0, backend: 'BackendLike' = None, results: 'dict[str, object]' = <factory>) -> 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]' = <factory>) -> 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

    Every repeat references the **same** input angles: re-uploading means feeding the
    same data in again, not consuming new features.

    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]' = <factory>, optional: 'dict[str, str | None]' = <factory>, seed: 'int | None' = None, extra: 'dict[str, Any]' = <factory>) -> 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 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"`` a Hadamard test, squared. They agree on a
        simulator and differ in width and circuit count on a device.
    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 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]' = <factory>) -> 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 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 ``<obs>`` 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]'
    ``(<X>, <Y>, <Z>)`` 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', *, 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]'
    ``<O>`` 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]'
    ``<O>`` 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]'
    ``<O>`` 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'
    ``<O>`` 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 ``<obs>`` 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<phi(x')|phi(x)>`` (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 ``<obs>`` 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 <Z>.
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 ``<obs>`` 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 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 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``.
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, **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') -> 'float'
    Standard error of an expectation estimated from ``shots`` samples.
def state_fidelity(state_a: 'CircuitSpec | npt.NDArray[Any]', state_b: 'CircuitSpec | npt.NDArray[Any]', backend: 'BackendLike' = None) -> 'float'
    ``|<a|b>|^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 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'
    <Z> from P(0).
