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
| Method | Scaling | Noise-robust | Best for |
|---|---|---|---|
| Adjoint | O(1) | No | Deep circuits, many params |
| Parameter-shift | O(N) | No | Shallow circuits |
| SPSA | O(1) | Yes | Noisy, many params |
| QNG | O(N²) | No | Fast convergence |
| Riemannian | O(N²) | No | Natural 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
