Superfermion logo
Guides

Gradients & VQE

5 gradient methods, VQE optimization, QAOA, and custom training loops.

Two Ways to Compute Gradients

Fast path — sf.State.grad() in Rust:

import superfermion as sf

ansatz = sf.Circuit(2).ry(sf.param("t0"), 0).ry(sf.param("t1"), 1).cnot(0, 1)
obs = [([3, 3], 1.0, 0.0)]          # ZZ observable
params = {"t0": 0.5, "t1": 1.2}

state = sf.simulate(ansatz, params=params)
dag = ansatz.to_ir()  # keep parameters symbolic — .bind() would erase them
grads = state.grad(obs, dag, params)
print(grads)

Observable labels are q0-leftmost: "ZI" means Z on qubit 0 (first character = qubit 0). All gradient paths — State.grad(), adjoint, parameter-shift — agree with each other and with finite differences of sf.expval() on the same convention.

Framework path — through PyTorch, JAX, or TensorFlow autograd (see ML Frameworks).

5 Gradient Methods

MethodScalingNoise-robustBest for
AdjointO(1)NoDeep circuits, many params
Parameter-shiftO(N)NoShallow circuits
SPSAO(1)YesNoisy, many params
QNGO(N²)NoFast convergence
RiemannianO(N²)NoNatural gradient via QFIM

Adjoint

One forward + one backward pass regardless of parameter count. The default — fastest for most circuits.

from superfermion.qml.gradient.adjoint import adjoint_grad_vector
import numpy as np

# Observable: str / dict / SparsePauliOp / Hamiltonian all work
H = sf.Hamiltonian([sf.PauliString("ZZ", coeff=1.0)])
grads = adjoint_grad_vector(ansatz, H, ["t0", "t1"], np.array([0.5, 1.2]))
print(grads)

Two sweeps instead of 2P circuit runs. Measured on 20-qubit SU(2) circuits: ~77x faster than parameter-shift at 40 parameters and ~73x at 80 parameters, where the ratio stays flat because both sides grow with depth. The grid, the correctness checks and the caveats are on the performance page.

Parameter-Shift

Evaluates the circuit at shifted parameter values. O(N) scaling but exact.

from superfermion.qml.gradient.parameter_shift import parameter_shift_grad_vector

grads = parameter_shift_grad_vector(ansatz, H, ["t0", "t1"], np.array([0.5, 1.2]))
print(grads)

SPSA (Simultaneous Perturbation Stochastic Approximation)

Stochastic gradient estimation. O(1) scaling, noise-robust.

from superfermion.qml.gradient.spsa import spsa_grad
import numpy as np

def energy_fn(p):
    state = sf.simulate(ansatz, params=dict(zip(["t0", "t1"], p)))
    return state.expectation(obs)

grads = spsa_grad(energy_fn, np.array([0.5, 1.2]), seed=42, delta=0.01)
print(grads)

Quantum Natural Gradient

Uses the Quantum Fisher Information Matrix for metric-aware optimization. Converges in fewer iterations.

from superfermion.qml.gradient.qng import qng_step

state = sf.simulate(ansatz, params=params)
dag = ansatz.to_ir()   # unbound DAG for the QFIM metric
new_params = qng_step(state, dag, obs, ["t0", "t1"], params, learning_rate=0.1)
print(new_params)

Riemannian Gradient

Natural gradient on the Riemannian manifold of quantum states.

from superfermion.qml.gradient.riemannian import riemannian_gradient
import numpy as np

state = sf.simulate(ansatz, params=params)
dag = ansatz.to_ir()
metric = state.qfim(dag, params)                       # QFIM as metric tensor

grad = adjoint_grad_vector(ansatz, H, ["t0", "t1"], np.array([params["t0"], params["t1"]]))

natural_grad = riemannian_gradient(grad, metric)
print(natural_grad)

VQE — Variational Quantum Eigensolver

Manual loop

import superfermion as sf
import numpy as np

H = sf.Hamiltonian([
    sf.PauliString("II", coeff=-1.0523),
    sf.PauliString("IZ", coeff=0.3979),
    sf.PauliString("ZI", coeff=-0.3979),
    sf.PauliString("ZZ", coeff=-0.0112),
    sf.PauliString("XX", coeff=0.1809),
])

ansatz = (sf.Circuit(2)
    .ry(sf.param("t0"), 0).ry(sf.param("t1"), 1)
    .cnot(0, 1)
    .ry(sf.param("t2"), 0).ry(sf.param("t3"), 1))

params = {f"t{i}": np.random.uniform(0, np.pi) for i in range(4)}
obs = [([0, 0], -1.0523, 0.0),   # II
       ([0, 3], 0.3979, 0.0),    # IZ
       ([3, 0], -0.3979, 0.0),   # ZI
       ([3, 3], -0.0112, 0.0),   # ZZ
       ([1, 1], 0.1809, 0.0)]    # XX
lr = 0.1

for step in range(100):
    state = sf.simulate(ansatz, params=params)
    energy = state.expectation(obs)

    dag = ansatz.to_ir()  # keep parameters symbolic — .bind() would erase them
    grads = state.grad(obs, dag, params)

    for k in params:
        params[k] -= lr * grads.get(k, 0.0)

    if step % 20 == 0:
        print(f"Step {step:3d}: energy = {energy:.8f}")

print(f"Final energy: {energy:.8f}")

Built-in VQE class

from superfermion.algorithms.variational import VQE

vqe = VQE(ansatz, H)
result = vqe.minimize(iterations=100)
print(f"Energy: {result.optimal_value:.8f}")
print(f"Optimal params: {result.optimal_params}")

QAOA

from superfermion.algorithms.variational import QAOA
import networkx as nx

# MaxCut on a random 8-node graph
G = nx.random_regular_graph(3, 8)

qaoa = QAOA(n_qubits=8, edges=list(G.edges()), p_layers=2)
result = qaoa.minimize(iterations=100)
print(f"MaxCut value: {result.metadata['max_cut_value']}")
print(f"Best bitstring: {result.metadata['best_bitstring']}")

Custom Training Loop with SciPy

from scipy.optimize import minimize

def cost_function(param_array):
    params = {f"t{i}": v for i, v in enumerate(param_array)}
    state = sf.simulate(ansatz, params=params)
    return state.expectation(obs)

result = minimize(cost_function, x0=np.zeros(4), method="L-BFGS-B")
print(f"Optimal: {result.fun:.8f} at {result.x}")

Quantum Fisher Information Matrix

import numpy as np

state = sf.simulate(ansatz, params=params)
dag = ansatz.to_ir()               # unbound DAG — needed for QFIM
qfim = state.qfim(dag, params)     # n_params × n_params matrix

print(qfim.shape)
print(f"Condition number: {np.linalg.cond(qfim):.2f}")

The QFIM measures the sensitivity of the quantum state to parameter changes. Used by QNG and Riemannian gradient methods.

Tips

  • Start with adjoint — it's the fastest for most circuits
  • Use SPSA for noisy hardware — it tolerates shot noise and hardware noise
  • Switch to QNG if convergence is slow — fewer iterations, but each is more expensive
  • Monitor gradient norms — if they vanish, you're at a barren plateau; try different initial params

On this page