Skip to the content.

Variational quantum algorithms (VQE, ADAPT-VQE, QAOA)

Variational quantum algorithms (VQAs) find the lowest eigenvalue of a Hamiltonian by preparing a parametrized quantum state (an ansatz), measuring its energy, and letting a classical optimizer adjust the parameters to drive that energy down. This page covers the ways VQAs run in qpubench: plain VQE as a loop over ordinary circuit runs (any registered backend, your optimizer), QAOA as the same kind of loop applied to a combinatorial cost Hamiltonian, and self-driving implementations like ADAPT-VQE behind a package-agnostic contract, so you can swap the underlying engine — QForte, a from-scratch Qiskit-convention engine, or a QDK/Azure-flavored one — without changing your run configuration.

The family

Algorithm AlgorithmFamily Ansatz What varies
VQE VQE (UCC-type ansätze and the ExcitationSolve optimizer are choices under it; a hand-rolled circuit-loop run needs no tag) Fixed, chosen up front (e.g. UCCSD) Only the parameters are optimized
QAOA QAOA Fixed: p alternating cost/mixer layers Only the 2·p angles (γ, β) are optimized, over a combinatorial cost Hamiltonian
ADAPT-VQE ADAPT_VQE Grown one operator at a time The ansatz structure adapts to the problem, then parameters are optimized
UCC-PQE / SPQE UCC_PQE / SPQE Fixed / adaptive Projective (residual) equations instead of energy minimization

The distinction that matters for benchmarking: in plain VQE the ansatz circuit is fixed before the run, whereas ADAPT-VQE builds its own circuit as it goes, adding whichever pool operator has the largest energy gradient each macro-iteration until that gradient falls below a threshold. The two therefore take different execution paths through qpubench:

See Backends & adapters for the two protocols. AlgorithmFamily tags exist for the library-driven algorithms so that runs of “the same algorithm” from different libraries can be compared — including VQE for a library-driven fixed-ansatz VQE such as QForte’s UCCNVQE (there UCC is the ansatz choice, not a family of its own). A plain, hand-rolled VQE loop needs no family tag, since it is fully described by its circuit, observable, and VQAConfig metadata — though you may set family=VQE on it if you want it to group with library-driven VQE runs in the store.

Running plain VQE

A VQE run needs four ingredients, and qpubench keeps them separate:

  1. A parametrized ansatz, with its Hamiltonian — a CircuitSpec with named parameters and the Hamiltonian attached as observables. The two are conceptually distinct (the ansatz prepares the state; the Hamiltonian is the observable whose energy you measure), but they travel together on one CircuitSpec. Keep this unbound: it is the reusable master copy.
  2. A backend — any registered BackendAdapter (Aer, PennyLane Lightning, IBM hardware, …). This is where “which machine runs it” is decided.
  3. An initial guess — a plain vector of starting parameter values, separate from the circuit.
  4. A classical optimizer — e.g. scipy.optimize.minimize. qpubench deliberately does not bundle one; the loop is a few lines and any optimizer works.

Each energy evaluation binds the current parameters into a copy of the ansatz (.bind() never mutates the master) and runs it; every evaluation is persisted as a full BenchmarkRecord, so the stored history is the convergence trace:

import numpy as np
from scipy.optimize import minimize
from qpubench import BenchmarkRunner, VQAConfig
from qpubench.backends import AerAdapter        # pip install "qpubench[qiskit]"

runner = BenchmarkRunner()
runner.register(AerAdapter(), name="aer")       # 2. the backend: swap for hardware here

# 1. ansatz: unbound CircuitSpec with parameters=["theta_0", ...] and the
#    Hamiltonian attached as ansatz.observables

def energy(theta: np.ndarray) -> float:
    bound = ansatz.bind({name: float(v) for name, v in zip(ansatz.parameters, theta)})
    # VQAConfig only *labels* this run for the record — it records what was
    # run (problem, molecule, ansatz/optimizer names) so records are
    # filterable later. It sets no execution parameters: the actual values
    # are the bound `theta` above, not anything here.
    record = runner.run(bound, "aer",
        vqa=VQAConfig(problem_type="chemistry", molecule="H2", basis="sto-3g",
                      ansatz="my_ansatz", optimizer="BFGS"))
    return record.result.expectation_values[0].value

x0 = np.zeros(len(ansatz.parameters))           # 3. initial guess — data, not circuit
res = minimize(energy, x0, method="BFGS")       # 4. the optimizer

Two conventions to note. First, the input to a VQE workflow is the parametrized circuit plus a separate initial guess — you never store parameter values in your master ansatz; binding happens per evaluation, and the bound copy inside each BenchmarkRecord is what makes that record exactly reproducible later. Second, changing where the energies are evaluated — statevector simulator, shot-based sampling, real hardware — means re-registering a different adapter and changing the backend name in runner.run(); nothing about the ansatz, guess, or optimizer changes.

For building the Hamiltonian to attach as ansatz.observables, see Examples — Real Hamiltonian sources. For UCC-specific VQE where a library drives the loop for you (a full chemistry stack behind it), see the SlowQuant integration (integrations/slowquant/, an AlgorithmAdapter for AlgorithmFamily.VQE-family runs with a UCC ansatz).

Running QAOA

QAOA is the same execution path as plain VQE — a classical optimization loop over ordinary circuit runs, no AlgorithmAdapter — pointed at a combinatorial cost Hamiltonian instead of a molecular one. It takes the exact same four ingredients (ansatz, backend, initial guess, optimizer), with two specifics:

import numpy as np
from scipy.optimize import minimize
from qpubench import BenchmarkRunner, ExecutionOptions, QAOARunConfig, VQAConfig
from qpubench.schemas.circuit import CircuitSpec

runner = BenchmarkRunner()
runner.register(AerAdapter(), name="aer")            # or any registered backend

config = QAOARunConfig(reps=3, mixer="x", optimizer="COBYLA")   # p = 3 layers
cost   = maxcut_cost_observable(edges)               # SparsePauliObservable: Σ Z_iZ_j

def energy(params: np.ndarray) -> float:
    gammas, betas = params[:config.reps], params[config.reps:]
    circuit = CircuitSpec.from_openqasm3(              # p cost+mixer layers, angles bound
        qaoa_maxcut_qasm(edges, gammas, betas), num_qubits=n, observables=[cost])
    record = runner.run(circuit, "aer",
        options=ExecutionOptions(qaoa_run_config=config),
        vqa=VQAConfig(problem_type="optimization", algorithm="QAOA",
                      optimizer=config.optimizer))
    return record.result.expectation_values[0].value  # ⟨H_C⟩

x0  = np.zeros(2 * config.reps)                        # 2·p angles
res = minimize(energy, x0, method=config.optimizer)
best_cut = (len(edges) - res.fun) / 2                  # read the cut off the objective

Everything else is identical to plain VQE: each evaluation binds the current angles into a fresh circuit, runs it as an ordinary CircuitSpec, and is persisted as a full BenchmarkRecord, so the stored history is the QAOA convergence trace. Swapping simulator for shot-based sampling or real hardware means re-registering a different adapter and setting shots=..., nothing more. A complete, genuinely-executing version (graph definition, qaoa_maxcut_qasm, brute-force cross-check, approximation ratio) is in examples/guides/qaoa_maxcut.py.

The package-agnostic ADAPT-VQE contract (library-driven runs)

An ADAPT-VQE run is described by two objects, neither tied to any vendor SDK:

Field Meaning Default
pool_type Operator pool: "SD", "GSD", "SDTQ", "sa_SD" "SD"
optimizer Classical optimizer name (each adapter maps it onto its own set) "BFGS"
gradient_threshold Ansatz-growth stop: ‖gradient‖ below this ends growth 1e-2
energy_threshold Micro-optimizer (parameter-fit) convergence 1e-5
max_macro_iterations Ansatz-growth steps / depth cap 20
max_micro_iterations Optimizer steps per macro-iteration 200
use_analytic_gradient Analytic gradient vs. finite differences True

Because the config is package-agnostic, the same AdaptVQERunConfig runs against any registered ADAPT-VQE adapter — register a different one under a different name and compare the resulting BenchmarkRecords directly:

from qpubench import (
    AdaptVQERunConfig, AlgorithmFamily, AlgorithmSpec, BenchmarkRunner, ExecutionOptions,
)
from qpubench.schemas.circuit import CircuitSpec
from qpubench.schemas.primitives import CircuitFormat

# The "circuit" is a problem description: a molecule file, not gates
mol = CircuitSpec(num_qubits=0, format=CircuitFormat.MOLECULE_JSON,
                  serialized="/path/to/He-ccpvdz.json")

options = ExecutionOptions(
    algorithm_spec=AlgorithmSpec(name="ADAPTVQE", family=AlgorithmFamily.ADAPT_VQE),
    adapt_vqe_run_config=AdaptVQERunConfig(pool_type="SD", optimizer="BFGS",
                                    gradient_threshold=1e-4, max_macro_iterations=20),
)

runner = BenchmarkRunner()
# register QForteAlgorithmAdapter from integrations/qforte/ ...
record = runner.run(mol, "qforte", options)
# ... or the exact same options against a different implementation:
# register IBMQiskitAdaptVQEAdapter from integrations/ibm_qiskit_adapt_vqe/
record = runner.run(mol, "ibm_qiskit_adapt_vqe", options)

Here ExecutionOptions is built explicitly because the run needs more than a shot count — which algorithm to run and its hyperparameters. The result comes back in the same BenchmarkRecord format as any single-circuit run: record.vqa (a VQAConfig) holds the experiment inputs, and record.vqa_result (a VQAResult) holds the computed outputs, including the derived chemistry metrics (energy_error, chemical_accuracy) when final_eigenvalue and a reference such as ground_truth are present.

The three interchangeable ADAPT-VQE engines

AlgorithmFamily.ADAPT_VQE has three implementations that share the package-agnostic config. Pick by what you have installed and where you want energies evaluated:

Adapter Location Engine Needs
QForteAlgorithmAdapter integrations/qforte/ QForte’s native C++ statevector pip install qforte (C++ compiler)
IBMQiskitAdaptVQEAdapter integrations/ibm_qiskit_adapt_vqe/ integrations/generic_adapt_vqe/ — pure Python + scipy pip install 'qpubench[adapt_vqe]'
MicrosoftQDKAdaptVQEAdapter integrations/microsoft_qdk_adapt_vqe/ Same generic engine, QDK/Azure BackendSpec defaults pip install 'qpubench[adapt_vqe]'

QForte engine

QForte’s pybind11 object model (Circuit, Gate, QubitOperator, …) and its Algorithm/AnsatzAlgorithm/ADAPTVQE attribute surface are modeled as typed schemas in schemas/mirrors/evangelistalab_qforte.py — no ad-hoc getattr() scraping of private attributes. QForteAlgorithmConfig wraps the shared AdaptVQERunConfig plus QForte-only extras (diis_max_dim, use_cumulative_thresh, add_equiv_ops). The adapter also ships an ExternalEvalAlgorithmAdapter that routes every energy evaluation through any qpubench BackendAdapter (Aer, IBM, …) instead of QForte’s own simulator. See integrations/qforte/README.md for the file-by-file layout.

Generic engine (Qiskit / QDK adapters)

integrations/generic_adapt_vqe/ is a package-agnostic engine — pure Python + scipy for the classical optimizer, no vendor quantum SDK. It implements from scratch:

Every energy evaluation is delegated to whichever BackendAdapter you pass as the energy oracle, so the same engine drives the IBM/Qiskit and QDK/Azure adapters — they differ only in BackendSpec naming and defaults. Nothing here is taken on faith: pool.py’s Jordan-Wigner formulas and circuit_synthesis.py’s gate sequence are both independently verified against dense-matrix ground truth (creation/annihilation operator construction; scipy.linalg.expm) in tests/test_generic_adapt_vqe.py.

Two design choices worth knowing:

Direct use of the engine (bypassing the adapters):

from qpubench import StubGateAdapter
from qpubench.schemas.execution import AdaptVQERunConfig
from generic_adapt_vqe.engine import GenericAdaptVQEEngine

engine = GenericAdaptVQEEngine(
    hamiltonian=my_qubit_hamiltonian,   # SparsePauliObservable
    num_qubits=4,
    num_electrons=2,
    energy_backend=StubGateAdapter(seed=0),   # or a real backend
    config=AdaptVQERunConfig(pool_type="SD", optimizer="BFGS"),
)
result, vqa, vqa_result = engine.run()

Where to go next