Improving Results with Error Mitigation¶
Divi provides built-in quantum error mitigation (QEM) to improve results from noisy quantum hardware. Two built-in protocols ship with the library:
Zero Noise Extrapolation (ZNE) — runs circuits at artificially increased noise levels and extrapolates to the zero-noise limit.
Quantum Enhanced Pauli Propagation (QuEPP) — decomposes the circuit into Clifford Pauli paths, simulates them classically, and corrects the noisy quantum result with an empirical rescaling factor.
Pass either protocol into variational programs (for example VQE
or QAOA) with the qem_protocol argument. You can also
subclass QEMProtocol for custom mitigation; see
Custom Error Mitigation Protocols below.
Zero Noise Extrapolation (ZNE)¶
Divi’s ZNE runs the target circuit at several amplified noise levels and
extrapolates the per-scale expectation values back to the zero-noise limit.
Folding and extrapolation are both built-in — ZNE
ships with global-unitary folding (global_fold())
by default and uses RichardsonExtrapolator
unless a custom extrapolator is provided. Both integer and fractional
scale factors are supported; for per-gate folding on deep circuits or
scales close to 1, switch to local_fold().
Basic Usage:
from divi.circuits.qem import ZNE, RichardsonExtrapolator
from divi.qprog import VQE
from divi.backends import QiskitSimulator
import pennylane as qp
import numpy as np
# Create a ZNE protocol with three noise scale factors. The default
# folding function is global unitary folding, which supports both
# integer (e.g. [1, 3, 5]) and fractional (e.g. [1.0, 1.5, 2.0])
# scale factors.
scale_factors = [1, 3, 5]
zne_protocol = ZNE(
scale_factors=scale_factors,
extrapolator=RichardsonExtrapolator(),
)
# Apply to VQE
h2_molecule = qp.qchem.Molecule(
symbols=["H", "H"],
coordinates=np.array([[0.0, 0.0, -0.6614], [0.0, 0.0, 0.6614]])
)
vqe = VQE(
molecule=h2_molecule,
qem_protocol=zne_protocol,
backend=QiskitSimulator(qiskit_backend="auto"),
max_iterations=10,
)
vqe.run()
print(f"Mitigated energy: {vqe.best_loss:.6f}")
Configuration Options (same imports as in Basic Usage above):
# Light mitigation (faster, 2 scale factors)
light_zne = ZNE(
scale_factors=[1, 3],
extrapolator=RichardsonExtrapolator(),
)
# Heavy mitigation (more accurate, 5 scale factors)
heavy_zne = ZNE(
scale_factors=[1, 3, 5, 7, 9],
extrapolator=RichardsonExtrapolator(),
)
Choosing a folding strategy. The default
global_fold() folds the entire circuit
(U · (U†·U)^k · L†·L, with the tail L handling fractional
remainders); it is deterministic and a sensible first choice when scale
factors are widely spaced. For deep circuits, scales close to 1, or
finer-grained noise scaling, swap in
local_fold(), which folds each gate
independently (G · (G†·G)^k) and distributes fractional remainders
across a random subset of gates:
from divi.circuits.qem import ZNE, local_fold
# Per-gate folding with fractional scale factors
zne_local = ZNE(
scale_factors=[1.0, 1.25, 1.5, 1.75, 2.0],
folding_fn=local_fold,
)
local_fold accepts keyword arguments via functools.partial for
deterministic output (selection="from_left" / "from_right") or
to skip gates during folding — for example, excluding 2-qubit gates to
isolate single-qubit noise, or excluding everything except cx to
target 2-qubit gate errors specifically:
from functools import partial
from divi.circuits.qem import ZNE, local_fold
zne_selective = ZNE(
scale_factors=[1.0, 1.5, 2.0],
folding_fn=partial(local_fold, selection="from_left", exclude={"cx"}),
)
Note
The achievable scale factors form a discrete grid of granularity
2/d where d is the number of foldable gates. For very small
d a requested non-integer scale may snap to a different value;
ZNE forwards the effective scale to the extrapolator so
extrapolation stays unbiased, and warns if two requested scales
collapse to the same effective value.
Quantum Enhanced Pauli Propagation (QuEPP)¶
QuEPP is a hybrid classical-quantum protocol based on Clifford Perturbation Theory (CPT) from Majumder et al. (2026).
It works by decomposing the target circuit into a set of Clifford circuits (Pauli paths) whose expectation values can be computed exactly with a classical simulator. The low-order paths capture most of the signal; the residual higher-order contribution is estimated from the noisy quantum hardware and corrected with a rescaling factor derived from comparing noisy and ideal values on the ensemble circuits.
Basic Usage:
from divi.circuits.quepp import QuEPP
from divi.qprog import VQE
from divi.backends import QiskitSimulator
import pennylane as qp
import numpy as np
h2_molecule = qp.qchem.Molecule(
symbols=["H", "H"],
coordinates=np.array([[0.0, 0.0, -0.6614], [0.0, 0.0, 0.6614]])
)
vqe = VQE(
molecule=h2_molecule,
qem_protocol=QuEPP(truncation_order=2),
backend=QiskitSimulator(qiskit_backend="auto"),
max_iterations=10,
)
vqe.run()
print(f"Mitigated energy: {vqe.best_loss:.6f}")
Parameters:
truncation_order(int, default 2) — Maximum CPT expansion order K. Forsampling="exhaustive", higher K includes more Pauli paths (cost grows combinatorially with the number of non-Clifford gates). For the defaultsampling="montecarlo", paths are drawn with a fixed budget instead; order still affects diagnostics such as the shallow-circuit warning, but path count is controlled primarily byn_samples.coefficient_threshold(float, optional) — Prune paths whose absolute weight falls below this threshold during DFS enumeration (sampling="exhaustive"only; see the QuEPP class docstring for symbolic-circuit behavior).sampling—"montecarlo"(default) usesn_samplesrandom paths;"exhaustive"enumerates paths up totruncation_order(deterministic; cost grows with order and circuit size).n_samples(int, default 200) — Monte Carlo path budget whensampling="montecarlo".seed(int, optional) — RNG seed for Monte Carlo reproducibility.n_twirls(int, default 10) — Pauli twirl count;0disables twirling. The parameterbind_before_mitigationonQuEPPtrades repeated structural work against path count when angles are symbolic.
ZNE vs QuEPP¶
Property |
ZNE |
QuEPP |
|---|---|---|
Noise model required? |
No |
No |
Classical pre-computation |
None |
Clifford simulation of ensemble |
Circuit overhead |
1 extra circuit per scale factor |
1 + C(n, 1) + … + C(n, K_T) paths |
Best for |
Coherent gate noise |
Uniform noise (e.g. readout error) |
Observable required? |
No |
Yes (used for classical simulation) |
Estimating Circuit Cost with Dry Run¶
Error mitigation can multiply the number of circuits significantly. Use
dry_run() to preview the per-stage expansion
before committing to a full run, and pipe the returned reports through
format_dry_run() to render them as a tree:
from divi.pipeline import format_dry_run
vqe = VQE(
molecule=h2_molecule,
qem_protocol=QuEPP(truncation_order=2, n_twirls=10),
backend=QiskitSimulator(qiskit_backend="auto"),
)
# Collect the analytic reports and render a per-stage factor tree per pipeline.
format_dry_run(vqe.dry_run())
The output shows the factor each pipeline stage contributes — including how
many Pauli paths QuEPP generates, the Clifford simulation count, and the
twirl fan-out. Observable grouping in
MeasurementStage appears as a reduction
(÷K) rather than a fan-out: commuting Pauli terms are collapsed into
shared measurement circuits, which claws some cost back. Use this to tune
truncation_order, coefficient_threshold, and n_twirls before
spending any shots.
See Pipelines for full documentation of the dry-run tool.
Signal Destruction and Automatic Fallback¶
QuEPP corrects the noisy quantum result by dividing by the empirical rescaling
factor η. When noise is so severe that η drops below a safety threshold
(min_eta=0.1), the 1/η correction would amplify noise rather than
suppress it. In this case QuEPP falls back to the raw noisy value for that
observable group and emits a summary warning after the evaluation:
UserWarning: QuEPP: signal destroyed — η fell below the safety threshold
and mitigation fell back to the raw noisy value. Consider increasing shots
or reducing noise.
This is distinct from observable groups whose classical Pauli-path values are near zero — those carry negligible weight in the Hamiltonian and do not trigger the warning.
If you see this warning frequently, consider:
Increasing the number of shots to reduce statistical noise in η.
Enabling Pauli twirling (
n_twirls > 0) to convert coherent noise into stochastic noise that QuEPP handles more gracefully.Lowering the noise level (e.g. using a less noisy backend or reducing circuit depth).
Shallow Circuit Warning¶
QuEPP’s correction relies on the CPT expansion being a small perturbation of the
target circuit. When the truncation order K replaces a large fraction of the
non-Clifford rotations, path circuits differ too much from the target for
reliable η estimation. QuEPP emits a warning when K / n_rotations > 33%:
UserWarning: QuEPP: truncation order K=2 replaces a large fraction of the
4 non-Clifford rotations (50%). Mitigation quality may degrade on shallow
circuits — consider reducing truncation_order or using a deeper circuit.
This typically occurs on small circuits (< 10 qubits) where the number of non-Clifford rotations is comparable to K. The paper validates QuEPP on 49-qubit circuits with hundreds of rotations.
If you see this warning:
Reduce truncation_order to
K=1or usesampling="montecarlo"which does not enumerate all branches.Use a deeper circuit (more qubits or Trotter steps).
Use ZNE instead for shallow circuits where QuEPP is unreliable.
Performance Considerations¶
ZNE: Expect roughly one backend evaluation per scale factor per unmitigated evaluation (plus extrapolation overhead on the classical side).
QuEPP: Cost grows with path count (Monte Carlo budget or exhaustive enumeration), twirls, and circuit size. Classical Clifford simulation of paths is comparatively cheap next to quantum shots.
Budget: Mitigation increases total shots or circuit evaluations; use
dry_run()to preview expansion before a long run.
Multi-Observable Programs¶
Programs that accept several observables in one run (for example
TimeEvolution with
observable=[O1, O2, ...] — see Multi-Observable Mode)
amortise mitigation cost across the group:
ZNE runs each scale factor’s circuit once for the whole observable set, not once per observable. Total shots scale with the number of scale factors, not with
#scales × #observables.QuEPP shares the target circuit across all observables and dedupes path DAGs across observables that produce coincident branches, so a large fraction of the classical Clifford simulation is reused.
Both protocols return one mitigated value per input observable, in input order.
Custom Error Mitigation Protocols¶
You can implement custom error mitigation strategies by inheriting from
QEMProtocol. The protocol operates on Qiskit
DAGCircuit bodies — the same IR the rest of the
pipeline uses — and must implement three members:
import copy
from collections.abc import Sequence
from qiskit.dagcircuit import DAGCircuit
from divi.backends import MaestroSimulator
from divi.circuits.qem import QEMContext, QEMProtocol
class WeightedAveraging(QEMProtocol):
"""A simple protocol that runs the circuit twice and averages results."""
@property
def name(self) -> str:
return "weighted_avg"
def expand(self, dag: DAGCircuit, observable=None):
"""Return circuits to execute and a reduce-time context.
``expand`` *consumes* the input ``dag`` — implementations may
mutate it, and downstream stages may mutate the returned DAGs
in place. When you need multiple distinct variants, deep-copy
the dag explicitly (as shown below); reusing the same reference
would cause later edits to affect every slot it appears in.
The optional ``observable`` argument carries the observable being
measured (as a Qiskit
:class:`~qiskit.quantum_info.SparsePauliOp`) — hybrid protocols
like QuEPP use it for classical pre-computation.
"""
# Run the circuit twice as two independent DAG copies so later
# pipeline stages can mutate each one without interference.
return (copy.deepcopy(dag), dag), {}
def reduce(self, quantum_results: Sequence[float], context: QEMContext) -> float:
"""Combine the quantum results into a single mitigated value.
``quantum_results`` contains one expectation value per circuit
returned by ``expand``, in the same order.
"""
return sum(quantum_results) / len(quantum_results)
# Pass the custom protocol when constructing any variational program
vqe = VQE(
molecule=h2_molecule,
qem_protocol=WeightedAveraging(),
backend=MaestroSimulator(),
)
Key Members to Implement:
name(property) — Unique protocol name used as the pipeline axis identifierexpand(dag, observable)— Generate one or more QiskitDAGCircuitbodies to execute on the quantum backend and aQEMContextcarrying any classical side-channel data for the reduce phase. Return atuple[tuple[DAGCircuit, ...], QEMContext].reduce(quantum_results, context)— Combine aSequence[float]of per-circuit expectation values with theQEMContextinto a singlefloat.post_reduce(contexts)(optional) — Called once after all per-groupreducecalls in an evaluation. Override to inspect the collected contexts and emit summary diagnostics (e.g. QuEPP’s signal-destruction warning). The default implementation is a no-op.
Note
When a qem_protocol is provided, the circuit pipeline
automatically wraps it in a QEMStage.
During execution, expand is called in the pipeline’s expand pass and
reduce is called in the reduce pass — you don’t need to manage
pipeline integration yourself.
Next Steps¶
Circuits —
QEMProtocol,ZNE, and QuEPPProgram Ensembles and Workflows — running many mitigated programs together
Pipelines — how QEM fits into the circuit pipeline