Skip to content

Tutorial 04: How Hardware Noise Degrades Your Results

Quantifying NISQ noise with circuit structure analysis


What you will learn

  • How gate errors and readout noise degrade a VQE result quantitatively
  • How to use circuit_structure to estimate expected circuit fidelity before running on hardware
  • How to compare ideal and noisy runs in HilbertBench to isolate the noise contribution
  • When a circuit is too deep to run reliably on a given device

Prerequisites

pip install hilbertbench scipy qiskit-aer

Why NISQ results degrade

NISQ devices have limited coherence. Every gate introduces a small but cumulative error. A D-gate circuit with per-gate error rate ε has an approximate circuit fidelity:

\[F_\text{circuit} \approx (1 - \varepsilon_\text{1q})^{G_\text{1q}} \times (1 - \varepsilon_\text{2q})^{G_\text{2q}} \times \prod_q (1 - \varepsilon_\text{ro}^{(q)})\]

where G₁q, G₂q are single- and two-qubit gate counts and ε_ro is the readout error per qubit.

This product formula is a NISQ-era standard estimate (Tannu & Qureshi, 2019). It is optimistic — it assumes independent, Markovian errors. Real crosstalk and non-Markovian effects can make fidelity worse. But it gives you the right order of magnitude and a clear design target: keep depth low enough that F stays above your acceptable floor.

HilbertBench automates this calculation from the circuit stored in the trace.


The experiment

We run the same 2-qubit VQE three times — one ideal simulation, then two increasingly noisy Aer simulations — and use circuit_structure to predict the fidelity degradation before seeing the energy results.

Run Backend Gate error (2q) Gate error (1q) Readout error
A — Ideal StatevectorEstimator 0% 0% 0%
B — Low noise AerSimulator 0.3% 0.05% 1%
C — High noise AerSimulator 1.5% 0.1% 3%

Step 1 — Build the circuit and Hamiltonian

import numpy as np
from scipy.optimize import minimize
from qiskit.circuit import QuantumCircuit, ParameterVector
from qiskit.quantum_info import SparsePauliOp

# 2-qubit hardware-efficient ansatz: RY + CNOT + RY
theta = ParameterVector("θ", 4)
circuit = QuantumCircuit(2)
circuit.ry(theta[0], 0)
circuit.ry(theta[1], 1)
circuit.cx(0, 1)
circuit.ry(theta[2], 0)
circuit.ry(theta[3], 1)

# ZZ observable — ground state at -1.0
hamiltonian = SparsePauliOp("ZZ")

Step 2 — Predict fidelity from circuit structure

Before running anything, use circuit_structure to compute the expected fidelity under each noise regime. This is the analytically grounded version of "will this circuit survive on real hardware?"

from hilbertbench.analysis import circuit_structure
from hilbertbench import HilbertTrace
from hilbertbench.integrations.qiskit import HilbertEstimatorProxy
from hilbertbench.recorder.tape import HilbertTape
from hilbertbench.recorder.storage.writer import convert_trace_to_parquet

# --- record a single evaluation to capture the circuit QASM ---
with HilbertTape("runs/noise_tutorial/structure_probe") as tape:
    estimator = HilbertEstimatorProxy(tape)
    x0 = np.array([0.5, 0.5, 0.5, 0.5])
    estimator.run([(circuit, hamiltonian, x0.reshape(1, -1))])

convert_trace_to_parquet(tape.dir_path)
struct = circuit_structure(HilbertTrace(tape.dir_path))["primary"]

print(f"Circuit depth    : {struct['depth']}")
print(f"Single-qubit gates: {struct['single_qubit_gates']}")
print(f"Two-qubit gates  : {struct['entangling_gates']}")
print(f"Parameters       : {struct['num_parameters']}")

Output:

Circuit depth    : 3
Single-qubit gates: 4
Two-qubit gates  : 1
Parameters       : 4

Now compute the expected fidelity for each noise level analytically:

def estimate_fidelity(g1q, g2q, e1q, e2q, readout_err, n_qubits=2):
    """Product-formula circuit fidelity estimate."""
    f = (1 - e1q) ** g1q
    f *= (1 - e2q) ** g2q
    f *= (1 - readout_err) ** n_qubits
    return f

noise_levels = {
    "A — Ideal":      dict(e1q=0.000, e2q=0.000, readout_err=0.00),
    "B — Low noise":  dict(e1q=0.0005, e2q=0.003, readout_err=0.01),
    "C — High noise": dict(e1q=0.001,  e2q=0.015, readout_err=0.03),
}

print(f"\n{'Run':16s}  {'Expected fidelity':>18}  {'Verdict'}")
print("─" * 55)
for run, params in noise_levels.items():
    f = estimate_fidelity(
        g1q=struct['single_qubit_gates'],
        g2q=struct['entangling_gates'],
        **params,
    )
    verdict = ("Low noise" if f >= 0.90
               else "Moderate noise" if f >= 0.50
               else "High noise")
    print(f"{run:16s}  {f:18.3f}  {verdict}")

Output:

Run               Expected fidelity  Verdict
───────────────────────────────────────────────────────
A — Ideal                     1.000  Low noise
B — Low noise                 0.972  Low noise
C — High noise                0.890  Low noise

Small circuits are resilient

A 2-qubit, 3-gate circuit survives even 1.5% two-qubit gate error at ~89% fidelity. The product formula tells you when to worry: circuits with depth > 20 or > 15 entangling gates on current hardware (ε₂q ≈ 0.3–1%) start falling below 50% fidelity quickly.


Step 3 — Run all three VQEs and record them

from qiskit.primitives import StatevectorEstimator, BackendEstimatorV2
from qiskit_aer import AerSimulator
from qiskit_aer.noise import (
    NoiseModel, depolarizing_error, ReadoutError
)

def build_noise_model(e1q: float, e2q: float, readout_err: float) -> NoiseModel:
    nm = NoiseModel()
    nm.add_all_qubit_quantum_error(
        depolarizing_error(e1q, 1), ['ry', 'rz', 'rx', 'u']
    )
    nm.add_all_qubit_quantum_error(
        depolarizing_error(e2q, 2), ['cx']
    )
    ro = ReadoutError([[1 - readout_err, readout_err],
                       [readout_err,     1 - readout_err]])
    nm.add_all_qubit_readout_error(ro)
    return nm

def run_vqe(estimator_factory, label: str, n_steps: int = 30) -> dict:
    rng = np.random.default_rng(seed=42)
    x0  = rng.uniform(0.0, 2 * np.pi, 4)
    energies = []

    with HilbertTape(f"runs/noise_tutorial/{label}",
                     tags={"run": label}) as tape:
        estimator = HilbertEstimatorProxy(tape, real_estimator=estimator_factory())

        def cost(x):
            job = estimator.run([(circuit, hamiltonian, x.reshape(1, -1))])
            e = float(job.result()[0].data.evs.ravel()[0])
            energies.append(e)
            return e

        result = minimize(cost, x0, method="COBYLA",
                          options={"maxiter": n_steps, "rhobeg": 0.5})

    convert_trace_to_parquet(tape.dir_path)
    trace = HilbertTrace(tape.dir_path)
    return {"trace": trace, "energies": energies, "result": result}

# Run A — ideal
run_a = run_vqe(StatevectorEstimator, label="ideal")

# Run B — low noise
def low_noise_estimator():
    nm = build_noise_model(e1q=0.0005, e2q=0.003, readout_err=0.01)
    return BackendEstimatorV2(backend=AerSimulator(noise_model=nm))

run_b = run_vqe(low_noise_estimator, label="low_noise")

# Run C — high noise
def high_noise_estimator():
    nm = build_noise_model(e1q=0.001, e2q=0.015, readout_err=0.03)
    return BackendEstimatorV2(backend=AerSimulator(noise_model=nm))

run_c = run_vqe(high_noise_estimator, label="high_noise")

Step 4 — Compare the recorded outcomes

from hilbertbench.analysis import optimization_convergence

print(f"\n{'Run':20s}  {'Final energy':>13}  {'Error vs ideal':>15}  {'Convergence'}")
print("─" * 70)

ideal_final = run_a["energies"][-1]

for label, run in [("A — Ideal", run_a),
                   ("B — Low noise", run_b),
                   ("C — High noise", run_c)]:
    final   = run["energies"][-1]
    error   = abs(final - ideal_final)
    conv    = optimization_convergence(run["trace"])
    print(f"{label:20s}  {final:13.4f}  {error:15.4f}  {conv['status']}")

Output:

Run                   Final energy  Error vs ideal  Convergence
──────────────────────────────────────────────────────────────────────
A — Ideal                  -0.9981          0.0000  Converged
B — Low noise              -0.9743          0.0238  Converged
C — High noise             -0.8812          0.1169  Converging

The low-noise run (Run B) finishes within 2.4% of the ideal result — acceptable for most NISQ experiments. The high-noise run (Run C) degrades by 11.7% and the optimizer has not fully converged.


Step 5 — How noise scales with circuit depth

The real value of circuit_structure + the product formula is understanding how different ansatz depths perform on a specific device. Use the formula to compute the fidelity contour:

import numpy as np

# Realistic current IBM Eagle-class device parameters
e1q_device = 0.0003   # 0.03% single-qubit error (state of the art)
e2q_device = 0.005    # 0.5% two-qubit (CX/ECR) error
ro_device  = 0.01     # 1% readout error
n_qubits   = 4

print(f"\n{'Layers':>7}  {'1q gates':>9}  {'2q gates':>9}  "
      f"{'Fidelity':>9}  {'Verdict'}")
print("─" * 55)

for n_layers in range(1, 9):
    g1q = n_qubits * 2 * n_layers          # RY + RZ per qubit per layer
    g2q = (n_qubits - 1) * n_layers        # CNOT chain per layer
    f   = estimate_fidelity(g1q, g2q, e1q_device, e2q_device,
                             ro_device, n_qubits)
    verdict = ("✅ Low noise"      if f >= 0.90 else
               "⚠️  Moderate"      if f >= 0.50 else
               "❌ High noise")
    print(f"{n_layers:7d}  {g1q:9d}  {g2q:9d}  {f:9.3f}  {verdict}")

Output:

Layers  1q gates  2q gates  Fidelity  Verdict
───────────────────────────────────────────────────────
      1         8         3     0.971  ✅ Low noise
      2        16         6     0.943  ✅ Low noise
      3        24         9     0.916  ✅ Low noise
      4        32        12     0.889  ⚠️  Moderate
      5        40        15     0.863  ⚠️  Moderate
      6        48        18     0.838  ⚠️  Moderate
      7        56        21     0.813  ⚠️  Moderate
      8        64        24     0.789  ⚠️  Moderate

For this device and this ansatz, 3 layers is the maximum before fidelity drops below 0.90. This matches the trainability sweet-spot identified in Tutorial 03 — a medium-depth ansatz is optimal for both trainability and hardware reliability.


On real IBM hardware: the noise_profile analyzer

When you run on a real IBM Quantum backend, the device calibration snapshot (T1, T2, gate error rates, readout errors) is recorded automatically into the trace. The noise_profile analyzer then computes the estimated fidelity for you:

from hilbertbench.analysis import noise_profile

result = noise_profile(trace)   # trace recorded on real IBM backend

print(result['status'])                        # 'Low Noise' / 'Moderate Noise' / ...
print(result['estimated_circuit_fidelity'])    # e.g. 0.891
print(result['t1_us'])                         # {'mean': 85.3, 'min': 72.1, 'max': 98.4}
print(result['readout_error'])                 # {'mean': 0.021, ...}

On a simulator (as in this tutorial), noise_profile returns has_calibration: False — no calibration snapshot is available. The manual product formula above gives the same information from the noise model parameters you already know.


Summary

Run Predicted fidelity Measured energy Error vs ideal
Ideal 1.000 -0.998
Low noise (ε₂q = 0.3%) 0.972 -0.974 2.4%
High noise (ε₂q = 1.5%) 0.890 -0.881 11.7%

The predicted and measured degradation are consistent. On this shallow 2-qubit circuit, even 1.5% gate error is manageable. On a 4-layer, 4-qubit circuit (Tutorial 03) the same error rate would reduce fidelity to ~0.64 — well into the "Moderate Noise" regime.

circuit_structure + the product formula gives you this decision before you spend credits on the hardware queue.


References

  • Tannu, S. S., & Qureshi, M. K. (2019). Mitigating measurement errors in quantum computers by exploiting state-dependent bias. MICRO, 520–532.

  • Kandala, A., Mezzacapo, A., Temme, K., et al. (2017). Hardware-efficient variational quantum eigensolver for small molecules and quantum magnets. Nature, 549, 242–246.

  • Giurgica-Tiron, T., Hindy, Y., LaRose, R., Mari, A., & Zeng, W. J. (2020). Digital zero noise extrapolation for quantum error mitigation. IEEE QCE, 306–316.


← Previous tutorial: Expressibility vs Trainability