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
¶
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: |
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 |
None
|
optimizer
|
str | Optimizer
|
A name from :data: |
'rotosolve'
|
gradient
|
str
|
Passed through to :func: |
'auto'
|
Source code in src/qmlkit/algorithms/vqe.py
energy ¶
<H> at these parameters.
Source code in src/qmlkit/algorithms/vqe.py
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
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
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
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
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
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.
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 |
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. |
'x'
|
ansatz
|
str
|
The structure. |
'x'
|
Source code in src/qmlkit/algorithms/qaoa.py
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
¶
Atoms and where they are. Positions in angstrom.
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.
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
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]
|
|
required |
two_body
|
NDArray[Any]
|
|
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 — |
None
|
Notes
This is the general entry point: it never asks where the integrals came from.
Source code in src/qmlkit/algorithms/molecule.py
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | |
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
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
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
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
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
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
hamiltonian_matrix ¶
Dense 2**n x 2**n matrix. Exponential — for checking, not for running.
Source code in src/qmlkit/algorithms/hamiltonians.py
exact_ground_energy ¶
Lowest eigenvalue, by dense diagonalisation. The oracle, not the method.
exact_ground_state ¶
Lowest eigenvalue and its eigenvector.
Source code in src/qmlkit/algorithms/hamiltonians.py
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 |
required |
n_latent
|
int
|
Width in, width kept. The remaining |
required |
encoder
|
Ansatz | None
|
Any |
None
|
trash
|
Sequence[int] | None
|
Which wires to discard. Defaults to the last ones. |
None
|
Source code in src/qmlkit/algorithms/autoencoder.py
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
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
loss ¶
loss(
theta: Sequence[float], states: Sequence[CircuitSpec]
) -> float
One minus the trash fidelity. No decoder is ever built to train this.
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
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
|
|
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
distances ¶
(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
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 ¶
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
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
grad_log_prob ¶
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
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.