Skip to content
Kartikey Purohit
← Course overview

Day 6 — Estimator & Error Mitigation

Lesson

Syllabus coverage: Section 5 — Use the Estimator Primitive (12%) Verified against: qiskit 2.5.1, qiskit-ibm-runtime 0.48.0 (all runnable snippets executed locally; account-only snippets marked # not run - requires account and cross-checked against IBM docs guides/error-mitigation-and-suppression-techniques and guides/estimator-noise-management).


1. Estimator concept: expectation values, not counts

The Estimator primitive computes expectation values ⟨ψ|O|ψ⟩ of observables O with respect to the state |ψ⟩ prepared by a circuit.

Contrast with Sampler (Day 5):

Sampler Estimator
Returns bitstring samples / counts expectation values (floats)
Circuit must contain measurements? YES NO (must NOT)
PUB shape (circuit, param_values, shots) (circuit, observables, param_values, precision)
Accuracy knob shots / default_shots precision / default_precision
Result data result[0].data.<creg>.get_counts() result[0].data.evs and .stds
Error mitigation options (resilience) none TREX, ZNE, PEC

Why no measurements? The observable defines what gets measured. The Estimator internally rotates into the right basis and estimates ⟨O⟩. If you pass a circuit that contains a measure instruction, it is an error. Verified locally:

# StatevectorEstimator raises:
# QiskitError: 'Cannot apply instruction with classical bits: measure'

Minimal working example (verified, runs locally)

from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from qiskit.primitives import StatevectorEstimator
 
qc = QuantumCircuit(2)        # NO ClassicalRegister, NO measure
qc.h(0)
qc.cx(0, 1)                   # Bell state (|00> + |11>)/sqrt(2)
 
obs = SparsePauliOp("ZZ")
 
estimator = StatevectorEstimator()
job = estimator.run([(qc, obs)])     # a list of PUBs
result = job.result()
print(result[0].data.evs)            # 1.0  (ideal <ZZ> of a Bell state)
print(result[0].data.stds)           # 0.0  (exact simulation)
print(result[0].metadata)            # {'target_precision': 0.0, 'circuit_metadata': {}}

2. Estimator PUB shapes

A Primitive Unified Bloc (PUB) for the Estimator is a tuple:

(circuit, observables)                          # non-parameterized
(circuit, observables, parameter_values)        # parameterized
(circuit, observables, parameter_values, precision)   # per-PUB precision
  • estimator.run(pubs) takes a list of PUBs — even a single PUB must be wrapped: run([(qc, obs)]).
  • precision can also be given for the whole run: estimator.run(pubs, precision=0.01).
  • Precision = target standard error on the expectation value. Smaller precision = more shots.

Observables: SparsePauliOp

from qiskit.quantum_info import SparsePauliOp
 
obs = SparsePauliOp("ZZ")                      # single Pauli string
H = SparsePauliOp.from_list([("ZZ", 0.5),      # weighted sum (Hamiltonian)
                             ("XI", 0.3),
                             ("IX", 0.3)])

Pauli strings read left-to-right = highest-to-lowest qubit ("ZI" = Z on qubit 1, I on qubit 0 — the usual Qiskit little-endian convention). Verified: for the Bell state, ⟨ZZ⟩ = 1, ⟨XX⟩ = 1, ⟨ZI⟩ = 0, and ⟨H⟩ above = 0.5.

Plain strings, Pauli objects, and dicts like {"ZZ": 0.5} are also accepted where observables go; SparsePauliOp is the canonical exam answer.


3. THE #1 EXAM TRAP: apply_layout

After transpiling to ISA form, the circuit typically has more qubits (backend width) and your logical qubits sit on different physical qubits. A 2-qubit observable no longer matches a 127-qubit ISA circuit. You must remap the observable:

from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
from qiskit_ibm_runtime import EstimatorV2 as Estimator
 
backend = FakeManilaV2()                       # 5-qubit fake backend, runs locally
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_circuit = pm.run(qc)                       # now 5 qubits, has .layout
 
isa_obs = obs.apply_layout(isa_circuit.layout) # <-- THE step people forget
 
estimator = Estimator(mode=backend)
result = estimator.run([(isa_circuit, isa_obs)]).result()
print(result[0].data.evs)                      # ~0.88 (noisy fake backend)

Verified failure mode if you skip it:

ValueError: The number of qubits of the circuit (5) does not match the
number of qubits of the ()-th observable (2).
Common exam traps
  1. apply_layout is trap #1. Transpiled circuit + original observable → qubit-count mismatch ValueError. Fix: isa_obs = observable.apply_layout(isa_circuit.layout). It is a method on the observable, taking the circuit's .layout.
  2. Estimator circuits must NOT contain measurements (Sampler circuits MUST).
  3. Precision vs shots: shots ≈ 1/precision². Verified locally: precision 0.1 → 100 shots, 0.05 → 400 shots, 0.01 → 10,000 shots. Halving precision quadruples shots. Estimator uses precision/default_precision; Sampler uses shots/default_shots (Sampler options have no default_precision and no resilience — verified by attribute inspection).

4. Broadcasting rules (observables × parameters)

Observables and parameter values inside one PUB broadcast like NumPy arrays. The result evs array has the broadcast shape.

observables shape parameter_values shape evs shape
scalar (one obs) (4, k) → 4 sets of k params (4,)
(3,) (list of 3 obs) none (3,)
(3, 1) (4,) sets (3, 4)
(n,) (n,) sets (n,) (paired element-wise)

Verified example — 3 observables × 4 parameter sets:

import numpy as np
from qiskit.circuit import Parameter
 
theta = Parameter("theta")
pqc = QuantumCircuit(1)
pqc.ry(theta, 0)
 
params = [[0.0], [np.pi/2], [np.pi], [3*np.pi/2]]        # 4 sets of 1 param
obs = [[SparsePauliOp("Z")], [SparsePauliOp("X")], [SparsePauliOp("Y")]]  # shape (3,1)
 
result = StatevectorEstimator().run([(pqc, obs, params)]).result()
print(result[0].data.evs.shape)   # (3, 4)
# [[ 1.  0. -1. -0.]     <Z> = cos(theta)
#  [ 0.  1.  0. -1.]     <X> = sin(theta)
#  [ 0.  0.  0.  0.]]    <Y> = 0 for RY rotations

Passing a flat list of 3 observables (shape (3,)) with 4 parameter sets (shape (4,)) does not produce a 3×4 grid — it fails to broadcast. You need shapes (3,1) and (4,).


5. Results

result = job.result()          # PrimitiveResult, indexable per PUB
pub_result = result[0]         # PubResult for PUB 0
pub_result.data.evs            # ndarray of expectation values
pub_result.data.stds           # ndarray of standard errors
pub_result.metadata            # dict: target_precision, shots, circuit_metadata...
result.metadata                # job-level metadata, e.g. {'version': 2}

Verified metadata from a fake-backend run: {'target_precision': 0.05, 'shots': 400, 'circuit_metadata': {}}.

evs/stds are stored in data (a DataBin), one PubResult per input PUB — same container pattern as Sampler, but with evs/stds instead of a classical-register field.


6. EstimatorV2 options (qiskit-ibm-runtime)

All option paths below verified by instantiating EstimatorV2(mode=FakeManilaV2()) against runtime 0.48.0 and setting/reading each attribute.

from qiskit_ibm_runtime import EstimatorV2 as Estimator
 
est = Estimator(mode=backend)
 
# Accuracy
est.options.default_precision = 0.01   # Estimator's native knob
est.options.default_shots = 4096       # alternative; overrides precision if set
 
# Mitigation preset
est.options.resilience_level = 2       # 0, 1 (default), or 2. Value 3 -> ValidationError
 
# Fine-grained mitigation (options.resilience.*)
est.options.resilience.measure_mitigation = True            # TREX
est.options.resilience.measure_noise_learning.num_randomizations = 32
est.options.resilience.zne_mitigation = True                # ZNE
est.options.resilience.zne.noise_factors = (1, 3, 5)
est.options.resilience.zne.extrapolator = "exponential"
est.options.resilience.pec_mitigation = False               # PEC (awareness)
 
# Suppression (also available on Sampler)
est.options.dynamical_decoupling.enable = True
est.options.dynamical_decoupling.sequence_type = "XY4"      # e.g. "XX", "XpXm", "XY4"
est.options.twirling.enable_gates = True                    # Pauli (gate) twirling
est.options.twirling.enable_measure = True                  # measurement twirling
est.options.twirling.num_randomizations = 32
est.options.twirling.shots_per_randomization = 100

Options can also be passed as a dict at construction (verified):

est = Estimator(mode=backend, options={"resilience_level": 1, "default_shots": 4096})

Unset options report the sentinel Unset (not None) when inspected.

Resilience levels (Estimator only)

Level Name What it enables Cost
0 No mitigation Nothing — raw results cheapest
1 Minimal mitigation (default) Measurement (readout) error mitigation via TREX + measurement twirling small overhead (calibration circuits)
2 Medium mitigation Level 1 plus ZNE (and gate twirling) ~3× overhead (default 3 noise factors)
  • Sampler has no resilience levels or resilience options at all.
  • PEC is not part of any resilience level in current runtime — enable it explicitly with options.resilience.pec_mitigation = True.
  • resilience_level is a preset; individual options.resilience.* flags give fine-grained control and can be combined.

ZneOptions (verified field names)

ZneOptions fields: amplifier, noise_factors, extrapolator, extrapolated_noise_factors.

  • noise_factors: sequence ≥ 1, default (1, 3, 5) — circuits are re-run at amplified noise (digital gate folding: replace U with U U†U for factor 3).
  • extrapolator: "linear", "exponential" (default behavior uses exponential+linear fallback), "polynomial_degree_k" (e.g. "polynomial_degree_2"), "fallback". An invalid string like "cubic" raises ValidationError (verified).
  • amplifier="pea" switches noise amplification to Probabilistic Error Amplification.

Suppression vs Mitigation — know this table cold

Error SUPPRESSION Error MITIGATION
When it acts Before/during execution (modifies the circuits/pulses sent to hardware) Post-processing (classical processing of results; may need extra circuit executions)
Techniques Dynamical decoupling (DD), Pauli twirling TREX (measurement/readout), ZNE, PEC
Option paths options.dynamical_decoupling.*, options.twirling.* options.resilience.*
Applies to Sampler and Estimator Estimator only
Result bias reduces coherent errors on idle qubits (DD); tailors noise to Pauli channel (twirling) ZNE: biased estimate possible; PEC: unbiased but exponential sampling overhead
  • DD inserts pulse sequences (net identity) on idle qubits; can hurt dense circuits.
  • Twirling sandwiches (two-qubit) gates with random Paulis; converts arbitrary noise into Pauli noise.
  • TREX = Twirled Readout Error eXtinction: twirls measurements (X gate + measure + classical bit flip) to diagonalize the readout-error matrix, then inverts it.
  • ZNE = run at noise factors 1, 3, 5, ... then extrapolate to zero noise.
  • PEC = express ideal circuit as quasi-probability mix of noisy circuits; unbiased, overhead scales with γ² (exponential in depth). options.resilience.pec.max_overhead caps it.

Real-hardware pattern (for the exam)

# not run - requires account
from qiskit_ibm_runtime import QiskitRuntimeService, EstimatorV2 as Estimator
 
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
 
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_circuit = pm.run(qc)
isa_obs = obs.apply_layout(isa_circuit.layout)
 
estimator = Estimator(mode=backend, options={"resilience_level": 2})
job = estimator.run([(isa_circuit, isa_obs)])
print(job.result()[0].data.evs)

Note: in local testing mode (fake backends / AerSimulator) resilience_level is accepted but ignored with a UserWarning: The resilience_level option has no effect in local testing mode. Shots/precision still work locally.


7. Quick recall list

  • Estimator = ⟨ψ|O|ψ⟩; circuit has no measurements; observable = SparsePauliOp.
  • PUB: (circuit, observables[, param_values[, precision]]); run takes a list of PUBs.
  • ISA workflow: transpile circuit, then obs.apply_layout(isa_circuit.layout).
  • Results: result[0].data.evs, result[0].data.stds, result[0].metadata.
  • shots ≈ 1/precision².
  • Resilience: 0 = none, 1 = TREX (default), 2 = +ZNE. PEC is opt-in only.
  • Suppression (DD, twirling) = during execution; Mitigation (TREX, ZNE, PEC) = post-processing.
  • ZNE: noise_factors=(1,3,5), extrapolator ∈ linear / exponential / polynomial_degree_k.

8. CODING LABS — predict, then run

Use /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python. Write your prediction down before running. Solutions in solution-bank.md.

Lab 1 — Bell-state observable safari

Build the Bell state (H on q0, CX 0→1, no measurements). Using one PUB with the observable list [ZZ, XX, YY, ZI, IZ, XY], predict all six expectation values, then verify with StatevectorEstimator. Bonus prediction: what is result[0].data.evs.shape and what does result[0].data.stds contain for an exact simulator?

Lab 2 — Broadcasting grid

Circuit: single qubit, ry(theta, 0). Observables shaped (3, 1): [[Z], [X], [Y]]. Parameter values: theta ∈ {0, π/2, π, 3π/2} (4 sets). Predict the shape of evs and the full 3×4 matrix of values (hint: ⟨Z⟩ = cos θ, ⟨X⟩ = sin θ for RY). Then predict what happens if you pass the observables as a flat list of 3 instead of shape (3,1). Run both.

Lab 3 — The apply_layout gauntlet + precision economics

Transpile the Bell circuit for FakeManilaV2() (opt level 1). (a) Run runtime EstimatorV2 in local mode with the untranspiled observable — predict the exact exception type. (b) Fix it with apply_layout and predict roughly what ⟨ZZ⟩ will be (noisy!). (c) Run with precision=0.1, 0.05, 0.01 and predict the shots value reported in result[0].metadata for each before looking.