Skip to content

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, including the case where a too-shallow ansatz converges confidently to an energy 601 mHa wrong.

vqe

The variational quantum eigensolver.

VQE is a loop, not an architecture: prepare a parameterised state, measure an energy, step downhill, repeat. Everything that makes one VQE differ from another — the ansatz, the optimiser, the gradient method, the shot budget — is therefore an argument, and the class itself is thin on purpose.

from qmlkit.algorithms import VQE, ising_hamiltonian

H = ising_hamiltonian(4, j=1.0, h=0.5)
result = VQE(H, n_qubits=4).run(seed=0)
print(result.energy, result.error_vs_exact)

VQEResult dataclass

VQEResult(
    energy: float,
    theta: NDArray[Any],
    history: list[float] = list(),
    exact: float | None = None,
    n_evaluations: int = 0,
)

What a run produced, and how good it actually is.

error_vs_exact property
error_vs_exact: float | None

Absolute error against dense diagonalisation, when that was computed.

VQE

VQE(
    hamiltonian: Observable,
    ansatz: Ansatz | None = None,
    n_qubits: int | None = None,
    optimizer: str | Optimizer = "rotosolve",
    gradient: str = "auto",
    backend: BackendLike = None,
    shots: int | None = None,
)

Minimise <H> over a parameterised state.

Parameters:

Name Type Description Default
hamiltonian Observable

Any observable. :mod:qmlkit.algorithms.hamiltonians has constructors.

required
ansatz Ansatz | None

The trial state. Defaults to a hardware-efficient circuit wide enough for the Hamiltonian's support — replaceable with anything, including one you invented, because an Ansatz is the only contract.

None
optimizer str | Optimizer

A name from :data:OPTIMIZERS or any fn(loss, theta0, **kw) returning (theta, history).

'rotosolve'
gradient str

Passed through to :func:qmlkit.grad. Only consulted by gradient-based optimisers; Rotosolve and SPSA never ask for one.

'auto'
Source code in src/qmlkit/algorithms/vqe.py
def __init__(
    self,
    hamiltonian: Observable,
    ansatz: Ansatz | None = None,
    n_qubits: int | None = None,
    optimizer: str | Optimizer = "rotosolve",
    gradient: str = "auto",
    backend: BackendLike = None,
    shots: int | None = None,
) -> None:
    support = observable_support(hamiltonian)
    width = n_qubits or (max(support) + 1 if support else 1)
    if ansatz is not None and ansatz.n_qubits < width:
        raise ValueError(
            f"the ansatz has {ansatz.n_qubits} qubits but the Hamiltonian acts on {width}"
        )
    self.hamiltonian = hamiltonian
    self.ansatz = ansatz or hardware_efficient(width, n_layers=2)
    self.n_qubits = self.ansatz.n_qubits
    self.optimizer = optimizer
    self.gradient = gradient
    self.backend = backend
    self.shots = shots
    self._spec = self.ansatz.build()
    self.n_evaluations = 0
energy
energy(theta: Sequence[float]) -> float

<H> at these parameters.

Source code in src/qmlkit/algorithms/vqe.py
def energy(self, theta: Sequence[float]) -> float:
    """``<H>`` at these parameters."""
    self.n_evaluations += 1
    value = expectation(
        self._spec,
        self.hamiltonian,
        theta=np.asarray(theta, dtype=float),
        shots=self.shots,
        backend=self.backend,
    )
    return float(value)
run
run(
    theta0: Sequence[float] | None = None,
    seed: int | None = None,
    compare_exact: bool | None = None,
    **optimizer_kwargs: Any,
) -> VQEResult

Optimise, and report against the exact answer when that is affordable.

Source code in src/qmlkit/algorithms/vqe.py
def run(
    self,
    theta0: Sequence[float] | None = None,
    seed: int | None = None,
    compare_exact: bool | None = None,
    **optimizer_kwargs: Any,
) -> VQEResult:
    """Optimise, and report against the exact answer when that is affordable."""
    start = (
        np.asarray(theta0, dtype=float)
        if theta0 is not None
        else self.ansatz.init("small", seed=seed)
    )
    fn = OPTIMIZERS[self.optimizer] if isinstance(self.optimizer, str) else self.optimizer
    # both gradient optimisers need the gradient injected; naming only one of them
    # here is what made optimizer="adam" raise a TypeError from every algorithm
    # that advertises it
    if fn in (_gradient_descent, _adam):
        optimizer_kwargs.setdefault("grad", self.gradient_of_energy)
    if fn is _spsa:
        optimizer_kwargs.setdefault("seed", seed)

    self.n_evaluations = 0
    theta, history = fn(self.energy, start, **optimizer_kwargs)

    # dense diagonalisation is exponential, so only offer it where it is cheap
    if compare_exact is None:
        compare_exact = self.n_qubits <= 12
    exact = exact_ground_energy(self.hamiltonian, self.n_qubits) if compare_exact else None

    return VQEResult(
        energy=float(history[-1]),
        theta=theta,
        history=list(history),
        exact=exact,
        n_evaluations=self.n_evaluations,
    )

qmlkit.algorithms.adapt

ADAPT-VQE: grow the ansatz one operator at a time, chosen by gradient magnitude.

The trap worth knowing. A molecular Hamiltonian conserves particle number, so any operator that does not has exactly zero gradient at Hartree–Fock — the generic pool grows an empty circuit and reports convergence. Use chemistry_operator_pool. This is physics, not a bug, and a test pins it.

adapt

ADAPT-VQE — grow the ansatz instead of guessing it.

A fixed ansatz is a bet placed before you have seen the Hamiltonian. ADAPT-VQE (Grimsley et al. 2019) makes the circuit itself part of the optimisation: keep a pool of candidate generators, and at each iteration append the one whose gradient is largest, then re-optimise everything.

The gradient of appending :math:e^{-i\theta P/2} to the current state, evaluated at :math:\theta = 0, is

.. math:: \left.\frac{\partial E}{\partial\theta}\right|_0 = -i\langle\psi|[H, P]|\psi\rangle

so ranking the pool costs one commutator expectation per candidate — no re-training to find out which operator would have helped.

This is the algorithm that most depends on a circuit being data: growing an ansatz mid-optimisation is a list append here, not a rebuild.

AdaptVQE

AdaptVQE(
    hamiltonian: Observable,
    n_qubits: int,
    pool: Sequence[PauliString] | None = None,
    optimizer: str | Optimizer = "gradient-descent",
    backend: BackendLike = None,
    reference: Sequence[int] | None = None,
)

Build the ansatz one operator at a time, largest gradient first.

Source code in src/qmlkit/algorithms/adapt.py
def __init__(
    self,
    hamiltonian: Observable,
    n_qubits: int,
    pool: Sequence[PauliString] | None = None,
    optimizer: str | Optimizer = "gradient-descent",
    backend: BackendLike = None,
    reference: Sequence[int] | None = None,
) -> None:
    self.hamiltonian = hamiltonian
    self.n_qubits = n_qubits
    self.pool = list(pool) if pool is not None else default_operator_pool(n_qubits)
    self.optimizer = optimizer
    self.backend = backend
    #: the starting state, as bits. Hartree-Fock plays this role in chemistry.
    self.reference = list(reference) if reference is not None else []

pauli_rotation

pauli_rotation(
    qc: QCircuit, term: PauliString, angle: Any
) -> None

Emit :math:e^{-i\theta P/2} for an arbitrary Pauli string P.

The standard construction: rotate each wire into the Z basis, run a CX ladder to collect the parity onto one wire, apply a single rz, then undo both. Built from ordinary registered gates, so it runs on every backend.

Source code in src/qmlkit/algorithms/adapt.py
def pauli_rotation(qc: QCircuit, term: PauliString, angle: Any) -> None:
    r"""Emit :math:`e^{-i\theta P/2}` for an arbitrary Pauli string ``P``.

    The standard construction: rotate each wire into the Z basis, run a CX ladder to
    collect the parity onto one wire, apply a single ``rz``, then undo both. Built
    from ordinary registered gates, so it runs on every backend.
    """
    wires = [q for q, p in term.paulis if p != "I"]
    if not wires:
        return
    for qubit, pauli in term.paulis:
        if pauli == "X":
            qc.h(qubit)
        elif pauli == "Y":
            qc.sdg(qubit)
            qc.h(qubit)
    for a, b in zip(wires, wires[1:], strict=False):
        qc.cx(a, b)
    qc.rz(wires[-1], angle)
    for a, b in reversed(list(zip(wires, wires[1:], strict=False))):
        qc.cx(a, b)
    for qubit, pauli in term.paulis:
        if pauli == "X":
            qc.h(qubit)
        elif pauli == "Y":
            qc.h(qubit)
            qc.s(qubit)

default_operator_pool

default_operator_pool(n_qubits: int) -> list[PauliString]

Single-qubit Y and neighbouring YZ generators.

Deliberately all imaginary-valued generators: those are the ones that move a real starting state, which is what a real-amplitude ground state needs. The pool is an argument, so a chemistry-flavoured (UCCSD-style) pool drops straight in.

Source code in src/qmlkit/algorithms/adapt.py
def default_operator_pool(n_qubits: int) -> list[PauliString]:
    """Single-qubit ``Y`` and neighbouring ``YZ`` generators.

    Deliberately all imaginary-valued generators: those are the ones that move a real
    starting state, which is what a real-amplitude ground state needs. The pool is an
    argument, so a chemistry-flavoured (UCCSD-style) pool drops straight in.
    """
    # Note these do *not* conserve particle number, so they are useless on a
    # molecular Hamiltonian -- see chemistry_operator_pool.
    pool = [PauliString(((q, "Y"),), 1.0) for q in range(n_qubits)]
    pool += [PauliString(((q, "Y"), (q + 1, "Z")), 1.0) for q in range(n_qubits - 1)]
    pool += [PauliString(((q, "Y"), (q + 1, "X")), 1.0) for q in range(n_qubits - 1)]
    return pool

chemistry_operator_pool

chemistry_operator_pool(n_qubits: int) -> list[PauliString]

A particle-number-conserving pool, for molecular Hamiltonians.

:func:default_operator_pool is generic and wrong for chemistry: a molecular Hamiltonian commutes with the number operator, so any generator that changes particle number has exactly zero gradient at the Hartree-Fock state. Measured on H\ :sub:2: every operator in the default pool scores 0.00e+00, ADAPT correctly concludes nothing helps, and returns an empty circuit.

This is the qubit-ADAPT pool of Tang et al. (2021) — the individual Pauli strings appearing in single and double excitations, which under Jordan-Wigner carry an odd number of Y factors. On H\ :sub:2 the winning operator is the double excitation Y0 X1 X2 X3, and one parameter is enough to reach the exact ground state.

Source code in src/qmlkit/algorithms/adapt.py
def chemistry_operator_pool(n_qubits: int) -> list[PauliString]:
    r"""A particle-number-conserving pool, for molecular Hamiltonians.

    :func:`default_operator_pool` is generic and *wrong for chemistry*: a molecular
    Hamiltonian commutes with the number operator, so any generator that changes
    particle number has exactly zero gradient at the Hartree-Fock state. Measured on
    H\ :sub:`2`: every operator in the default pool scores ``0.00e+00``, ADAPT
    correctly concludes nothing helps, and returns an empty circuit.

    This is the qubit-ADAPT pool of Tang et al. (2021) — the individual Pauli strings
    appearing in single and double excitations, which under Jordan-Wigner carry an
    **odd number of Y factors**. On H\ :sub:`2` the winning operator is the double
    excitation ``Y0 X1 X2 X3``, and one parameter is enough to reach the exact ground
    state.
    """
    pool: list[PauliString] = []
    for p_, q_ in itertools.combinations(range(n_qubits), 2):  # singles
        pool.append(PauliString(((p_, "Y"), (q_, "X")), 1.0))
        pool.append(PauliString(((p_, "X"), (q_, "Y")), 1.0))
    for quad in itertools.combinations(range(n_qubits), 4):  # doubles
        for y_at in range(4):
            paulis = tuple((wire, "Y" if index == y_at else "X") for index, wire in enumerate(quad))
            pool.append(PauliString(paulis, 1.0))
    return pool

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.

qaoa

QAOA as a solver, not just an ansatz.

The ansatz has been in the zoo since Phase 3. What was missing is the part that makes it an algorithm: turn a combinatorial problem into a cost Hamiltonian, optimise the angles, then sample the state and read off a bitstring you can act on.

from qmlkit.algorithms import QAOA

edges = [(0, 1), (1, 2), (2, 3), (3, 0)]
result = QAOA(edges, p=2).run(seed=0)
print(result.bitstring, result.cut_value)

The cost Hamiltonian is an argument, so anything expressible as a Pauli sum — MaxCut, Max-2-SAT, a weighted graph, a portfolio constraint — is the same call.

QAOAResult dataclass

QAOAResult(
    energy: float,
    theta: NDArray[Any],
    bitstring: str,
    probability: float,
    history: list[float] = list(),
    exact: float | None = None,
    top: list[tuple[str, float]] = list(),
)

The angles, and — more usefully — the bitstring they point at.

cut_value property
cut_value: float

For a MaxCut cost, the number of edges the returned bitstring cuts.

approximation_ratio property
approximation_ratio: float | None

Energy reached over the best possible, when the exact answer is known.

QAOA

QAOA(
    problem: Observable | Sequence[tuple[int, int]],
    p: int = 1,
    n_qubits: int | None = None,
    mixer: str = "x",
    ansatz: Ansatz | None = None,
    optimizer: str | Optimizer = "gradient-descent",
    backend: BackendLike = None,
    shots: int | None = None,
)

Optimise QAOA angles, then sample a solution out of the state.

Parameters:

Name Type Description Default
problem Observable | Sequence[tuple[int, int]]

Either an edge list (treated as MaxCut) or any cost Observable.

required
p int

Rounds. Two angles per round regardless of problem size — which is the whole appeal, and also why more rounds is the only way to improve.

1
mixer str

The structure. mixer is passed to :func:qaoa_ansatz; pass ansatz directly to use a warm-started or otherwise non-standard construction.

'x'
ansatz str

The structure. mixer is passed to :func:qaoa_ansatz; pass ansatz directly to use a warm-started or otherwise non-standard construction.

'x'
Source code in src/qmlkit/algorithms/qaoa.py
def __init__(
    self,
    problem: Observable | Sequence[tuple[int, int]],
    p: int = 1,
    n_qubits: int | None = None,
    mixer: str = "x",
    ansatz: Ansatz | None = None,
    optimizer: str | Optimizer = "gradient-descent",
    backend: BackendLike = None,
    shots: int | None = None,
) -> None:
    if isinstance(problem, (list, tuple)) and not problem:
        raise ValueError(
            "QAOA needs a problem: an edge list for MaxCut, or a cost observable. "
            "An empty edge list defines nothing to optimise."
        )
    if isinstance(problem, (list, tuple)) and isinstance(problem[0], tuple):
        edges = [(int(a), int(b)) for a, b in problem]
        width = n_qubits or max(max(e) for e in edges) + 1
        self.cost: Observable = max_cut_hamiltonian(edges)
        self.edges: list[tuple[int, int]] | None = edges
    else:
        self.cost = problem  # type: ignore[assignment]
        support = observable_support(self.cost)
        width = n_qubits or (max(support) + 1 if support else 1)
        self.edges = None

    self.n_qubits = width
    self.p = p
    self.ansatz = ansatz or qaoa_ansatz(width, edges=self.edges, p=p, mixer=mixer)
    self.optimizer = optimizer
    self.backend = backend
    self.shots = shots
    self._spec = self.ansatz.build()
    self.n_evaluations = 0
distribution
distribution(theta: ArrayLike) -> NDArray[Any]

Outcome probabilities of the optimised state.

Source code in src/qmlkit/algorithms/qaoa.py
def distribution(self, theta: ArrayLike) -> npt.NDArray[Any]:
    """Outcome probabilities of the optimised state."""
    return probabilities(self._spec.bind(np.asarray(theta, dtype=float)), backend=self.backend)
cut_size
cut_size(bitstring: str) -> int

Edges cut by an assignment — the classical objective, computed classically.

Source code in src/qmlkit/algorithms/qaoa.py
def cut_size(self, bitstring: str) -> int:
    """Edges cut by an assignment — the classical objective, computed classically."""
    if self.edges is None:
        raise ValueError("cut_size only applies when the problem was given as edges")
    return sum(1 for a, b in self.edges if bitstring[a] != bitstring[b])

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.

molecule

Molecular Hamiltonians for any molecule, by two routes.

Route one — bring your own integrals. This is the general one, and the one to reach for past a couple of light atoms. Anything that can produce one- and two-electron integrals in a molecular-orbital basis — PySCF, OpenFermion, Psi4 — hands them to :func:from_integrals and gets a qubit Hamiltonian back::

from pyscf import gto, scf, ao2mo
import numpy as np

mol = gto.M(atom="Li 0 0 0; H 0 0 1.6", basis="sto-3g")
mf = scf.RHF(mol).run()
c = mf.mo_coeff
h1 = c.T @ mf.get_hcore() @ c
h2 = ao2mo.restore(1, ao2mo.kernel(mol, c), c.shape[1])

hamiltonian, info = from_integrals(h1, h2, n_electrons=mol.nelectron,
                                   nuclear_repulsion=mol.energy_nuc())

That decoupling is deliberate. A quantum ML library should not also be a quantum chemistry package, and pretending otherwise would mean shipping a worse version of software that already exists.

Route two — the built-in SCF. For molecules built only from s-orbital atoms (hydrogen and helium in STO-3G) the integrals are computed here, with a real restricted Hartree–Fock loop rather than a symmetry shortcut. That covers the systems VQE is usually benchmarked on — H\ :sub:2, H\ :sub:3\ :sup:+, H\ :sub:4 chains and rings, HeH\ :sup:+ — at arbitrary geometry::

from qmlkit.algorithms import Molecule, molecular_hamiltonian

h4 = Molecule([("H", (0, 0, 0)), ("H", (0, 0, 0.9)),
               ("H", (0, 0, 1.8)), ("H", (0, 0, 2.7))])
hamiltonian, info = molecular_hamiltonian(h4)

Anything with p orbitals needs route one. That boundary is stated rather than papered over.

Molecule dataclass

Molecule(
    atoms: list[tuple[str, tuple[float, float, float]]],
    charge: int = 0,
)

Atoms and where they are. Positions in angstrom.

n_orbitals property
n_orbitals: int

One 1s function per atom, in this basis.

MolecularInfo dataclass

MolecularInfo(
    n_qubits: int,
    n_electrons: int,
    n_orbitals: int,
    n_terms: int,
    nuclear_repulsion: float,
    hartree_fock_energy: float | None = None,
    hartree_fock_occupation: list[int] = list(),
    active_space: tuple[int, ...] | None = None,
)

Everything that went into the Hamiltonian, so the result can be audited.

hydrogen_chain

hydrogen_chain(n: int, spacing: float = 0.74) -> Molecule

n hydrogens in a line — the standard scaling benchmark for VQE.

Source code in src/qmlkit/algorithms/molecule.py
def hydrogen_chain(n: int, spacing: float = 0.74) -> Molecule:
    """``n`` hydrogens in a line — the standard scaling benchmark for VQE."""
    return Molecule([("H", (0.0, 0.0, i * spacing)) for i in range(n)])

hydrogen_ring

hydrogen_ring(n: int, radius: float = 1.0) -> Molecule

n hydrogens on a circle; frustrated, and harder than the chain.

Source code in src/qmlkit/algorithms/molecule.py
def hydrogen_ring(n: int, radius: float = 1.0) -> Molecule:
    """``n`` hydrogens on a circle; frustrated, and harder than the chain."""
    return Molecule(
        [
            ("H", (radius * np.cos(2 * np.pi * i / n), radius * np.sin(2 * np.pi * i / n), 0.0))
            for i in range(n)
        ]
    )

from_integrals

from_integrals(
    one_body: NDArray[Any],
    two_body: NDArray[Any],
    n_electrons: int,
    nuclear_repulsion: float = 0.0,
    active_space: tuple[int, ...] | None = None,
    tol: float = 1e-10,
) -> tuple[PauliSum, MolecularInfo]

A qubit Hamiltonian from molecular-orbital integrals.

Parameters:

Name Type Description Default
one_body NDArray[Any]

h[p, q], the one-electron integrals in the MO basis.

required
two_body NDArray[Any]

g[p, q, r, s] in chemist notation (pq|rs), which is what PySCF's ao2mo.restore(1, ...) returns.

required
active_space tuple[int, ...] | None

Spatial orbitals to keep. Everything else is dropped, which is the usual way to fit a molecule onto a machine you actually have — 2 * len(active_space) qubits instead of 2 * n_orbitals.

None
Notes

This is the general entry point: it never asks where the integrals came from.

Source code in src/qmlkit/algorithms/molecule.py
def from_integrals(
    one_body: npt.NDArray[Any],
    two_body: npt.NDArray[Any],
    n_electrons: int,
    nuclear_repulsion: float = 0.0,
    active_space: tuple[int, ...] | None = None,
    tol: float = 1e-10,
) -> tuple[PauliSum, MolecularInfo]:
    r"""A qubit Hamiltonian from molecular-orbital integrals.

    Parameters
    ----------
    one_body
        ``h[p, q]``, the one-electron integrals in the MO basis.
    two_body
        ``g[p, q, r, s]`` in **chemist notation** ``(pq|rs)``, which is what PySCF's
        ``ao2mo.restore(1, ...)`` returns.
    active_space
        Spatial orbitals to keep. Everything else is dropped, which is the usual way
        to fit a molecule onto a machine you actually have — ``2 * len(active_space)``
        qubits instead of ``2 * n_orbitals``.

    Notes
    -----
    This is the general entry point: it never asks where the integrals came from.
    """
    h = np.asarray(one_body, dtype=float)
    g = np.asarray(two_body, dtype=float)
    if h.ndim != 2 or h.shape[0] != h.shape[1]:
        raise ValueError(f"one_body must be square, got shape {h.shape}")
    if g.shape != (h.shape[0],) * 4:
        raise ValueError(f"two_body must have shape {(h.shape[0],) * 4}, got {g.shape}")

    if active_space is not None:
        keep = list(active_space)
        h = h[np.ix_(keep, keep)]
        g = g[np.ix_(keep, keep, keep, keep)]

    n_spatial = h.shape[0]
    n_spin = 2 * n_spatial
    if n_spin > 12:
        raise ValueError(
            f"{n_spin} qubits means a {2**n_spin}-dimensional matrix. Use active_space "
            "to pick the orbitals that matter."
        )

    a = [_annihilator(p, n_spin) for p in range(n_spin)]
    adag = [x.conj().T for x in a]
    spin = [p % 2 for p in range(n_spin)]
    spatial = [p // 2 for p in range(n_spin)]

    matrix = np.zeros((2**n_spin, 2**n_spin), dtype=complex)
    for p, q in itertools.product(range(n_spin), repeat=2):
        if spin[p] == spin[q]:
            matrix += h[spatial[p], spatial[q]] * (adag[p] @ a[q])
    for p, q, r, s in itertools.product(range(n_spin), repeat=4):
        if spin[p] == spin[q] and spin[r] == spin[s]:
            matrix += (
                0.5
                * g[spatial[p], spatial[q], spatial[r], spatial[s]]
                * (adag[p] @ adag[r] @ a[s] @ a[q])
            )
    matrix += float(nuclear_repulsion) * np.eye(2**n_spin)

    terms = _decompose(matrix, n_spin, tol)
    occupation = [1 if i < n_electrons else 0 for i in range(n_spin)]
    info = MolecularInfo(
        n_qubits=n_spin,
        n_electrons=n_electrons,
        n_orbitals=n_spatial,
        n_terms=len(terms),
        nuclear_repulsion=float(nuclear_repulsion),
        hartree_fock_occupation=occupation,
        active_space=active_space,
    )
    return PauliSum(tuple(terms)), info

molecular_hamiltonian

molecular_hamiltonian(
    molecule: Molecule,
    active_space: tuple[int, ...] | None = None,
    tol: float = 1e-10,
) -> tuple[PauliSum, MolecularInfo]

Compute the integrals here, then hand them to :func:from_integrals.

Restricted to s-orbital elements — see the module docstring for why, and for the route to take when that is not enough.

Source code in src/qmlkit/algorithms/molecule.py
def molecular_hamiltonian(
    molecule: Molecule,
    active_space: tuple[int, ...] | None = None,
    tol: float = 1e-10,
) -> tuple[PauliSum, MolecularInfo]:
    """Compute the integrals here, then hand them to :func:`from_integrals`.

    Restricted to s-orbital elements — see the module docstring for why, and for the
    route to take when that is not enough.
    """
    overlap, core, eri, repulsion = _ao_integrals(molecule)
    coefficients, electronic = _rhf(overlap, core, eri, molecule.n_electrons)
    h = coefficients.T @ core @ coefficients
    g = np.einsum(
        "pi,qj,rk,sl,pqrs->ijkl",
        coefficients,
        coefficients,
        coefficients,
        coefficients,
        eri,
        optimize=True,
    )
    hamiltonian, info = from_integrals(
        h, g, molecule.n_electrons, float(repulsion), active_space=active_space, tol=tol
    )
    info.hartree_fock_energy = float(electronic + repulsion)
    return hamiltonian, info

chemistry

Molecular Hamiltonians, computed here rather than quoted.

VQE's canonical demonstration is the ground-state energy of H\ :sub:2, and most tutorials get the Hamiltonian by importing coefficients from a chemistry package or copying a table out of a paper. This module computes it: STO-3G integrals over Gaussian primitives, symmetry-adapted molecular orbitals, second quantisation, and a Jordan–Wigner map to four qubits.

That matters for a library whose whole argument is that you should be able to see the cost of what you run. It is also checkable — the curve below reproduces the published FCI/STO-3G result to five decimals:

>>> from qmlkit.algorithms.chemistry import h2_hamiltonian
>>> from qmlkit.algorithms import exact_ground_energy
>>> h, info = h2_hamiltonian(0.735)
>>> round(exact_ground_energy(h, 4), 5)
-1.13731

Minimal basis only, and two centres only. Anything larger wants PySCF or OpenFermion, and the point here is transparency rather than coverage.

h2_hamiltonian

h2_hamiltonian(
    bond_length: float = 0.735, tol: float = 1e-10
) -> tuple[PauliSum, dict[str, Any]]

The H2 qubit Hamiltonian at a given bond length in angstrom.

Returns the observable and a dictionary of what went into it. The Pauli coefficients come from projecting the dense matrix, c_P = Tr(P H) / 2^n, which needs no symbolic algebra and is trivially checkable in the other direction with :func:~qmlkit.algorithms.hamiltonian_matrix.

Source code in src/qmlkit/algorithms/chemistry.py
def h2_hamiltonian(
    bond_length: float = 0.735, tol: float = 1e-10
) -> tuple[PauliSum, dict[str, Any]]:
    """The H2 qubit Hamiltonian at a given bond length in angstrom.

    Returns the observable and a dictionary of what went into it. The Pauli
    coefficients come from projecting the dense matrix, ``c_P = Tr(P H) / 2^n``,
    which needs no symbolic algebra and is trivially checkable in the other
    direction with :func:`~qmlkit.algorithms.hamiltonian_matrix`.
    """
    matrix, repulsion = _matrix(bond_length)
    terms: list[PauliString] = []
    for letters in itertools.product("IXYZ", repeat=4):
        operator = _kron(*[_PAULI[c] for c in letters])
        coefficient = float(np.real(np.trace(operator @ matrix)) / 16)
        if abs(coefficient) > tol:
            paulis = tuple((q, c) for q, c in enumerate(letters) if c != "I")
            terms.append(PauliString(paulis, coefficient))
    info = {
        "bond_length": bond_length,
        "n_qubits": 4,
        "n_terms": len(terms),
        "nuclear_repulsion": repulsion,
        "hartree_fock_occupation": [1, 1, 0, 0],  # both electrons in sigma_g
    }
    return PauliSum(tuple(terms)), info

h2_curve

h2_curve(
    bond_lengths: NDArray[Any] | list[float],
) -> list[tuple[float, PauliSum]]

(bond_length, hamiltonian) pairs — the dissociation curve as input data.

Source code in src/qmlkit/algorithms/chemistry.py
def h2_curve(bond_lengths: npt.NDArray[Any] | list[float]) -> list[tuple[float, PauliSum]]:
    """``(bond_length, hamiltonian)`` pairs — the dissociation curve as input data."""
    return [(float(r), h2_hamiltonian(float(r))[0]) for r in bond_lengths]

qmlkit.algorithms.hamiltonians

Standard model Hamiltonians — Ising, Heisenberg, and the rest — as PauliSums.

hamiltonians

Hamiltonians to hand to VQE, and an exact answer to check it against.

A Hamiltonian here is just a :class:~qmlkit.core.observables.PauliSum — the same type an expectation value takes — so nothing new has to learn about it. These are constructors, not a new class hierarchy.

:func:exact_ground_energy diagonalises the dense matrix. That is exponential and useless past ~14 qubits, which is exactly the point: it is the oracle a variational result gets checked against on small systems, not a method to compete with.

pauli_hamiltonian

pauli_hamiltonian(
    terms: Iterable[tuple[str, Sequence[int], float]],
) -> PauliSum

Build from (paulis, qubits, coefficient) triples.

pauli_hamiltonian([("ZZ", (0, 1), 1.0), ("X", (0,), -0.5)]) Z0 Z1 + -0.5*X0

Source code in src/qmlkit/algorithms/hamiltonians.py
def pauli_hamiltonian(terms: Iterable[tuple[str, Sequence[int], float]]) -> PauliSum:
    """Build from ``(paulis, qubits, coefficient)`` triples.

    >>> pauli_hamiltonian([("ZZ", (0, 1), 1.0), ("X", (0,), -0.5)])
    Z0 Z1 + -0.5*X0
    """
    out: list[PauliString] = []
    for letters, qubits, coeff in terms:
        # A constant term is the identity: no letters and no qubits. Allowed, because
        # dropping it would silently shift every energy the Hamiltonian reports.
        if not letters and not tuple(qubits):
            out.append(PauliString((), float(coeff)))
            continue
        if len(letters) != len(qubits):
            raise ValueError(f"{letters!r} needs {len(letters)} qubits, got {tuple(qubits)}")
        paulis = tuple(
            (int(q), p.upper()) for q, p in zip(qubits, letters, strict=True) if p.upper() != "I"
        )
        out.append(PauliString(tuple(sorted(paulis)), float(coeff)))
    return PauliSum(tuple(out))

ising_hamiltonian

ising_hamiltonian(
    n_qubits: int,
    j: float = 1.0,
    h: float = 1.0,
    edges: Sequence[tuple[int, int]] | None = None,
    pattern: str = "chain",
) -> PauliSum

Transverse-field Ising model, :math:H = J\sum Z_iZ_j + h\sum X_i.

The standard first test for any variational eigensolver: it is exactly solvable, frustration-free at h=0, and its ground state becomes genuinely entangled as h grows, so a working VQE has to do real work.

Source code in src/qmlkit/algorithms/hamiltonians.py
def ising_hamiltonian(
    n_qubits: int,
    j: float = 1.0,
    h: float = 1.0,
    edges: Sequence[tuple[int, int]] | None = None,
    pattern: str = "chain",
) -> PauliSum:
    r"""Transverse-field Ising model, :math:`H = J\sum Z_iZ_j + h\sum X_i`.

    The standard first test for any variational eigensolver: it is exactly solvable,
    frustration-free at ``h=0``, and its ground state becomes genuinely entangled as
    ``h`` grows, so a working VQE has to do real work.
    """
    graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
    terms: list[tuple[str, tuple[int, ...], float]] = [("ZZ", (a, b), j) for a, b in graph]
    terms += [("X", (q,), h) for q in range(n_qubits)]
    return pauli_hamiltonian(terms)

heisenberg_hamiltonian

heisenberg_hamiltonian(
    n_qubits: int,
    jx: float = 1.0,
    jy: float = 1.0,
    jz: float = 1.0,
    h: float = 0.0,
    edges: Sequence[tuple[int, int]] | None = None,
    pattern: str = "chain",
) -> PauliSum

Heisenberg model, :math:\sum J_\alpha \sigma^\alpha_i\sigma^\alpha_j + h\sum Z_i.

Source code in src/qmlkit/algorithms/hamiltonians.py
def heisenberg_hamiltonian(
    n_qubits: int,
    jx: float = 1.0,
    jy: float = 1.0,
    jz: float = 1.0,
    h: float = 0.0,
    edges: Sequence[tuple[int, int]] | None = None,
    pattern: str = "chain",
) -> PauliSum:
    r"""Heisenberg model, :math:`\sum J_\alpha \sigma^\alpha_i\sigma^\alpha_j + h\sum Z_i`."""
    graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
    terms: list[tuple[str, Sequence[int], float]] = []
    for a, b in graph:
        for letter, coupling in (("XX", jx), ("YY", jy), ("ZZ", jz)):
            if coupling:
                terms.append((letter, (a, b), coupling))
    terms += [("Z", (q,), h) for q in range(n_qubits) if h]
    return pauli_hamiltonian(terms)

max_cut_hamiltonian

max_cut_hamiltonian(
    edges: Sequence[tuple[int, int]],
    n_qubits: int | None = None,
) -> PauliSum

MaxCut cost, :math:\tfrac12\sum_{(i,j)\in E}(Z_iZ_j - 1).

Minimising this maximises the cut, and its ground-state energy is -(number of edges cut). The constant is kept rather than dropped so the energy VQE or QAOA reports is the negated cut size, with nothing to add back.

Source code in src/qmlkit/algorithms/hamiltonians.py
def max_cut_hamiltonian(edges: Sequence[tuple[int, int]], n_qubits: int | None = None) -> PauliSum:
    r"""MaxCut cost, :math:`\tfrac12\sum_{(i,j)\in E}(Z_iZ_j - 1)`.

    Minimising this maximises the cut, and its ground-state energy is
    ``-(number of edges cut)``. The constant is kept rather than dropped so the
    energy VQE or QAOA reports *is* the negated cut size, with nothing to add back.
    """
    if not edges:
        raise ValueError("MaxCut needs at least one edge")
    _ = n_qubits  # width comes from the edges themselves; kept for a symmetric API
    terms: list[tuple[str, Sequence[int], float]] = [("ZZ", (a, b), 0.5) for a, b in edges]
    terms.append(("", (), -0.5 * len(edges)))
    return pauli_hamiltonian(terms)

hamiltonian_matrix

hamiltonian_matrix(
    obs: Observable, n_qubits: int
) -> NDArray[Any]

Dense 2**n x 2**n matrix. Exponential — for checking, not for running.

Source code in src/qmlkit/algorithms/hamiltonians.py
def hamiltonian_matrix(obs: Observable, n_qubits: int) -> npt.NDArray[Any]:
    """Dense ``2**n x 2**n`` matrix. Exponential — for checking, not for running."""
    if n_qubits > 14:
        raise ValueError(
            f"a dense matrix for {n_qubits} qubits needs {4**n_qubits * 16 / 1e9:.0f} GB; "
            "this function exists to verify small cases, not to solve large ones"
        )
    dim = 2**n_qubits
    total = np.zeros((dim, dim), dtype=complex)
    for term in as_sum(obs).terms:
        letters = dict(term.paulis)
        matrix = np.eye(1, dtype=complex)
        for qubit in range(n_qubits):
            matrix = np.kron(matrix, _PAULI[letters.get(qubit, "I")])
        total += complex(term.coeff) * matrix
    return total

exact_ground_energy

exact_ground_energy(
    obs: Observable, n_qubits: int
) -> float

Lowest eigenvalue, by dense diagonalisation. The oracle, not the method.

Source code in src/qmlkit/algorithms/hamiltonians.py
def exact_ground_energy(obs: Observable, n_qubits: int) -> float:
    """Lowest eigenvalue, by dense diagonalisation. The oracle, not the method."""
    return float(np.linalg.eigvalsh(hamiltonian_matrix(obs, n_qubits))[0])

exact_ground_state

exact_ground_state(
    obs: Observable, n_qubits: int
) -> tuple[float, NDArray[Any]]

Lowest eigenvalue and its eigenvector.

Source code in src/qmlkit/algorithms/hamiltonians.py
def exact_ground_state(obs: Observable, n_qubits: int) -> tuple[float, npt.NDArray[Any]]:
    """Lowest eigenvalue and its eigenvector."""
    values, vectors = np.linalg.eigh(hamiltonian_matrix(obs, n_qubits))
    return float(values[0]), np.asarray(vectors[:, 0])

qmlkit.algorithms.autoencoder

Quantum autoencoders: compress a state onto fewer qubits and measure what the discarded "trash" qubits retain.

autoencoder

Quantum autoencoder — compress n qubits into k (Romero, Olson & Aspuru-Guzik 2017).

The trick is that you never need the decoder to train. If the encoder has genuinely pushed all the information into k latent qubits, the discarded "trash" qubits must be left in a known pure state — so maximising the trash qubits' purity is the whole loss, and it costs no extra circuits.

from qmlkit.algorithms import QuantumAutoencoder

model = QuantumAutoencoder(n_qubits=4, n_latent=2)
result = model.fit(states, seed=0)
print(result.fidelity)       # how well the input survives a round trip

The encoder is an Ansatz argument like everywhere else, so "which circuit compresses best" is an experiment you run, not a fork of this file.

QuantumAutoencoder

QuantumAutoencoder(
    n_qubits: int,
    n_latent: int,
    encoder: Ansatz | None = None,
    n_layers: int = 3,
    trash: Sequence[int] | None = None,
    optimizer: str | Optimizer = "rotosolve",
    backend: BackendLike = None,
)

Train an encoder that concentrates a state into n_latent qubits.

Parameters:

Name Type Description Default
n_qubits int

Width in, width kept. The remaining n_qubits - n_latent are the trash.

required
n_latent int

Width in, width kept. The remaining n_qubits - n_latent are the trash.

required
encoder Ansatz | None

Any Ansatz of the right width. Defaults to hardware-efficient.

None
trash Sequence[int] | None

Which wires to discard. Defaults to the last ones.

None
Source code in src/qmlkit/algorithms/autoencoder.py
def __init__(
    self,
    n_qubits: int,
    n_latent: int,
    encoder: Ansatz | None = None,
    n_layers: int = 3,
    trash: Sequence[int] | None = None,
    optimizer: str | Optimizer = "rotosolve",
    backend: BackendLike = None,
) -> None:
    if not 0 < n_latent < n_qubits:
        raise ValueError(f"n_latent must be between 1 and {n_qubits - 1}, got {n_latent}")
    self.n_qubits = n_qubits
    self.n_latent = n_latent
    self.trash = list(trash) if trash is not None else list(range(n_latent, n_qubits))
    self.encoder = encoder or hardware_efficient(n_qubits, n_layers)
    self.optimizer = optimizer
    self.backend = backend
    self._spec = self.encoder.build()
trash_fidelity
trash_fidelity(
    theta: ArrayLike, states: Sequence[CircuitSpec]
) -> float

Mean :math:\langle 0|\rho_\mathrm{trash}|0\rangle — 1.0 is perfect compression.

Purity alone is not enough, and getting that wrong is easy: an encoder can leave the trash in a pure state pointing somewhere other than :math:|0\rangle, scoring purity 0.998 while the round trip only returns fidelity 0.21. Measured, on the way to writing this. What the decoder needs is the trash reset to a known state, so that is what the loss asks for.

Source code in src/qmlkit/algorithms/autoencoder.py
def trash_fidelity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
    r"""Mean :math:`\langle 0|\rho_\mathrm{trash}|0\rangle` — 1.0 is perfect compression.

    Purity alone is **not** enough, and getting that wrong is easy: an encoder can
    leave the trash in a pure state pointing somewhere other than
    :math:`|0\rangle`, scoring purity 0.998 while the round trip only returns
    fidelity 0.21. Measured, on the way to writing this. What the decoder needs is
    the trash reset to a *known* state, so that is what the loss asks for.
    """
    arr = np.asarray(theta, dtype=float)
    projector = self._trash_projector()
    total = 0.0
    for prep in states:
        encoded = prep.compose(self._spec.bind(arr))
        total += float(expectation(encoded, projector, backend=self.backend))
    return total / len(states)
trash_purity
trash_purity(
    theta: ArrayLike, states: Sequence[CircuitSpec]
) -> float

Mean purity of the discarded wires. Reported, but not what is optimised.

Source code in src/qmlkit/algorithms/autoencoder.py
def trash_purity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
    """Mean purity of the discarded wires. Reported, but not what is optimised."""
    arr = np.asarray(theta, dtype=float)
    total = 0.0
    for prep in states:
        encoded = prep.compose(self._spec.bind(arr))
        total += purity(encoded, self.trash, backend=self.backend)
    return total / len(states)
loss
loss(
    theta: Sequence[float], states: Sequence[CircuitSpec]
) -> float

One minus the trash fidelity. No decoder is ever built to train this.

Source code in src/qmlkit/algorithms/autoencoder.py
def loss(self, theta: Sequence[float], states: Sequence[CircuitSpec]) -> float:
    """One minus the trash fidelity. No decoder is ever built to train this."""
    return 1.0 - self.trash_fidelity(theta, states)
round_trip_fidelity
round_trip_fidelity(
    theta: ArrayLike, states: Sequence[CircuitSpec]
) -> float

Encode, reset the trash to |0>, decode, and compare to the input.

This is the quantity the compression claims, and it is deliberately not the training loss — it is the independent check that maximising trash purity was the right proxy at all.

Source code in src/qmlkit/algorithms/autoencoder.py
def round_trip_fidelity(self, theta: ArrayLike, states: Sequence[CircuitSpec]) -> float:
    """Encode, reset the trash to ``|0>``, decode, and compare to the input.

    This is the quantity the compression *claims*, and it is deliberately not the
    training loss — it is the independent check that maximising trash purity was
    the right proxy at all.
    """
    arr = np.asarray(theta, dtype=float)
    encoder = self._spec.bind(arr)
    decoder = encoder.adjoint()
    total = 0.0
    for prep in states:
        original = statevector(prep, backend=self.backend)
        encoded = statevector(prep.compose(encoder), backend=self.backend)
        restored = self._apply_spec(self._reset_trash(encoded), decoder)
        total += state_fidelity(restored, original)
    return total / len(states)

qmlkit.algorithms.clustering

QMeans. Scored with qmlkit.evaluate.clustering, which reports internal and external quality because they routinely disagree — see study 5.

clustering

q-means — Lloyd's algorithm with a quantum distance.

The unsupervised gap. k-means is entirely defined by one operation, "how far apart are these two points", so replacing that with a quantum kernel distance is the whole algorithm:

.. math:: d(x, x')^2 = 2\bigl(1 - k(x, x')\bigr)

for a normalised kernel. Everything else — assign, recentre, repeat — is Lloyd's, and is deliberately unchanged so that any difference in the result is attributable to the distance and nothing else.

The feature map is the argument, exactly as in :class:~qmlkit.QSVC: a clustering method built on a kernel is its embedding.

QMeans

QMeans(
    n_clusters: int = 2,
    feature_map: FeatureMap | None = None,
    max_iterations: int = 50,
    tol: float = 1e-06,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
)

k-means where the distance comes from a quantum kernel.

Parameters:

Name Type Description Default
n_clusters int

k.

2
feature_map FeatureMap | None

The embedding the distance is measured in. This is the only quantum part, and swapping it is the entire experiment.

None
Source code in src/qmlkit/algorithms/clustering.py
def __init__(
    self,
    n_clusters: int = 2,
    feature_map: FeatureMap | None = None,
    max_iterations: int = 50,
    tol: float = 1e-6,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> None:
    if n_clusters < 1:
        raise ValueError("n_clusters must be at least 1")
    self.n_clusters = n_clusters
    self.feature_map = feature_map
    self.max_iterations = max_iterations
    self.tol = tol
    self.shots = shots
    self.backend = backend
    self.seed = seed
    self.centroids_: npt.NDArray[Any] | None = None
    self.labels_: npt.NDArray[Any] | None = None
distances
distances(
    X: NDArray[Any], centroids: NDArray[Any]
) -> NDArray[Any]

(n_samples, k) of :math:2(1 - k(x, c)).

A kernel with unit diagonal induces a genuine squared distance this way, so the assignment step below is the ordinary one — no special-casing.

Source code in src/qmlkit/algorithms/clustering.py
def distances(self, X: npt.NDArray[Any], centroids: npt.NDArray[Any]) -> npt.NDArray[Any]:
    r"""``(n_samples, k)`` of :math:`2(1 - k(x, c))`.

    A kernel with unit diagonal induces a genuine squared distance this way, so
    the assignment step below is the ordinary one — no special-casing.
    """
    kernel = self._kernel(X.shape[1])
    gram = kernel(np.asarray(X, dtype=float), np.asarray(centroids, dtype=float))
    return 2.0 * (1.0 - gram)

qmlkit.algorithms.rl

Variational policies for reinforcement learning.

rl

Quantum policy gradient — a variational circuit as an RL policy.

REINFORCE, with the policy replaced by a circuit: encode the observation, measure a few observables, softmax them into action probabilities, and push up the log- probability of whatever earned reward.

policy = QuantumPolicy(n_observations=2, n_actions=2)
result = train_reinforce(policy, ContextualBandit(seed=0), n_episodes=200, seed=0)

The environment is an argument with a three-method protocol (reset, step, n_observations/n_actions), so a Gym environment wraps in a few lines and nothing here needs Gym installed. The ansatz and feature map are arguments too, as everywhere else.

The gradient is the ordinary policy-gradient one — grad log pi(a|s) * return — computed through :func:qmlkit.grad, so it is exact rather than finite-differenced.

Environment

Bases: Protocol

The three things an RL loop needs. Deliberately not Gym.

ContextualBandit

ContextualBandit(seed: int | None = None)

A tiny environment with a known optimal policy, so training is checkable.

The observation is a random vector; the correct action is the sign of its first coordinate. One step per episode, reward 1 for right and 0 for wrong — so the optimal return is exactly 1.0 and "did it learn" has an unambiguous answer.

Source code in src/qmlkit/algorithms/rl.py
def __init__(self, seed: int | None = None) -> None:
    self._rng = np.random.default_rng(seed)
    self._state: npt.NDArray[Any] = np.zeros(self.n_observations)

QuantumPolicy

QuantumPolicy(
    n_observations: int,
    n_actions: int,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    n_layers: int = 2,
    beta: float = 2.0,
    backend: BackendLike = None,
    seed: int | None = None,
)

A circuit policy: observation in, action probabilities out.

Source code in src/qmlkit/algorithms/rl.py
def __init__(
    self,
    n_observations: int,
    n_actions: int,
    feature_map: FeatureMap | None = None,
    ansatz: Ansatz | None = None,
    observables: Sequence[Observable] | None = None,
    n_layers: int = 2,
    beta: float = 2.0,
    backend: BackendLike = None,
    seed: int | None = None,
) -> None:
    n_qubits = max(n_observations, n_actions)
    self.n_observations = n_observations
    self.n_actions = n_actions
    self.n_qubits = n_qubits
    self.feature_map = feature_map or AngleFeatureMap(n_qubits, entangle=n_qubits > 1)
    self.ansatz = ansatz or hardware_efficient(n_qubits, n_layers)
    self.observables = list(observables) if observables else [Z(a) for a in range(n_actions)]
    self.beta = beta  # softmax inverse temperature
    self.backend = backend
    # Seeded from the argument, not from a constant: with seed baked in, every
    # QuantumPolicy started from the same point and "average over seeds" was
    # impossible to express.
    self.theta = self.ansatz.init("small", seed=seed)
    self._spec = self.feature_map.build_parametric().compose(self.ansatz.build())
    self._n_inputs = self.feature_map.n_angles
grad_log_prob
grad_log_prob(
    observation: NDArray[Any], action: int
) -> NDArray[Any]

d/dtheta log pi(a|s).

For a softmax over measured observables this is beta * (dO_a/dtheta - sum_b pi_b dO_b/dtheta) — one exact circuit gradient per action, no finite differences anywhere.

Source code in src/qmlkit/algorithms/rl.py
def grad_log_prob(self, observation: npt.NDArray[Any], action: int) -> npt.NDArray[Any]:
    r"""``d/dtheta log pi(a|s)``.

    For a softmax over measured observables this is
    ``beta * (dO_a/dtheta - sum_b pi_b dO_b/dtheta)`` — one exact circuit gradient
    per action, no finite differences anywhere.
    """
    full = self._full(observation, self.theta)
    probs = self.probabilities(observation)
    jac = np.stack(
        [
            grad(self._spec, full, o, backend=self.backend)[self._n_inputs :]
            for o in self.observables
        ]
    )
    return self.beta * (jac[action] - probs @ jac)

train_reinforce

train_reinforce(
    policy: QuantumPolicy,
    env: Environment,
    n_episodes: int = 200,
    lr: float = 0.2,
    baseline: bool = True,
    seed: int | None = None,
) -> ReinforceResult

REINFORCE with an optional moving-average baseline.

The baseline subtracts a running mean return before scaling the gradient. It does not change what is being optimised, only the variance of the estimate — which for a policy sampled one episode at a time is the thing that decides whether it learns at all.

Source code in src/qmlkit/algorithms/rl.py
def train_reinforce(
    policy: QuantumPolicy,
    env: Environment,
    n_episodes: int = 200,
    lr: float = 0.2,
    baseline: bool = True,
    seed: int | None = None,
) -> ReinforceResult:
    """REINFORCE with an optional moving-average baseline.

    The baseline subtracts a running mean return before scaling the gradient. It does
    not change what is being optimised, only the variance of the estimate — which for
    a policy sampled one episode at a time is the thing that decides whether it
    learns at all.
    """
    rng = np.random.default_rng(seed)
    returns: list[float] = []
    running = 0.0

    for episode in range(n_episodes):
        observation = env.reset()
        action = policy.sample(observation, rng)
        _, reward, _ = env.step(action)
        returns.append(float(reward))

        advantage = reward - running if baseline else reward
        policy.theta = policy.theta + lr * advantage * policy.grad_log_prob(observation, action)
        running += (reward - running) / (episode + 1)

    return ReinforceResult(theta=policy.theta, returns=returns)