# qmlkit

> A quantum machine learning library built to refuse a plausible wrong number. In
> this field a mistake usually does not raise — a collapsed re-uploading model, a
> concentrated kernel and a lead inside the fold spread all run and all return
> numbers in the right range — so `diagnose`, `baseline`, `plan`, `selfcheck` and
> `progress` are the point of the library rather than an add-on.
>
> Underneath: reusable feature maps, a composable ansatz vocabulary, quantum
> kernels, PyTorch layers, and one `grad()` that points at any circuit and
> observable. The same circuit runs on the built-in NumPy reference, SpinQit,
> Qiskit, Cirq, OpenQARP or Torch, and on two mixed-state backends under noise.

Install with `pip install qmlkit`. The core depends on NumPy and nothing else;
every SDK is an optional extra (`qmlkit[torch]`, `[qiskit]`, `[cirq]`, `[spinqit]`,
`[openqarp]`, `[sklearn]`).

What is worth knowing before writing any of it:

- **Simulator-only for the whole 0.x line.** No hardware. Expectations and
  gradients are exact unless you pass `shots=N`, and `adjoint` is therefore the
  default gradient method — parameter-shift costs `2P` circuit evaluations for the
  same answer, so it is the teaching subject and the test-suite reference, not the
  performance default.
- **Three layers, and nothing at a higher one hides a lower one.** `VQC(...).fit(X, y)`
  for a ready-made model; `QuantumLayer(...)` as an `nn.Module` inside any
  `nn.Sequential`; or circuits and `grad()` directly. Drop a level without giving up
  what the level above was doing.
- **Estimators are scikit-learn clonable** and models are `nn.Module`s, so `Pipeline`,
  `GridSearchCV`, `cross_val_score` and ordinary torch training loops all work.
- **Run `qk.diagnose(model)` before trusting a result.** In this field a mistake
  usually returns a plausible number rather than raising: a re-uploading model whose
  trainable block commutes with its encoding reaches one Fourier frequency instead of
  the `L` it was designed for, and it trains and converges anyway. `diagnose` returns
  findings with a code, what was measured, and the edit that fixes it.
- **The names are qmlkit's, not PennyLane's or Qiskit's**, and a wrong one tells you
  the right one: `qk.AngleEmbedding` reports that it is `qk.AngleFeatureMap` here.
  Unknown registry names suggest the nearest valid one, so guessing is cheap.
- **Every extension point is a registry:** `register_ansatz`, `register_gate`,
  `register_gradient`, `register_backend`, `register_conv_filter`. Registering makes
  your thing a first-class citizen everywhere that kind of argument is taken.
- **Data re-uploading is a pattern, not a class.** It is `EncodingLayer` composed
  into the block vocabulary, with `reupload()` as a convenience over it.


==============================================================================
# Home    (source: docs/index.md)
==============================================================================

# qmlkit

A quantum machine learning library built to **refuse a plausible wrong number**.

What makes this field difficult is not writing the circuit. It is that a mistake here
usually does not raise: a re-uploading model whose trainable rotations commute with its
encoding trains happily and reaches one Fourier frequency instead of eight; a kernel
concentrates until every pair of points looks alike and still returns a Gram matrix; a
quantum model beats its classical baseline by less than the spread between folds and
gets written up as a result. All three run. All three return numbers in the right
range. All three are wrong.

So the tools that catch those are not an afterthought — they are the library:

```python
# docs: requires torch
import qmlkit as qk

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

[`diagnose`](guides/agents.md) names the failure and the edit that fixes it,
[`baseline`](guides/evaluation.md) puts the classical bar on identical folds and
refuses to call a lead inside the fold spread a result, [`progress`](guides/watching-a-run.md)
says how much of a run is left, and `selfcheck` compares every exact gradient route
against every other. The same instinct runs through the rest: `adjoint`
[refuses on a noisy backend](guides/noise.md) rather than quietly differentiating a
noiseless one, and an unknown gate is refused by name rather than approximated.

## Why one `diagnose` can read any model

Because **a circuit is data, not a backend object**.

That one decision is what makes the layer above possible: `diagnose` walks
`spec.ops`, so it works on any ansatz, on any backend, whether or not the library has
ever seen the gates in it. The same structure serves a single gradient implementation
across every backend, lets inventing an ansatz take one line and inherit correct
gradients for free, and makes weight tying expressible at all — a parameter that fills
several slots, differentiated one occurrence at a time, which is the thing a shift rule
gets silently wrong when circuits are opaque objects.

```python
import numpy as np
import qmlkit as qk

ansatz = qk.hardware_efficient(3, n_layers=2)
theta = ansatz.init(seed=0)
spec = ansatz.build()
observable = qk.Z(0) + 0.5 * qk.ZZ(0, 2)

print(f"<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)")
```

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

    Eight 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, OpenQARP, 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 — and any `x -> CircuitSpec` function of your own, via `as_feature_map` |
| **Ansätze** | a composable block vocabulary plus ten templates written in it |
| **Kernels** | three overlap estimators, PSD repair, `QSVC`/`QSVR`, trainable and projected kernels — over any encoding, one circuit per row rather than one per pair |
| **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]"
    ```

=== "OpenQARP"

    ```bash
    pip install "qmlkit[openqarp]"
    ```

    Adds the OpenQARP backend, whose C++ core contracts expectations itself and sweeps
    a batch of them in one call — several times faster than anything else here for
    expectation-heavy work above about eight qubits, and no faster at all below it.
    Python 3.11+ only: OpenQARP publishes no wheel below it, so on 3.10 the extra
    resolves to nothing rather than failing. It is also the heaviest extra in this
    list — SciPy, SymPy, NetworkX, Matplotlib and IPython come with it.

=== "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] openqarp -> pip install 'qmlkit[openqarp]'
  [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.

### The function loads one vector; the feature map loads a dataset

`amplitude_encode` builds a circuit *per vector*, which is all a one-shot load needs
and exactly wrong for anything that encodes a whole dataset. A kernel wants one
circuit whose angles change per row, so that a Gram matrix can go through a single
batched evaluation — and there was no such thing, so `qk.QuantumKernel(qk.amplitude_encode)`
failed on `'function' object has no attribute 'build'`.

The cascade's *structure* never depended on the data, though. Only its rotation
angles do. `AmplitudeFeatureMap` is the same construction with those left free:

```python
import numpy as np
import qmlkit as qk

fmap = qk.AmplitudeFeatureMap(4)
x = np.array([1.0, -2.0, 3.0, -4.0])

spec = fmap.build_parametric()
print(f"one circuit, {spec.n_params} free angles, {len(spec.ops)} gates")

psi = qk.statevector(spec.bind(fmap.angles(x)))
psi = psi * np.exp(-1j * np.angle(psi[0]))  # divide out the global phase dropped above
print("bound state:", np.round(psi.real, 4))
print("target:     ", np.round(x / np.linalg.norm(x), 4))
```

```text
one circuit, 6 free angles, 10 gates
bound state: [ 0.1826 -0.3651  0.5477 -0.7303]
target:      [ 0.1826 -0.3651  0.5477 -0.7303]
```

Three of those six angles are the `Rz` cascade that carries the **signs**, and this
vector needs them. Standardised features are routinely negative, and leaving the
cascade out would encode `|x|` without raising anything, so it is on by default;
`phases=False` halves the circuit and refuses a negative entry rather than quietly
mis-encoding it.

Anything else that turns a vector into a circuit works too, function or not —
`as_feature_map` adapts it, and learns the widths from the first vector it sees:

```python
import qmlkit as qk

wrapped = qk.as_feature_map(qk.amplitude_encode)
print(wrapped)
print("encoded:", qk.statevector(wrapped.build([1.0, 2.0, 3.0, 4.0])).size, "amplitudes")
print(wrapped, "parametric:", wrapped.supports_parametric)
```

```text
CallableFeatureMap(amplitude_encode, n_features=?, n_qubits=?)
encoded: 4 amplitudes
CallableFeatureMap(amplitude_encode, n_features=4, n_qubits=2) parametric: False
```

`supports_parametric` is `False` because a bare function has no angles to vary
independently of the circuit, and that is a contract rather than a label: callers
read it to decide whether one circuit can stand in for a whole dataset. What they do
instead is in the [kernels tutorial](06-quantum-kernels.md#any-encoding-is-a-kernel).

## 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 parameterised *slot* instead of two, using an
ancilla and controlled generators, and unlike adjoint it is a real measurement, so it
stays valid on hardware.

## Sampling

Ask for `shots` and you get parameter-shift with shot noise, which is what a device
would give you:

```python
import numpy as np
import qmlkit as qk

ansatz = qk.hardware_efficient(3, n_layers=2)
spec, theta = ansatz.build(), ansatz.init(seed=0)
obs = qk.Z(0) + 0.5 * qk.ZZ(0, 2)

exact = qk.grad(spec, theta, obs, method="parameter-shift")
sampled = qk.grad(spec, theta, obs, method="parameter-shift", shots=4096, seed=0)
print(f"4096 shots per circuit: max deviation {np.abs(sampled - exact).max():.4f}")
```

```text
4096 shots per circuit: max deviation 0.0211
```

## Second derivatives

`hessian` differences the *exact* gradient, so only the outer derivative is
approximate:

```python
import numpy as np
import qmlkit as qk

qc = qk.QCircuit(1)
qc.ry(0, qk.ParamRef(0))

h = qk.hessian(qc.to_spec(), np.array([0.8]), qk.Z(0))
print(f"hessian    {h[0, 0]:+.8f}")
print(f"-cos(0.8)  {-np.cos(0.8):+.8f}")
```

```text
hessian    -0.69670671
-cos(0.8)  -0.69670671
```

## Bringing your own

The gradient dispatcher is a registry, so a new estimator becomes a keyword
everywhere the library takes `method=`:

```python
import numpy as np
import qmlkit as qk

@qk.register_gradient("central_diff_demo")
def central_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-5, **kw):
    out = np.zeros(spec.n_params)
    for k in range(spec.n_params):
        step = np.zeros_like(theta)
        step[k] = eps
        plus = qk.expval(spec, obs, theta=theta + step, backend=backend, shots=shots)
        minus = qk.expval(spec, obs, theta=theta - step, backend=backend, shots=shots)
        out[k] = (plus - minus) / (2 * eps)
    return out

ansatz = qk.hardware_efficient(2, n_layers=1)
spec, theta = ansatz.build(), ansatz.init(seed=0)
mine = qk.grad(spec, theta, qk.Z(0), method="central_diff_demo")
exact = qk.grad(spec, theta, qk.Z(0), method="adjoint")
print(f"agrees with adjoint to {np.abs(mine - exact).max():.2e}")
```

---

**Next:** [Designing an ansatz](04-ansatz-design.md) — one line, and it inherits all
of the above for free.


==============================================================================
# Tutorials / 4. Designing an ansatz    (source: docs/tutorials/04-ansatz-design.md)
==============================================================================

# 4. Designing an ansatz

Most libraries give you a list of templates and a note saying "or write your own",
where writing your own means implementing parameter counting, gradients and resource
estimation from scratch. Here an ansatz is an expression in a small vocabulary, and
anything you build in it inherits correct gradients, resource counting and a PyTorch
layer without opting into any of them.

## The vocabulary

Four block types, composed with `+`, `repeat` and `share`.

| | |
|---|---|
| `RotationLayer("ry", "rz")` | one rotation per qubit, per named axis |
| `EntanglerLayer("cz", "ring")` | a fixed two-qubit gate over a named pattern |
| `ParametricEntangler("crz", "chain")` | the same, but the entangler carries an angle |
| `PoolLayer(...)` | measure-and-discard, for QCNN-shaped circuits |

Patterns are `chain`, `ring`, `full`, `alternating`.

## One line

```python
import qmlkit as qk

brick = qk.Ansatz(
    4,
    qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")),
    "brick_wall",
)
print(qk.draw(brick.build()))
print(brick)
```

```text
q0: ─RY(θ0)──@──RY(θ4)──────────@─────
q1: ─RY(θ1)──Z────@─────RY(θ5)──Z──@──
q2: ─RY(θ2)──@────Z─────RY(θ6)──@──Z──
q3: ─RY(θ3)──Z──RY(θ7)──────────Z─────
Ansatz('brick_wall', n_qubits=4, n_params=8)
```

Nothing declared the parameter count. It is **inferred** from a dry build, so
miscounting is not a failure mode — a whole category of bug that simply cannot occur.

## It is already a first-class citizen

```python
import numpy as np
import qmlkit as qk

brick = qk.Ansatz(
    4,
    qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")),
    "brick_wall",
)
spec, theta = brick.build(), brick.init(seed=0)

print("gradient:", np.round(qk.grad(spec, theta, qk.Z(0)), 5))
print("resources:", brick.resources()["depth"], "depth,", brick.resources()["n_2q"], "two-qubit gates")
```

## Measure it, do not assert about it

`AnsatzReport` runs the diagnostics that actually distinguish ansätze:

```python
import qmlkit as qk

print(qk.metrics.AnsatzReport(qk.hardware_efficient(4, 2), n_samples=300))
```

```text
hardware_efficient on 4 qubits
  parameters            16
  depth                 9
  two-qubit gates       6
  gradient circuits     32
  expressibility        0.0736   (KL from Haar,
                                 lower is more expressive)
  entangling capability 0.7254   (Meyer-Wallach Q)
  gradient variance     3.096e-01   (higher = more trainable)
```

**Expressibility** is the KL divergence between the distribution of fidelities the
ansatz produces and the Haar distribution — lower means closer to covering the space
uniformly. **Entangling capability** is the mean Meyer–Wallach measure. Neither is
"good" on its own; they trade against each other and against trainability.

## Comparing candidates

```python
import qmlkit as qk

brick = qk.Ansatz(
    4,
    qk.repeat(2, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")),
    "brick_wall",
)

print(f"{'ansatz':<24}{'params':>7}{'depth':>7}{'2q':>5}{'expr':>10}{'entang':>9}")
candidates = ["hardware_efficient", "strongly_entangling", "tree_tensor_network", "mps", "qcnn"]
for name in candidates:
    r = qk.metrics.AnsatzReport(qk.get_ansatz(name, n_qubits=4), n_samples=300).results
    print(f"{name:<24}{r['n_params']:>7}{r['depth']:>7}{r['n_2q']:>5}"
          f"{r['expressibility']:>10.4f}{r['entangling_capability']:>9.4f}")
r = qk.metrics.AnsatzReport(brick, n_samples=300).results
print(f"{'brick_wall (ours)':<24}{r['n_params']:>7}{r['depth']:>7}{r['n_2q']:>5}"
      f"{r['expressibility']:>10.4f}{r['entangling_capability']:>9.4f}")
```

```text
ansatz                   params  depth   2q      expr   entang
hardware_efficient           16      9    6    0.0736   0.7254
strongly_entangling          24     14    8    0.0602   0.8495
tree_tensor_network           6      4    3    0.7550   0.3581
mps                           6      6    3    0.4550   0.4029
qcnn                          4      8    4    0.6075   0.4259
brick_wall (ours)             8      6    6    0.4165   0.4504
```

Read the trade honestly: `strongly_entangling` is the most expressive and the most
entangling, and it costs 24 parameters and depth 14. `tree_tensor_network` is the
least expressive by a wide margin and costs 6 parameters at depth 4. Expressibility
is not free, and — as [tutorial 8](08-trainability.md) shows — it is not always what
you want.

## Weight tying, and why it is a first-class idea

`share` makes several applications of a block use the **same** parameters. That is
what makes a QCNN convolutional rather than merely deep:

```python
import qmlkit as qk

qcnn = qk.get_ansatz("qcnn", n_qubits=8)
spec = qcnn.build()

print(f"logical parameters: {spec.n_params}")
print(f"angle slots:        {len(spec.slots())}")
print(f"gradient circuits:  {qk.gradient_cost(spec, 'parameter-shift')}")
```

```text
logical parameters: 6
angle slots:        22
gradient circuits:  44
```

Six free parameters filling twenty-two slots. The gradient cost scales with the
*logical* parameter count, not the slot count — which is the real advantage of a
convolutional ansatz, and the reason [tutorial 3](03-gradients.md) made such a fuss
about summing over occurrences.

## Initialisation matters more than it looks

```python
import qmlkit as qk

ansatz = qk.hardware_efficient(3, 2)
for strategy in ("small", "uniform", "zeros"):
    theta = ansatz.init(strategy, seed=0)
    print(f"{strategy:<9} mean {theta.mean():+.4f}  std {theta.std():.4f}")
```

```text
small     mean +0.0022  std 0.0704
uniform   mean +0.1695  std 2.1294
zeros     mean +0.0000  std 0.0000
```

`small` is the default and it is not arbitrary: near-identity initialisation keeps
the circuit shallow in effect at the start, which is one of the few reliable defences
against barren plateaus. `zeros` is worse than it looks — a symmetric starting point
can leave whole parameter groups with identical gradients forever.

## Registering it

```python
import qmlkit as qk

@qk.register_ansatz("brick_wall_demo")
def brick_wall(n_qubits, n_layers=2):
    return qk.Ansatz(
        n_qubits,
        qk.repeat(n_layers, qk.RotationLayer("ry") + qk.EntanglerLayer("cz", "alternating")),
        "brick_wall_demo",
    )

print(qk.get_ansatz("brick_wall_demo", n_qubits=3, n_layers=1))
```

```text
Ansatz('brick_wall_demo', n_qubits=3, n_params=3)
```

It is now reachable by name anywhere the library takes one, including
`AnsatzReport`, `QuantumLayer` and `compare_ansatze`.

---

**Next:** [Training with PyTorch](05-training-torch.md) — putting the ansatz in a
network that actually learns.


==============================================================================
# Tutorials / 5. Training with PyTorch    (source: docs/tutorials/05-training-torch.md)
==============================================================================

# 5. Training with PyTorch

Needs the extra:

```bash
pip install "qmlkit[torch]"
```

A circuit becomes an `nn.Module` and everything torch already knows how to do —
optimisers, schedulers, batching, autograd — applies unchanged.

## The two-line path

```python
# docs: requires torch
import numpy as np
import qmlkit as qk

rng = np.random.default_rng(0)
X = rng.normal(size=(120, 4))
y = (X[:, 0] * X[:, 1] > 0).astype(int)  # XOR-like: not linearly separable

model = qk.VQC(n_features=4, n_classes=2, seed=0).fit(X, y, epochs=30)
print(f"accuracy {model.score(X, y):.1%}")
print(f"loss {model.history_[0]:.4f} -> {model.history_[-1]:.4f}")
```

```text
accuracy 78.3%
loss 0.7836 -> 0.4640
```

`VQC` is a convenience, not a wall — every default is one keyword away from being
something else, and the feature map, ansatz, observables and optimiser are all
arguments.

## The layer underneath

```python
# docs: requires torch
import torch
from torch import nn

import qmlkit as qk

torch.manual_seed(0)

layer = qk.QuantumLayer(
    qk.ZZFeatureMap(3, reps=1),
    qk.hardware_efficient(3, 2),
    [qk.Z(0), qk.Z(1)],
    init_seed=0,
).double()

net = nn.Sequential(nn.Linear(6, 3), nn.Tanh(), layer, nn.Linear(2, 2)).double()

xb = torch.randn(8, 6, dtype=torch.float64)
loss = nn.CrossEntropyLoss()(net(xb), torch.randint(0, 2, (8,)))
loss.backward()

print(f"output shape       {tuple(net(xb).shape)}")
print(f"pre-net grad norm  {float(net[0].weight.grad.norm()):.6f}")
print(f"quantum grad norm  {float(layer.theta.grad.norm()):.6f}")
print(f"post-net grad norm {float(net[3].weight.grad.norm()):.6f}")
```

```text
output shape       (8, 2)
pre-net grad norm  0.330885
quantum grad norm  0.114621
post-net grad norm 0.012605
```

## The line that matters

**`pre-net grad norm 0.330885`.** The `nn.Linear(6, 3)` sitting *before* the quantum
layer receives a real gradient, so it trains.

This is not automatic. It requires `∂f/∂x` — the derivative of the circuit with
respect to its *encoding angles*, not just its weights — and many hand-rolled
implementations return `None` there. When they do, everything upstream of the circuit
silently freezes. The loss still falls, because the quantum weights still train, so
the failure presents as slow convergence rather than as a bug. A dressed network
whose classical pre-net never moves is doing far less than it appears to.

qmlkit computes it by differentiating the circuit with respect to its encoding angles
and finishing the chain rule classically, so a *nonlinear* feature map costs no extra
circuits.

```python
# docs: requires torch
import torch
from torch.autograd import gradcheck

import qmlkit as qk

layer = qk.QuantumLayer(
    qk.AngleFeatureMap(2, entangle=False),
    qk.hardware_efficient(2, 1),
    [qk.Z(0)],
    init_seed=0,
).double()

x = torch.randn(1, 2, dtype=torch.float64, requires_grad=True)
print("gradcheck on the inputs:", gradcheck(lambda v: layer(v), (x,), eps=1e-6, atol=1e-6))
```

```text
gradcheck on the inputs: True
```

`torch.autograd.gradcheck` compares the analytic backward pass against numerical
differentiation of the forward pass. Passing it for the *inputs* is the assertion
that the pre-net gradient is real rather than merely non-`None`.

## Regression

```python
# docs: requires torch
import numpy as np
import qmlkit as qk

xs = np.linspace(-1, 1, 60).reshape(-1, 1)
ys = np.sin(3 * xs).ravel()

model = qk.VQRegressor(n_features=1, seed=0).fit(xs, ys, epochs=60)
print(f"R² {model.score(xs, ys):.4f}")
print(f"loss {model.history_[0]:.4f} -> {model.history_[-1]:.4f}")
```

```text
R² 0.9917
loss 0.6780 -> 0.0042
```

A one-qubit re-uploading model fits `sin(3x)` because three uploads reach frequency 3
— which is exactly the claim [tutorial 7](07-reuploading.md) turns into a measurement
rather than an assertion.

## Choosing the gradient method

`QuantumLayer` takes `grad_method`. On a simulator, leave it alone: the default
resolves to adjoint, which costs one pass regardless of the parameter count. Set it
to `"parameter-shift"` when you want to see what the model would do on hardware,
optionally with `shots`.

```python
# docs: requires torch
import qmlkit as qk

hardware_like = qk.QuantumLayer(
    qk.AngleFeatureMap(2, entangle=False),
    qk.hardware_efficient(2, 1),
    [qk.Z(0)],
    grad_method="parameter-shift",
    shots=2048,
    init_seed=0,
)
print(hardware_like)
```

## Structured architectures

The same layer machinery, arranged into the shapes the literature names:

| | |
|---|---|
| `QCNNLayer` | convolution and pooling with tied weights |
| `MPSLayer` | matrix-product-state contraction order |
| `QLSTM` | a recurrent cell with quantum gates |
| `DressedQuantumNet` | classical → quantum → classical, the transfer-learning shape |

---

**Next:** [Quantum kernels](06-quantum-kernels.md) — the other way to use a feature
map, with no variational training at all.


==============================================================================
# Tutorials / 6. Quantum kernels    (source: docs/tutorials/06-quantum-kernels.md)
==============================================================================

# 6. Quantum kernels

The other way to use a feature map. No variational parameters, no training loop, no
barren plateaus — just an inner product between encoded states, handed to a classical
solver that is convex and has a unique optimum.

The price is that you pay it in circuits: `m(m−1)/2` of them for an `m`-sample
training set, and that quadratic is what limits the approach long before qubit counts
do.

## The kernel is an overlap

```python
import numpy as np
import qmlkit as qk

fmap = qk.ZZFeatureMap(2, reps=2)
x1, x2 = np.array([0.4, 1.3]), np.array([1.9, 0.6])

print(f"k(x, x)  = {qk.fidelity_kernel(fmap, x1, x1):.10f}")
print(f"k(x, x') = {qk.fidelity_kernel(fmap, x1, x2):.10f}")
```

```text
k(x, x)  = 1.0000000000
k(x, x') = 0.7055431570
```

`fidelity_kernel` is compute-uncompute: run `U(x)`, then `U(x')†`, and read the
probability of measuring all zeros. That probability *is* `|⟨φ(x')|φ(x)⟩|²`. It falls
straight out of `spec.adjoint()` and needs no ancilla.

Three estimators, same quantity:

```python
import numpy as np
import qmlkit as qk

fmap = qk.ZZFeatureMap(2, reps=2)
x1, x2 = np.array([0.4, 1.3]), np.array([1.9, 0.6])

print(f"fidelity (compute-uncompute) {qk.fidelity_kernel(fmap, x1, x2):+.10f}")
print(f"swap test                    {qk.swap_test_kernel(fmap, x1, x2):+.10f}")
print(f"hadamard test (signed)       {qk.hadamard_test(fmap, x1, x2):+.10f}")
```

```text
fidelity (compute-uncompute) +0.7055431570
swap test                    +0.7055431570
hadamard test (signed)       -0.2029143060
```

The Hadamard test is the odd one out on purpose: it estimates the **signed** inner
product `Re⟨φ(x')|φ(x)⟩`, not its square. Use it when the sign carries information;
use `fidelity_kernel` otherwise, since it needs the fewest qubits and no ancilla.

As a *kernel*, `QuantumKernel(fmap, estimator="hadamard")` runs it **twice** — once
for the real part, once (with an `Sdg` on the ancilla) for the imaginary one — and
returns `Re² + Im²`, which is the same `|⟨φ(x')|φ(x)⟩|²` the other two estimate. One
Hadamard test measures one *component* of a complex overlap, so the modulus costs two
circuits, and `kernel.n_evaluations` counts both.

## A Gram matrix

```python
import numpy as np
import qmlkit as qk

X, y = qk.datasets.ad_hoc_data(n_samples=40, n_features=2, gap=0.4, seed=0)
X_train, X_test, y_train, y_test = qk.datasets.train_test_split(X, y, 0.3, seed=0)

kernel = qk.QuantumKernel(qk.ZZFeatureMap(2, reps=2))
K = kernel(X_train)

print(f"shape {K.shape}  symmetric {np.allclose(K, K.T)}  unit diagonal {np.allclose(np.diag(K), 1)}")
print(f"positive semi-definite: {qk.is_psd(K)}")
print(f"circuits run: {kernel.n_evaluations}  (one per row: m = {len(X_train)})")
print(f"on a device that would be: {kernel.circuits_on_hardware}  (m(m-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: 28  (one per row: m = 28)
on a device that would be: 378  (m(m-1)/2)
target alignment: +0.3360
```

Those two numbers are both real and they are not the same number.

The inversion test reads `P(0…0)` of `U(x′)† U(x)|0⟩`, which *is* `|⟨ψ(x′)|ψ(x)⟩|²`.
A device has no choice but to build that composed circuit once per pair, because it
cannot hand you a state. A simulator can hand you one — so `QuantumKernel` evaluates
each row **once** and forms every overlap with a single matrix product. That is the
difference between linear and quadratic in the size of your dataset, and it is why a
1000-point Gram matrix is a thousand circuits here rather than half a million.

`n_evaluations` is what actually ran. **`circuits_on_hardware` is the number to budget
a real run from** — the shortcut above is simulator-only, and taking the first number
to a device would under-count it by a factor of `(m−1)/2`.

The row-keyed cache is the other half of this: predicting against the training set
re-encodes only the test points, because the training states are already in hand.

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

## Any encoding is a kernel

`QuantumKernel` takes a `FeatureMap`, and it also takes **any function from one
feature vector to one circuit**. `qk.amplitude_encode` is such a function, a loader
you wrote is such a function, and `as_feature_map` adapts either into the shape the
rest of the machinery expects.

```python
import numpy as np
import qmlkit as qk

X, y = qk.datasets.ad_hoc_data(n_samples=40, n_features=4, gap=0.4, seed=0)

kernel = qk.QuantumKernel(qk.amplitude_encode)      # a function, not a FeatureMap
K = kernel(X)
print(f"shape {K.shape}  circuits {kernel.n_evaluations}  on hardware {kernel.circuits_on_hardware}")

fast = qk.QuantumKernel(qk.AmplitudeFeatureMap(4))  # the same encoding, batchable
print(f"AmplitudeFeatureMap agrees to {np.abs(fast(X) - K).max():.1e}")

U = X / np.linalg.norm(X, axis=1, keepdims=True)
print(f"...and both equal the squared cosine similarity to {np.abs(K - (U @ U.T) ** 2).max():.1e}")
```

```text
shape (40, 40)  circuits 40  on hardware 780
AmplitudeFeatureMap agrees to 0.0e+00
...and both equal the squared cosine similarity to 8.9e-16
```

Two things in that output are worth separating.

**Forty circuits, not 780.** The row-state shortcut is what makes a Gram matrix
linear, and it needs a *state*, not a parametric circuit — so a bare callable keeps
the linear scaling. `AmplitudeFeatureMap` wins a constant factor on top, by putting
every row through one batched evaluation instead of building 40 circuits. Measured
on 300 rows: 2.8× at 4 features, 5.4× at 16, 7.1× at 64. Use the function when the
encoding is a one-off; use the feature map when it is on a hot path.

**The kernel it induces is classical, and it says so in closed form.** Amplitude
encoding prepares `x/‖x‖`, so the fidelity is the squared cosine similarity between
the two inputs — a linear kernel on normalised data, computable in microseconds
without a circuit. That is not a bug in the encoding; it is what amplitude encoding
*is*. It earns its place as a loading step underneath something that does more, not
as a feature map you would pick for expressivity. Knowing which side of that line
you are on is the difference between a benchmark and a demonstration.

Two more consequences, neither of which raises:

```python
import numpy as np
import qmlkit as qk

X, _ = qk.datasets.ad_hoc_data(n_samples=12, n_features=4, gap=0.4, seed=0)

narrow = qk.QuantumKernel(qk.AmplitudeFeatureMap(4), bandwidth=0.2)  # this warns
print(f"bandwidth 0.2 vs 1.0: {np.abs(narrow(X) - qk.QuantumKernel(qk.AmplitudeFeatureMap(4))(X)).max():.1e}")
print(f"k(x, 3x) = {qk.QuantumKernel(qk.AmplitudeFeatureMap(4)).evaluate(X[0], 3 * X[0]):.6f}")
```

```text
bandwidth 0.2 vs 1.0: 4.4e-16
k(x, 3x) = 1.000000
```

Rescaling a vector does not change its direction, so **`bandwidth` is inert** against
a map that normalises — the usual first fix for a concentrated kernel does nothing
here, and a sweep over it returns the same Gram matrix every time. `QuantumKernel`
warns rather than letting that pass silently. For the same reason the **magnitude is
discarded**: two points on one ray through the origin are the same point to this
kernel. If the norm of a sample carries information, this encoding throws it away.

### Your own encoding

Nothing above is special to amplitude encoding. Anything you can write as a function
is a kernel:

```python
import numpy as np
import qmlkit as qk

def chebyshev_load(x):
    """One qubit per feature, at arccos(x) rather than x."""
    qc = qk.QCircuit(len(x))
    for i, xi in enumerate(x):
        qc.ry(i, 2.0 * float(np.arccos(np.clip(xi, -1, 1))))
    qc.entangle("ring")
    return qc.to_spec()

X, y = qk.datasets.ad_hoc_data(n_samples=24, n_features=3, gap=0.4, seed=0)
X = np.tanh(X)  # into [-1, 1], which is where arccos is defined

kernel = qk.QuantumKernel(chebyshev_load)
print(f"alignment {qk.target_alignment(kernel(X), y):+.4f}  circuits {kernel.n_evaluations}")
```

```text
alignment +0.0426  circuits 24
```

If you want the batched path as well, subclass `FeatureMap` and implement `angles`,
`n_angles` and `_emit` — the base class composes `build`, `build_parametric` and
`adjoint` out of those three. `CallableFeatureMap` is the honest fallback for when
you cannot: it declares `supports_parametric = False`, and everything downstream
takes the slower route rather than producing a wrong number quietly.

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

## How many parameters is enough

The metric has a rank, and the rank stops growing. It is capped by the dimension of
the circuit's dynamical Lie algebra, so past some depth extra parameters can only add
directions the state does not move along:

```python
import qmlkit as qk

scan = qk.overparametrisation(lambda layers: qk.hardware_efficient(2, layers), range(1, 6))
print(scan)
```

Measured, that saturated rank is exactly `2 * 2**n - 2` — the real dimension of
projective Hilbert space — and it predicts what the ansatz can reach. One layer below
saturation, `hardware_efficient(2, 1)` converges from every random start to -1.65803
on a Hamiltonian whose true ground state is -1.77652. At saturation it finds the
ground state exactly. The threshold is not a heuristic about depth; it is the point
where the ansatz stops being the constraint.

Being past it is also what flattens the spurious minima — on a frustrated 4-qubit
model, the layer count that saturates the rank is the first at which 16 random starts
all agree. Which is the trade the whole page has been circling: the depth that buys
you a benign landscape is the same depth that drives you towards a 2-design.

## What to check before a long run

```python
import qmlkit as qk

print(qk.landscape(qk.hardware_efficient(4, 2), n_samples=60, minima=False))
```

Read the **silent** count before the variance. Eleven of those sixteen parameters have
a gradient of exactly zero against `Z(0)` — they change the state, and `Z(0)` cannot
see them. That is not a plateau, and the fix is not the one a plateau would need:
measure something those parameters can move, rather than reducing depth.

Then the variance. If it is genuinely small, ask **which** plateau:

```python
import qmlkit as qk

ansatz = qk.hardware_efficient(6, 1)
globalz = qk.Z(0) * qk.Z(1) * qk.Z(2) * qk.Z(3) * qk.Z(4) * qk.Z(5)
for finding in qk.plateau_mechanism(ansatz, globalz, n_samples=60):
    print(finding.code, "->", finding.value)
```

The attribution is a controlled comparison, not a rule of thumb: that finding fires
because re-measuring the *same circuit* against `Z(0)` returns several times the
variance, and the gap widens with every qubit. A circuit with no such gap is reported
clean, which is what makes the finding worth reading when it does appear.

`minima=True` adds the expensive half — several optimiser runs from random starts,
and whether they agreed. A loss curve cannot tell you that one, because the run that
lands in the worse basin looks converged too.

---

That is the tour. From here: the [case studies](../studies/index.md) run the whole
arc on real, messy data — raw numbers, a classical bar, `diagnose`, `evaluate`, and a
verdict that is often that the quantum model lost. The [guides](../guides/index.md) go
deeper on the parameter-shift rule and on extending the library, and
[Validation](../about/validation.md) covers how any of this is known to be correct.


==============================================================================
# Case studies / Case studies    (source: docs/studies/index.md)
==============================================================================

# Case studies

The tutorials show how each piece works. These show a whole problem worked through,
from the raw data to a number you could defend — and in most of them the number is
that the quantum model **lost**.

That is deliberate. A library that only publishes its wins teaches you nothing about
when to trust it, and every case study here ends with the verdict the library itself
prints rather than the one the author would have preferred.

| Study | Task | What it is really about |
|---|---|---|
| [1. Imbalanced classification](01-imbalanced-classification.md) | 32,581 loan applications, 21.8% default | The metric that lies, and the one keyword that fixes the model |
| [2. Is a quantum kernel worth it?](02-quantum-kernels.md) | Kernel methods on small data | Two diagnostics that point opposite ways, answered before fitting |
| [3. Regression](03-regression.md) | A smooth non-linear target | Why `r2` and `rmse` disagree, and what re-uploading buys |
| [4. Chemistry: H₂ ground state](04-chemistry.md) | VQE to chemical accuracy | An ansatz that converges confidently to the wrong energy |
| [5. Clustering and generative models](05-beyond-classification.md) | Unsupervised, and a Born machine | The metrics that exist because accuracy does not apply |
| [6. Clinical decisions](06-clinical.md) | 569 breast-cancer biopsies, 30 features | When a false negative is not the same error as a false positive |
| [7. Images and structure](07-images-and-structure.md) | Handwritten digits, a QCNN | Putting the structure of the data into the circuit |
| [8. A classifier that declines to answer](08-selective-classification.md) | Abstention on breast-cancer biopsies | The reported number rises while the comparable one falls |

Every code block on these pages runs in CI (`tests/test_docs.py`), so the numbers are
produced by the code beside them and cannot drift from it.

## The shape they all share

Each study follows the same five questions, because they are the questions that decide
whether a result means anything:

1. **What will the data break?** — `imbalance_report`, and the split it implies
2. **What is the bar?** — `baseline`, run *before* any quantum code
3. **Is the model quietly broken?** — `diagnose`, before training rather than after
4. **What does the metric actually say?** — `evaluate`, with its notes
5. **Is the number right, and reproducible?** — `selfcheck` and `fingerprint`

Four of them have a heavier version in `examples/`, run at full size rather than
trimmed to fit a page:

| Study | Full-size version |
|---|---|
| 1 | [`credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py) — twelve steps on the real 32,581-row Kaggle table |
| 4 | [`experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py) experiment 1 — H₂ across a range of bond lengths, checked against dense diagonalisation at each |
| 6 | `experiments.py` experiment 3 — all 30 clinical features, compared across qubit counts |
| 7 | `experiments.py` experiment 2 — real MNIST at 784 pixels and eight qubits, with three convolution filters compared |

`experiments.py` takes about twenty minutes; the QCNN is most of it.


==============================================================================
# Case studies / 1. Imbalanced classification    (source: docs/studies/01-imbalanced-classification.md)
==============================================================================

# Study 1 — Imbalanced classification, and the metric that lies

The full version of this runs on the
[Kaggle credit-risk table](https://www.kaggle.com/datasets/laotse/credit-risk-dataset) —
32,581 loan applications, 21.8% of which defaulted — in
[`examples/credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py).
This page is the same twelve steps compressed onto data the page can generate, so
every number below is produced by the code beside it.

## The data, and what its labels will break

```python
import numpy as np
import qmlkit as qk

rng = np.random.default_rng(0)
X = rng.normal(size=(400, 4))
score = 1.6 * X[:, 0] - 1.1 * X[:, 1] + 0.5 * X[:, 2] + 0.6 * rng.normal(size=400)
y = (score > np.quantile(score, 0.78)).astype(int)   # 22% positive, like the real thing

print(qk.imbalance.imbalance_report(y))
```

Two findings, and both name the call that fixes them. Everything this study does about
the skew comes from that report and nowhere else:

```python
train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0)
print(f"{train.size} train / {test.size} test, {int(y[test].sum())} positives held out")
```

A random split of a 78/22 problem leaves the minority class thin or absent in test
often enough to make a single test score meaningless. Stratified splitting removes
that as a source of variance before it becomes one.

## The bar, before any quantum code

```python
table = qk.baseline(X, y, cv=3, seed=0,
                    include=["majority", "logistic", "random-forest", "rbf-kernel-ridge"])
print(table)
```

Read the *failures* as well as the winner. On the real credit table, `svc-rbf` and
`mlp` sit at the 0.500 floor because the raw columns span six orders of magnitude —
the table diagnosed the preprocessing before any model was tuned.

## The naive model, and the metric that admits it

```python
# docs: requires torch
import torch

pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train])
Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test])

torch.manual_seed(0)
naive = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=2, seed=0)
naive.fit(Xtr, y[train], epochs=20, lr=0.08, batch_size=256)
naive_scores = qk.evaluate.classification(y[test], naive.predict(Xte))
print(naive_scores)
```

`Scores` returns every metric at once precisely so the disagreement between them stays
visible, and prints a note when accuracy is overstating the model. `primary` is
`balanced_accuracy` rather than `accuracy` here, chosen from the class distribution
rather than by the author.

## One keyword, taken from step one

```python
# docs: requires torch
torch.manual_seed(0)
weighted = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=2,
                  class_weight="balanced", seed=0)
weighted.fit(Xtr, y[train], epochs=20, lr=0.08, batch_size=256)
weighted_scores = qk.evaluate.classification(y[test], weighted.predict(Xte))

for name, s in (("naive", naive_scores), ("class_weight='balanced'", weighted_scores)):
    print(f"  {name:24} balanced_accuracy {s['balanced_accuracy']:.3f}"
          f"  mcc {s['mcc']:+.3f}  accuracy {s['accuracy']:.3f}")
```

On the real table accuracy *falls* — 0.811 to 0.714 — while balanced accuracy rises
0.692 to 0.730. The weighted model finds more of the defaults and pays in false
alarms. MCC can move the other way, and the library hands back both rather than
picking the one that flatters the change. Which trade you want is a lending decision,
not a modelling one.

## Is the circuit quietly broken?

```python
# docs: requires torch
print(qk.diagnose(weighted))
```

`hardware_efficient` ends each layer in `rz`, which commutes with the `cx` entanglers
*and* with any Z-basis observable — so a quarter of its weights cannot move the
readout. The optimiser carries them every step and the loss curve never shows it.

## The verdict

```python
# docs: requires torch
def build():
    torch.manual_seed(0)
    model = qk.VQC(n_features=4, n_classes=2, n_qubits=4,
                   class_weight="balanced", seed=0)
    fit = model.fit
    model.fit = lambda a, b: fit(a, b, epochs=15, lr=0.08, batch_size=256)
    return model

Xq = qk.FeaturePipeline(n_qubits=4).fit(X).transform(X)
final = qk.baseline(Xq, y, model=build, cv=3, seed=0,
                    include=["majority", "logistic", "random-forest"])
print(final.verdict)
```

The classical rows are scored on the **same four principal components** the quantum
model sees. Comparing a 4-component model against a 21-feature one would be a claim
about the input rather than the model — the full example reports both bars and clears
neither, and says so in as many words.

A negative result you can defend is worth more than a positive one you cannot.


==============================================================================
# Case studies / 2. Is a quantum kernel worth it?    (source: docs/studies/02-quantum-kernels.md)
==============================================================================

# Study 2 — Is a quantum kernel worth trying at all?

A quantum kernel is the most appealing thing in this field: no training loop, no
barren plateau, a Gram matrix you hand to any kernel method. It is also the easiest
place to spend a month on a matrix that could never have separated anything.

Two numbers decide it, and both are available **before** the kernel is fitted to
anything.

## The data and the bar

```python
import numpy as np
import qmlkit as qk

X, y = qk.datasets.make_circles(n_samples=80, seed=0)
table = qk.baseline(X, y, cv=3, seed=0,
                    include=["majority", "rbf-kernel-ridge", "nearest-centroid"])
print(table)
```

`rbf-kernel-ridge` is the row that matters. It is the *identical algorithm* to a
quantum kernel method — a closed-form kernel ridge solve — differing only in which
kernel fills the Gram matrix. Any gap between it and a quantum kernel is attributable
to the kernel and to nothing else, which is what makes it the honest foil.

## Question one: has the kernel concentrated?

A fidelity kernel's off-diagonal entries shrink like `2^-n`. Once the spread is at
that scale, every pair of inputs looks equally similar and no model built on the
matrix can separate them — the Gram matrix still exists, the SVM still fits, and the
accuracy is chance.

```python
feature_map = qk.ZZFeatureMap(2, reps=2)
kernel = qk.QuantumKernel(feature_map)
gram = kernel(X[:60])

report = qk.concentration_report(gram, n_qubits=2)
print(f"off-diagonal spread {report['off_diagonal_std']:.4f}")
print(f"predicted at 2 qubits {report['predicted_spread']:.4f}")
print(f"positive semi-definite: {report['is_psd']}")
```

At two qubits there is plenty of spread. The check earns its place as the width grows:
`shots_to_resolve` says roughly `4^n` shots are needed to see a `2^-n` signal above
sampling noise, so a 10-qubit fidelity kernel needs about a million shots *per entry*
before the number means anything.

## Question two: is the geometry even different?

Concentration says the kernel can still distinguish things. It does not say it
distinguishes anything the classical kernel could not. That is the geometric
difference:

```python
classical = np.exp(-0.5 * ((X[:60, None, :] - X[None, :60, :]) ** 2).sum(-1))
g = qk.geometric_difference(classical, gram)
bar = np.sqrt(len(gram))          # Huang et al.'s threshold, not a number chosen here
print(f"g(K_classical, K_quantum) = {g:.3g}   (bar: sqrt(N) = {bar:.2f})")
print("above the bar -> a geometry the RBF kernel cannot reach" if g > bar
      else "below the bar -> the classical kernel already spans this")
```

Huang et al.'s statistic, and their bar: `g` above `sqrt(N)` means the two kernels
induce genuinely different geometries, so a separation is at least *possible*. Below
it means the classical kernel already sees everything the quantum one does, whatever
the accuracy table later says. Identical kernels give exactly 1, which is what makes
the scale readable — the threshold is the caller's to apply rather than folded into
the number.

## Both at once

`diagnose` takes a Gram matrix directly:

```python
print(qk.diagnose(gram, n_qubits=2))
```

## The verdict

```python
# docs: requires sklearn
K_train = kernel(X[:60])
svc = qk.QSVC(feature_map).fit(X[:60], y[:60])
scores = qk.evaluate.classification(y[60:], svc.predict(X[60:]))
print(f"QSVC balanced accuracy {scores['balanced_accuracy']:.3f}")
print(f"best classical         {table.best_classical.mean:.3f}  ({table.best_classical.name})")
print(f"circuits run: {kernel.n_evaluations:,}")
```

The two checks are worth running in that order because they can disagree, and the
disagreement is the finding. On the credit-risk data in
[`examples/credit_risk.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/credit_risk.py)
they do exactly that: geometric difference **66.9** — a geometry the RBF kernel cannot
reach — against a concentration report saying the spread is *already* at the `2^-n`
scale at four qubits. The reachable geometry is being squeezed out as fast as it
appears, and widening the register improves the first number while making the second
worse.

That tension is publishable, and it cost two function calls rather than a fortnight of
fitting SVMs.


==============================================================================
# Case studies / 3. Regression    (source: docs/studies/03-regression.md)
==============================================================================

# Study 3 — Regression, and two metrics that disagree

Classification hides a bad model behind accuracy. Regression hides one behind `r2`,
which is scaled by the variance of whatever you happened to sample — so the same model
scores differently on a narrow test set and a wide one, and neither number is wrong.

`qk.evaluate.regression` returns all of them, and says when `r2` has stopped meaning
anything.

## A target with structure a linear model cannot reach

```python
import numpy as np
import qmlkit as qk

rng = np.random.default_rng(0)
X = rng.uniform(-1.0, 1.0, size=(160, 3))
y = np.sin(2.0 * X[:, 0]) + 0.4 * X[:, 1] ** 2 + 0.1 * rng.normal(size=160)

train, test = np.arange(120), np.arange(120, 160)
print(f"target range [{y.min():.2f}, {y.max():.2f}], variance {y.var():.3f}")
```

## The bar first

```python
table = qk.baseline(X, y, cv=3, seed=0, include=["mean", "linear", "rbf-kernel-ridge"])
print(table)
```

`mean` scores `r2 = 0` by construction — it is the definition of the zero point, not a
model. `linear` is the one that matters here: the target is deliberately non-linear, so
the gap between `linear` and `rbf-kernel-ridge` is how much non-linear structure is
actually available to be captured. If that gap is small, no model of any kind is going
to look impressive, and it is better to know before training one.

## The quantum regressor

```python
# docs: requires torch
import torch

pipeline = qk.FeaturePipeline(n_qubits=3).fit(X[train])
Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test])

torch.manual_seed(0)
model = qk.VQRegressor(n_features=3, n_qubits=3, n_layers=2, seed=0)
model.fit(Xtr, y[train], epochs=30, lr=0.08, batch_size=256)

scores = qk.evaluate.regression(y[test], model.predict(Xte))
print(scores)
```

Seven metrics, and they answer different questions:

- **`r2`** — how much of the variance is explained, relative to predicting the mean.
  Comparable across models on *this* data and not across datasets.
- **`rmse`** and **`mae`** — error in the target's own units. `rmse` punishes large
  misses quadratically; `mae` does not. When they disagree, the residuals are skewed.
- **`median_absolute_error`** — the typical miss, immune to a handful of outliers.
- **`max_error`** — the worst single case, which is the only one that matters if the
  prediction feeds a decision with a floor under it.
- **`explained_variance`** — `r2` without the bias term, so the gap between them is
  exactly the model's systematic offset.

`mape` is omitted here rather than returned as infinity, and the note says why: it is
undefined wherever the target is zero, and this target crosses zero.

## Where the quantum model's ceiling comes from

A variational circuit with angle encoding is a **Fourier series in the input**, and its
reachable frequencies are set by how many times the data is uploaded — not by how many
weights it has:

```python
print(qk.fourier.reachable_frequencies(1))   # one upload
print(qk.fourier.reachable_frequencies(3))   # three uploads
```

The target contains `sin(2x)`, so a single-upload model has no frequency-2 component to
fit it with, however many layers are stacked on top. That is a representational limit,
not an optimisation one, and no learning rate will move it.

Re-uploading is the fix — with the trap the library warns about:

```python
fmap = qk.AngleFeatureMap(3, entangle=False)
good = qk.reupload(fmap, n_layers=3, block=qk.RotationLayer(("rz", "ry", "rz")))
print(f"{good.n_params} weights, reaching frequencies 0..3")
```

If the trainable block used `ry` — the same generator the encoding uses — the uploads
would merge into a single rotation, the model would reach one frequency, and every
weight would become a phase. It would still train, still converge, and still report a
loss. `reupload()` warns at construction, and `qk.diagnose` catches it on a model
composed by hand:

```python
collapsed = qk.Ansatz(3, qk.repeat(3, qk.EncodingLayer(fmap) + qk.RotationLayer("ry")),
                      name="ry-reupload", n_inputs=3)
print(qk.diagnose(collapsed))
```

## The verdict

```python
# docs: requires torch
print(f"quantum r2 {scores['r2']:.3f}  vs  best classical "
      f"{table.best_classical.mean:.3f} ({table.best_classical.name})")
```

Verify the spectrum of any re-uploading model you build with
`qmlkit.fourier.spectrum` rather than trusting the upload count — the frequencies are
reachable, not guaranteed.


==============================================================================
# Case studies / 4. Chemistry - H2 ground state    (source: docs/studies/04-chemistry.md)
==============================================================================

# Study 4 — H₂, and an ansatz that converges to the wrong energy

Chemistry is the one place quantum computing has a target it can be scored against
exactly: diagonalise the Hamiltonian and compare. That makes it the best available
test of whether a variational method is working, and the worst place to be
approximately right without knowing it.

Chemical accuracy is 1 kcal/mol — **1.594 mHa**. Anything outside that is not a
chemistry result.

## The molecule

```python
import qmlkit as qk
from qmlkit.algorithms import VQE, exact_ground_energy, h2_hamiltonian

hamiltonian, info = h2_hamiltonian()
exact = exact_ground_energy(hamiltonian, 4)
print(f"{info['n_terms']} Pauli terms on {info['n_qubits']} qubits")
print(f"exact ground state {exact:.6f} Ha")
```

The Hartree–Fock reference is the occupation `[1, 1, 0, 0]` — the two electrons in the
two lowest spin-orbitals. An `Ansatz` starts from `|0000>`, so that state has to be
prepared first:

```python
occupation = info["hartree_fock_occupation"]

def hartree_fock(inner):
    """Prepend the HF occupation to any ansatz."""
    def build(circuit, context):
        for wire, occupied in enumerate(occupation):
            if occupied:
                circuit.x(wire)
        inner.block.emit(circuit, context)
    return qk.Ansatz(4, qk.Custom(build, "hf"), f"hf_{inner.name}")

reference = qk.QCircuit(4)
reference.x(0)
reference.x(1)
print(f"Hartree-Fock energy {qk.expectation(reference.to_spec(), hamiltonian):.6f} Ha")
```

That prints `-1.116999`. So HF already has most of the answer, and the whole job of
the VQE is the **20.3 mHa of correlation energy** between it and the exact value. That
framing matters: a method that returns something near `-1.1` has not necessarily done
anything at all.

## The failure

```python
shallow = hartree_fock(qk.hardware_efficient(4, 2))
result = VQE(hamiltonian, ansatz=shallow).run(seed=0)
print(f"VQE {result.energy:.6f} Ha   error {abs(result.energy - exact) * 1000:.1f} mHa")
```

`-0.536370 Ha`, which is **601 mHa** out — off by thirty times the entire correlation
energy, and *worse than doing nothing*, since Hartree–Fock alone was 20 mHa away.

Nothing raised. The optimiser converged. `-0.53` is a plausible-looking number in
Hartree, and it is the energy of the `|1000>` state — the circuit found a comfortable
minimum in the wrong particle-number sector and settled there.

## What the library says about it

Depth is the problem, and it is visible before the energy is:

```python
for layers in (2, 4):
    ansatz = hartree_fock(qk.hardware_efficient(4, layers))
    energy = VQE(hamiltonian, ansatz=ansatz).run(seed=0).energy
    error = abs(energy - exact) * 1000
    verdict = "chemical accuracy" if error < 1.594 else "NOT chemical accuracy"
    print(f"  {layers} layers, {ansatz.n_params:2d} params: {energy:+.6f} Ha"
          f"  ({error:7.2f} mHa)  {verdict}")
```

Four layers reaches `-1.137306` — **0.00 mHa**, exact to the printed precision. Two
layers cannot represent the state at all, and says so only through an energy you have
to already know the answer to recognise as wrong.

`strongly_entangling(4, 3)` also reaches it, so this is about expressive capacity
rather than about one particular template.

## Two traps this study sits on top of

**Rotosolve is not always valid.** `VQE` defaults to it, and its three-point fit
assumes the loss is a single sinusoid in each angle. That fails when one angle drives
several gates that do not compose — QAOA's cost angle drives one `rz` per edge, five
frequencies — and it then converges instantly to the wrong point and reports it. Check
before trusting it:

```python
from qmlkit.optim import supports_rotosolve

print(supports_rotosolve(hartree_fock(qk.hardware_efficient(4, 4)).build()))
```

**Particle number is conserved.** A molecular Hamiltonian commutes with the number
operator, so any excitation that does not conserve it has *exactly zero* gradient at
Hartree–Fock. An ADAPT run with a generic operator pool grows an empty circuit and
reports convergence. Use `chemistry_operator_pool`; this is physics, not a bug, and a
test pins it.

## The verdict

The quantum method wins this one outright — `-1.137306` against an exact
`-1.137306` — which is worth stating plainly given how the other studies end. H₂ in a
minimal basis is four qubits and sixteen amplitudes, so a laptop diagonalises it
instantly and no advantage is claimed. What the study demonstrates is narrower and
more useful: **the same code, one layer count apart, produces an answer that is exact
and an answer that is 601 mHa wrong, and only one of them looks different from the
outside.**


==============================================================================
# Case studies / 5. Clustering and generative    (source: docs/studies/05-beyond-classification.md)
==============================================================================

# Study 5 — Clustering and generative models

Accuracy needs labels and a decision. Two large parts of machine learning have
neither, and they fail in ways a classification metric cannot describe: a clustering
that is beautifully separated and answers the wrong question, and a generative model
that assigns zero probability to something that actually happens.

## Clustering: two kinds of "good" that routinely disagree

```python
import numpy as np
import qmlkit as qk

X, y = qk.datasets.make_blobs(n_samples=90, centers=3, seed=0)
labels = qk.algorithms.QMeans(n_clusters=3, seed=0).fit_predict(X)

scores = qk.evaluate.clustering(X, labels, y_true=y)
print(scores)
```

`qk.evaluate.clustering` returns both families, because they answer different
questions and you usually only have one of them:

**Internal** — no ground truth needed. `silhouette` asks whether points sit closer to
their own cluster than to the next one; `davies_bouldin` compares within-cluster
spread to between-cluster distance (lower is better). These say the partition is
*clean*.

**External** — needs labels. `adjusted_rand` corrects for chance agreement, so zero
means "no better than a random partition of the same shape"; `normalized_mutual_info`
measures shared information; `purity` is the honest-but-flattering one, since it rises
automatically as clusters get smaller.

They disagree constantly. A silhouette of 0.7 with an ARI of 0.05 is a well-separated
partition of something other than what you were looking for — and that is a finding,
not a failure, but only if you looked at both.

```python
print(f"cluster sizes {scores.extras['cluster_sizes']}")
```

The primary metric is `adjusted_rand` when labels are given and `silhouette` when they
are not, so the number you quote does not silently change meaning with the arguments.

## Generative: the model that assigns zero to something real

A quantum circuit Born machine samples bitstrings from `|psi|^2`. The standard target
is bars-and-stripes — six valid patterns out of sixteen four-bit strings:

```python
patterns = qk.datasets.bars_and_stripes(2)
target = np.zeros(16)
for row in patterns:
    target[int("".join(map(str, row.astype(int))), 2)] += 1
target /= target.sum()

uniform = np.full(16, 1 / 16)
print(f"a uniform guess scores TV = "
      f"{qk.evaluate.generative(uniform, target)['total_variation']:.3f}")
```

That is the number to beat. Reporting a total variation of 0.5 means nothing until you
know that guessing scores 0.625.

```python
model = qk.generative.QCBM(n_qubits=4, n_layers=3, seed=0)
model.fit(patterns, n_iterations=60)

scores = qk.evaluate.generative(model.probabilities(), target)
print(scores)
```

### Why total variation is the primary, and KL is not

```python
missing = qk.evaluate.generative(np.eye(16)[0], target)
print(f"KL(target || model) = {missing['kl_target_model']}")
print(f"total variation     = {missing['total_variation']:.3f}")
```

A model that puts zero mass where the target has some gives an **infinite** KL. That
is mathematically correct and useless as a training signal — it is infinite for a model
that misses one rare pattern and equally infinite for one that has learned nothing.

Total variation stays finite, is bounded in `[0, 1]`, and is a metric. `hellinger` and
`js_distance` share those properties. `support_coverage` reports the fraction of the
target's support the model reaches at all, and a note fires whenever mass is missing,
so the infinity is explained rather than just printed.

All divergences here are in **nats**, stated because half the literature uses bits and
the factor of `ln 2` is exactly the kind of silent discrepancy this library exists to
avoid.

## The verdict

A four-qubit QCBM at this depth and iteration budget beats a uniform guess and does not
master the distribution. Depth helps — eight layers reaches roughly `TV = 0.49` against
the uniform `0.625`, with about half its mass on valid patterns against the `0.375` a
random distribution puts there — but that is a modest win on a sixteen-outcome problem
a lookup table solves exactly.

Which is the point of measuring it against the uniform baseline rather than against
zero. `total_variation = 0.49` sounds like a result; `0.49 against a 0.625 floor`
is one.


==============================================================================
# Case studies / 6. Clinical decisions    (source: docs/studies/06-clinical.md)
==============================================================================

# Study 6 — Clinical data, where the two errors are not the same error

Credit risk in [study 1](01-imbalanced-classification.md) was about a skewed dataset.
This one is about something else that no amount of balancing fixes: **a false negative
and a false positive are not equivalent mistakes**, and no single-number metric knows
that.

Telling a woman with a malignant tumour that she is fine is not the same kind of error
as calling her back for a second scan. Accuracy scores them identically. So does
balanced accuracy, and so does F1.

## The data

```python
# docs: requires sklearn
import numpy as np
from sklearn.datasets import load_breast_cancer

import qmlkit as qk

data = load_breast_cancer()
X, y = data.data, data.target          # 0 = malignant, 1 = benign
print(f"{X.shape[0]} samples, {X.shape[1]} clinical features")
print(f"classes {list(data.target_names)}, malignant rate {(y == 0).mean():.3f}")
```

At 37% malignant this is only mildly imbalanced — `imbalance_report` says as much, and
correctly does not recommend the remedies study 1 needed:

```python
# docs: requires sklearn
report = qk.imbalance.imbalance_report(y)
print(report if report else "nothing to report: the classes are close enough to even")
```

That is the point of the report being falsy when it finds nothing. The skew is not the
problem here; the **asymmetry of the costs** is, and no diagnostic can infer that from
the labels. It comes from the domain.

## Train it

```python
# docs: requires torch
# docs: requires sklearn
import torch

train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0)
pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train])
Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test])
print(f"30 features -> 4 angles, {pipeline.explained_variance_:.0%} of the variance")

torch.manual_seed(0)
model = qk.VQC(n_features=4, n_classes=2, n_qubits=4, n_layers=3, seed=0)
model.fit(Xtr, y[train], epochs=25, lr=0.08, batch_size=256)

scores = qk.evaluate.classification(y[test], model.predict(Xte))
print(f"accuracy {scores['accuracy']:.3f}   balanced {scores['balanced_accuracy']:.3f}")
```

Around 0.93 either way. A good-looking result, and on its own it does not tell you
whether the model is safe to use.

## The number that actually matters

```python
# docs: requires torch
# docs: requires sklearn
per_class = scores.extras["per_class"]
malignant = per_class["0"]
print(f"malignant recall {malignant['recall']:.3f} "
      f"on {malignant['support']} malignant cases")
print(f"malignant precision {malignant['precision']:.3f}")
print("\nconfusion matrix (rows = truth, columns = prediction):")
print(scores.extras["confusion_matrix"])
```

The confusion matrix is where the model stops being a score. The top-right entry —
malignant cases predicted benign — is roughly **five patients** out of sixty-four.
Accuracy 0.93 and *five missed cancers* are the same model.

`qk.evaluate.classification` hands back `per_class`, `support` and the confusion matrix
alongside the summary metrics for exactly this reason. The summary is what you report;
the breakdown is what you decide on.

## Moving the operating point

Nothing above is fixed. The model outputs probabilities, and the threshold is a policy
choice rather than a modelling one:

```python
# docs: requires torch
# docs: requires sklearn
probabilities = model.predict_proba(Xte)[:, 0]          # P(malignant)
print(f"{'threshold':>10}{'malignant recall':>19}{'false alarms':>15}")
for threshold in (0.5, 0.35, 0.2):
    predicted = np.where(probabilities > threshold, 0, 1)
    s = qk.evaluate.classification(y[test], predicted)
    recall = s.extras["per_class"]["0"]["recall"]
    false_alarms = int(((predicted == 0) & (y[test] == 1)).sum())
    print(f"{threshold:>10.2f}{recall:>19.3f}{false_alarms:>15}")
```

Lowering the threshold catches more cancers and calls back more healthy patients.
There is no setting that does both, and the library will not pick for you — that is a
clinical decision with a cost ratio attached, and a library that chose silently would
be making it on your behalf.

`average_precision` is the summary to quote when the threshold is not fixed, because it
integrates over all of them:

```python
# docs: requires torch
# docs: requires sklearn
full = qk.evaluate.classification(y[test], model.predict(Xte), model.predict_proba(Xte))
print(f"average_precision {full['average_precision']:.3f}   roc_auc {full['roc_auc']:.3f}")
```

## The verdict

```python
# docs: requires torch
# docs: requires sklearn
Xq = qk.FeaturePipeline(n_qubits=4).fit(X).transform(X)
table = qk.baseline(Xq, y, cv=3, seed=0,
                    include=["majority", "logistic", "random-forest"])
print(table.verdict)
```

A logistic regression on the same four components is hard to beat here, which is the
expected outcome and worth stating. The study's contribution is not the model. It is
that **the metric was chosen from the decision the model feeds**, not from the shape of
the data — and that the library gives you the confusion matrix and the threshold sweep
without being asked twice.

The full-size version, on all 30 features with a per-qubit comparison, is experiment 3
in [`examples/experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py).


==============================================================================
# Case studies / 7. Images and structure    (source: docs/studies/07-images-and-structure.md)
==============================================================================

# Study 7 — Image data, and putting the structure in the circuit

Every other study on this page flattens its data and hands it to a general-purpose
ansatz. Images are the case where that is obviously wasteful: neighbouring pixels are
related, and a circuit that treats all wires as interchangeable has to learn that from
scratch with parameters it did not need to spend.

A quantum convolutional network builds the assumption in — a two-qubit filter slid
across the register, then a pooling layer halving the width, repeated until one wire
carries the answer.

## The data

```python
# docs: requires sklearn
import numpy as np
from sklearn.datasets import load_digits

import qmlkit as qk

digits = load_digits()
keep = (digits.target == 0) | (digits.target == 1)
X, y = digits.data[keep], digits.target[keep]
print(f"{X.shape[0]} images, {X.shape[1]} pixels (8x8), classes 0 and 1")

train, test = qk.imbalance.stratified_split(y, test_size=0.3, seed=0)
pipeline = qk.FeaturePipeline(n_qubits=4).fit(X[train])
Xtr, Xte = pipeline.transform(X[train]), pipeline.transform(X[test])
print(f"64 pixels -> 4 angles, {pipeline.explained_variance_:.0%} of the variance")
```

The full-size version uses real MNIST at 784 pixels and eight qubits — experiment 2 in
[`examples/experiments.py`](https://github.com/Ziadt160/qmlkit/blob/main/examples/experiments.py).
It needs a download, so this page uses the bundled 8×8 digits instead.

## The architecture

`QCNNLayer` is a torch module, so it composes with ordinary layers:

```python
# docs: requires torch
# docs: requires sklearn
import torch
from torch import nn

from qmlkit.nn.advanced import QCNNLayer

torch.manual_seed(0)
layer = QCNNLayer(4, filter="su4", tie_weights=True, init_seed=0).double()
model = nn.Sequential(layer, nn.Linear(1, 2).double())
print(f"{sum(p.numel() for p in model.parameters())} parameters in total")
```

**`tie_weights=True` is the convolution.** One filter block is reused at every position
rather than learning an independent block per pair, which is what makes it a
convolution and not just a sparse ansatz. It is also the case the gradient code has to
get right: one logical parameter drives several gates, so the derivative is the sum
over occurrences, each shifted on its own. Shifting them together computes something
else entirely.

```python
# docs: requires torch
# docs: requires sklearn
spec = layer.ansatz.build() if hasattr(layer, "ansatz") else None
print("weight tying means one parameter, several gates — see the parameter-shift guide")
```

## The filter is a choice, and it is measurable

```python
# docs: requires torch
print(qk.list_conv_filters())
```

Four are registered, and `register_conv_filter` adds your own. They are shared with
`mps_ansatz` and `tree_tensor_network`, since all three slide the same two-qubit block.

A name that is not one of them is refused with the right one rather than accepted:

```python
try:
    QCNNLayer(4, filter="ry_cz")
except Exception as error:
    print(error)
```

## Train it

```python
# docs: requires torch
# docs: requires sklearn
inputs = torch.tensor(Xtr[:150])
targets = torch.tensor(y[train][:150], dtype=torch.long)
optimiser = torch.optim.Adam(model.parameters(), lr=0.15)
criterion = nn.CrossEntropyLoss()

for _ in range(12):
    optimiser.zero_grad()
    criterion(model(inputs), targets).backward()
    optimiser.step()

with torch.no_grad():
    predicted = model(torch.tensor(Xte)).argmax(1).numpy()
scores = qk.evaluate.classification(y[test], predicted)
print(f"balanced accuracy {scores['balanced_accuracy']:.3f} "
      f"on {scores.n_samples} held-out images")
```

Around 0.93 from roughly thirty parameters — the parameter count is the interesting
number, not the accuracy. A dense ansatz on four qubits with comparable depth carries
several times as many, and 0 vs 1 is a problem a linear model solves perfectly:

```python
# docs: requires sklearn
table = qk.baseline(np.vstack([Xtr, Xte]), np.concatenate([y[train], y[test]]),
                    cv=3, seed=0, include=["majority", "logistic"])
print(table)
```

## What this study is actually for

Not the accuracy. It is that **structure in the data can be structure in the circuit**,
and that the library makes that a one-line choice — `filter=`, `tie_weights=` — rather
than a rewrite. The same block vocabulary builds `mps_ansatz` and
`tree_tensor_network`, and `qk.compare_ansatze` will score them side by side on
expressibility, entanglement and gradient variance before any of them is trained.

The honest caveat is the one every study here shares: MNIST 0 vs 1 is separable by a
linear model, so nothing below is evidence of advantage. It is evidence that the
architecture can be expressed, trained, and measured without leaving the library.


==============================================================================
# Case studies / 8. A classifier that declines    (source: docs/studies/08-selective-classification.md)
==============================================================================

# Study 8 — A classifier that declines to answer

Letting a model abstain is an old and good idea: answer where you are confident,
refuse where you are not, and hand the rest to a human. In quantum machine learning
it arrives with an extra motive — a quantum model's output is a *distribution*, so
reading it costs repeated circuit executions, and a rule that accepts a prediction
early saves shots as well as errors.

It also produces the most flattering number in classification, and the flattery is
free. This study builds an abstaining classifier, reports the number that looks
good, and then shows why it is the wrong one to quote.

## The trap, stated first

Selective accuracy — accuracy on the samples the model chose to answer — rises
monotonically as you abstain more. Push the threshold far enough and it reaches
1.000 on the single sample you are surest about. So a selective accuracy quoted
against a model that answered *everything* is not a comparison between two models;
it is a comparison between two different questions.

```python
# docs: requires sklearn
import numpy as np
import qmlkit as qk

truth = np.array([0, 1, 0, 1, 0, 1, 0, 1])
answered_only_when_certain = np.array([0, 1, None, None, None, None, None, None],
                                      dtype=object)

scores = qk.evaluate.selective(truth, answered_only_when_certain)
print(f"selective_accuracy {scores['selective_accuracy']:.3f} "
      f"at coverage {scores['coverage']:.0%}")
print(f"accuracy           {scores['accuracy']:.3f}   <- comparable to a model that cannot abstain")
for note in scores.notes:
    print("note:", note)
```

A perfect selective score, bought with coverage. `qk.evaluate.selective` reports
both halves and makes the comparable one the **primary** metric, so
`scores.score` cannot quietly become the flattering number.

## On a real model

Breast cancer, three PCA components, a two-layer `VQC`. The classical bar first, as
always:

```python
# docs: requires sklearn
from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X = PCA(n_components=3, random_state=0).fit_transform(StandardScaler().fit_transform(X))
X = qk.AngleScaler().fit_transform(X)
print(f"{len(X)} samples, {X.shape[1]} features")
```

Training a `VQC` on 455 rows and evaluating on 114 held out, the model's own
confidence — the larger of its two class probabilities — becomes the abstention
signal. Sweeping the threshold gives this, measured:

| threshold | selective accuracy | coverage | **comparable accuracy** |
|---|---|---|---|
| answer everything | 0.8947 | 100% | **0.8947** |
| 0.60 | 0.9182 | 96.5% | 0.8860 |
| 0.70 | 0.9320 | 90.4% | 0.8421 |
| 0.80 | **0.9479** | 84.2% | **0.7982** |

Read the two shaded columns together. As the threshold rises, selective accuracy
climbs from 0.895 to **0.948** — a 5.3-point gain that would headline well. Over the
same sweep the comparable accuracy *falls* from 0.895 to **0.798**.

Abstention did not make this model better. It made the reported number better and
the model worse, and only one of the two columns says so.

For reference, `qk.baseline` puts `svc-linear` at **0.9507** on the same three
features — above every row in the table, including the flattering one.

## The curve, because the point was chosen

A single `(coverage, accuracy)` pair is a threshold someone picked. The whole trade
is a curve, and it is what makes two abstaining models comparable:

```python
# docs: requires sklearn
rng = np.random.default_rng(0)
labels = rng.integers(0, 2, 200)
confidence = rng.uniform(0, 1, 200)
predictions = np.where(confidence > 0.35, labels, 1 - labels)   # right when confident

curve = qk.evaluate.risk_coverage(labels, predictions, confidence, n_points=5)
for c, r in zip(curve["coverage"], curve["risk"]):
    print(f"  coverage {c:.2f}   risk {r:.3f}")
print(f"AURC {curve['aurc']:.4f}")
```

**AURC** — area under the risk–coverage curve — is the summary that abstaining more
cannot inflate. A model that buys a high selective accuracy by refusing most of the
data pays for it in the low-coverage region, where its risk is measured against the
handful of samples it kept. On the `VQC` above the curve is `(0.25, 0.000)`,
`(0.50, 0.000)`, `(0.75, 0.024)`, `(1.00, 0.105)` with **AURC 0.0198**: the
confidence really is informative — errors concentrate where the model is unsure —
which is what justifies abstaining at all, and is a separate question from whether
abstaining helped.

## Which number to quote

- Comparing against a model that **cannot** abstain — quote `accuracy`. It counts an
  abstention as an error, which is what an abstention costs you when something
  downstream needs an answer.
- Comparing two abstaining models — quote **AURC**, or fix a coverage and compare
  selective accuracy *there*. Comparing at two different coverages compares nothing.
- Reporting the saving in circuit executions — say what it bought and what it cost,
  in the same sentence. `qk.plan()` prices the run; the execution saving at
  prediction time is usually small beside the training budget that produced the model.

## Where this comes from

The construction is classical — Chow's reject option, 1970 — and recurs in quantum
machine learning because a quantum prediction is already a repeated measurement. For
a recent treatment in this setting, and the measurement rule this study's shape is
drawn from, see Ptáček, Lewandowska and Kukulski, *Resource-Efficient Variational
Quantum Classifier*, [arXiv:2511.09204](https://arxiv.org/abs/2511.09204). Their
construction discards shots whose qubits disagree and votes on the survivors, which
is abstention implemented in the readout rather than in post-processing.

Quoting any selective result, theirs or your own, needs the coverage beside it. That
is the whole argument for `qk.evaluate.selective` reporting both.

---

**Next:** back to the [study index](index.md), or the
[evaluation guide](../guides/evaluation.md) for the metrics this one builds on.


==============================================================================
# Guides / Guides    (source: docs/guides/index.md)
==============================================================================

# Guides

The tutorials show you how. These explain why, and are meant to be read out of order
when a particular question comes up.

| | |
|---|---|
| **[Coming from PennyLane](from-pennylane.md)** | Borrow the three loops worth borrowing without migrating anything, the name map, and the four places the two libraries genuinely differ |
| **[The parameter-shift rule](parameter-shift.md)** | Why the famous two-term formula is not the whole story, and how qmlkit derives a rule per gate instead of transcribing one |
| **[Choosing a gradient method](choosing-a-gradient.md)** | Six options, a decision table, and measured costs |
| **[Backends and conventions](backends.md)** | Endianness, controlled-gate ordering, precision floors, and three upstream discrepancies worth knowing about |
| **[Integrating OpenQARP](openqarp.md)** | What qmlkit takes from Fujitsu's C++ engine, how the translation works, and the measurements that decide when to reach for it |
| **[Running under noise](noise.md)** | Mixed-state backends, why noise never picks a simulator for you, and what a noise model does to a gradient |
| **[Extending qmlkit](extending.md)** | Adding a gate, an ansatz, a gradient estimator or a backend — every extension point is a registry |
| **[Evaluating a model honestly](evaluation.md)** | Metrics that say when they mislead, skewed classes, the classical bar, and whether a number can be trusted or reproduced |
| **[Watching a run](watching-a-run.md)** | A live line with an honest ETA, what the run spent its time on, and the whole thing written out as one self-contained page |
| **[Working with a coding agent](agents.md)** | Why a wrong name answers with the right one, and what `diagnose()` catches that nothing raises for |

For how any of this is known to be correct, see [Validation](../about/validation.md).


==============================================================================
# Guides / Coming from PennyLane    (source: docs/guides/from-pennylane.md)
==============================================================================

# Coming from PennyLane

You do not have to migrate anything to use this library, and the cheapest way to start
is not to.

## Don't migrate. Borrow the loops that are worth borrowing

`qk.from_pennylane` reads a tape, a QNode or a plain quantum function — templates
decompose on the way in — so a PennyLane circuit can be handed to qmlkit for the parts
where qmlkit is faster and left exactly where it is for everything else.

```python
# docs: skip
import pennylane as qml
import qmlkit as qk

@qml.qnode(qml.device("default.qubit", wires=3))
def circuit(x):
    qml.AngleEmbedding(x, wires=range(3))
    qml.StronglyEntanglingLayers(weights, wires=range(3))
    return qml.expval(qml.PauliZ(0))

spec = qk.from_pennylane(circuit)     # nothing above this line changes
```

Three inner loops are worth stealing, and `examples/accelerate_pennylane.py` runs all
three against PennyLane's own answer before quoting a number:

| Loop | Why it moves | Measured |
|---|---|---|
| **Kernel Gram matrix** | the inversion test's `P(0…0)` *is* the state overlap, so a statevector backend runs one circuit per row and takes one matrix product — linear in the dataset rather than quadratic | **17×** at 20 points, and it widens with the dataset |
| **Batched gradients** | a training batch is the same circuit at one parameter vector per sample, and the batched kernel is a BLAS `gemm` | **5.4×** on a batch of 32 |
| **Fubini–Study metric** | closed-form differentiation of the state against `O(P)` adjoint or `O(P²)` Hadamard tests | **50×** at `P=12`, and it *widens* with parameter count |

All three are algorithmic rather than overhead, which is what makes them worth
stealing: each agrees with PennyLane's own answer to ~`1e-16`, and each gets *better*
the bigger the problem is.

**Every PennyLane column there is its fastest route, and getting that right changed two
of the three numbers.** The Gram row is timed against `default.qubit` *broadcasting* a
stacked array rather than one QNode call per pair, and the gradient row against a single
broadcast backward pass rather than a loop over `lightning.qubit`. Timed the naive way
those rows read 69× and 41×; they are 17× and 5.4× against what PennyLane can actually
do. A loop of QNode calls is what a newcomer writes, not the library's speed.

One more caveat: the state-overlap shortcut needs a statevector, so a *device* still
pays the pairwise count. `QuantumKernel.circuits_on_hardware` reports that number, and
it is the one to budget a hardware run from.

Everywhere else the gap is 1.0–1.2×. See [Validation](../about/validation.md) for the
whole table, including the row where PennyLane wins.

## When you guess the wrong name, the error tells you the right one

Most code written against this library is written by a model, and the training data
holds far more PennyLane than qmlkit — so the first guess is usually PennyLane's. Sixty
foreign names are answered with the qmlkit one:

```pycon
>>> qk.AngleEmbedding
AttributeError: module 'qmlkit' has no attribute 'AngleEmbedding'.
'AngleEmbedding' is PennyLane's name for qmlkit.AngleFeatureMap
(or angle_encode(x) for a one-shot circuit).
```

**It is a translation, not an alias — the foreign name still raises.** An alias would
become API, and it would hide the drift underneath: `qml.expval` takes a QNode,
`qk.expectation` takes a `CircuitSpec` and an observable. A name that silently resolved
would fail later, further from its cause, with a worse message.

A rough map of the ones people reach for first:

| PennyLane | qmlkit |
|---|---|
| `qml.device` | `qk.get_backend` |
| `qml.probs` / `qml.state` / `qml.sample` | `qk.probabilities` / `qk.statevector` / `qk.run_counts` |
| `qml.jacobian` / `qml.grad` | `qk.grad` |
| `AngleEmbedding` / `AmplitudeEmbedding` / `BasisEmbedding` | `AngleFeatureMap` / `amplitude_encode` / `basis_encode` |
| `StronglyEntanglingLayers` / `BasicEntanglerLayers` | `strongly_entangling` / `basic_entangler` |
| `SimplifiedTwoDesign` / `RandomLayers` / `MPS` / `TTN` | `simplified_two_design` / `random_layers` / `mps_ansatz` / `tree_tensor_network` |
| `qml.math.reduced_dm` | `qk.reduced_dm` |
| `qml.about()` | `qk.backend_report()` |

## Four places the two libraries genuinely differ

None is a bug in either, each is pinned by its own parity test so it stays deliberate,
and every one of them would be very hard to spot by eye. The full account is in
[Validation](../about/validation.md#four-convention-differences); the short version:

**IQP angle convention.** PennyLane's `IQPEmbedding` emits `RZ(xᵢ)`; qmlkit follows
Qiskit and emits `Rz(2φ)`. A kernel differing by exactly this factor looks fine.

**Amplitude-encoding phase.** qmlkit builds it from uniformly controlled rotations and
drops one overall factor — unobservable alone, observable inside a *controlled* block.

**Two-qubit "ring".** A ring on two qubits would revisit the same pair, so qmlkit emits
one `CX` where PennyLane's templates emit both directions. On two qubits these are
genuinely different circuits.

**`approx="block-diag"` does not port.** PennyLane blocks the metric tensor by circuit
layer and zeroes every cross-layer entry; qmlkit computes the exact metric, which costs
nothing on a simulator. This one is not cosmetic: on a 3-qubit, 2-layer problem at equal
step count and step size, qmlkit's QNG reaches `−2.9999999` where PennyLane's default
`block-diag` QNG stalls at `−2.22`. Pointed at the exact metric (`approx=None`),
PennyLane traces qmlkit's trajectory to `1e-8`.

## What PennyLane does that qmlkit does not

Worth knowing before you commit to anything.

- **Hardware.** qmlkit is **simulator-only for the whole `0.x` line**. If you need to
  run on a device, you need PennyLane.
- **Gradients through a noise channel.** `default.mixed` backpropagates through the
  channel; qmlkit's density-matrix backends offer parameter-shift only. This is the
  clearest place PennyLane is ahead.
- **JAX and `jit`.** Untested here and unclaimed — the benchmark machine has no JAX.
- **Catalyst, pulse-level control, plugin breadth, quantum chemistry depth.** Not
  contested.

## A dead parameter reads exactly like a fully collapsed plateau

The standard barren-plateau recipe is to fix a parameter index and watch
`Var[dC/dtheta_k]` as the register grows. It has a trap, and the trap does not raise.

Some indices are *structurally* silent for a given circuit and observable — the
gradient is exactly zero, not small — and which index that is moves with the depth and
with the cost you measure. Probe one of those and the number that comes back is
machine zero, which reads as the most collapsed plateau imaginable and is nothing of
the kind:

```python
import qmlkit as qk

n = 6
glob = qk.Z(0)
for i in range(1, n):
    glob = glob * qk.Z(i)

ansatz = qk.hardware_efficient(n, 1)
stats = qk.gradient_stats(ansatz, glob, n_samples=50)

print(f"parameter 0:         {stats.variance[0]:.2e}")
print(f"best live parameter: {stats.best_variance:.2e}")
print(f"silent parameters:   {stats.silent.size} of {stats.n_params}")
```

Thirty-two orders of magnitude between "this model is dead" and "this model is fine",
decided by which index you happened to type. `qk.gradient_variance` carries a warning
for exactly this case, and `qk.gradient_stats` sidesteps it by returning every
parameter at once — one adjoint pass already computes the whole vector, so the other
`p - 1` entries are free and they are what tells a plateau from a dead parameter.

There is no equivalent warning in PennyLane, and none is implied: `qml.grad` is a
differentiation primitive and an exact zero is the correct answer to what it was
asked. The difference is that qmlkit ships the diagnosis on top, because this is the
mistake the barren-plateau literature is easiest to reproduce wrongly with.

## What you get in exchange

The layer that answers *is this result real*, which is the reason the library exists:

```python
# docs: skip
qk.plan(model)             # what the run costs in circuits, before you pay for it
qk.baseline(X, y)          # the classical bar, on identical folds
qk.diagnose(model)         # the failures that return a number instead of raising
qk.diagnose(model, X, y)   # whether the quantum layer earned its place
qk.selfcheck(spec, theta)  # every exact gradient route against every other
qk.progress()              # how much of the run is left
```

## If you do want to move a whole project

```python
# docs: skip
qk.from_pennylane(qnode)    # a tape, QNode or quantum function; templates decompose
qk.from_qasm(text)          # standard library only, no extras needed
qk.from_qiskit(circuit)     # unbound Parameters carried onto ParamRef
qk.from_cirq(circuit)       # sympy symbols carried onto ParamRef
```

Qubit order is where importers actually break, so it is what the tests check:
`from_qiskit(to_qiskit(spec))` reproduces the **statevector** to `1e-12` over randomly
generated circuits, not merely the same list of gates. Qiskit and QASM are little-endian
and get flipped; **PennyLane is big-endian like qmlkit and does not** — and that claim is
asserted against PennyLane's own simulator rather than assumed.

A gate qmlkit has no definition for is refused by name, never approximated. The one
exception is the `u`/`u3` family, decomposed into rotations with a warning that an
overall phase was dropped.


==============================================================================
# Guides / The parameter-shift rule    (source: docs/guides/parameter-shift.md)
==============================================================================

# The parameter-shift rule

Every QML course teaches this formula:

$$\frac{\partial E}{\partial \theta} = \frac{E(\theta + \pi/2) - E(\theta - \pi/2)}{2}$$

It is correct — for a gate whose generator has a single frequency, differentiated one
occurrence at a time. Both of those conditions are easy to violate without noticing,
and violating either returns a smooth, finite, believable number rather than an
error.

qmlkit therefore **derives** each rule from the gate's declared generator
frequencies. Nothing is transcribed.

## Where the rule comes from

For a gate $U(\theta) = e^{-i\theta G/2}$, the expectation $E(\theta)$ is a
trigonometric polynomial whose frequencies are the gaps between eigenvalues of $G$.
A gate that declares frequencies $\{\Omega_1, \dots, \Omega_k\}$ has

$$E(\theta) = a_0 + \sum_{j=1}^{k} \left[ a_j \cos(\Omega_j \theta) + b_j \sin(\Omega_j \theta) \right]$$

Reconstructing $E'(\theta)$ exactly from finitely many evaluations of $E$ is then a
linear algebra problem: choose $2k$ shifts, write down what each evaluation
contributes, and solve for the coefficients. `general_shift_rule` does exactly that.

```python
import qmlkit as qk

rule = qk.general_shift_rule((0.5, 1.0))
for shift, coeff in zip(rule.shifts, rule.coeffs):
    print(f"shift {shift:+.6f}   coefficient {coeff:+.6f}")
```

```text
shift +1.570796   coefficient +0.426777
shift -1.570796   coefficient -0.426777
shift +4.712389   coefficient -0.073223
shift -4.712389   coefficient +0.073223
```

Solving a linear system rather than looking up a formula means a gate you register
yourself gets a correct rule automatically, as long as you declare its frequencies.

## Rules are per gate

```python
import qmlkit as qk

for gate in ("rx", "ry", "rz", "phase", "crx", "cry", "crz"):
    rule = qk.rule_for_gate(gate)
    print(f"{gate:<7} frequencies {str(qk.get_gate(gate).frequencies):<14} {len(rule.shifts)}-term rule")
```

```text
rx      frequencies (1.0,)        2-term rule
ry      frequencies (1.0,)        2-term rule
rz      frequencies (1.0,)        2-term rule
phase   frequencies (1.0,)        2-term rule
crx     frequencies (0.5, 1.0)    4-term rule
cry     frequencies (0.5, 1.0)    4-term rule
crz     frequencies (0.5, 1.0)    4-term rule
```

A controlled rotation's generator is a projector times a Pauli. Its eigenvalue gaps
include both ½ and 1, so a two-term rule cannot reconstruct the derivative.
[Tutorial 3](../tutorials/03-gradients.md) has a worked case where the naive rule
returns exactly √2 times the right answer — same sign, same shape, wrong magnitude.

## Occurrences are shifted one at a time

When one logical parameter fills several slots, the chain rule gives a **sum** over
occurrences:

$$\frac{\partial E}{\partial \theta_k} = \sum_{\text{slots } s \text{ using } \theta_k} \frac{\partial E}{\partial \phi_s}$$

Shifting every occurrence simultaneously computes a directional derivative along a
different direction entirely. For three tied `Ry(θ)` on one qubit — which is
`Ry(3θ)` — it is wrong by a factor of −3.

This is not an exotic case. It is exactly what a QCNN does, and weight tying is the
entire point of a convolutional ansatz.

## Cost is not `2P`

Because rules differ per gate, the real cost is a sum over slots, not a multiple of
the parameter count:

```python
import qmlkit as qk

qc = qk.QCircuit(2)
qc.ry(0, qk.ParamRef(0)).crz(0, 1, qk.ParamRef(1))
spec = qc.to_spec()

print(f"P = {spec.n_params}")
print(f"naive 2P      = {2 * spec.n_params}")
print(f"actual cost   = {qk.grad_circuit_cost(spec)}   (2 for the ry, 4 for the crz)")
```

```text
P = 2
naive 2P      = 4
actual cost   = 6   (2 for the ry, 4 for the crz)
```

`grad_circuit_cost` sums the real per-slot rule cost. On hardware, where each circuit
carries fixed latency, that difference is the difference between a job that fits in
your queue allocation and one that does not.

## Scaling and offsets

A `ParamRef` may carry a scale and an offset — `φ = scale·θ + offset`. The chain rule
then multiplies the slot's contribution by `scale`:

```python
import numpy as np
import qmlkit as qk

qc = qk.QCircuit(1)
qc.ry(0, qk.ParamRef(0, scale=2.0))
spec = qc.to_spec()

g = qk.grad(spec, np.array([0.3]), qk.Z(0), method="parameter-shift")
print(f"gradient           {g[0]:+.10f}")
print(f"2 · -sin(2·0.3)    {-2 * np.sin(0.6):+.10f}")
print(f"without the scale  {-np.sin(0.6):+.10f}")
```

```text
gradient           -1.1292849468
2 · -sin(2·0.3)    -1.1292849468
without the scale  -0.5646424734
```

Dropping the `scale` factor halves the gradient here. Like the other two failure
modes on this page, the result stays smooth and finite — training still descends,
just at the wrong rate.

## When it still does not apply

A generator with a continuum of frequencies — a general time-evolution operator, say
— has no finite shift rule. The literature's answer is the **stochastic**
parameter-shift rule (Banchi & Crooks 2020), which samples an integral instead. It is
not implemented here; `method="adjoint"` covers those cases on a simulator, and
`register_gradient` is the hook if you need the hardware-valid version.


==============================================================================
# Guides / Choosing a gradient method    (source: docs/guides/choosing-a-gradient.md)
==============================================================================

# Choosing a gradient method

Six methods, and `qk.grad(spec, theta, obs)` with no `method` picks a sensible one.
This page is for when you want to override it.

## The short answer

| Situation | Use |
|---|---|
| Simulating, and you just want the gradient | **`adjoint`** (the default) |
| The circuit lives inside a torch autograd graph | **`backprop`** |
| You are modelling what hardware would do | **`parameter-shift`**, with `shots` |
| Hardware, and circuit count is the binding constraint | **`hadamard`**, if the ancilla can reach every wire |
| Very many parameters, or very noisy evaluations | **`spsa`** |
| Checking another method | **`finite-diff`** |

## The full picture

| Method | Cost | Exact | Runs on hardware |
|---|---|---|---|
| `adjoint` | one backward pass | yes | no — needs the statevector |
| `backprop` | one autograd pass | yes | no — needs the statevector |
| `hadamard` | one circuit per parameterised *slot*, + one ancilla | yes | yes, given the connectivity |
| `parameter-shift` | `2P` circuits, more for four-term gates | yes | yes |
| `spsa` | 2 evaluations, any `P` | no — unbiased estimate | yes |
| `finite-diff` | `2P` | no — `O(h²)` bias | technically, but don't |

Measured on a 5-qubit hardware-efficient ansatz with a two-term observable:

| `P` | `adjoint` | `backprop` | `hadamard` | `parameter-shift` | `finite-diff` |
|---|---|---|---|---|---|
| 20 | **2.2 ms** | 8.5 ms | 15 ms | 28 ms | 29 ms |
| 60 | **6.2 ms** | 24 ms | 109 ms | 213 ms | 226 ms |
| 120 | **12.6 ms** | 50 ms | 404 ms | 823 ms | 870 ms |

## Choosing a gradient is not choosing an optimiser

`grad()` decides how the derivative is computed; what you do with it is separate.
Four optimisers are registered, reachable by name wherever a `VQE`-style algorithm
takes one:

| name | needs a gradient | when |
|---|---|---|
| `adam` | yes | the default choice for a variational circuit of any depth |
| `gradient-descent` | yes | when you want the plainest possible baseline to compare against |
| `rotosolve` | no | shallow circuits where each angle drives one Pauli rotation — no learning rate to pick |
| `spsa` | no | two evaluations per step whatever `P` is; the shot-budget option |

`adam` is the one to reach for first. Parameter gradients in a deep ansatz differ in
scale by orders of magnitude — a rotation near the readout moves the expectation far
more than one behind a wall of entanglers — so a single learning rate either crawls
on the small ones or diverges on the large. Adam divides by the running gradient
magnitude, which makes the step size per-parameter.

```python
from qmlkit.optim import minimize_adam
import qmlkit as qk

ansatz = qk.hardware_efficient(3, 2)
spec, obs = ansatz.build(), qk.Z(0)
theta, history = minimize_adam(
    lambda t: qk.expval(spec, obs, theta=t),
    ansatz.init("small", seed=0),
    lambda t: qk.grad(spec, t, obs),
    n_steps=40,
)
print(f"{history[0]:+.4f} -> {history[-1]:+.4f}")
```

`adam_step` and `AdamState` are public for the case where the loop is yours. Keep the
state between steps: dropping it turns Adam back into gradient descent with a
decaying learning rate, which trains, converges, and is not what you asked for.

## What `method="auto"` decides

```python
import qmlkit as qk

ansatz = qk.hardware_efficient(3, 2)
spec = ansatz.build()

print("no shots:  ", qk.choose_method(spec))
print("with shots:", qk.choose_method(spec, shots=1000))
```

```text
no shots:   adjoint
with shots: parameter-shift
```

Adjoint when every gate has a closed-form derivative and the backend can produce a
statevector; parameter-shift otherwise. Asking for `shots` rules out adjoint by
definition — you cannot sample a statevector you are not allowed to read.

Methods that need the statevector **refuse** a shot budget rather than silently
ignoring it:

```python
import qmlkit as qk

ansatz = qk.hardware_efficient(2, 1)
spec, theta = ansatz.build(), ansatz.init(seed=0)
try:
    qk.grad(spec, theta, qk.Z(0), method="adjoint", shots=1000)
except ValueError as exc:
    print(exc)
```

## Notes on each

**`adjoint`** — one forward pass and one backward pass, whatever `P` is. Exact. The
right default on a simulator, and the reason this library exists in a simulator-only
0.x: it makes the cost of a gradient independent of the parameter count.

**`backprop`** — differentiates a torch statevector simulator directly. Exact, and
slower than adjoint for a standalone gradient because of per-gate tensor overhead.
Its reason to exist is that the circuit sits *inside* an autograd graph, which is
what `QuantumLayer` needs. It is also the least physical method here: it reads
intermediate states no device will expose, and its memory grows with depth.

How much slower, measured on a hardware-efficient ansatz:

| qubits | `P` | `adjoint` | `backprop` | `parameter-shift` |
|---|---|---|---|---|
| 3 | 12 | **1.4 ms** | 4.7 ms | 8.2 ms |
| 4 | 24 | **2.3 ms** | 9.0 ms | 31.5 ms |
| 6 | 36 | **3.8 ms** | 15.9 ms | 79.2 ms |
| 8 | 32 | **3.6 ms** | 16.3 ms | 93.5 ms |

Batched over a training batch of 128, the gap widens and `backprop` drops out
entirely - it has no batched form, and `grad_batch` says so rather than falling back:

| qubits | `P` | `adjoint` | `parameter-shift` |
|---|---|---|---|
| 4 | 16 | **7.6 ms** | 58.7 ms |
| 6 | 24 | **28.3 ms** | 430.9 ms |
| 8 | 32 | **118 ms** | 2552 ms |

This is worth stating because the intuition travels badly. In a framework where every
circuit evaluation goes through a dispatch layer, `backprop` can be dramatically
*faster* than `adjoint` - the dispatch dominates, and backprop pays it once instead of
once per parameter. qmlkit's adjoint is a direct NumPy sweep with no dispatch to
amortise, so the ranking inverts. If you arrive expecting backprop to win, measure
before switching; `method="auto"` already picks the fast one here.

## Inside a torch layer, only three methods stay batched

`QuantumLayer` and the models built on it compute a whole batch's gradient in one
backend call. That is the difference between a training step costing one call and
costing one per sample, and it is why `qmlkit.gradients.batch` exists at all.

Only three `grad_method` values take that path:

| `grad_method` | inside `QuantumLayer` |
|---|---|
| `auto`, `adjoint`, `parameter-shift` | **batched** — one call per observable for the whole batch |
| `hadamard`, `spsa`, `finite-diff`, anything you registered | **per sample**, in a Python loop |
| `backprop` | refused outright by `grad_batch`, which says so rather than falling back |

The middle row is the one to watch, because nothing raises and nothing warns: the
answer is correct and the run is quietly an order of magnitude slower. If you set
`grad_method="hadamard"` on a `VQC` because the comparison table above says it costs
half what parameter-shift does, you get the per-slot saving *and* lose the batching,
which on any realistic batch size is the worse trade.

The refusal on `backprop` is deliberate and worth contrasting: it needs the circuit
inside an autograd graph, which a batched NumPy call cannot provide, so it raises
instead of silently doing something else.

**`hadamard`** — one circuit per parameterised *slot* 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, ten backends, one answer. `tests/test_cross_backend.py` runs the same
circuit zoo through every installed backend and asserts agreement with the NumPy
reference on statevectors, probabilities, expectations over X/Y/Z and two-body terms,
seeded sampling, and parameter-shift gradients.

| | simulator | when |
|---|---|---|
| `numpy` | the built-in reference | the default. Exact, vectorised, fastest up to ~13 qubits |
| `aer` | `AerSimulator(method="statevector")` | **above ~13 qubits** — C++, and it does not degrade the way a Python gate loop does |
| `qiskit` | `quantum_info.Statevector` | Qiskit's reference. Exact, no extra install, and the slowest of the three |
| `cirq` | `cirq.Simulator` | Cirq's reference |
| `openqarp` | OpenQARP's `QarpSimulator` | **batched expectations** — swept in C++, several times faster than anything else here above ~8 qubits |
| `spinqit` | SpinQit's simulator | the diploma's own SDK; Python 3.10 only |
| `torch` | a differentiable simulator | `backprop` only |
| `mps` | `AerSimulator(method="matrix_product_state")` | wide but lightly entangled circuits. No statevector, and exact only until the bond dimension truncates |
| `cirq-density` · `qiskit-aer` | density matrices | noise, when you ask for it by name |

### Which one, in practice

The NumPy reference wins at the widths quantum machine learning actually runs at,
because it carries the batch as a leading axis and contracts gate by gate rather than
sample by sample. That advantage is a Python-level one, and it stops paying once the
statevector gets big:

| qubits | `numpy` | `qiskit` | `aer` |
|---|---|---|---|
| 10 | **0.05s** | 0.17s | 0.13s |
| 12 | 0.16s | 0.28s | **0.16s** |
| 14 | 0.33s | 0.63s | **0.21s** |
| 16 | 0.91s | 4.58s | **0.28s** |
| 18 | 2.87s | 17.47s | **0.60s** |

*(a 40×40 Gram matrix on a `ZZFeatureMap(reps=2)`; single machine, exact throughout,
all three agreeing to 2e-16.)*

The crossover is around **12 qubits**, and what sets it is the *Python* cost of the
reference against Aer's C++ kernel: below it the reference is batched and gate-bound,
above it the statevector is large enough that the C++ inner loop wins outright. If you
are working wider than a dozen qubits, `backend="aer"` is the answer; below it, the
default already is.

`NumpyBackend.batch_max_qubits` (11) is a *different* boundary — it is where NumPy
stops carrying the batch as a leading axis and falls back to one sample at a time. The
two sit next to each other and an earlier version of this page claimed they were the
same one. They move independently: making the batched kernel a BLAS `gemm` moved
`batch_max_qubits` from 10 to 11 without touching where Aer takes over.

`aer` is deliberately a separate name rather than a silent upgrade to `qiskit`, so the
simulator that produced a number stays visible in the code that produced it.

### When the answer is an expectation

Every backend above answers an expectation the same way: produce the statevector, hand
it back, contract it here. `openqarp` is the one that does not. It contracts inside the
simulator, and it will sweep a whole batch of parameter vectors without returning to
Python between rows — which is the shape of nearly everything this library asks a
backend for. A batched parameter-shift gradient is `2P × batch` circuits behind one
call, and so is every forward pass of `QuantumLayer`.

| 256 rows, 3-layer `ry`/`rz` ring | `numpy` | `aer` | `openqarp` |
|---|---|---|---|
| 8 qubits | 52 ms | 556 ms | **17 ms** |
| 10 qubits | 217 ms | 723 ms | **38 ms** |
| 12 qubits | 1043 ms | 972 ms | **156 ms** |
| 14 qubits | 2591 ms | 1498 ms | **626 ms** |

*(one `expectation_over_slots` call on `Z0 + Z0·Z(n−1) + 0.3·X1`; exact throughout, all
three agreeing to `1e-15`.)*

For a single statevector it is a different picture — competitive to about 16 qubits and
behind Aer above that — so this is the backend to reach for when the work is
expectations, which in this library it usually is.

```python
# docs: requires qarp
import numpy as np

import qmlkit as qk

spec = qk.hardware_efficient(4, 2).build()
thetas = np.random.default_rng(0).uniform(-np.pi, np.pi, (32, spec.n_params))
print(qk.get_backend("openqarp").expectation_over(spec, thetas, qk.Z(0)).shape)
```

Where it does not win is worth knowing too, and both places are structural. The NumPy
reference carries a batch as a *leading axis* and contracts gate by gate, so anything
that asks for many statevectors at once — an adjoint gradient, a fidelity kernel —
gets a whole batch in one contraction there and one row at a time here: 256 states of
an eight-qubit ansatz cost 104 ms on this backend against 45 ms on the reference. That
reverses between eight and ten qubits (150 ms against 212 ms at ten), for the same
reason the Aer crossover exists. And at four or five qubits nothing here matters next
to Python overhead per call, so a small `VQC` trains no faster — with a second of SDK
import before the first circuit runs.

So: reach for it when the answer is an expectation and the register is wide enough to
notice. `grad_method="parameter-shift"` is where it shows most, because that method is
made of expectations; `adjoint`, which is the default, is made of statevectors and
lands roughly where the reference does.

Because the number now comes out of the SDK rather than out of this library's own
contraction, there are two routes to it and `native_expectations=False` is the other
one — same backend, same circuit, expectation derived from the statevector instead.
`tests/test_openqarp_backend.py` runs both and compares, which is the only reason to
trust the fast one.

The install is `pip install "qmlkit[openqarp]"`, Python 3.11+ — OpenQARP publishes no
wheel below it, so the extra is gated by an environment marker and resolves to nothing
on 3.10 rather than failing. It is also the heaviest dependency here: the compiled core
arrives with SciPy, SymPy, NetworkX, Matplotlib and IPython behind it.

**[Integrating OpenQARP](openqarp.md)** is the long form — what the translation is made
of, where the endianness map shows up in the block's own QASM, how a registered gate
survives the crossing, and every measurement above with the script that produced it.

```python
import qmlkit as qk

print(qk.available_backends())
print(qk.backend_report())
```

Every SDK import is lazy, so `import qmlkit` requires none of them and a missing one
produces an install command rather than an `ImportError`. Set the default with
`QMLKIT_BACKEND` or `qk.set_default_backend(...)`, or pass `backend=` per call.

## Qubit ordering

**qmlkit is big-endian: qubit 0 is the most significant bit.** A count key `'011'`
means qubit 0 measured `|0⟩`, qubit 1 `|1⟩`, qubit 2 `|1⟩`. This matches SpinQit and
PennyLane.

Qiskit and OpenQARP are little-endian. Rather than reversing statevectors after
the fact, both backends map qmlkit qubit `i` to that SDK's qubit `n−1−i` **at build
time**, so the index conventions coincide and no reversal is needed anywhere
downstream. On OpenQARP that is also the faster of the two: its own
`lsb_to_msb_statevector` builds the permutation from a Python loop over `2**n`
formatted strings, 40 ms at 16 qubits against the 11 ms simulation it would be
decorating.

## 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, "openqarp": 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`, `to_spinqit` and `to_openqarp` are the four.

An OpenQARP block brings its own exporters with it, which makes the way back a
one-liner:

```python
# docs: requires qarp
import numpy as np

import qmlkit as qk

qc = qk.QCircuit(2)
qc.h(0).cx(0, 1).ry(1, 0.7)
spec = qc.to_spec()
block = qk.get_backend("openqarp").to_openqarp(spec)
back = qk.from_qasm(block.to_qasm2())
print(np.allclose(qk.statevector(spec), qk.statevector(back)))
```

qarp writes little-endian QASM, the same convention Qiskit's exporter uses, and
`from_qasm` already inverts that — so the `n−1−i` map applied on the way out is undone
on the way back, and the circuit returns as the one you started with rather than as its
mirror image.

## 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 / Integrating OpenQARP    (source: docs/guides/openqarp.md)
==============================================================================

# Integrating OpenQARP

[OpenQARP](https://github.com/OpenQARP/openqarp) — Fujitsu's Open Quantum Application
Research Package, open-sourced in September 2026 under Apache-2.0 — is a Python
framework over a compiled C++ core. It is assembled from three interchangeable layers:
**blocks** describe circuits, **primitives** say what to extract from them, and
**engines** say how they run, with some seventy building blocks and twenty-odd
ready-to-run algorithms on top.

qmlkit takes one layer of that: the **engine**, `qarpx.QarpSimulator`. This library
already has the feature maps, the ansatz vocabulary, the kernels, the gradients and the
torch bridge; what it wanted from OpenQARP is a fast place to run them — and two entry
points that no other backend here offers.

```bash
pip install "qmlkit[openqarp]"
```

```python
# docs: requires qarp
import numpy as np

import qmlkit as qk

spec = qk.hardware_efficient(6, 2).build()
thetas = np.random.default_rng(0).uniform(-np.pi, np.pi, (64, spec.n_params))
print(qk.get_backend("openqarp").expectation_over(spec, thetas, qk.Z(0)).shape)
```

Nothing else in a script changes: `backend="openqarp"` per call, `QMLKIT_BACKEND` per
session, `qk.set_default_backend("openqarp")` for the process. Python 3.11+, because
OpenQARP publishes no wheel below it.

## What a backend owes qmlkit, and what this one supplies

A backend supplies *primitives*; [`base.py`](../reference/backends.md) supplies
*semantics*. Sampling, basis rotation, qubit-wise-commuting grouping, expectation and
the whole batch stack are derived **once**, above the SDK. That is what makes agreement
between ten backends a property rather than a coincidence, and it is why most backends
here implement exactly one method.

This one implements three, because the engine can do two things the protocol otherwise
derives:

| qmlkit asks for | OpenQARP answers with | why it is worth crossing the boundary |
|---|---|---|
| `statevector(spec)` | `QarpSimulator.statevector(commands, n)` | the primitive every simulator backend supplies |
| `expectation(spec, obs)` | `QarpSimulator.expectation(commands, n, terms)` | ⟨O⟩ contracted in C++ — the `2**n` vector is never built on the Python side |
| `expectation_over_slots(spec, rows, obs)` | `QarpSimulator.batch_expectation(commands, n, terms, param_sets)` | one circuit, many parameter vectors, no return to Python between rows |
| `counts(spec, shots, seed)` | **nothing — deliberately** | shots stay with the shared estimator, so a seed reproduces the *same* counts on every backend |

The last row is the important one. It would have been easy to hand sampling to
OpenQARP as well, and the cost would have been silent: `tests/test_cross_backend.py`
asserts that `counts(spec, shots=2048, seed=42)` is identical on every backend, and a
second shot-drawing implementation cannot keep that promise.

## The translation

### Gates, one table, no transpiler

Twenty qmlkit gate names map one-to-one onto `SimpleBlock` methods — `phase` is qarp's
`p`, `i` is its `id`, and the rest carry their own names. Wires come first and the
angle last, which is the opposite of Qiskit's order and the kind of thing that is right
until it is not, so every entry is asserted against the NumPy reference rather than
argued for.

Nothing is transpiled on the way in. OpenQARP simulates this library's gate set
directly, so a transpiler pass would cost more than it could save.

### Endianness: the map, not the reversal

qarp indexes amplitudes **LSB-first** — qubit `q` carries bit `q` — which is the
opposite of qmlkit. The fix is the one the Qiskit backend uses: qmlkit qubit `i` is
emitted as qarp qubit `n−1−i` **at build time**, so the two index conventions coincide
and nothing downstream reverses anything. You can see the map in the block's own QASM:

```python
# docs: requires qarp
qc = qk.QCircuit(3)
qc.h(0).cx(0, 1).ry(2, 0.7)
block = qk.get_backend("openqarp").to_openqarp(qc.to_spec())
print(block.to_qasm2())
```

```text
// qmlkit
OPENQASM 2.0;
include "qelib1.inc";

qreg q[3];

h q[2];
cx q[2], q[1];
ry(0.7) q[0];
```

qmlkit's qubit 0 is qarp's `q[2]`, and the statevector index is then the same integer
on both sides. The alternative — simulate, then bit-reverse the vector — is both easier
to get quietly wrong and slower: `qarp.endianness.lsb_to_msb_statevector` builds its
permutation from a Python loop over `2**n` formatted strings, which costs 40 ms at 16
qubits against the 11 ms simulation it would be decorating.

The same `n−1−i` map is applied to observable terms, which is the only other place a
qubit index crosses the boundary.

### A gate no SDK has heard of

`qk.register_gate` promises that a gate you invent works everywhere. On OpenQARP it
arrives as a **matrix**: the run of named gates around it closes into one `SimpleBlock`,
the custom gate becomes a `SynthesizedUnitaryBlock` — qarp's C++ Quantum Shannon
decomposition, exact including global phase — and the two are wired together as a
`CompositeBlock`.

```python
# docs: skip
qc = qk.QCircuit(3)
qc.h(0).apply("xy", (2, 0), 0.7)          # "xy" registered with register_gate
block = qk.get_backend("openqarp").to_openqarp(qc.to_spec())

print(type(block).__name__, block.n_gates())            # CompositeBlock 25
print([type(c).__name__ for c in block.children()])     # SimpleBlock, SynthesizedUnitaryBlock
```

Two operations became twenty-five gates, which is what a Shannon decomposition of an
arbitrary two-qubit unitary costs. The subtle part is not the decomposition but the
**wire order**: a matrix carries its qubit order in its *basis* rather than in a wire
list, qmlkit writes the op's first wire as the most significant bit, and a qarp
sub-block reads its own local qubit 0 as the least significant — so the wire list is
reversed on top of the `n−1−i` map. Get that wrong and the circuit still runs, with the
operands swapped. `tests/test_cross_backend.py` pins it against the reference over
ascending, descending and non-adjacent wire orders, and over a three-qubit custom gate.

### Parameters: slots, not parameters

The sweep is where the integration gets interesting. A qmlkit circuit has `n_params`
logical parameters, but those map onto **slots** — one per (operation, parameter
position) — and a weight-tied parameter fills several. A shift rule moves one *slot* at
a time, so the batch a gradient hands the backend is a batch of slot-angle vectors, not
parameter vectors.

The translation therefore names one qarp symbol per slot, `qmlkit_slot_0`,
`qmlkit_slot_1`, …, binds them through `batch_expectation`'s parameter sets, and lets
the C++ kernel do the substitution. Naming them per *logical parameter* instead would
still build, still run, and still return numbers in range — with every occurrence of a
tied weight forced to the same shift. That is precisely the failure the slot
abstraction exists to prevent, so it is asserted where the binding happens, in
`tests/test_openqarp_backend.py`.

## Does it agree?

Speed is worth nothing if the answers move. Every number below is `max |difference|`
against the NumPy reference, over a three-layer `ry`/`rz` ring:

| qubits | statevector | ⟨O⟩ contracted | ⟨O⟩ swept | parameter-shift gradient |
|---|---|---|---|---|
| 4 | 1.7e-16 | 2.8e-17 | 6.7e-16 | 4.6e-16 |
| 6 | 1.2e-16 | 1.1e-16 | 7.8e-16 | 4.0e-16 |
| 8 | 1.1e-16 | 4.3e-16 | 9.1e-16 | 3.3e-16 |
| 10 | 8.4e-17 | 2.8e-16 | 1.2e-15 | 7.4e-16 |

That is machine precision, not a tolerance chosen to pass. The backend also runs the
whole cross-backend zoo — endianness probes, every non-parametric gate, controlled
rotations, idle qubits, basis rotations, seeded sampling, registered gates at six wire
orders — 33 cases, plus 12 of its own.

## What it buys

### The call it exists for

One `expectation_over_slots` of 256 rows, `Z0 + Z0·Z(n−1) + 0.3·X1`:

| qubits | `openqarp` | `numpy` | `aer` | vs the reference |
|---|---|---|---|---|
| 4 | 5.3 ms | **3.1 ms** | 316 ms | 0.6× |
| 6 | 11.7 ms | **10.0 ms** | 397 ms | 0.9× |
| 8 | **20.8 ms** | 48.3 ms | 523 ms | 2.3× |
| 10 | **37.3 ms** | 237 ms | 666 ms | 6.4× |
| 12 | **164 ms** | 1085 ms | 874 ms | 6.6× |
| 14 | **646 ms** | 2687 ms | 1484 ms | 4.2× |

The crossover is between six and eight qubits, and the reference wins below it — which
is the same story as Aer's crossover and for the same reason: below it, this library is
bound by per-call overhead rather than arithmetic.

### Where the win comes from

Not from the C++ kernel alone. Against this backend's *own* row-by-row path — same
engine, same circuit, binding done in Python and the block rebuilt per row — the sweep
is worth:

| qubits | swept | row by row | ratio | |
|---|---|---|---|---|
| 8 | 16.4 ms | 107 ms | 6.5× | bit-identical |
| 12 | 165 ms | 297 ms | 1.8× | bit-identical |
| 14 | 635 ms | 786 ms | 1.2× | bit-identical |

So most of the eight-qubit win is *translation* that the sweep pays once instead of 256
times, and by fourteen qubits the simulation dominates and the two converge. The two
paths agree to the last bit, because it is the same circuit either way — asserted with
`np.array_equal`, not a tolerance.

### One expectation at a time

| qubits | contracted | derived from the statevector | `numpy` | `aer` |
|---|---|---|---|---|
| 10 | **0.39 ms** | 0.54 ms | 2.05 ms | 3.14 ms |
| 12 | **0.91 ms** | 1.09 ms | 3.72 ms | 4.75 ms |
| 14 | **2.77 ms** | 3.45 ms | 9.15 ms | 10.32 ms |
| 16 | 16.96 ms | 13.92 ms | 26.94 ms | **13.39 ms** |

Contracting inside the kernel is worth about 25% up to fourteen qubits and stops paying
by sixteen, where Aer catches both. The other reason to contract there is not in the
table: nothing allocates `2**n` amplitudes on the Python side.

## Where it does not win

The NumPy reference carries a batch as a **leading axis** and contracts gate by gate,
which is a different kind of fast. Anything asking for many statevectors at once — an
adjoint gradient, a fidelity kernel — gets a whole batch in one contraction there and
one row at a time here:

| qubits | 256 statevectors, `openqarp` | `numpy` |
|---|---|---|
| 6 | 78.1 ms | **6.7 ms** |
| 8 | 104 ms | **45.3 ms** |
| 10 | **150 ms** | 212 ms |
| 12 | **284 ms** | 975 ms |

Which shows up exactly where you would expect it to, in a batched gradient of 32
samples:

| qubits | method | `openqarp` | `numpy` | |
|---|---|---|---|---|
| 8 | parameter-shift | **93 ms** | 322 ms | 3.5× |
| 8 | adjoint | 17.4 ms | **10.8 ms** | 0.6× |
| 10 | parameter-shift | **263 ms** | 1779 ms | 6.8× |
| 10 | adjoint | 37.0 ms | 33.5 ms | 0.9× |
| 12 | parameter-shift | **1260 ms** | 8589 ms | 6.8× |
| 12 | adjoint | **301 ms** | 356 ms | 1.2× |

`parameter-shift` is made of expectations and gains the whole factor; `adjoint`, which
is the default, is made of statevectors and lands where the reference does. Both return
the same gradient to `1e-9` — the probe asserts that before it times anything.

**The rule of thumb.** Reach for this backend when the answer is an expectation and the
register is wide enough to notice — eight qubits and up. Below that, and for
statevector-shaped work under about ten qubits, the reference is still the right
default. And the first `get_backend("openqarp")` pays a second of SDK import, which
matters for a script that evaluates one circuit and exits.

## Two routes to every number

Handing an observable to an SDK is a second implementation of something this library
otherwise defines once, and a second implementation is a second chance to be wrong. So
the backend keeps both:

```python
# docs: requires qarp
native = qk.get_backend("openqarp")                              # contracted in C++
derived = qk.get_backend("openqarp", native_expectations=False)  # from the statevector

bound = spec.bind(thetas[0])
obs = qk.Z(0) + 0.5 * qk.X(1)
print(abs(native.expectation(bound, obs) - derived.expectation(bound, obs)) < 1e-12)
```

`native_expectations=False` is not a fallback for when something breaks. It is how the
suite asks one backend the same question by two routes that share the circuit
translation and no arithmetic, and compares the answers — and it is there for you to do
the same when a result surprises you. `qk.selfcheck` reaches for it too, since it runs
every installed backend against the reference.

## The block is yours

`to_openqarp` hands back a real qarp block, not an internal handle, so OpenQARP's own
tooling — its plotting, its exporters, its algorithm library — is one call away. And
because qarp writes little-endian QASM, the same convention Qiskit's exporter uses,
`from_qasm` inverts the build-time map and the circuit comes back as itself:

```python
# docs: requires qarp
back = qk.from_qasm(block.to_qasm2())
print(np.allclose(qk.statevector(qc.to_spec()), qk.statevector(back)))
```

## Reproduce it

Every number on this page comes out of one script, on one machine (Windows, Python
3.14, OpenQARP 0.1.0). The ratios travel; the milliseconds do not.

```bash
python scripts/probe_openqarp.py
```

It measures agreement first and speed second, and it refuses to report a timing for a
route whose gradient does not already match the reference.


==============================================================================
# Guides / Running under noise    (source: docs/guides/noise.md)
==============================================================================

# Running under noise

qmlkit is simulator-only, but a simulator does not have to be a *perfect* one. Two
backends evolve a density matrix and take a noise model, so you can ask what a
circuit does on a device that makes mistakes.

| Backend | Simulator | Noise model | Install |
|---|---|---|---|
| `cirq-density` | `cirq.DensityMatrixSimulator` | any `cirq` channel or `cirq.NoiseModel` | `pip install 'qmlkit[cirq]'` |
| `qiskit-aer` | `AerSimulator(method="density_matrix")` | `qiskit_aer.noise.NoiseModel`, including `NoiseModel.from_backend(...)` | `pip install 'qmlkit[aer]'` |

`qiskit-aer` is a separate distribution from `qiskit`: installing the Qiskit backend
does not give you this one. SpinQit has no noisy simulator — its noisy path is the
real NMR hardware, which `0.x` does not target.

## You always name the backend

Noise never selects a simulator for you.

```python
# docs: requires cirq
import cirq
import qmlkit as qk

backend = qk.get_backend("cirq-density", noise=cirq.depolarize(0.01))
```

Asking for noise without naming a mixed-state backend is an error, not a guess:

```python
# docs: requires cirq
try:
    qk.get_backend(noise=cirq.depolarize(0.01))
except ValueError as exc:
    print(str(exc).splitlines()[0])
# noise was given, but the default backend evolves a pure state and cannot carry it.
```

This is deliberate. A mixed-state run costs more, refuses two of the gradient
methods, and answers a *different question* — so which simulator produced a number
should be visible in the code that produced it, not inferred from a global default.

## Noise and shot noise are separate, and stay separate

`shots=None` on a noisy backend is not a contradiction. The density matrix is
evolved exactly; what is exact is the answer *given the noise model*.

```python
# docs: requires cirq
import numpy as np

spec = qk.angle_encode([0.7])
noisy = qk.get_backend("cirq-density", noise=cirq.depolarize(0.05))

exact_pure = qk.expectation(spec, qk.Z(0), backend="numpy")
exact_noisy = qk.expectation(spec, qk.Z(0), backend=noisy)
sampled = qk.expectation(spec, qk.Z(0), backend=noisy, shots=10_000, seed=0)

print(f"no noise, no shots  {exact_pure:+.4f}")   # cos(0.7)
print(f"noise, no shots     {exact_noisy:+.4f}")  # cos(0.7) * (1 - 4p/3)
print(f"noise and shots     {sampled:+.4f}")
```

Being able to turn one off is the point. Decoherence and sampling error both pull a
number around, and studying either one with the other layered on top means never
knowing which you are looking at. Ask for `shots=N` when you want both — that is
what a device would give you.

With no noise model at all, these backends reproduce the pure-state ones to machine
precision. That is not a trivial case; it is what makes the noisy numbers
trustworthy, and `tests/test_noisy_backends.py` asserts it across circuits,
observables and both SDKs.

## What is refused

There is no statevector, so the two gradient methods that differentiate one decline:

```python
# docs: requires cirq
ansatz = qk.hardware_efficient(2, 2)
theta = np.linspace(0.1, 1.2, ansatz.n_params)

try:
    qk.grad(ansatz.build(), theta, qk.Z(0), method="adjoint", backend=noisy)
except ValueError as exc:
    print(str(exc)[:60])
```

Refusing is the whole point. `adjoint` and `backprop` would have quietly
differentiated a *noiseless* circuit and handed back a machine-precision gradient to
someone who asked about a noisy one — a number that is wrong in a way no assertion
would catch.

`parameter-shift` works, because a shift rule never inspects a state — it evaluates
the same circuit at shifted angles, which is exactly what a device does:

```python
# docs: requires cirq
g = qk.grad(ansatz.build(), theta, qk.Z(0), method="parameter-shift", backend=noisy)
print(g.shape)
```

`qk.grad_batch` routes through the same path, so a batched training step works under
noise too.

## What noise does to trainability

This is the number worth knowing before you spend a week on an experiment. A
3-qubit, 3-layer hardware-efficient ansatz, gradient norm averaged over 8 random
parameter vectors, against depolarizing strength:

| `p` | mean ‖∇‖ | vs noiseless | purity |
|---|---|---|---|
| 0 | 0.9145 | 1.000 | 1.000 |
| 0.005 | 0.8257 | 0.903 | 0.753 |
| 0.01 | 0.7425 | 0.812 | 0.585 |
| 0.02 | 0.5052 | 0.552 | 0.334 |
| 0.05 | 0.3243 | 0.355 | 0.151 |
| 0.1 | 0.1270 | 0.139 | 0.126 |

The gradient direction survives — it correlates above 0.99 with the noiseless one at
`p = 0.05` — but the *magnitude* collapses. That is the failure mode: the cost
landscape flattens toward the maximally mixed state, and it flattens faster with
depth. Past some point the gradient is smaller than the standard error of the shot
budget you can afford, and training stops working for a reason no amount of tuning
fixes.

`qk.plan()` will tell you the standard error your shot budget buys. Comparing it to
the gradient norm above is the arithmetic that decides whether an experiment is
feasible at all.

```python
# docs: requires cirq
print(f"purity under 5% depolarizing: {noisy.purity(ansatz.build(theta)):.4f}")
```

`purity()` is the cheapest single number that says how much the noise model actually
did. `1.0` is a pure state; `1/2**n` is maximally mixed.

## Traps

**A fidelity kernel loses its unit diagonal.** `k(x, x) = 1` is a fact about a
noiseless compute-uncompute circuit. Under noise the circuit does not return to
`|0⟩`, so the diagonal drops below one — and anything that fills the diagonal in by
assumption is writing down a number it did not measure.

```python
# docs: requires cirq
x = np.array([0.4, 0.5])
kernel = qk.QuantumKernel(qk.ZZFeatureMap(2), backend=noisy)
print(f"k(x, x) under noise: {float(np.atleast_2d(kernel(x, x))[0, 0]):.4f}")  # < 1
```

**The diagnostic thresholds were calibrated on exact gradients.** `qk.diagnose()`
reports `FLAT_GRADIENTS` against a cutoff chosen for shot-free, noiseless runs. Under
noise, gradients are genuinely smaller, so that finding fires more readily than on an
exact backend — the deep ansatz below is clean at `p = 0` and flagged at `p = 0.1`.
The finding says which of the two it is measuring, but it cannot separate them for
you: read it as "the gradient is small here", which is true, rather than "the ansatz
is badly designed", which may not be. Rerun on `numpy` to tell them apart.

Two of `diagnose()`'s probes need a statevector — whether a parameter is dead, and
whether the circuit entangles — and a mixed-state backend has none. Both are questions
about the *ansatz* rather than the device, so they run on the exact reference, and the
report's subject line says so rather than substituting quietly:

```python
# docs: requires cirq
report = qk.diagnose(qk.hardware_efficient(3, 2), backend="cirq-density", n_samples=6)
print(report.subject)
# hardware_efficient on 3 qubits [structure checked on the numpy reference: ...]
```

That notice is not a finding, so `if qk.diagnose(model):` keeps meaning "something is
wrong".

**Some measures do not exist on a mixed state, and say so.** Expressibility,
Meyer-Wallach entangling capability and the Fubini-Study metric tensor are defined
between *state vectors*. Asking for one on a density-matrix backend is refused by
name rather than approximated:

```python
# docs: requires cirq
try:
    qk.metrics.entangling_capability(qk.hardware_efficient(2, 1), n_samples=4,
                                     backend=noisy)
except ValueError as exc:
    print(str(exc)[:96])
# entangling_capability is defined on a pure state, and the 'cirq-density' backend ...
```

Note the difference from `diagnose()`, which substitutes the reference instead. That
is not an inconsistency: `diagnose()` was asked to check a *model*, and whether a
parameter is dead is a property of the ansatz. Here you named a backend and asked for
a measure on it, so the honest answer is that the measure is not defined there. To ask
what the noise did to a particular circuit, use `purity()`.

**Cirq applies a bare channel after every moment.** `cirq.depolarize(p)` passed as a
noise model becomes a `ConstantQubitNoiseModel` — every qubit, every moment,
including idle ones. That is a reasonable first model and a poor imitation of a real
device. Pass a `cirq.NoiseModel`, or lift one off hardware with Qiskit's
`NoiseModel.from_backend(...)`, when the answer needs to mean something.

## What qmlkit does not do

**Error mitigation.** No ZNE, PEC or CDR here. [Mitiq](https://mitiq.readthedocs.io)
is backend-agnostic and already good at this, and a qmlkit backend is an executor
function away from it. Reimplementing it would be a worse copy of a solved problem.

**Error correction.** Simulating a code with a QML circuit inside it is not a
library gap — an encoded VQC is non-Clifford, so the stabilizer simulators that make
QEC tractable at scale do not apply, and dense simulation of the encoded register is
out of reach. [Stim](https://github.com/quantumlib/Stim) is the tool for codes.

What is missing and worth having is the honest comparison: whether mitigation
actually *improved* an estimate, or only traded bias for variance, measured on
identical seeds with the shot cost stated. That is the shape of `qk.baseline`, and
it is where this will go next.


==============================================================================
# Guides / Evaluating a model honestly    (source: docs/guides/evaluation.md)
==============================================================================

# Evaluating a quantum model honestly

Three things go wrong between a trained model and a reported result, and none of
them raises an exception.

1. **The metric flatters the model.** Accuracy on a skewed dataset is high for a
   model that has learned to predict the majority class.
2. **The comparison is missing.** "Compared to what?" is the first question a
   reviewer asks, and the RBF-kernel SVM usually never got run.
3. **The number cannot be reproduced.** Library version, SDK version, backend and
   seed all move the answer, and none of them is recorded.

This guide covers the three modules that close those gaps:
[`qmlkit.evaluate`](../reference/evaluation.md), [`qmlkit.imbalance`](../reference/evaluation.md)
and [`qk.baseline`](../reference/evaluation.md).

## Every metric for the task, in one call

`qk.evaluate` groups metrics by task rather than making you assemble them:

```python
import numpy as np
import qmlkit as qk

y_true = np.array([0] * 95 + [1] * 5)
y_pred = np.zeros(100, dtype=int)          # a model that always says "class 0"

scores = qk.evaluate.classification(y_true, y_pred)
print(round(scores["accuracy"], 3), round(scores["balanced_accuracy"], 3))
```

That prints `0.95 0.5`. The model has learned nothing, and accuracy says 95%.

The point of returning every metric at once is that the disagreement between them
stays visible. `Scores` also says so directly:

```python
print(scores.primary)
print(scores.notes[0][:60])
```

`primary` is `balanced_accuracy` here rather than `accuracy`, because the class
distribution makes accuracy unusable — and `notes` explains why in a sentence.

There are four tasks: `classification`, `regression`, `clustering` and
`generative`. Each returns the same `Scores` object, which indexes like a dict:

```python
reg = qk.evaluate.regression([1.0, 2.0, 3.0, 4.0], [1.1, 1.9, 3.2, 3.8])
sorted(reg.keys())[:4]
```

Nothing in `qmlkit.evaluate` needs scikit-learn. Everything in it is asserted equal
to scikit-learn in `tests/test_evaluate.py`, on randomly generated inputs — the same
cross-validation-against-a-second-implementation approach used for the
[PennyLane parity suite](../about/validation.md).

## Skewed classes

Imbalance breaks the loss and the split, not only the score.
`qk.imbalance.imbalance_report` says which:

```python
report = qk.imbalance.imbalance_report(y_true)
print(report.codes)
```

Each finding carries the call that fixes it. The three that matter:

```python
qk.imbalance.class_weights(y_true)          # {0: 0.526..., 1: 10.0} - for a weighted loss
qk.imbalance.pos_weight(y_true)             # 19.0 - for BCEWithLogitsLoss
train, test = qk.imbalance.stratified_split(y_true, test_size=0.2, seed=0)
int(y_true[test].sum())                      # 1, never 0
```

A random 80/20 split of this data leaves the minority class out of the test set
entirely about a third of the time, which makes the test score noise.
`stratified_split` and `stratified_folds` guarantee it cannot happen.

`VQC` takes the weighting directly, computed from the `y` passed to `fit`:

```python
# docs: requires torch
model = qk.VQC(n_features=2, n_classes=2, class_weight="balanced")
```

`focal_gamma=2.0` additionally down-weights examples the model already gets right
— worth reaching for when the majority class is not merely abundant but trivially
separable. On a variational circuit that matters more than it does classically,
because gradient signal spent on easy examples is a budget measured in circuits.

## The classical bar

`qk.baseline` runs every classical baseline on the same folds, the same metric and
the same preprocessing as the model under test:

```python
X, y = qk.datasets.make_moons(n_samples=60, seed=0)
table = qk.baseline(X, y, cv=3, seed=0, include=["majority", "rbf-kernel-ridge"])
print(table.best_classical.name)
```

`rbf-kernel-ridge` is the one to watch for a quantum kernel method: it is the
*identical* algorithm — a closed-form kernel ridge solve — differing only in which
kernel fills the Gram matrix. Any gap between it and a quantum kernel is
attributable to the kernel and nothing else.

Pass `model=` and the model joins the table with a verdict:

```python
table = qk.baseline(X, y, model=qk.baselines.NearestCentroid(), cv=3, seed=0,
                    include=["majority"])
print(table.beats_classical)
```

The verdict does not call a lead a result when the lead is smaller than the
fold-to-fold spread:

> `quantum (0.810) leads svc-rbf (0.800) by 0.010, which is inside the fold-to-fold
> spread (0.071) — not yet a result`

Baselines that need scikit-learn are listed as **skipped** when it is absent rather
than dropped, because a table that quietly omits the strong baseline is the problem
this module exists to solve.

The companion check for kernel methods is `qk.geometric_difference`, which asks
whether the quantum kernel reaches a geometry the classical one cannot. A large
geometric difference with no accuracy gain is a real finding; a small one says the
classical kernel was always going to be enough.

## Sweeping everything tunable

`qk.search` takes any axis as a list and leaves the rest at its default:

```python
# docs: requires torch
import qmlkit as qk

X, y = qk.datasets.make_moons(n_samples=60, seed=0)
result = qk.search(X, y, n_layers=[1, 2], lr=[0.05, 0.15], epochs=8, cv=2, verbose=False)
print(result.verdict)
```

Ansätze and feature maps are named through the registries, so anything you registered
with `register_ansatz` or `register_feature_map` joins the grid with no special
handling. `qk.AXES` lists every axis; a name outside it is an error with a suggestion,
because a typo'd axis is a sweep that silently varies nothing.

### It skips what the diagnostics already condemn

This is the part a grid search cannot normally do. Before fitting anything, each
assembled model goes through [`qk.diagnose`](#after-the-run-was-it-right-and-can-it-be-repeated),
and configurations that are already broken are reported with their reason instead of
costing a full fit and then sitting in the table looking merely unlucky:

```python
# docs: requires torch
import numpy as np

rng = np.random.default_rng(0)
Xw = rng.uniform(0, np.pi, (60, 4))
yw = (Xw[:, 0] + Xw[:, 1] > np.pi).astype(int)
plan = qk.search(Xw, yw, ansatz=["hardware_efficient", "basic_entangler"],
                 n_layers=[2, 3], cv=2, dry_run=True, prune="untrainable")
print(len(plan.pruned), "of", len(plan.rows), "skipped before fitting")
```

Pruning is by finding *code*, not severity — `DEAD_WEIGHTS` is a warning meaning
"wasteful" while `FLAT_GRADIENTS` is a warning meaning "cannot learn". `prune="error"`
is the default and skips only what cannot work at all; `"untrainable"` adds flat
gradients; `"warning"` is aggressive enough to empty a grid, since
`hardware_efficient` carries an `UNMEASURABLE_WEIGHTS` finding by construction. Every
configuration is diagnosed either way, and its findings print beside its score.

`dry_run=True` builds and prunes the grid without fitting anything, so a sweep that
would take a week is something you learn in a second.

## Before the run: what it will cost

`qk.plan` computes the circuit budget from the ansatz, the training set size and
the gradient method, and lists the cheaper routes with what each gives up:

```python
budget = qk.plan(qk.hardware_efficient(4, 3), n_samples=100, steps=50, shots=1024)
print(f"{budget.circuits:,} circuits, {budget.hours(0.5):.1f} hours at 0.5 s each")
print([r.name for r in budget.reductions])
```

Qubit-wise-commuting grouping is counted rather than assumed, so a four-term
observable that shares one measurement setting is costed as one circuit, not four.

## After the run: was it right, and can it be repeated

`qk.selfcheck` computes the gradient by every exact route available — adjoint,
backprop, Hadamard-test, parameter-shift — and compares them. They share the
circuit IR and almost nothing else, so agreement is evidence and disagreement
localises the method that is wrong:

```python
ansatz = qk.hardware_efficient(3, 2)
check = qk.selfcheck(ansatz.build(), np.full(ansatz.n_params, 0.3), qk.Z(0))
bool(check)          # False: every route agrees, nothing to report
```

This is what to run when a number looks wrong and nothing raised. It catches a
custom gate with wrong declared `frequencies`, which produces a plausible gradient
rather than an error.

`qk.fingerprint` records the stack that decided the number:

```python
stamp = qk.fingerprint(seed=0, shots=1024)
sorted(stamp.as_dict())[:4]
```

It is JSON-serialisable, so it is cheap enough to attach to every result file.

## Putting it together

The order these run in is the order the questions arise:

| When | What to call |
|---|---|
| Before training | `qk.imbalance.imbalance_report(y)` · `qk.plan(model, ...)` · `qk.diagnose(ansatz)` |
| During | `class_weight="balanced"` · `stratified_folds(y)` |
| After | `qk.evaluate.classification(...)` · `qk.baseline(X, y, model=...)` |
| Before publishing | `qk.selfcheck(...)` · `qk.fingerprint(...)` |


==============================================================================
# Guides / Watching a run    (source: docs/guides/watching-a-run.md)
==============================================================================

# Watching a run

`qk.plan` says what a run will cost before it starts. `qk.diagnose` says what went
wrong after it finishes. In between there was nothing, and in between is where the
hours go.

A quantum kernel on a few hundred points is tens of thousands of circuits. Every
library in this field runs them behind a single call that returns when it returns,
and *how much is left* is one of the most common questions asked about all of them.

```python
# docs: run
import numpy as np
import qmlkit as qk

rng = np.random.default_rng(0)
X = rng.uniform(0, np.pi, (24, 2))
kernel = qk.QuantumKernel(qk.ZZFeatureMap(2), shots=64, seed=0)

with qk.progress(live=False) as run:      # live=True draws the line below
    gram = kernel(X)

print(run.report())
```

```text
kernel gram  3,412/12,720   27%   14.2s elapsed  ~37s left
```

Nothing is printed unless you enter `qk.progress()`, and the live line goes to stderr,
so piping a script's stdout to a file does not collect redraws.

## Where the time actually went

```python
# docs: skip
print(run.report())
```

```text
Run finished in 51.4s
kernel gram               12,720 items    47.9s    3.77 ms/item
fit VQC                      600 items     3.2s    5.33 ms/item
  (untracked)                              0.3s   - setup, data handling, and anything outside a task
```

The `(untracked)` row is there because the first thing a timing breakdown usually gets
wrong is implying that the parts it measured are the whole run. On a short run it is
often the largest row, and that is worth seeing.

## Three properties, in this order

**It does not change any number.** A seeded kernel and a seeded circuit produce
bit-identical results watched and unwatched, and a test asserts exactly that. A
reporter that perturbed a result would be a worse defect than the silence it replaces.

**It is free when nobody is watching.** With no active reporter, the tracking calls
inside the library cost one comparison against `None`, and the live line redraws at
most ten times a second however fast the loop runs.

**It does not claim to know what it does not.** An estimate from four items in half a
second says more about scheduling noise than about the run, so it prints `estimating`
until there is evidence — the same rule the rest of the library follows about reporting
a measurement.

## The trajectory, not just the timings

`HybridModel.fit` — so `VQC` and `VQRegressor` — logs three series per epoch without
being asked:

| | |
|---|---|
| **loss** | whether it is learning |
| **gradient norm** | whether it *can*. A plateau and a solved problem look identical in loss |
| **parameter norm** | whether the weights are running away, which looks like nothing at all in a loss curve |

Computing the two norms costs a pass over the parameters, so it happens only when
something is watching. Anything else can log its own:

```python
# docs: skip
with qk.progress() as run:
    run.note(dataset="breast-cancer", seed=0)   # facts about the run
    for step, value in enumerate(history):
        run.log("validation auc", value, step)
```

## One file, no server

```python
# docs: skip
run.save_html("run.html")
```

Timings, every logged series as a chart, and the versions and settings the run was
made with — in a single self-contained page with no assets directory and nothing to
fetch.

It is deliberately a *file* rather than a dashboard. A dashboard you have to start,
connect to and keep alive is a dependency, a port and a process; a page you can open,
email, and leave next to the result in a directory is none of those and outlives all of
them. The charts are inline SVG for the same reason: nothing to pin, nothing to break
in two years.

A flat series is labelled `flat — never moved` rather than drawn as a straight line at
an arbitrary height, because a loss curve that never moved is the run you most want to
look at, and a picture that hides it is worse than no picture.

## Counting your own loop

```python
# docs: skip
for row in qk.track(rows, "rows"):
    ...
```

`qk.track` counts an iterable as it is consumed and takes its total from `len` when
there is one. Inside the library, `task` is the equivalent — it hands back a silent
stand-in when nothing is watching, so a loop never has to branch on whether anyone is
there.

Reach for either of those through a direct import, `from qmlkit.progress import log,
task`, rather than the dotted `qmlkit.progress.log`. Importing the `progress` context
manager into the top-level namespace rebinds `qmlkit.progress` to the *function*, so
the dotted path raises `AttributeError` — which is what this page used to tell you to
type.

Already instrumented: the pair-at-a-time kernel Gram matrix — the path that takes hours,
used for sampled kernels, non-inversion estimators, and any backend without a
statevector — and `HybridModel.fit`.


==============================================================================
# Guides / Extending qmlkit    (source: docs/guides/extending.md)
==============================================================================

# Extending qmlkit

Almost every extension point is a registry. Register something and it becomes reachable
by name everywhere the library takes one — no subclassing, no plugin manifest, no
coordination with anything else, and no fork.

| I want to change | Use | And it becomes reachable from |
|---|---|---|
| The circuit shape | `register_ansatz` — or build an `Ansatz` inline | `get_ansatz`, `qk.search`'s `ansatz=` axis, `compare_ansatze` |
| A gate the library lacks | `register_gate` | every circuit, every backend, every gradient method |
| The two-qubit block inside a QCNN | `register_conv_filter` | `conv_block`, `qcnn_ansatz`, `mps_ansatz`, `tree_tensor_network` |
| How data becomes angles | `register_feature_map` | `qk.search`'s `feature_map=` axis |
| How data becomes a circuit | any `x -> CircuitSpec` callable | `QuantumKernel`, `QSVC`, `QSVR`, `NearestFidelityClassifier`, `projected_kernel_matrix` |
| ...with batching and `df/dx` as well | a `FeatureMap` subclass | all of the above, plus `QuantumLayer` and the torch stack |
| How gradients are estimated | `register_gradient` | `method=` anywhere, including `QuantumLayer` |
| Where circuits run | `register_backend` | `backend=`, `QMLKIT_BACKEND`, `backend_report()` |
| What the classical bar is | `register_baseline` | `qk.baseline`'s comparison table |
| Reading circuits in from elsewhere | `register_importer` | `get_importer`, `list_importers` |

Three things are extended **without** a registry, and that is deliberate — see
[a new encoding](#a-new-encoding), [a new optimiser](#a-new-optimiser) and
[a new algorithm](#a-new-algorithm). An encoding is the odd one out: the registry
above exists so a *search* can vary maps by name, but the interface itself is wider
than any list of names, so nothing needs registering to be usable.

## A new ansatz

```python
import qmlkit as qk

@qk.register_ansatz("my_ladder")
def my_ladder(n_qubits, n_layers=2):
    block = qk.RotationLayer(("ry", "rz")) + qk.EntanglerLayer("cx", "chain")
    return qk.Ansatz(n_qubits, qk.repeat(n_layers, block), "my_ladder")

ansatz = qk.get_ansatz("my_ladder", n_qubits=3, n_layers=2)
print(ansatz)
print(qk.draw(ansatz.build()))
```

It now has correct gradients, resource counting, drawing, an `AnsatzReport`, and a
`QuantumLayer` — none of which you wrote. The parameter count is inferred from a dry
build, so there is nothing to miscount.

## A new gate

A gate needs a matrix. Declare its **generator frequencies** and parameter-shift
works on it; add a **derivative matrix** and adjoint differentiation works too.

```python
import numpy as np
import qmlkit as qk

def _sqrt_x(_=None):
    return 0.5 * np.array([[1 + 1j, 1 - 1j], [1 - 1j, 1 + 1j]], dtype=complex)

qk.register_gate(qk.GateDef("sx", n_qubits=1, n_params=0, matrix=_sqrt_x))

qc = qk.QCircuit(1)
qc.apply("sx", 0)
print(np.round(qk.statevector(qc.to_spec()), 4))
```

For a *parameterised* gate, the two optional fields are what unlock differentiation:

```python
import numpy as np
import qmlkit as qk

def _rzz(theta):
    return np.diag([np.exp(-0.5j * theta), np.exp(0.5j * theta),
                    np.exp(0.5j * theta), np.exp(-0.5j * theta)])

def _d_rzz(theta):
    return np.diag([-0.5j * np.exp(-0.5j * theta), 0.5j * np.exp(0.5j * theta),
                    0.5j * np.exp(0.5j * theta), -0.5j * np.exp(-0.5j * theta)])

qk.register_gate(qk.GateDef(
    "rzz", n_qubits=2, n_params=1,
    matrix=_rzz,
    frequencies=(1.0,),   # -> a correct 2-term shift rule, derived not transcribed
    dmatrix=_d_rzz,       # -> adjoint differentiation
))

qc = qk.QCircuit(2)
qc.h(0).apply("rzz", (0, 1), qk.ParamRef(0))
spec, theta = qc.to_spec(), np.array([0.7])

shift = qk.grad(spec, theta, qk.X(0), method="parameter-shift")
adj = qk.grad(spec, theta, qk.X(0), method="adjoint")
print(f"parameter-shift {shift[0]:+.10f}")
print(f"adjoint         {adj[0]:+.10f}")
print(f"agree to        {abs(shift[0] - adj[0]):.2e}")
```

Getting the same number from two independent routes is the check worth making on any
gate you add — see [The parameter-shift rule](parameter-shift.md) for why the
frequencies matter.

!!! warning "Gate registration is global"
    The registry is process-wide, so a gate registered in a test is visible to every
    later test. If you register throwaway gates, snapshot the registry rather than
    reading it live — the parity suite learned this the hard way.

### How far does your gate travel?

The obvious worry: Qiskit and Cirq have their own gate tables, and your gate is not in
either. So what happens when you ask for `backend="qiskit"`?

It works. No SDK here has heard of your gate, so it is emitted **as its matrix** —
`UnitaryGate` on Qiskit, `MatrixGate` on Cirq, a Quantum Shannon decomposition on
OpenQARP — built by calling the `matrix=` you registered. Nothing else about your
circuit changes, and the result agrees with the NumPy reference to machine precision.

```python
# docs: skip
qk.register_gate(qk.GateDef("xy", n_qubits=2, n_params=1, matrix=xy, frequencies=(1.0,)))

qk.statevector(spec, backend="qiskit")   # UnitaryGate, exact
qk.statevector(spec, backend="cirq")     # MatrixGate,  exact
qk.grad(spec, theta, qk.Z(1), method="parameter-shift", backend="qiskit")
```

The subtlety this hides is qubit order, and it is the kind that does not raise. qmlkit
is big-endian and Qiskit is little-endian, so `to_qiskit` already maps qubit `i` to
`n-1-i` — but a raw matrix carries its qubit order in its *basis* rather than in its
wire list, so the basis has to be reversed as well or a two-qubit gate on `(a, b)`
quietly acts as though it were on `(b, a)`. OpenQARP is little-endian too and
reverses the *wire list* for the same reason. Cirq needs no reversal at all, being
big-endian like qmlkit. Rather than reason about that, the cross-backend suite asserts
it against the NumPy reference over ascending, descending and non-adjacent wire
orders, and over a three-qubit custom gate.

| Backend | A registered gate |
|---|---|
| `numpy` · `qiskit` · `cirq` · `aer` · `openqarp` | **works**, to machine precision |
| `cirq-density` · `qiskit-aer` | **works** — they inherit the same translation |
| `spinqit` | refuses. Its builder takes named gates, not an arbitrary matrix |
| `torch` | refuses for `backprop`. It differentiates *through* the gate, which needs a torch-native form; your `matrix=` is NumPy and no gradient flows through it. `parameter-shift` works, because a shift rule never inspects a state |

Both refusals say that, and what to do instead, rather than telling you to edit the
library.

## A new encoding

An encoding is **any function from one feature vector to one circuit**, and that is
the whole interface. `as_feature_map` adapts a bare function; everything that takes a
`feature_map=` — `QuantumKernel`, `QSVC`, `projected_kernel_matrix`,
`NearestFidelityClassifier` — accepts either.

```python
import numpy as np
import qmlkit as qk

def pairwise_load(x):
    """Two features per qubit, one into Ry and one into Rz."""
    qc = qk.QCircuit(len(x) // 2)
    for q in range(len(x) // 2):
        qc.ry(q, 2.0 * float(x[2 * q]))
        qc.rz(q, 2.0 * float(x[2 * q + 1]))
    qc.entangle("ring")
    return qc.to_spec()

rng = np.random.default_rng(0)
X = rng.uniform(0, np.pi, size=(20, 6))
kernel = qk.QuantumKernel(pairwise_load)
print(f"gram {kernel(X).shape} in {kernel.n_evaluations} circuits")
```

```text
gram (20, 20) in 20 circuits
```

That is enough for every kernel method here, and it is all a function can give you.
It cannot be batched, and no gradient can reach the data through it, because the
angles exist only inside a circuit that was already built — so `QuantumLayer` will
not take one. `CallableFeatureMap` says so up front: `supports_parametric` is
`False`, the parametric methods raise with that explanation, and every caller takes
the slower route rather than guessing.

**Subclass `FeatureMap` to get the rest.** Three methods, and `build`,
`build_parametric`, `adjoint` and `resources` are composed out of them:

```python
import numpy as np
import qmlkit as qk

class PolarFeatureMap(qk.FeatureMap):
    """The same encoding, written so one circuit serves the whole dataset."""

    def __init__(self, n_features):
        self.n_features = n_features
        self.n_qubits = n_features // 2

    @property
    def n_angles(self):
        return self.n_features

    def angles(self, x):
        return 2.0 * self._validate(x)

    def _emit(self, angles):
        qc = qk.QCircuit(self.n_qubits)
        for q in range(self.n_qubits):
            qc.ry(q, angles[2 * q])
            qc.rz(q, angles[2 * q + 1])
        qc.entangle("ring")
        return qc.to_spec()

fmap = PolarFeatureMap(6)
print(fmap, "parametric:", fmap.supports_parametric)
print("resources:", fmap.resources()["gate_counts"])
```

```text
PolarFeatureMap(n_features=6, n_qubits=3) parametric: True
resources: {'ry': 3, 'rz': 3, 'cx': 3}
```

`_emit` is handed either floats or `ParamRef` slots, so it must **place** angles and
never do arithmetic on them. Any arithmetic belongs in `angles`, which only ever sees
numbers — `AmplitudeFeatureMap` is the load-bearing example, and the whole reason
`ucr_emitted_angles` is a separate function from `ucr_layout`.

Override `build` instead of `_emit` and `build_parametric` still calls the base
`_emit`, which raises. If you must, set `supports_parametric = False` alongside it:
that is a contract, not a label, and a map that lies about it fails inside a batched
evaluation rather than at the point of the mistake.

Two optional extras. `angle_jacobian` gives `d(angle)/d(feature)` in closed form, and
is what lets a gradient reach back through a nonlinear encoding to a classical
pre-net; the default differences `angles` classically, which costs nothing quantum
but is less accurate. `scale_invariant = True` declares that the map ignores the
length of its input — `QuantumKernel` then warns when a `bandwidth` is set that
cannot possibly reach it.

## A new gradient estimator

```python
import numpy as np
import qmlkit as qk

@qk.register_gradient("forward_diff")
def forward_diff(spec, theta, obs, *, backend=None, shots=None, eps=1e-6, **kwargs):
    base = qk.expval(spec, obs, theta=theta, backend=backend, shots=shots)
    out = np.zeros(spec.n_params)
    for k in range(spec.n_params):
        step = np.zeros_like(theta)
        step[k] = eps
        out[k] = (qk.expval(spec, obs, theta=theta + step, backend=backend, shots=shots) - base) / eps
    return out

ansatz = qk.hardware_efficient(2, 1)
spec, theta = ansatz.build(), ansatz.init(seed=0)
mine = qk.grad(spec, theta, qk.Z(0), method="forward_diff")
exact = qk.grad(spec, theta, qk.Z(0), method="adjoint")
print(f"max deviation from adjoint: {np.abs(mine - exact).max():.2e}")
```

The signature is fixed: `(spec, theta, obs, *, backend, shots, **kwargs)`, returning
an array of length `spec.n_params`. Anything else you need arrives through `kwargs`,
and callers pass it straight through `qk.grad(..., your_kwarg=...)`.

## A new backend

Subclass `Backend` and implement **one** method — `statevector`. The base class
supplies the measurement *semantics*: sampling, basis rotation, expectation values,
seeded counts. That is deliberate: if every backend re-implemented those, agreement
between them would be a coincidence rather than a property.

```python
import numpy as np
import qmlkit as qk
from qmlkit.core.backends.base import Backend

class MirrorBackend(Backend):
    """The NumPy reference, but proving the extension point works."""

    name = "mirror"
    supports_statevector = True
    supports_exact = True

    def statevector(self, spec):
        self._check_bound(spec)
        return qk.get_backend("numpy").statevector(spec)

qk.register_backend("mirror", MirrorBackend)

qc = qk.QCircuit(2)
qc.h(0).cx(0, 1)
spec = qc.to_spec()
print(np.round(qk.statevector(spec, backend="mirror"), 4))
print("agrees with numpy:", np.allclose(
    qk.statevector(spec, backend="mirror"), qk.statevector(spec, backend="numpy")))
```

If you add a real backend, the thing to run is `tests/test_cross_backend.py` — it
parametrises over every installed backend automatically, so yours is covered the
moment it is registered.

### A device, which cannot hand you a state

A real QPU has no statevector and no shot-free expectation. It can run a circuit and
report bitstrings, and that is *also* one method:

```python
import numpy as np
import qmlkit as qk
from qmlkit.core.backends.base import Backend

class Device(Backend):
    """Everything a QPU is, and nothing it is not."""

    name = "example_device"
    supports_statevector = False        # no amplitudes
    supports_exact = False              # no shot-free expectation

    def counts(self, spec, shots, seed=None):
        self._check_bound(spec)
        # a real provider would submit the circuit here
        probabilities = np.abs(qk.get_backend("numpy").statevector(spec)) ** 2
        rng = np.random.default_rng(0 if seed is None else seed)
        drawn = rng.multinomial(shots, probabilities / probabilities.sum())
        return {format(i, f"0{spec.n_qubits}b"): int(n) for i, n in enumerate(drawn) if n}

device = Device()
ansatz = qk.hardware_efficient(3, 2)
spec, theta = ansatz.build(), ansatz.init(seed=0)
observable = qk.Z(0) + 0.5 * qk.ZZ(0, 2)
print(f"sampled expectation {qk.expectation(spec, observable, theta, shots=4096, backend=device):+.3f}")
```

### What that one method gets you

Everything above it, derived once in the base class:

```python
thetas = np.random.default_rng(0).uniform(-np.pi, np.pi, (5, ansatz.n_params))

values = qk.expectation_over(spec, thetas, observable, shots=4096, backend=device)
print(f"batched expectations {values.shape}")

gradients = qk.grad_batch(spec, thetas, observable,
                          method="parameter-shift", shots=4096, backend=device)
print(f"batched gradients    {gradients.shape}")

kernel = qk.QuantumKernel(qk.AngleFeatureMap(3), shots=4096, backend=device, seed=0)
print(f"Gram matrix          {kernel(np.random.default_rng(1).uniform(0, np.pi, (4, 3))).shape}")
```

Qubit-wise-commuting grouping comes with it, so a four-term observable diagonal in `Z`
costs one circuit rather than four — on a device, where circuit count is the binding
constraint, that is the difference between a feasible run and an infeasible one.

`param_shift_grad_batch` matters most here. A shift rule only ever needs the circuit
*run* at shifted angles, so a whole batch's gradient is one set of evaluations with no
state inspection anywhere — which on hardware is a single job submission instead of
`batch x 2P` blocking calls.

### And what it refuses

```python
for method in ("adjoint", "backprop"):
    try:
        qk.grad(spec, theta, observable, method=method, backend=device)
    except ValueError as error:
        print(f"{method}: {str(error)[:70]}...")
```

Both need the statevector, so both refuse and name `parameter-shift` instead. So does
exact mode:

```python
try:
    qk.expectation(spec, observable, theta, backend=device)
except ValueError as error:
    print(error)
```

That is the half of "backend-agnostic" that matters. Anything can *run* everywhere; the
useful property is that what cannot work is refused by name rather than silently
computed on a simulator and handed back looking perfect. `backprop` was doing exactly
that until it was caught by writing this section.

### What is still missing for real hardware

The 0.x line ships no device backend, and the protocol supporting one is a different
claim from having one. `examples/toward_hardware.py` runs a mock QPU end to end and
states the four gaps in the order they would bite: **batched submission** (now largely
in place — `expectation_over_slots` is the call a provider would turn into a job),
**transpilation and routing**, **error mitigation**, and **asynchronous jobs**. The
last is the one that would still change the `Backend` protocol.

## A new optimiser

There is no `register_optimizer`, because there is nothing to look up — `OPTIMIZERS` is
an open dict and an optimiser is just a function:

```python
# docs: skip
from qmlkit.algorithms.vqe import OPTIMIZERS

def my_optimiser(loss, theta0, *, grad, n_steps=200, lr=0.1):
    theta = np.asarray(theta0, dtype=float).copy()
    history = [float(loss(theta))]
    for _ in range(n_steps):
        theta = theta - lr * grad(theta)      # qk.grad, exact, not a difference
        history.append(float(loss(theta)))
    return theta, history                     # the contract: (theta, history)

OPTIMIZERS["mine"] = my_optimiser
VQE(hamiltonian, n_qubits=3, optimizer="mine").run(n_steps=50, lr=0.05)
```

The contract is `fn(loss, theta0, **kw) -> (theta, history)`. `VQE`, `QAOA` and
`AdaptVQE` all take `optimizer=` as either a name from that dict **or the function
itself**, so you do not have to register anything to use one.

!!! warning "If your optimiser needs the gradient, the caller has to inject it"
    Each solver decides which optimisers get `grad=` passed in. Naming only some of
    them there is how `optimizer="adam"` came to raise `TypeError: _adam() missing 1
    required keyword-only argument: 'grad'` from all three algorithms that advertised
    it — a documented option that had never run. `tests/test_optimizer_wiring.py` now
    parametrises over `OPTIMIZERS` itself, so a new entry cannot be added without
    being covered.

Each optimiser also spells its own iteration count (`n_sweeps`, `n_iterations`,
`n_steps`), because `run(**optimizer_kwargs)` passes them straight through.

## A new algorithm

There is no `register_algorithm` either, and this one is worth explaining because it
looks like an omission.

A registry exists so the library can look something up **on your behalf** — a gate name
inside a circuit, a `method=` string, a `backend=` string. An algorithm is the outermost
layer. Nothing inside qmlkit needs to find your VQE by name; you call it. So an
algorithm is not a plugin, it is a loop:

> Each one is a *loop* over machinery that already exists — ansatz, gradients,
> optimisers, observables — so each is thin, and every structural choice it makes is an
> argument rather than something baked in.
>
> — `qmlkit/algorithms/__init__.py`

`VQE` is 200 lines and the shape is worth copying:

```python
# docs: skip
@dataclass(frozen=True)
class MyResult:
    value: float
    theta: np.ndarray
    history: list[float]
    exact: float | None = None          # what it was checked against

class MyAlgorithm:
    def __init__(self, hamiltonian, n_qubits, *, ansatz=None,
                 optimizer="rotosolve", backend=None, shots=None):
        self.ansatz = ansatz or qk.hardware_efficient(n_qubits, 2)   # default, not hardcode

    def cost(self, theta): ...              # one qk.expectation call
    def gradient_of_cost(self, theta): ...  # one qk.grad call
    def run(self, seed=None, compare_exact=None, **optimizer_kwargs) -> MyResult: ...
```

Four things make it belong here rather than merely work:

**Take the structure as an argument, and actually use it.** `tests/test_injection.py`
injects two ansätze of *different parameter counts* and asserts the model's count
follows — because a constructor that accepts `ansatz=` and quietly ignores it looks
identical from the outside.

**Check yourself against something exact while you still can.** `VQE`, `QAOA` and
`AdaptVQE` diagonalise densely when it is affordable (12 qubits or fewer by default)
and report `error_vs_exact`. A variational algorithm converging confidently on the
wrong answer is the ordinary failure here, not the exotic one.

**Refuse, or warn, when a component's assumptions do not hold.** `QAOA` consults
`supports_rotosolve(spec)` and warns when the optimiser is invalid for the circuit it
just built: Rotosolve assumes each angle drives a single sinusoid, and QAOA's cost angle
drives one `rz` per edge — five frequencies on a five-edge problem, measured. It
converges immediately, on the wrong point, and reports it as a result.

**Use the exact gradient.** `qk.grad` already gives you one on any circuit and
observable. Finite differences are for debugging and tests, and say so in their own
docstring.

### Where to put it

If it is your own research, **it does not need to be in the library at all** — import
qmlkit, write your loop, keep it in your project. That is what the three layers are
for, and nothing is gained by vendoring it in.

If it is genuinely general, it goes in `src/qmlkit/algorithms/`, exported from that
package's `__init__`, with a reference page entry and a changelog entry. And register
the *pieces* it introduces — a gate, an ansatz shape, a filter, an estimator — because
those are the parts other people's code will want to reach by name.


==============================================================================
# Guides / Working with a coding agent    (source: docs/guides/agents.md)
==============================================================================

# Working with a coding agent

Most code written against a library now is written by a model, and a model does not
read this page before it types. It guesses a name from what it has read elsewhere,
runs it, reads the traceback, and tries again.

That loop is the real interface. Three things follow from taking it seriously, and
all three help a human equally — none of this is a special mode.

## A wrong name answers with the right one

The training data for any model contains far more PennyLane and Qiskit than qmlkit,
so the first guess at a name is usually theirs. That guess now costs one line
instead of a search:

```python
import qmlkit as qk

try:
    qk.AngleEmbedding
except AttributeError as exc:
    print(exc)
```

```
module 'qmlkit' has no attribute 'AngleEmbedding'. 'AngleEmbedding' is PennyLane's
name for qmlkit.AngleFeatureMap (or angle_encode(x) for a one-shot circuit).
```

It is a **translation, not an alias** — the foreign name still raises. Two reasons.
Anything importable is something somebody will depend on, and a shadow vocabulary of
thirty PennyLane spellings is a second public surface to keep working forever. More
importantly, an alias would hide semantic drift: `qml.expval` takes a QNode where
`qk.expectation` takes a circuit and an observable, so a name that silently resolved
would fail later and further from its cause.

Names that are merely misspelled get the same treatment from every registry, and
case or separator drift resolves to a single certain suggestion rather than a fuzzy
one — `parameter_shift` for `parameter-shift` is what half-remembering another
library looks like, not a typo:

```python
try:
    qk.get_ansatz("hardware-efficient")
except KeyError as exc:
    print(exc)
```

```
unknown ansatz 'hardware-efficient'. Did you mean 'hardware_efficient'? Valid:
basic_entangler, hardware_efficient, mps, qaoa, qcnn, random_layers,
simplified_two_design, strongly_entangling, tree_tensor_network, two_local. Add
your own with register_ansatz(name, factory).
```

Every gate, backend, gradient method, ansatz and conv filter behaves this way. So
does asking for a backend you have not installed — but that is a *different* answer,
because the name was right and the environment was wrong, and it names the install
command instead.

## `diagnose()` catches what does not raise

This is the part that has nothing to do with names, and it is the one that matters
most. In this field a mistake usually returns a number of the right shape and the
right range, and the model trains, converges and reports an accuracy.

Here is a re-uploading model composed by hand out of blocks. It has three uploads,
six weights, and one frequency:

```python
from qmlkit.ansatz import Ansatz, EncodingLayer, RotationLayer, repeat

fmap = qk.AngleFeatureMap(2, rotation="ry", entangle=False)  # entangle=False: an entangler defeats the collapse
model = Ansatz(2, repeat(3, EncodingLayer(fmap) + RotationLayer("ry")), n_inputs=2)

print(qk.diagnose(model))
```

```
ansatz on 2 qubits: 1 finding(s)
  [error] ENCODING_COMMUTES: 3 uploads, but every trainable rotation is 'ry', the
  same generator the encoding uses. RY(x) RY(t) composes into one rotation, so the
  model reaches 1 frequency rather than 0..3, and its weights do nothing beyond a
  phase.  Fix: Use a non-commuting block, e.g. RotationLayer(('rz', 'ry', 'rz')).
```

Nothing about that model raises. It builds, binds, differentiates and trains; it is
simply not the model that was intended. `reupload()` warns about this case at
construction, but a model composed directly out of blocks has no constructor to warn
from — and composing directly is the whole point of the block vocabulary.

Take the fix and the report goes quiet:

```python
fixed = Ansatz(2, repeat(3, EncodingLayer(fmap) + RotationLayer(("rz", "ry", "rz"))), n_inputs=2)
report = qk.diagnose(fixed)
print(bool(report), report.codes)
```

```
False ()
```

A report is falsy when it found nothing, so `if qk.diagnose(model): ...` reads the
way it should. Each finding carries a stable `code` to branch on, the number that
was measured, and the edit that resolves it:

```python
finding = qk.diagnose(model)[0]
print(finding.code, finding.severity, finding.value)
```

```
ENCODING_COMMUTES error 3.0
```

It takes an `Ansatz`, anything holding one — a `QuantumLayer`, a `VQC`, an
`nn.Sequential` with a quantum layer somewhere inside it — or a Gram matrix, where
it checks for concentration, for a signal below the shot noise, and for a matrix
that has stopped being positive semi-definite:

```python
import numpy as np

flat = np.full((8, 8), 0.5)
np.fill_diagonal(flat, 1.0)
print(qk.diagnose(flat).codes)
```

```
('KERNEL_CONCENTRATED',)
```

What it checks, and on what evidence, is in
[the API reference](../reference/analysis.md#qmlkit.diagnostics.diagnose). Checks
that can be exact are exact: a parameter is dead if shifting it cannot change the
state at all. Checks that are statistical report the number they measured, so the
threshold can be argued with rather than trusted.

## The whole library in one fetch

A model that has to click through a documentation site mostly does not. Two files
are generated from these pages and from the package itself, and served at the root
of the site:

- **[`/llms.txt`](https://ziadt160.github.io/qmlkit/llms.txt)** — what is here, where
  it is, and the handful of constraints that are not inferable from the API.
- **[`/llms-full.txt`](https://ziadt160.github.io/qmlkit/llms-full.txt)** — every
  tutorial and guide in full, then the entire public API with signatures and summary
  lines. One fetch, no navigation.

Both are generated by `scripts/generate_llms_txt.py`, committed, and checked in CI,
for the same reason `tests/test_docs.py` executes every snippet on every page: a
summary of an API written by hand is a second copy of the truth, and second copies
rot silently. Change a page or a public signature without regenerating and the build
goes red.

For working *on* qmlkit rather than with it, [`AGENTS.md`](https://github.com/Ziadt160/qmlkit/blob/main/AGENTS.md)
carries the commands, the conventions the tests enforce, and the traps that have
already cost time.


==============================================================================
# Reference / Reference    (source: docs/reference/index.md)
==============================================================================

# Reference

Generated from the docstrings in the source, so it cannot drift from the code.

| Section | What lives there |
|---|---|
| [Core](core.md) | The circuit IR, gates, observables, execution, backends |
| [Gradients](gradients.md) | Six estimators behind one `grad()` |
| [Encoding](encoding.md) | Angle, amplitude, basis and Pauli feature maps |
| [Ansatz](ansatz.md) | The block vocabulary, the template zoo, re-uploading |
| [Kernels](kernels.md) | Overlap estimators, Gram matrices, `QSVC`/`QSVR` |
| [PyTorch](nn.md) | `QuantumLayer`, `VQC`, structured architectures |
| [Algorithms](algorithms.md) | VQE, QAOA, ADAPT, chemistry, autoencoders, clustering, RL |
| [Analysis](analysis.md) | Metrics, Fourier spectra, quantum information, optimisers |
| [Evaluation](evaluation.md) | Scores, class imbalance, classical baselines, budget, provenance |

If you are looking for the shape of the library rather than a particular function,
the [tutorials](../tutorials/index.md) are the faster route.


==============================================================================
# Reference / Core    (source: docs/reference/core.md)
==============================================================================

# Core

The circuit IR, gates, observables and execution — the data model everything else in
the library reads or writes. The simulators that run it have their own page:
[Backends](backends.md).

## `qmlkit.core.ir`

## `qmlkit.core.gates`

## `qmlkit.core.observables`

## `qmlkit.core.builder`

## `qmlkit.core.execute`

## `qmlkit.interop`

Reading circuits *in*, from OpenQASM, Qiskit, PennyLane and Cirq.


==============================================================================
# Reference / Backends    (source: docs/reference/backends.md)
==============================================================================

# Backends

Ten simulators behind one protocol. A backend supplies a statevector — or, for a
device, counts — and sampling, basis rotation, qubit-wise-commuting grouping,
expectation and the whole batch stack are derived once in `base.py`. That is what makes
agreement between them a property rather than a coincidence, and it is why adding one
is a single `register_backend` call.

The reference below was split out of [Core](core.md) because seven of the nine
implementation modules that existed then had no entry anywhere: their constructor
options, their refusals and their approximations were readable only in the source.
A backend that silently truncates past a bond dimension, or refuses a GPU it cannot
find, should say so on the page you look it up on.

## Which one supports what

`supports_statevector` and `supports_exact` are a contract, not metadata — they decide
which gradients, diagnostics and metrics are even legal on a given backend. Anything
needing a statevector (adjoint differentiation, the metric tensor, expressibility,
`diagnose`'s structural checks) refuses rather than substituting a sampled stand-in.

| Backend | statevector | exact | notes |
|---|---|---|---|
| `numpy` | yes | yes | the reference. Vectorised, with gate fusion; hard cap at 24 qubits |
| `aer` | yes | yes | C++ statevector, faster above ~13 qubits |
| `mps` | **no** | until truncation | matrix-product state; wide but lightly entangled circuits |
| `qiskit` | yes | yes | `quantum_info.Statevector`, the slowest of the exact three |
| `cirq` | yes | yes | Cirq's reference simulator |
| `openqarp` | yes | yes | C++ core; contracts expectations itself and sweeps a batch of them without returning to Python |
| `spinqit` | yes | yes | Python 3.10 only |
| `torch` | yes | yes | differentiable — the only one `backprop` can use |
| `cirq-density` | no | yes, given the noise model | density matrix |
| `qiskit-aer` | no | yes, given the noise model | density matrix |

"Exact given the noise model" is deliberate on the two mixed-state rows: they return a
shot-free number, exact for the channel you asked for. That is not the same as the
noiseless answer, and `diagnose` separates the two rather than reporting their product.

## The protocol

## The registry

## Exact statevector simulators

### `qmlkit.core.backends.numpy_backend`

### `qmlkit.core.backends.aer_backend`

### `qmlkit.core.backends.qiskit_backend`

### `qmlkit.core.backends.cirq_backend`

### `qmlkit.core.backends.openqarp_backend`

The one statevector backend here that does not stop at the statevector: it contracts
expectations inside OpenQARP's C++ kernel and sweeps a batch of parameter vectors in
one call. That is two derived quantities computed by an SDK rather than by
`base.py`, so both are pinned against the NumPy reference — and
`native_expectations=False` asks the same backend for the other route.
[Integrating OpenQARP](../guides/openqarp.md) walks through the translation and the
measurements; `scripts/probe_openqarp.py` reproduces them.

### `qmlkit.core.backends.spinqit_backend`

## Differentiable

### `qmlkit.core.backends.torch_backend`

## Approximate

### `qmlkit.core.backends.mps_backend`

A matrix-product-state simulator reaches widths a statevector cannot, by keeping only
as much entanglement as its bond dimension allows. It has no `statevector()`, so every
route that needs one is refused rather than approximated; and past the bond dimension
it is no longer exact, which is a different thing from noisy.

## Mixed-state, for noise

### `qmlkit.core.backends.noisy`

### `qmlkit.core.backends.cirq_density_backend`

### `qmlkit.core.backends.qiskit_aer_backend`


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

What getting the data in costs, and the QRAM the exponential-speedup claims assume.

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

### What `QSVC` and `QSVR` take

Both inherit their constructor and their whole interface from a shared private base,
so the generated entries below show only `feature_map`, the solver argument (`C` or
`epsilon`) and `**kwargs`. These are the arguments hiding in that `**kwargs`, and they
are the ones worth knowing:

| Argument | Default | What it does |
|---|---|---|
| `shots` | `None` | Exact by default. A shot count makes the Gram matrix noisy, which is what pushes it out of the PSD cone |
| `backend` | `None` | Any registered backend |
| `bandwidth` | `1.0` | Scales the feature map's angles. **The first thing to try when a kernel has concentrated** — a narrower bandwidth spreads the off-diagonal entries back out |
| `estimator` | `"inversion"` | `inversion`, `swap` or `hadamard`. They agree on a simulator and differ in qubit count and circuit cost |
| `repair_psd` | `"threshold"` | Shot noise costs a Gram matrix its positive semi-definiteness; `fit` repairs it by default rather than handing an SVM something it cannot solve |
| `seed` | `None` | Seeds the sampling when `shots` is set |

They also inherit `fit`, `predict`, `decision_function`, `score`, `get_params`,
`set_params`, and the two cost counters `n_circuit_evaluations` and
`circuits_on_hardware` — budget a device run from the second, which counts the
pairwise circuits a simulator's statevector shortcut avoids.

Storing every constructor argument under its own name, unchanged, is scikit-learn's
one requirement for `clone` — and `clone` is what `Pipeline`, `GridSearchCV` and
`cross_val_score` are built on. That is what lets a quantum estimator sit inside an
ordinary scikit-learn workflow.


==============================================================================
# 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, and it has two halves.** 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.

The second half is that the pool alone is not enough. A particle-conserving pool cannot
*change* the particle number either, so starting from the default vacuum every
candidate has zero gradient too, and you get the same empty circuit — reporting
`+0.720` Ha against a true `-1.137`. Hartree–Fock is where `reference=` comes in:

```python
import qmlkit as qk
from qmlkit.algorithms import AdaptVQE, chemistry_operator_pool, h2_hamiltonian

hamiltonian, info = h2_hamiltonian()          # H2 at 0.735 A, 4 qubits
result = AdaptVQE(
    hamiltonian,
    n_qubits=4,
    pool=chemistry_operator_pool(4),
    reference=[0, 1],                          # Hartree-Fock: two occupied spin-orbitals
).run(seed=0)

print(f"{result.energy:.8f} Ha from {len(result.operators)} operator(s)")
# -1.13730603 Ha from 1 operator(s)
```

One operator reaches the exact ground state here, to `1e-11`. That is ADAPT's point:
it grows only what the gradient asks for. Selecting *zero* operators now warns, because
it is never convergence — it means nothing in the pool can move the energy off the
reference at all.

## `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. The circuit *is* the policy: an
observation is encoded, the trainable block runs, and one expectation per action
becomes a logit that a softmax turns into action probabilities.

```python
from qmlkit.algorithms import ContextualBandit, QuantumPolicy, train_reinforce

env = ContextualBandit(seed=0)                 # 2 observations, 2 actions, reward 0 or 1
policy = QuantumPolicy(n_observations=env.n_observations, n_actions=env.n_actions, seed=0)
result = train_reinforce(policy, env, n_episodes=200, seed=0)

print(f"{result.mean_return(50):.2f}")         # mean return over the last 50 episodes
# 0.70
```

Read that against the bar, not in isolation: two actions and a 0/1 reward means a random
policy averages **0.50**, and the first fifty episodes of this run do exactly that. The
last fifty average **0.70**. That gap is the learning, and it is the number to report —
a mean return quoted without the random baseline beside it says nothing at all.

`beta` is the knob most worth understanding. Expectations live in `[-1, 1]`, which is a
narrow range to softmax over, so `beta` sets how sharp the policy is: too low and it
never commits, too high and it stops exploring before it has learned anything.

Any environment satisfying the `Environment` protocol works — `n_observations`,
`n_actions`, `reset()` and `step(action)`. `ContextualBandit` is the toy that keeps the
test suite honest, not the interesting case.


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

Two halves. Expressibility, entangling capability and gradient variance describe the
*circuit*, and [Designing an ansatz](../tutorials/04-ansatz-design.md) and
[Trainability](../tutorials/08-trainability.md) work through them.

The other half describes what the model can be expected to *learn*, and is easy to
miss because nothing else links to it:

| | |
|---|---|
| `generalization_bound(T, N)` | the expected train-test gap, `O(sqrt(T log T / N))` (Caro et al. 2022), for `T` trainable gates and `N` samples |
| `samples_for_gap(T, gap)` | the same bound inverted: how much data a target gap needs |
| `fisher_information` / `effective_dimension` | how many parameters are *usefully* independent, which is generally far fewer than the raw count |
| `noise_survival(f, depth)` | `f**depth`, independent per-gate success compounding over a circuit |

These are worth running before a long training job rather than after.
`hardware_efficient(4, 3)` carries 24 trainable parameters; against 100 samples the
bound is **0.87**, which for a metric in `[0, 1]` is no constraint at all. Reaching a
gap of 0.1 would take **7,628 samples**. Go to `hardware_efficient(6, 8)` — 96
parameters — and the bound is **2.09** and the requirement **43,818 samples**. Quantum
datasets are rarely that large, and that arithmetic is usually the real reason a model
that trains beautifully does not generalise.

`noise_survival` is the same kind of sobriety on the hardware side: a 200-gate circuit
at 99.9% per-gate fidelity keeps **0.819** of its amplitude, and a 2,000-gate one keeps
0.135.

## `qmlkit.landscape`

Why a model is not training: gradients over every parameter, which of the four
barren-plateau mechanisms is responsible, curvature at a stationary point, whether
random starts agree, and where the QFIM rank saturates.

## `qmlkit.fourier`

## `qmlkit.info`

## `qmlkit.optim`

## `qmlkit.datasets`

## `qmlkit.draw`

## `qmlkit.generative`

Two families, and the difference decides what you can ask of them. A **Born machine**
(`QCBM`, `QGAN`) reads its distribution off measurement probabilities directly, so it
samples easily and cannot score an arbitrary point. An **energy-based** model
(`QuantumBoltzmannMachine`, `QuantumHopfield`) defines a distribution through an energy
and a partition function, so it scores easily and samples only through a chain.

`QCBM` trains against a target distribution through a sample-based loss —
`mmd_squared`, `kl_divergence` or `total_variation`. `mmd_squared` is the usual choice:
the other two need the target's support to cover the model's, which an untrained model
rarely arranges.

`QuantumBoltzmannMachine.grad` returns a **lower-bound** gradient, not an exact
log-likelihood gradient — the exact one needs the quantum relative entropy's derivative,
which is not sampled cheaply. The bound is what makes the model trainable at all; it
also means the loss it reports is a bound rather than a likelihood, and a run that
stops improving may have hit the bound rather than the optimum.

## `qmlkit.shadows`

Classical shadows: many observables from few measurements.

The trade is worth making explicit, because it is the opposite of the usual one.
Measuring `M` observables the direct way costs one measurement setting each, and each
setting needs enough shots to resolve its own value. A shadow takes randomised
single-qubit measurements *once* and predicts all `M` afterwards, at a cost that grows
with `log M` rather than `M` — but with a variance penalty that grows with each
observable's locality, sharply. Shadows win on many *local* observables and lose on a
few global ones.

`shadow_shot_cost` prices the shadow route; `qmlkit.kernels.kernel_shot_cost` prices
the direct one. Compare them before committing a device run, because the crossover
moves with locality and there is no rule of thumb that survives it.

## `qmlkit.utils.shots`


==============================================================================
# Reference / Evaluation    (source: docs/reference/evaluation.md)
==============================================================================

# Evaluation

Scoring predictions, handling skewed classes, comparing against classical
baselines, costing a run before it starts, and recording what produced a number.

The guide is [Evaluating a quantum model honestly](../guides/evaluation.md).

## `qmlkit.evaluate`

## `qmlkit.imbalance`

## `qmlkit.search`

## `qmlkit.baselines`

## `qmlkit.budget`

## `qmlkit.provenance`

## `qmlkit.nn.losses`

## `qmlkit.recommend`

Which backend to run a circuit on, from measured crossovers rather than a rule of
thumb. [Backends](backends.md) gives the static table; this computes the answer for
the circuit in front of you, and will name `mps` where a statevector would not fit.

## `qmlkit.parallel`

Independent work — folds, seeds, sweep configurations — run on a thread pool. Not for
parallelising *inside* one circuit, which is the backend's job. `qk.search(...,
n_jobs=4)` dispatches through this; so can your own loops.

## `qmlkit.progress`

See [Watching a run](../guides/watching-a-run.md) for the guide.

## `qmlkit.report`

## `qmlkit.utils.errors`

How every "unknown gate / backend / ansatz / method" message in the library is built.
Worth reading before adding one, since the shape of the message is the contract:
what was wrong, what was probably meant, and what is allowed.


==============================================================================
# About / About    (source: docs/about/index.md)
==============================================================================

# About

The rest of the site shows you what the library does. These four pages are for
deciding whether to believe it, and what happens to your code when it changes.

| | |
|---|---|
| **[Validation](validation.md)** | How any of this is known to be correct: an independent reference sharing no code, cross-library parity against PennyLane, property-based fuzzing, cross-backend equivalence, and executable documentation — ordered by how much each one can prove |
| **[API stability](stability.md)** | What is promised, what is internal, and what a version number means here |
| **[Changelog](changelog.md)** | Every release, and — more usefully — the failures that prompted each fix, written out rather than summarised |
| **[Releasing](releasing.md)** | The tag-to-PyPI procedure, for whoever cuts the next one |

Validation is the one to read first if you are deciding whether to adopt the library
at all. It leads with the check that can prove the most and ends with the ones that
can prove the least, and it records where the project has had to correct itself —
twice on its own benchmark numbers, both times in its own favour, both caught by
someone re-running the comparison rather than trusting the table.


==============================================================================
# About / Validation    (source: docs/about/validation.md)
==============================================================================

# Validation

How any of this is known to be correct.

A library's own test suite can only catch the bugs its author thought of. Worse, a
suite of *agreeing* implementations can only catch the bugs they do not share — five
backends and a second library once agreed, unanimously, with a wrong number.

So qmlkit leans on five checks, ordered here by how much each one can prove:

| | | |
|---|---|---|
| 1 | **An independent reference** | `tests/densesim.py` — 206 lines sharing no code and no conventions with the library it checks |
| 2 | **Cross-library parity** | 301 executed cases against PennyLane, and every metric in `qk.evaluate` against scikit-learn |
| 3 | **Property-based testing** | 17 mathematical invariants over Hypothesis-generated circuits — 1,825 per default run, ~19,500 before a release |
| 4 | **Cross-backend equivalence** | One circuit zoo through every installed SDK, compared to the NumPy reference |
| 5 | **Executable documentation** | Every snippet on this site is run by the test suite |

Plus the ordinary suite: **1,631 tests passing of 1,753 collected**, 0 failures, 94%
combined coverage measured in CI, `ruff` and `mypy --strict` clean over the whole
package.

Every one of the 122 skips is accounted for and none of them is silent: 65 constant
gates tested at a single angle rather than six, 37 needing SpinQit's Python 3.10, 18
documentation pages with no runnable Python, and 2 where the observable is wider than
the circuit. CI asserts each optional SDK actually imported before running its jobs,
because a suite guarded by `importorskip` and never given its dependency turns the job
green by skipping.

These counts drifted once before anyone noticed — the page claimed 1,563 collected
while the suite collected 1,729 — so `scripts/check_doc_numbers.py` now measures them
against the repository and says which sentence has gone stale.

## The reference that shares nothing

This is the check that matters most, and it is the newest.

Everything else here compares qmlkit against something that inherited its conventions —
six backends against each other, or against PennyLane, which qmlkit's gate table was
written by reading. Those agree when a convention is wrong *everywhere*. `densesim.py`
inherited none of them: hand-written gate matrices, hand-derived derivative matrices,
an explicit bit-index loop where the library uses `tensordot`, its own product rule.
It reads `spec.ops` and nothing else — no backend, no gradient routine, no execution
code.

It earned its place immediately. The torch backend had reimplemented `np.moveaxis`
without numpy's `sorted(zip(destination, source))`, so a two-qubit gate on descending
wires permuted the wires it was not acting on. `Z(3)` read `-0.1288` against `-0.7374`.
Nothing raised, `method="backprop"` differentiated through it, and it was reachable
from a built-in `conv_block(filter="su4")`. Four other references agreed with it,
because not one of them was independent of the convention that caused it.

The lesson generalises past this project: **when correctness actually matters, at least
one reference must share no code and no conventions with the thing being checked.**

Four properties in `tests/test_torture.py` run it against randomly generated circuits —
statevectors, expectations, gradients, and every installed backend at once.

## Property-based testing

`tests/test_torture.py` states 17 invariants that hold by mathematics rather than by
example, and lets Hypothesis look for circuits that break them: the four exact gradient
routes agree; a tied weight's gradient sums over its occurrences; batched equals looped;
`adjoint()` undoes; expectation lies inside the observable's spectrum; Qiskit and Cirq
round-trips reproduce the statevector; sampling lands within five standard errors.

A default run generates **1,825 cases**. Before a release:

```bash
QMLKIT_TORTURE_EXAMPLES=1500 pytest tests/test_torture.py
```

which is about 19,500 circuits and ten minutes. When something fails, Hypothesis
shrinks it to the smallest circuit that still shows it and replays that case
thereafter. No CI job runs the deep campaign — it is a release step, done by hand.

## Parity with PennyLane

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

| Layer | Compared | Agreement |
|---|---|---|
| Gates | 55 matrix comparisons over all 20 gates — the 7 parametric ones at 6 angles each, the 13 constant ones once — plus 21 closed-form `dU/dθ` against a differenced PennyLane matrix | `1e-12` |
| Circuits | 40 **randomly generated** circuits over the full gate set, 1–5 qubits — statevectors, probabilities, random multi-term observables | `1e-12` |
| Gradients | 5 ansätze × 4 observables; all four exact methods; PennyLane's own four back against ours; fuzzed circuits | `1e-10` |
| Encodings | angle (X/Y/Z), amplitude, basis, IQP | `1e-12` |
| Templates | `BasicEntanglerLayers`, `StronglyEntanglingLayers` | `1e-12` |
| Kernels | full Gram matrices, fidelity and swap-test estimators | `1e-10` |
| Quantum info | reduced density matrices, von Neumann entropy, purity, mutual information, fidelity, over random states | `1e-10` |
| Fourier | re-uploading spectra at depths 1–4 | `1e-10` |
| Geometry | Fubini–Study metric (full and diagonal), QFIM | `1e-12` |
| Gradient statistics | 4,800 gradient vectors — the batched sampling the barren-plateau tooling runs on, over 24 cells of `n` 4–6 × `L` ∈ {1,2,4,8} × {local `Z₀`, global `Z⊗ⁿ`}, 200 draws each | `1e-15` |
| Optimisers | Rotosolve and QNG trajectories, step by step | `1e-10` |

The **randomised** tests are the ones that matter. Hand-picked cases confirm what the
author already believed; a fuzzer explores the space. Every bug found in this project
so far has been of the plausible-wrong-number kind that only a second opinion catches
— including two in this library's own parameter-shift implementation, and two in its
own tests.

## Four convention differences

None is a bug in either library. Each is pinned by its own test so it stays
deliberate rather than drifting.

**IQP angle convention.** PennyLane's `IQPEmbedding` emits `RZ(xᵢ)` and
`MultiRZ(xᵢxⱼ)`; qmlkit's `PauliFeatureMap` follows the Qiskit convention and emits
`Rz(2φ)`. Halving the data map lines them up exactly. A kernel differing by precisely
this factor would be very hard to spot.

**Amplitude encoding phase.** qmlkit builds amplitude encoding from uniformly
controlled rotations rather than a state-preparation primitive, and the phase cascade
drops one overall factor. Unobservable in isolation — every probability and
expectation is identical — but it stops being global inside a *controlled* block. The
docstring warns about it, and `check=True` re-simulates and asserts.

**Two-qubit "ring".** A ring on two qubits would revisit the same pair, so
`entangler_pairs` collapses it to a single `CX`. PennyLane's templates run their loop
uniformly and emit both `CNOT(0,1)` and `CNOT(1,0)`. A two-qubit strongly-entangling
layer is genuinely a different circuit in the two libraries; adding the second `CNOT`
by hand reconciles them exactly, which is what the test asserts.

**`approx="block-diag"`.** PennyLane blocks the metric tensor by circuit *layer* and
zeroes every cross-layer entry. qmlkit computes the exact metric, which costs no more
on a simulator. The same keyword does not port between the two libraries.

That last one is not cosmetic. On a 3-qubit, 2-layer problem at equal step count and
step size:

| | reaches |
|---|---|
| qmlkit QNG (exact metric) | **−2.9999999** |
| PennyLane QNG, default `approx="block-diag"` | −2.22 |
| PennyLane QNG, `approx=None` | traces qmlkit's trajectory to `1e-8` |

## Where qmlkit is more accurate

`state_fidelity` hits the analytic `|⟨a|b⟩|²` to `1e-16`. `qml.math.fidelity` takes
matrix square roots of rank-1 density matrices, which is ill-conditioned, and loses
about eight digits. Recorded as a test so a future tolerance change there is a
decision rather than an accident.

## Cross-backend equivalence

`tests/test_cross_backend.py` runs one circuit zoo through Qiskit, Cirq and — on Python
3.10 — SpinQit, asserting agreement with the NumPy reference on statevectors,
probabilities, expectations over X/Y/Z and two-body terms, seeded sampling, and
parameter-shift gradients. The zoo deliberately targets where SDKs differ: endianness,
controlled-gate qubit order, idle qubits, basis rotations.

**It does not cover the torch backend, and that omission has already cost something.**
Torch was excluded on the reasoning that a differentiable simulator is a different kind
of backend — and torch is exactly where the worst defect in 0.1.1 lived. An agreement
test that skips a participant tests nothing about that participant. Torch agreement is
now asserted instead by `tests/test_grad_batch.py` and by the property suite, which
iterates `available_backends()` and explicitly declines to skip it.

The zoo found three real upstream discrepancies, all handled — including **SpinQit's
`CY` applying `−iY` instead of `Y`** to the control-1 subspace, which is a relative
phase and therefore physically observable. Details in [Backends and
conventions](../guides/backends.md).

## Executable documentation

Every Python block on this site is executed by `tests/test_docs.py`. The snippets are
not illustrations of the API — they are tests of it, so a rename that breaks a
tutorial breaks the build, and the outputs shown were produced by running the code.

This caught two errors while the docs were being written: a wrong `RotationLayer`
call signature, and a hand-typed number that did not match what the code printed.

## Speed

`examples/benchmark_pennylane.py` times identical work on both libraries, against
PennyLane's **fastest** configuration rather than its reference one.

That choice is the point, and it costs us most of the headline. `pennylane-lightning`
is a dependency of PennyLane, so the C++ `lightning.qubit` is in every install whether
the user asked for it or not; and `qml.adjoint_metric_tensor` is an `O(P)` statevector
algorithm sitting right next to the `O(P²)` Hadamard-test `qml.metric_tensor`. Timing
against `default.qubit` and the `O(P²)` route would be timing an opponent nobody runs.

This section has had to correct itself twice, which is the useful part of it. It once
timed `default.qubit` when `lightning.qubit` ships with PennyLane, and reported a median
of 6.1×. It then timed a Gram matrix against a pair-at-a-time QNode loop and reported
**69×** on that row — when `default.qubit` *broadcasts* a stacked array and does the
same work in one call. Both were flattering; both were caught by someone re-running the
comparison rather than trusting the table. The figure is **1.7×**, it is the one below,
and the naive column is printed beside it so the size of the difference stays visible.

| Operation | qmlkit | PennyLane (best) | | vs the naive route |
|---|---|---|---|---|
| Expectation, 12 qubits | 3.8 ms | 4.7 ms `lightning` | 1.2× | 3.0× |
| Gradient, 8 qubits, `P=96` | 10.9 ms | 10.8 ms `lightning-adjoint` | 1.01× *slower* | 6.0× |
| Parameter-shift, 6 qubits, `P=72` | 303 ms | 333 ms `lightning` | 1.1× | 3.7× |
| 20×20 kernel Gram matrix | 0.31 ms | 3.1 ms `broadcast` | **10×** | 654× |
| Exact metric tensor, `P=24` | 6.7 ms | 691 ms `adjoint_metric` | **103×** | 271× |

qmlkit is ahead on 14 of 14 cases, median **1.7×**. The 8-qubit gradient row is a dead
tie that falls either way between machines; the table quotes the run where it falls
against qmlkit.

`examples/benchmark_pennylane.py` checks that both libraries produce the *same number*
before it reports a speedup, and prints the agreement alongside. An acceleration that
changes the answer is not an acceleration.

Read that in two 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. Those margins shrink as `2ⁿ` grows, and nothing
should be planned around them.

The last two rows are algorithmic, and they survive the fair comparison. A Gram matrix
costs one circuit per *row* rather than one per *pair*, because the inversion test's
`P(0…0)` is exactly `|⟨ψ(x′)|ψ(x)⟩|²` and a simulator can hand back the state — linear
in the dataset instead of quadratic, so the margin *grows* with it (10× at 20 points,
53× at 128). That shortcut needs a statevector, so a device still pays the pairwise
count and `QuantumKernel.circuits_on_hardware` reports it; budget a hardware run from
that number rather than from `n_evaluations`. A PennyLane user who hand-rolls the same
state-overlap trick comes within 0.86× of it, which is the honest framing: the algorithm
is the win, and what qmlkit contributes is that `QuantumKernel(fmap)(X)` already does
it. The metric tensor is closed-form differentiation of the state, agreeing with
PennyLane's own routes to `1.7e-16` and *widening* with parameter count (50× at `P=12`,
103× 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 AmplitudeFeatureMap(n_features: 'int', normalize: 'bool' = True, phases: 'bool' = True) -> 'None'
    Amplitude encoding with its angles left free — the form a kernel can batch.

    :func:`amplitude_encode` builds a circuit *per vector*, which is all a one-shot
    load needs and exactly wrong for a Gram matrix: nothing can be reused across rows,
    so ``m`` points cost ``m`` circuit constructions and the batched evaluation paths
    do not apply. The cascade's structure does not actually depend on the data,
    though — only its rotation angles do — so the whole thing fits the
    :class:`~qmlkit.encoding.feature_maps.FeatureMap` contract, and a Gram matrix
    becomes one batched call.

    ```python
    fmap = qk.AmplitudeFeatureMap(4)             # 4 features -> 2 qubits
    K = qk.QuantumKernel(fmap)(X)
    ```

    ``QuantumKernel(amplitude_encode)`` works too, through
    :class:`~qmlkit.encoding.feature_maps.CallableFeatureMap`, and costs the same
    number of circuits — the row-state shortcut needs a state, not a parametric
    circuit. What this buys is the batch underneath it. Measured on a Gram matrix
    over 300 rows, one circuit per row against one batched evaluation:

    | features | qubits | per row | batched | |
    |---|---|---|---|---|
    | 4 | 2 | 87.7 ms | 31.8 ms | 2.8x |
    | 16 | 4 | 467.9 ms | 86.0 ms | 5.4x |
    | 64 | 6 | 2374.4 ms | 333.4 ms | 7.1x |

    **The kernel this induces has a closed form, and it is a classical one.** The
    encoded state is :math:`x/\lVert x\rVert`, so
    :math:`k(x, x') = |\langle x, x'\rangle|^2 / (\lVert x\rVert^2 \lVert x'\rVert^2)`
    — the squared cosine similarity. That is a *linear* kernel on normalised data,
    computable in microseconds without a circuit. Amplitude encoding earns its place
    as a data-loading step underneath something that does more, not as a feature map
    you would choose for its expressivity. Knowing which side of that line you are on
    is the difference between a benchmark and a demonstration.

    Two consequences worth naming, because neither raises:

    * **Bandwidth does nothing.** Rescaling every feature before encoding leaves the
      direction unchanged, so the usual first fix for a concentrated kernel is inert
class AngleFeatureMap(n_features: 'int', rotation: 'str' = 'ry', entangle: 'bool' = True, entanglement: 'str' = 'chain', reps: 'int' = 1) -> 'None'
    One rotation per feature, optionally followed by an entangling layer.

    The plainest map there is, and the one whose kernel has a closed form:
    ``cos^2((x - x')/2)`` per feature when ``entangle=False``.
class AngleScaler(lo: 'float' = 0.0, hi: 'float' = 6.283185307179586, data_min: 'npt.NDArray[Any] | None' = None, data_max: 'npt.NDArray[Any] | None' = None) -> None
    Fit-then-transform angle scaling, so train and test share one range.
class Ansatz(n_qubits: 'int', block: 'Block', name: 'str' = 'ansatz', n_inputs: 'int | None' = None) -> 'None'
    A trainable circuit: a qubit count and a composable block.

    ``n_inputs`` is **inferred** from the encoding layers in the block — each feature
    map owns a disjoint range of input slots, and the same map re-used shares its own.
    Pass it only to assert a total you already know; a value that disagrees raises.
class AnsatzReport(ansatz: 'Ansatz', n_samples: 'int' = 300, seed: 'int | None' = 0, backend: 'BackendLike' = None, results: 'dict[str, object]' = <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 CallableFeatureMap(encode: 'Callable[[Any], CircuitSpec]', n_features: 'int | None' = None, n_qubits: 'int | None' = None, name: 'str | None' = None) -> 'None'
    Any ``x -> CircuitSpec`` function, as a feature map.

    ``amplitude_encode`` is a function, not a :class:`FeatureMap`, so
    ``QuantumKernel(qk.amplitude_encode)`` failed on ``'function' object has no
    attribute 'build'`` — an error about the adapter that was missing rather than
    about anything the caller had done. This is that adapter, and
    :func:`as_feature_map` applies it, so a kernel, a QSVC or a projected kernel takes
    a plain encoder and a Pauli map on the same footing.

    The wrapper stays honest about what a bare function gives up. There are no angles
    to vary independently of the circuit, so :attr:`supports_parametric` is ``False``,
    :meth:`build_parametric`, :meth:`angles` and :meth:`n_angles` raise with the
    reason, and callers that would have batched fall back to encoding one row at a
    time. It also checks what comes back: a non-circuit, a circuit with unbound
    parameters, or a register width that moves between inputs are each a mistake that
    would otherwise surface much later as a wrong number.

    Arguments
    ---------
    encode
        The function. It is handed one 1-D float array and must return a fully-bound
        :class:`~qmlkit.core.ir.CircuitSpec`.
    n_features, n_qubits
        Optional. Both are learnt from the first vector encoded, so a kernel takes
        ``amplitude_encode`` with no further ceremony. Pass them when something needs
        a width before any data has arrived.
    name
        What errors call it. Defaults to the function's own ``__name__``.
class CircuitSpec(n_qubits: 'int', ops: 'tuple[Op, ...]' = (), n_params: 'int' = 0) -> None
    An immutable circuit description.
class Custom(fn: 'Callable[[QCircuit, BuildContext], None]', name: 'str' = 'Custom')
    Wrap an arbitrary ``fn(qc, ctx)`` as a block.

    The escape hatch: anything the vocabulary cannot express, written directly
    against the builder, still composes with everything else.
class DataReuploadEncoder(n_features: 'int', n_uploads: 'int' = 3, rotations: 'Sequence[str]' = ('rz', 'ry', 'rz'), encoding_rotation: 'str' = 'ry', entanglement: 'str | None' = 'chain', trainable_input: 'bool' = False) -> 'None'
    One convenient re-uploading shape: angle encoding, rotations, entangler.

    .. note::
       Re-uploading is a **pattern, not a structure** — any feature map, any
       trainable block, any interleaving. This class fixes one convenient choice.
       For anything else use :func:`qmlkit.reupload`, or compose
       :class:`~qmlkit.ansatz.blocks.EncodingLayer` directly with the block
       vocabulary. This remains for the plain angle-encoding case.

    The circuit alternates ``S(x)`` — an angle encoding — with ``W(theta)``, a
    trainable rotation block, ``n_uploads`` times. Data enters as *literals* by
    default; pass ``trainable_input=True`` to make the features circuit parameters
    too, which is what yields ``df/dx`` for a classical pre-net.

    The parameter vector is laid out as ``(n_uploads, n_qubits, len(rotations))``,
    flattened, with the input parameters (if trainable) appended after it.
class DressedQuantumNet(backbone: 'nn.Module | None', in_features: 'int', n_qubits: 'int', n_outputs: 'int', n_layers: 'int' = 2, feature_map: 'FeatureMap | None' = None, ansatz: 'Ansatz | None' = None, freeze_backbone: 'bool' = True, **kwargs: 'Any') -> 'None'
    The dressed circuit: a frozen backbone, then ``Linear -> quantum -> Linear``.

    Transfer learning with a quantum head. The backbone is frozen, so only the
    dressed block trains — and because the layer returns input gradients, the
    ``Linear`` that feeds the circuit trains too. An implementation that returns
    ``None`` for the input gradient silently freezes exactly that layer, which is the
    one doing the adapting.
class EncodingLayer(feature_map: 'object') -> 'None'
    Insert a feature map as a block, so re-uploading is just composition.

    Data re-uploading is not one structure. It is *any* interleaving of an encoding
    with a trainable block, and which encoding, which block, and in what order are
    all design choices:

        repeat(3, EncodingLayer(fmap) + RotationLayer(("rz", "ry", "rz")) + EntanglerLayer())
        repeat(2, RotationLayer("ry") + EncodingLayer(fmap))          # W before S
        EncodingLayer(fmap) + repeat(4, RotationLayer("ry"))          # encode once, vary often
        EncodingLayer(zz) + RotationLayer("ry") + EncodingLayer(angle)  # two different maps

    **Which slots a layer reads.** Every repeat of *the same feature map* references
    the same input angles: re-uploading means feeding the same data in again, not
    consuming new features. Two *different* maps get disjoint ranges, allocated in the
    order they first appear, because a slot holds an **angle** and each map derives its
    angles its own way — ``ZZFeatureMap(2).angles([0.3, 0.7])`` is
    ``[0.6, 1.4, 13.876]``, since a Z term follows the ``Rz(2 phi)`` convention, while
    ``AngleFeatureMap(2).angles([0.3, 0.7])`` is ``[0.3, 0.7]``. Angle 0 of one is not
    angle 0 of the other, so sharing the slot would encode the wrong number without
    raising.

    So the composition above reserves ``3 + 2 = 5`` input slots, and
    :meth:`~qmlkit.ansatz.library.Ansatz.angles` concatenates the maps' angles in the
    same order. ``n_inputs`` is inferred from the block; pass it only to assert a
    total you already know.

    Slots cannot instead be keyed by *feature*, so that both maps read the raw ``x``:
    a slot is referenced by a :class:`~qmlkit.core.ir.ParamRef`, which is affine in one
    parameter (``scale * theta[i] + offset``), and a Pauli map's higher-order angle is
    ``2 * prod_j (pi - x_j)`` — nonlinear, in several features at once. The map from
    features to angles has to stay classical, which is what
    :meth:`~qmlkit.encoding.feature_maps.FeatureMap.angle_jacobian` is for.

    To learn the *frequencies* rather than inherit them from the encoding, put a
    classical layer in front — ``nn.Sequential(nn.Linear(d, d), QuantumLayer(...))``.
    That is a scaling of the inputs, which torch already differentiates; it does not
    need to live inside the circuit.
class EntanglerLayer(gate: 'str' = 'cx', pattern: 'str' = 'chain')
    A layer of fixed two-qubit gates following a named pattern.
class FeatureMap()
    Turns a feature vector into a circuit.

    Subclasses implement three methods, and :meth:`build` is **not** one of them --
    it is concrete, and composes the other three:

    * :meth:`angles` — the feature vector to its rotation angles.
    * :meth:`n_angles` — how many slots those angles occupy.
    * :meth:`_emit` — the angles to a circuit. It receives either floats or
      :class:`~qmlkit.core.ir.ParamRef` slots, so it must not assume arithmetic.

    Optionally also :meth:`angle_jacobian`, ``d(angle)/d(feature)``, which is what
    lets a gradient reach back through the encoding to a classical pre-net.
    Overriding :meth:`build` instead leaves :meth:`build_parametric` calling the base
    :meth:`_emit`, which raises -- so the torch layer breaks and nothing else does.

    ``adjoint`` comes free from the IR, which is what the fidelity kernel's
    compute-uncompute test needs.

    A map that genuinely cannot be written that way -- amplitude encoding as a raw
    function, or any loader you already have -- overrides :meth:`build` and sets
    :attr:`supports_parametric` to ``False``. That is a contract, not a label:
    callers read it to decide whether one circuit can stand in for the whole
    dataset, and a map that lies about it fails inside a batched evaluation rather
    than at the point of the mistake.
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 GradientStats(n_qubits: 'int', n_params: 'int', n_samples: 'int', observable: 'str', variance: 'npt.NDArray[Any]', mean: 'npt.NDArray[Any]') -> None
    Every parameter's gradient statistics, from one batch of random points.

    :func:`~qmlkit.metrics.gradient_variance` probes one parameter and throws away the
    other ``p - 1`` entries of every gradient it computes. They cost nothing extra —
    one adjoint pass returns the whole vector — and they are what distinguishes a
    barren plateau from a single dead parameter, so this keeps them.

    ``variance`` and ``mean`` are per parameter, over ``n_samples`` draws of
    ``theta ~ U(-pi, pi)``.
class HessianSpectrum(eigenvalues: 'npt.NDArray[Any]', gradient_norm: 'float') -> None
    What kind of stationary point this is, from the curvature around it.
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 LandscapeReport(subject: 'str', gradients: 'GradientStats', findings: 'Report', minima: 'MinimaScan | None' = None) -> None
    Gradients, mechanism, minima and curvature for one ansatz.
class LoadingCost(method: 'str', n_features: 'int', n_qubits: 'int', two_qubit_gates: 'int', depth: 'int', exact: 'bool') -> None
    Qubits, gates and depth to get one sample into a circuit.

    ``exact`` distinguishes a counted circuit from a fitted estimate: below a few
    hundred features :func:`loading_cost` builds the circuit and counts it, and above
    that it extrapolates from the measured constants rather than allocating a
    ``2^n``-amplitude statevector to find out.

    ``two_qubit_gates`` is structural — every generic vector of the same length gives
    the same count. ``depth`` moves by a percent or two with the particular angles
    (measured: 1,458 to 1,484 over six random 256-vectors), so read it as a scale
    rather than a guarantee.
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 MinimaScan(losses: 'npt.NDArray[Any]', kinds: 'tuple[str, ...]', n_steps: 'int', best_theta: 'npt.NDArray[Any]' = <factory>) -> None
    Where independent random starts ended up, and whether they agreed.
class NearestFidelityClassifier(feature_map: 'EncodingLike', 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 Overparametrisation(n_params: 'tuple[int, ...]', ranks: 'tuple[int, ...]', layers: 'tuple[int, ...]') -> None
    Where the QFIM rank stops growing, and what that means for the minima.
class PCAReducer(n_components: 'int', mean_: 'npt.NDArray[Any] | None' = None, components_: 'npt.NDArray[Any] | None' = None, explained_variance_ratio_: 'npt.NDArray[Any] | None' = None) -> None
    Project features onto their leading principal components, via SVD.

    Angle encoding needs one qubit per feature, so a 64-feature dataset needs 64
    qubits — usually out of reach. Reducing first is the ordinary way through, and
    ``explained_variance_ratio_`` says how much you gave up doing it.
class ParamRef(index: 'int', scale: 'float' = 1.0, offset: 'float' = 0.0) -> None
    A reference to logical parameter ``index``, optionally linearly rescaled.

    ``scale`` and ``offset`` let one logical parameter drive a gate angle of
    ``scale * theta[index] + offset`` without introducing a new parameter. The
    chain rule for that is handled in the gradient code.
class ParametricEntangler(gate: 'str' = 'crz', pattern: 'str' = 'ring')
    A layer of *trainable* two-qubit gates — exercises the four-term shift rule.
class PauliFeatureMap(n_features: 'int', paulis: 'Sequence[str]' = ('Z', 'ZZ'), reps: 'int' = 2, entanglement: 'str' = 'linear', data_map: 'DataMap | None' = None) -> 'None'
    The general Pauli feature map, for any set of Pauli strings.

    Parameters
    ----------
    n_features
        One qubit per feature.
    paulis
        Pauli strings to include, e.g. ``("Z", "ZZ")``.
    reps
        How many times to repeat the whole block. More reps means higher reachable
        frequencies, at proportional depth.
    entanglement
        Pattern for two-body terms: ``linear``/``chain``, ``ring``, ``full``, or
        ``alternating``.
    data_map
        Override the default :func:`default_data_map`.
class PauliString(paulis: 'tuple[tuple[int, str], ...]' = (), coeff: 'complex' = 1.0) -> None
    A weighted tensor product of Paulis, e.g. ``0.5 * Z0 X2``.

    Qubits not named act as identity.
class PauliSum(terms: 'tuple[PauliString, ...]' = ()) -> None
    A linear combination of Pauli strings.
class Plan(circuits: 'int', shots_total: 'int | None', method: 'str', n_params: 'int', n_samples: 'int', steps: 'int', shots: 'int | None', measurement_settings: 'int', observable_terms: 'int', reductions: 'tuple[Reduction, ...]' = (), notes: 'tuple[str, ...]' = ()) -> None
    The circuit budget for a training run, and the ways to shrink it.
class PoolLayer(keep: 'str' = 'odd', mode: 'str' = 'discard', tied: 'bool' = True)
    Halve the active register — the pooling half of a QCNN.

    ``keep="odd"`` retains every second wire starting from the second, matching the
    convention where the surviving qubit is the target of the preceding entangler.

    Two pooling modes, because the literature uses both:

    ``mode="discard"``
        Simply stop using the wire. Cheap, adds no parameters, and the information
        it held survives only through whatever the convolution already moved.

    ``mode="controlled"``
        Before dropping a wire, apply a trainable ``crz`` from it onto its surviving
        partner, so pooling *learns* what to carry forward. This is the simulator's
        stand-in for the measure-and-conditionally-rotate pooling of Cong, Choi &
        Lukin, which needs mid-circuit measurement and feed-forward.

    ``tied`` shares one pooling angle across the whole layer, matching the way a
    tied convolution shares one filter.
class Progress(stream: 'IO[str] | None' = None, live: 'bool' = True) -> 'None'
    Collects what the run is doing, and optionally shows it live.
class QCNNLayer(n_qubits: 'int', feature_map: 'FeatureMap | None' = None, tie_weights: 'bool' = True, observables: 'Sequence[Observable] | None' = None, filter: 'str | tuple[Any, int]' = 'ry_cx', pattern: 'str' = 'chain', pool: 'str' = 'discard', ansatz: 'Ansatz | None' = None, **kwargs: 'Any') -> 'None'
    Quantum convolutional layer: shared filter, then pooling.

    The filter is tied across every pair it slides over, so an 8-qubit QCNN carries
    6 parameters where an untied version needs 22 — at the same gradient cost.
class QCircuit(n_qubits: 'int', n_params: 'int' = 0) -> 'None'
    Builds a :class:`CircuitSpec` step by step.
class QLSTM(n_inputs: 'int', hidden_size: 'int', n_qubits: 'int' = 4, **kwargs: 'Any') -> 'None'
    A QLSTM over a sequence. Returns ``(outputs, (h, c))``.
class QLSTMCell(n_inputs: 'int', hidden_size: 'int', n_qubits: 'int' = 4, ansatz: 'Ansatz | None' = None, n_layers: 'int' = 2, **kwargs: 'Any') -> 'None'
    One LSTM cell with its four gates replaced by small circuits.

    ``forget``, ``input``, ``candidate`` and ``output`` each become a
    :class:`QuantumLayer`; the recurrence, the sigmoids and the tanh stay classical.
    A classical projection maps ``[x, h]`` down to the qubit count first, which is
    what keeps the circuits small enough to be worth running.
class QSVC(feature_map: 'EncodingLike', C: 'float' = 1.0, **kwargs: 'Any') -> 'None'
    Quantum-kernel support vector classifier.

    clf = QSVC(qk.ZZFeatureMap(2)).fit(X, y)
    clf.score(X_test, y_test)
class QSVR(feature_map: 'EncodingLike', C: 'float' = 1.0, epsilon: 'float' = 0.1, **kwargs: 'Any') -> 'None'
    Quantum-kernel support vector regressor.
class QramCost(n_addresses: 'int', address_qubits: 'int', memory_qubits: 'int', routing_qubits: 'int', ideal_depth: 'int', encoding: 'LoadingCost') -> None
    What a bucket-brigade QRAM would need, against what encoding needs instead.
class QuantumFunction(*args, **kwargs)
    Autograd boundary: forward runs circuits, backward differentiates them.
class QuantumKernel(feature_map: 'EncodingLike', estimator: 'str' = 'inversion', shots: 'int | None' = None, backend: 'BackendLike' = None, bandwidth: 'float' = 1.0, seed: 'int | None' = None, cache: 'bool' = True) -> 'None'
    Any encoding, 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
    ---------
    feature_map
        A :class:`~qmlkit.encoding.feature_maps.FeatureMap`, or **any callable that
        turns one feature vector into one circuit** — ``qk.amplitude_encode``, a
        closure over your own loader, whatever you have.
        :func:`~qmlkit.encoding.feature_maps.as_feature_map` adapts the second into
        the first, so nothing below this line cares which you passed.

        The adapter is honest about the cost. A ``FeatureMap`` exposes its angles,
        so one circuit structure serves the whole dataset and a Gram matrix is a
        single batched evaluation. A bare function does not, so its rows are encoded
        one at a time — still linear in the dataset on a simulator, and still far
        cheaper than the quadratic pair loop, but a constant factor slower than the
        batch. :class:`~qmlkit.encoding.amplitude.AmplitudeFeatureMap` is
        ``amplitude_encode`` written the first way, and is what to reach for if
        amplitude loading is on a hot path.
    estimator
        How the overlap is measured. ``"inversion"`` (the default) runs the
        compute-uncompute circuit and reads the all-zeros probability; ``"swap"``
        uses a swap test; ``"hadamard"`` runs **two** Hadamard tests and adds the
        squares of the real and imaginary parts, because one Hadamard test measures
        one component of a complex overlap and the kernel is its modulus. All three
        agree on a simulator — :attr:`n_evaluations` is what differs, and on a device
        so do the width and the connectivity each one needs.
    shots
        ``None`` reads the exact probability. A budget samples it, which is what a
        device does — and a sampled kernel is not positive semi-definite by
        construction, so pair it with :func:`threshold_matrix`.
    bandwidth
        **The first thing to try when a kernel has concentrated.** Every feature
        vector is scaled by this before encoding, so it sets how far apart two points
        are in the feature map rather than in the data. At the default ``1.0`` a
        fidelity kernel over a wide register drives every off-diagonal entry toward
class QuantumLayer(feature_map: 'FeatureMap', ansatz: 'Ansatz | None' = None, observables: 'Sequence[Observable] | None' = None, shots: 'int | None' = None, backend: 'Any' = None, grad_method: 'str' = 'auto', seed: 'int | None' = None, init: 'str' = 'small', init_seed: 'int | None' = None) -> 'None'
    A circuit as an ``nn.Module``.

    Maps ``(batch, n_features)`` to ``(batch, n_observables)``, each output an
    expectation value in ``[-1, 1]``.

    Defaults are chosen for a simulator: exact expectations and adjoint gradients.
    Pass ``shots=N`` to model a device, and ``grad_method="parameter-shift"`` to
    compute the way hardware would have to.
class Recommendation(n_qubits: 'int', n_gates: 'int', statevector_bytes: 'int', backend: 'str', reason: 'str', fusion: 'bool', batching: 'bool', alternatives: 'tuple[tuple[str, str], ...]' = (), notes: 'tuple[str, ...]' = ()) -> None
    What to run this on, and what that turns on.
class Reduction(name: 'str', circuits: 'int', factor: 'float', trade: 'str') -> None
    One way to make the run cheaper, and what it costs to take it.
class Report(subject: 'str', findings: 'tuple[Finding, ...]' = ()) -> None
    Everything :func:`diagnose` found, worst first.

    Falsy when empty, so it can be tested directly. Iterating yields
    :class:`Finding` objects; ``codes`` is the flat list to assert against.
class RotationLayer(gates: 'str | Sequence[str]' = ('ry',), wires: 'Sequence[int] | None' = None)
    One trainable rotation per gate per active wire.
class SPSASchedule(a: 'float' = 0.2, c: 'float' = 0.1, A: 'float | None' = None, alpha: 'float' = 0.602, gamma: 'float' = 0.101, n_iterations: 'int' = 100) -> 'None'
    Spall's decay schedules for the step size and the perturbation size.
class SearchResult(task: 'str', metric: 'str', n_samples: 'int', n_folds: 'int', varied: 'tuple[str, ...]', rows: 'tuple[SearchRow, ...]' = (), notes: 'tuple[str, ...]' = (), extras: 'dict[str, Any]' = <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 TaskRecord(label: 'str', items: 'int', seconds: 'float', depth: 'int' = 0) -> None
    One finished unit of work, kept for the report.
class TrainableKernel(feature_map_factory: 'Any', n_params: 'int', shots: 'int | None' = None, backend: 'BackendLike' = None) -> 'None'
    Train the *feature map itself* by maximising kernel-target alignment.

    A fixed feature map is a guess. Alignment gives a differentiable score for how
    well a kernel matches the labels, so the embedding's own parameters can be
    optimised before any classifier is fitted — usually a bigger win than tuning the
    classifier afterwards.
class UnsupportedGate
    A source circuit used a gate qmlkit has no definition for.
class VQC(n_features: 'int', n_classes: 'int' = 2, class_weight: 'str | None' = None, focal_gamma: 'float' = 0.0, **kwargs: 'Any') -> 'None'
    Variational quantum classifier.

    model = VQC(n_features=4, n_classes=3).fit(X, y)
    model.score(X, y)

    ``class_weight="balanced"`` reweights the loss by class frequency, which is
    what stops a skewed training set training the circuit to a constant. The
    weights are computed from the ``y`` passed to :meth:`fit`, so they describe the
    data actually trained on rather than an assumption made at construction.
    ``focal_gamma`` additionally down-weights examples the model already gets
    right; ``0.0`` disables it, ``2.0`` is the published default.
class VQRegressor(n_features: 'int', n_outputs: 'int' = 1, **kwargs: 'Any') -> 'None'
    Variational quantum regressor.

    model = VQRegressor(n_features=3).fit(X, y)
    model.predict(X)
def X(q: 'int') -> 'PauliString'
def Y(q: 'int') -> 'PauliString'
def Z(q: 'int') -> 'PauliString'
class ZFeatureMap(n_features: 'int', reps: 'int' = 2) -> 'None'
    First-order, no entanglement — so its kernel factorises over features.
def ZZ(a: 'int', b: 'int') -> 'PauliString'
class ZZFeatureMap(n_features: 'int', reps: 'int' = 2, entanglement: 'str' = 'linear') -> 'None'
    First order plus entangling ZZ couplings — the kernel stops factorising.
__version__
    str(object='') -> str
def adjoint_grad(spec: 'CircuitSpec', theta: 'npt.NDArray[Any]', obs: 'Observable | None' = None, backend: 'Backend | str | None' = None) -> 'npt.NDArray[Any]'
    Exact gradient of ``<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 as_feature_map(encoding: 'EncodingLike', n_features: 'int | None' = None, n_qubits: 'int | None' = None) -> 'FeatureMap'
    Whatever the caller passed as an encoding, as a :class:`FeatureMap`.
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', X: 'Any' = None, y: 'Any' = None, *, obs: 'Observable | None' = None, n_samples: 'int' = 30, probes: 'int' = 3, seed: 'int | None' = 0, backend: 'BackendLike' = None, shots: 'int | None' = None, n_qubits: 'int | None' = None) -> 'Report'
    Check a model or a Gram matrix for the failures that do not raise.
def displace_matrix(K: 'npt.NDArray[Any]') -> 'npt.NDArray[Any]'
    Shift the whole spectrum up until it is non-negative.
def draw(spec: 'CircuitSpec', max_width: 'int' = 160, ascii: 'bool | None' = None) -> 'str'
    A text diagram of the circuit.
def effective_dimension(fisher: 'npt.NDArray[Any]', n_samples: 'int' = 1000, gamma: 'float' = 1.0) -> 'float'
    Normalised effective dimension of a model, from its Fisher information.
def entangler_pairs(n_qubits: 'int', pattern: 'str' = 'chain') -> 'tuple[tuple[int, int], ...]'
    Qubit pairs for a named entanglement pattern.
def entangling_capability(ansatz: 'Ansatz', n_samples: 'int' = 200, seed: 'int | None' = None, backend: 'BackendLike' = None) -> 'float'
    Mean Meyer–Wallach ``Q`` over randomly sampled parameters.
evaluate
    Every score a task needs, in one call — and a note when a score is lying.
def expectation(spec: 'CircuitSpec', obs: 'Observable | None' = None, theta: 'ArrayLike | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None, return_std: 'bool' = False) -> 'float | tuple[float, float]'
    ``<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', n_avg: 'int' = 1) -> 'int | str'
    Circuit evaluations one gradient needs under a given method.
def gradient_stats(ansatz: 'Ansatz', obs: 'Observable | None' = None, *, n_samples: 'int' = 100, seed: 'int | None' = 0, backend: 'BackendLike' = None) -> 'GradientStats'
    Gradient mean and variance for **every** parameter, in one batched pass.
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 parameterised slot.
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: 'ArrayLike', obs: 'Observable | None' = None, backend: 'Backend | str | None' = None, eps: 'float' = 0.0001) -> 'npt.NDArray[Any]'
    Second derivatives, by differencing the exact gradient.
def hessian_spectrum(spec: 'CircuitSpec', theta: 'ArrayLike', obs: 'Observable | None' = None, *, free: 'Sequence[int] | None' = None, backend: 'BackendLike' = None, eps: 'float' = 0.0001) -> 'HessianSpectrum'
    Classify the point ``theta``: minimum, saddle, maximum, or still on a slope.
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 landscape(ansatz: 'Ansatz', obs: 'Observable | None' = None, *, x: 'ArrayLike | None' = None, n_samples: 'int' = 100, n_starts: 'int' = 6, n_steps: 'int' = 300, seed: 'int | None' = 0, backend: 'BackendLike' = None, minima: 'bool' = True) -> 'LandscapeReport'
    Everything this module measures, for one ansatz, in one call.
def list_ansatze() -> 'tuple[str, ...]'
    Every registered ansatz name, including any you registered yourself.
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, ...]'
    Every registered QCNN two-qubit filter name. Separate from the ansatz registry.
def list_feature_maps() -> 'tuple[str, ...]'
def list_gates() -> 'tuple[str, ...]'
def list_gradient_methods() -> 'tuple[str, ...]'
def list_importers() -> 'tuple[str, ...]'
def loading_cost(n_features: 'int', method: 'str' = 'amplitude') -> 'LoadingCost'
    Price one sample's encoding, by counting the circuit or extrapolating from it.
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 minima_scan(ansatz: 'Ansatz', obs: 'Observable | None' = None, *, x: 'ArrayLike | None' = None, n_starts: 'int' = 8, n_steps: 'int' = 300, lr: 'float' = 0.1, seed: 'int | None' = 0, backend: 'BackendLike' = None, classify: 'bool' = True) -> 'MinimaScan'
    Minimise from ``n_starts`` random points and see whether they agree.
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 overparametrisation(ansatz_factory: 'Callable[[int], Ansatz]', layer_range: 'Sequence[int]', *, probes: 'int' = 3, seed: 'int | None' = 0, backend: 'BackendLike' = None) -> 'Overparametrisation'
    Find the overparametrisation threshold by watching the QFIM rank saturate.
def p0_from_z(z: 'float') -> 'float'
    P(0) from <Z>.
def parallel_map(fn: 'Callable[[T], R]', items: 'Iterable[T]', n_jobs: 'int | None' = None) -> 'list[R]'
    Apply ``fn`` to each item, on ``n_jobs`` threads, preserving order.
def param_shift_grad(f_slots: 'SlotFn', spec: 'CircuitSpec', theta: 'Sequence[float]', rules: 'dict[int, ShiftRule] | None' = None, f0: 'float | None' = None) -> 'npt.NDArray[Any]'
    Exact gradient of ``f`` with respect to the logical parameter vector.
def param_shift_grad_batch(spec: 'CircuitSpec', thetas: 'ArrayLike', obs: 'Observable | None' = None, shots: 'int | None' = None, backend: 'Backend | str | None' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]'
    Parameter-shift gradients for a batch, as **one** set of evaluations.
def param_shift_grad_circuit(spec: 'CircuitSpec', theta: 'Sequence[float]', obs: 'Observable | None' = None, shots: 'int | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'npt.NDArray[Any]'
    Convenience wrapper: parameter-shift gradient of ``<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 plateau_mechanism(ansatz: 'Ansatz', obs: 'Observable | None' = None, *, n_samples: 'int' = 100, seed: 'int | None' = 0, backend: 'BackendLike' = None) -> 'list[Finding]'
    Attribute a flat gradient to a cause, by controlled comparison.
def probabilities(spec: 'CircuitSpec', theta: 'ArrayLike | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]'
    Exact outcome probabilities over the ``2**n`` basis states.
def progress(live: 'bool' = True, stream: 'IO[str] | None' = None) -> 'Iterator[Progress]'
    Watch a run, and keep what it did.
def projected_kernel_matrix(feature_map: 'EncodingLike', 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 qram_cost(n_addresses: 'int') -> 'QramCost'
    Resources a bucket-brigade QRAM would need, beside what encoding needs instead.
def quantum_fisher_information(spec: 'CircuitSpec', theta: 'Sequence[float]', backend: 'BackendLike' = None) -> 'npt.NDArray[Any]'
    QFIM — exactly ``4 x`` the Fubini–Study metric.
def random_layers(n_qubits: 'int', n_layers: 'int' = 2, ratio_imprimitive: 'float' = 0.3, seed: 'int | None' = None) -> 'Ansatz'
    Randomly placed rotations and CNOTs — the baseline a new ansatz must beat.
def recommend(model: 'Any', *, shots: 'int | None' = None, batch: 'int | None' = None) -> 'Recommendation'
    Which backend to run ``model`` on, and what that switches on.
def reduce_to_qubits(x: 'npt.NDArray[Any]', n_qubits: 'int', method: 'str' = 'pca', to_angles: 'bool' = True, lo: 'float' = 0.0, hi: 'float' = 6.283185307179586) -> 'npt.NDArray[Any]'
    Reduce a feature matrix to ``n_qubits`` columns, ready for angle encoding.
def reduced_dm(state: 'CircuitSpec | npt.NDArray[Any]', wires: 'Sequence[int]', n_qubits: 'int | None' = None, backend: 'BackendLike' = None) -> 'npt.NDArray[Any]'
    Trace out everything except ``wires``, kept **in the order given**.
def register_ansatz(name: 'str', factory: 'AnsatzFactory | None' = None) -> 'Callable[[AnsatzFactory], AnsatzFactory] | AnsatzFactory'
    Register an ansatz factory. Usable as a decorator or a direct call.
def register_backend(name: 'str', factory: 'Callable[..., Backend]', requires: 'str | None' = None, extra: 'str | None' = None) -> 'None'
    Register a backend factory.
def register_baseline(name: 'str', task: 'str', factory: 'Callable[[], Any]', requires: 'str | None' = None, note: 'str' = '') -> 'None'
    Add a baseline, so it appears in every table for that task from now on.
def register_conv_filter(name: 'str', fn: 'ConvFilter', n_params: 'int') -> 'None'
    Make a two-qubit filter reachable by name from :func:`conv_block`.
def register_feature_map(name: 'str', factory: 'Callable[..., FeatureMap]') -> 'None'
    Make a feature map reachable by name, here and in every later search.
def register_gate(gate: 'GateDef') -> 'GateDef'
    Add a gate to the registry. Re-registering the same name is an error.
def register_gradient(name: 'str', fn: 'GradFn | None' = None) -> 'Callable[[GradFn], GradFn] | GradFn'
    Register a gradient estimator under ``name``. Usable as a decorator.
def register_importer(name: 'str', fn: 'Callable[..., CircuitSpec]') -> 'None'
    Add an importer, so a new source format is reachable by name.
def repeat(times: 'int', block: 'Block') -> 'Repeat'
    ``repeat(3, RotationLayer("ry"))`` — three layers, fresh weights each.
def reupload(feature_map: 'object', n_layers: 'int' = 3, block: 'Block | None' = None, order: 'str' = 'SW', entangler: 'str | None' = 'cx', pattern: 'str' = 'chain', rotations: 'Sequence[str]' = ('rz', 'ry', 'rz'), share_weights: 'bool' = False, name: 'str' = 'reupload') -> 'Ansatz'
    Build a re-uploading ansatz from any feature map and any trainable block.
def rotosolve_step(f: 'LossFn', theta: 'ArrayLike', indices: 'Sequence[int] | None' = None) -> 'npt.NDArray[Any]'
    One sweep: set every coordinate to its exact optimum, in turn.
def rule_for_gate(gate: 'str') -> 'ShiftRule'
    Look the rule up from the gate's declared generator frequencies.
def run_counts(spec: 'CircuitSpec', shots: 'int' = 8192, theta: 'ArrayLike | None' = None, backend: 'BackendLike' = None, seed: 'int | None' = None) -> 'dict[str, int]'
    Sample the computational basis. Keys are ``n_qubits``-wide bitstrings.
def search(X: 'Any', y: 'Any', task: 'str' = 'auto', cv: 'int' = 3, metric: 'str | None' = None, seed: 'int | None' = 0, prune: 'str | Sequence[str]' = 'error', max_configs: 'int | None' = None, dry_run: 'bool' = False, verbose: 'bool' = True, n_jobs: 'int | None' = None, **axes: 'Any') -> 'SearchResult'
    Sweep every tunable axis, skipping the configurations that cannot work.
def selfcheck(spec: 'CircuitSpec', theta: 'ArrayLike', obs: 'Observable', backend: 'Any' = None, cross_backend: 'bool' = True) -> 'Any'
    Compute this circuit's value and gradient every available way, and compare.
def set_default_backend(backend: 'str | Backend', **kwargs: 'object') -> 'Backend'
    Set the process-wide default backend and return it.
shadows
    Classical shadows — estimate many observables from few measurements.
def share(times: 'int', block: 'Block') -> 'Share'
    ``share(3, conv_block)`` — three applications of one tied weight set.
def shots_for_precision(eps: 'float', z: 'float' = 0.0) -> 'int'
    Shots needed to reach standard error ``eps`` — the ``1/eps**2`` price.
def simplified_two_design(n_qubits: 'int', n_layers: 'int' = 2) -> 'Ansatz'
    The standard reference ansatz in barren-plateau studies.
def specs(spec: 'CircuitSpec') -> 'dict[str, object]'
    Everything worth knowing about a circuit's cost, in one dict.
def spsa_grad(f: 'LossFn', theta: 'ArrayLike', c: 'float' = 0.1, n_avg: 'int' = 1, seed: 'int | None' = None, rng: 'np.random.Generator | None' = None) -> 'npt.NDArray[Any]'
    A stochastic gradient estimate from ``2 * n_avg`` evaluations.
def standard_error(z: 'float', shots: 'int', scale: 'float' = 1.0) -> 'float'
    Standard error of a **single** Pauli term's expectation over ``shots`` samples.
def state_fidelity(state_a: 'CircuitSpec | npt.NDArray[Any]', state_b: 'CircuitSpec | npt.NDArray[Any]', backend: 'BackendLike' = None) -> 'float'
    ``|<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 track(iterable: 'Iterable[T]', label: 'str', total: 'int | None' = None) -> 'Iterator[T]'
    Count an iterable's items as they are consumed, if anything is watching.
def tree_tensor_network(n_qubits: 'int', filter: 'str | tuple[ConvFilter, int]' = 'ry_cx', tied: 'bool' = False) -> 'Ansatz'
    Log-depth merge tree — shallow, and resistant to barren plateaus.
def two_local(n_qubits: 'int', n_layers: 'int' = 2, rotations: 'Sequence[str]' = ('ry',), entangler: 'str' = 'cx', pattern: 'str' = 'full') -> 'Ansatz'
    A configurable rotation/entangler alternation, ending on a rotation layer.
def two_term_rule() -> 'ShiftRule'
    The familiar Pauli-rotation rule: shifts +-pi/2, coefficients +-1/2.
def variance(z: 'float') -> 'float'
    Single-shot variance of a +-1 observable with mean ``z``: ``1 - z**2``.
def vn_entropy(state: 'CircuitSpec | npt.NDArray[Any]', wires: 'Sequence[int]', n_qubits: 'int | None' = None, base: 'float | None' = None, backend: 'BackendLike' = None) -> 'float'
    Von Neumann entropy of a subsystem — how entangled it is with the rest.
def z_from_p0(p0: 'float') -> 'float'
    <Z> from P(0).
