Skip to content
Kartikey Purohit
← Course overview

Day 5 — Sampler & Results

Lesson

Exam weight: §4 Sampler = 12% + §7 Retrieve/Analyze Results = 10% → 22% of the exam. Environment used for every verified snippet: qiskit 2.5.1, qiskit-ibm-runtime 0.48.0, qiskit-aer 0.17.2 (.venv).

Local stand-in used throughout: qiskit.primitives.StatevectorSampler — its V2 result API mirrors runtime SamplerV2 exactly (result[0].data.meas.get_counts() — verified below). Runtime SamplerV2 snippets run against FakeManilaV2 / AerSimulator; anything needing a real account is marked # not run - requires account and cross-checked against the local docs clone.


1. SamplerV2 concept — what it is and is NOT

Sampler Estimator
Returns per-shot bitstrings / counts expectation values ⟨ψ|O|ψ⟩
Circuit needs measurements? YES — required NO — must not have them
Takes observables? never always (part of the PUB)
resilience_level option? NO yes (0/1/2)
  • Sampler samples the classical registers of your circuit, shot by shot. No observables, no quasi-probability post-processing (that was V1) — you get raw samples.
  • A circuit with no classical registers passed to Sampler does not crash: both StatevectorSampler and runtime SamplerV2 emit a UserWarning ("…no output classical registers and so the result will be empty…") and return an empty result (verified). The exam-level fact remains: Sampler circuits must contain measurements to give data.

Common exam trap #0 "Which primitive do I use to get counts?" → Sampler. "Which one for ⟨Z⟩?" → Estimator (or Sampler + manual math, see §7). If a question shows sampler.run([(qc, observable)]) it is wrong — observables never appear in a Sampler PUB.


2. PUB shapes (Primitive Unified Blocs)

A Sampler PUB is a tuple; run() always takes a list of PUBs:

sampler.run([(circuit,)])                          # 1. circuit only
sampler.run([(circuit, param_values)])             # 2. + parameter values
sampler.run([(circuit, param_values, shots)])      # 3. + per-PUB shots
sampler.run([circuit])                             # bare circuit auto-coerced to (circuit,)

Shots precedence (verified for both StatevectorSampler and runtime SamplerV2):

per-PUB shots   >   run(..., shots=N)   >   options.default_shots   >   built-in default

Built-in defaults: 1024 for StatevectorSampler, 4096 for runtime SamplerV2 (docs: SamplerOptions.default_shots "Default: 4096").

# VERIFIED (StatevectorSampler)
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorSampler
import numpy as np
 
theta = Parameter('theta')
qc = QuantumCircuit(1)
qc.ry(theta, 0)
qc.measure_all()
 
sampler = StatevectorSampler()
job = sampler.run([(qc, [np.pi], 333)], shots=1000)
print(job.result()[0].data.meas.num_shots)   # 333  <- PUB shots win over run-level 1000
# VERIFIED — multiple PUBs, one job, one result per PUB
qc0 = QuantumCircuit(1); qc0.h(0); qc0.measure_all()
res = sampler.run([(qc0,), (qc0, None, 77)], shots=500).result()
len(res)                        # 2
res[0].data.meas.num_shots      # 500  (falls back to run-level shots)
res[1].data.meas.num_shots      # 77   (PUB-level)

Common exam trap #1 — shots override sampler.run([(qc, None, 333)], shots=1000) runs 333 shots, not 1000. The PUB is the most specific setting and always wins. (Side note for real work, not the exam: qiskit-ibm-runtime 0.48 deprecates different shots across PUBs in one job — submit separate jobs/Batch instead. The PUB shape itself is still exam canon.)

Common exam trap #2 — parameter values at runtime With a parameterized circuit you do NOT have to call assign_parameters() first; pass values in the PUB: (qc, [0.5, 1.2]). One row of values per parameter, ordered like circuit.parameters (alphabetical).


3. Results: PrimitiveResult → PubResult → DataBin → BitArray

job.result()                 -> PrimitiveResult   (iterable; result.metadata = job-level, e.g. {'version': 2})
result[0]                    -> SamplerPubResult  (one per PUB)
result[0].metadata           -> dict, e.g. {'shots': 1024, 'circuit_metadata': {}}
result[0].data               -> DataBin           (one attribute PER CLASSICAL REGISTER)
result[0].data.<creg_name>   -> BitArray

3.1 The creg-name attribute trap (the #1 Sampler exam trap)

The attribute on .data is the name of the classical register:

How you measured Creg name Access
qc.measure_all() meas (auto-added register) result[0].data.meas
QuantumCircuit(2, 2) + measure() c (default name) result[0].data.c
ClassicalRegister(2, 'my_bits') my_bits result[0].data.my_bits
# VERIFIED
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
sampler = StatevectorSampler()
 
bell = QuantumCircuit(2)
bell.h(0); bell.cx(0, 1)
bell.measure_all()                                  # adds creg named 'meas'
r = sampler.run([bell], shots=1024).result()
print(r[0].data.meas.get_counts())                  # {'11': 527, '00': 497}
# r[0].data.c  -> AttributeError: 'DataBin' object has no attribute 'c'
 
bell2 = QuantumCircuit(2, 2)                        # default creg named 'c'
bell2.h(0); bell2.cx(0, 1)
bell2.measure([0, 1], [0, 1])
r2 = sampler.run([bell2], shots=100).result()
print(r2[0].data.c.get_counts())                    # {'00': 48, '11': 52}
# r2[0].data.meas -> AttributeError                  (VERIFIED)

Common exam trap #3 — .data.meas vs .data.c measure_all().meas. QuantumCircuit(n, m).c. Named register ⇒ that name. Wrong name = AttributeError at runtime. Exam questions love pairing QuantumCircuit(2,2) with .data.meas (wrong) or measure_all() with .data.c (also wrong).

3.2 BitArray API

ba = r[0].data.meas          # BitArray
ba.get_counts()              # {'11': 527, '00': 497}      dict bitstring -> count
ba.get_bitstrings()          # ['11', '11', '00', ...]     one string per shot, len == num_shots
ba.get_int_counts()          # {3: 527, 0: 497}            keys as integers
ba.num_shots                 # 1024
ba.num_bits                  # 2

All verified. Bitstrings are little-endian: rightmost character = qubit 0 (i.e. classical bit 0).

3.3 Multiple classical registers → join_data()

Each creg is a separate DataBin attribute. PubResult.join_data() concatenates them into one BitArray:

# VERIFIED
from qiskit import QuantumRegister, ClassicalRegister, QuantumCircuit
q = QuantumRegister(2, 'q')
a = ClassicalRegister(1, 'a')      # will hold qubit 0
b = ClassicalRegister(1, 'b')      # will hold qubit 1
qc = QuantumCircuit(q, a, b)
qc.x(0)
qc.measure(0, a[0]); qc.measure(1, b[0])
 
res = StatevectorSampler().run([qc], shots=10).result()[0]
res.data.a.get_counts()            # {'1': 10}
res.data.b.get_counts()            # {'0': 10}
res.join_data().get_counts()       # {'01': 10}   default order: circuit's creg order,
                                   #              FIRST register -> RIGHTMOST (least significant) bits
res.join_data(['b', 'a']).get_counts()   # {'10': 10}   explicit name order reverses it

Common exam trap #4 — join_data ordering Default join_data() uses the registers in circuit order, and the first register lands in the rightmost bits (little-endian, like everything else in Qiskit).


4. Sampler options

# VERIFIED against FakeManilaV2
from qiskit_ibm_runtime import SamplerV2
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
from qiskit.transpiler import generate_preset_pass_manager
 
backend = FakeManilaV2()
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
isa = pm.run(bell)                       # SamplerV2 on a backend needs ISA circuits
 
sampler = SamplerV2(mode=backend)
sampler.options.default_shots = 2048     # used when neither PUB nor run() give shots
 
# Error SUPPRESSION knob 1: dynamical decoupling
sampler.options.dynamical_decoupling.enable = True
sampler.options.dynamical_decoupling.sequence_type = "XY4"   # 'XX' | 'XpXm' | 'XY4' ONLY
 
# Error SUPPRESSION knob 2: Pauli twirling
sampler.options.twirling.enable_gates = True
sampler.options.twirling.enable_measure = True
sampler.options.twirling.num_randomizations = 32
sampler.options.twirling.shots_per_randomization = 64
 
job = sampler.run([isa])
job.result()[0].data.meas.get_counts()   # e.g. {'00': 991, '11': 931, '01': 59, '10': 67} (noisy!)

Verified facts:

  • sampler.options.resilience_level = 1 raises a ValidationError ("Object has no attribute 'resilience_level'") and sampler.options.resilience raises AttributeError. Sampler has NO resilience / error mitigation — only suppression (DD + twirling).
  • Valid sequence_type literals are exactly 'XX', 'XpXm', 'XY4' (anything else → ValidationError; confirmed in docs: Literal['XX', 'XpXm', 'XY4']).
  • SamplerOptions fields: default_shots, dynamical_decoupling, execution, twirling, environment, simulator, max_execution_time, experimental.

Common exam trap #5 — resilience_level on Sampler resilience_level / ZNE / PEC / TREX belong to EstimatorV2 only. Any answer choice setting sampler.options.resilience_level is wrong. Suppression (DD, twirling) exists on both; mitigation (resilience) is Estimator-only.

Common exam trap #6 — suppression vs mitigation Dynamical decoupling & twirling = error suppression (applied before/during execution). TREX / ZNE / PEC = error mitigation (post-processing) — Estimator territory.


5. §7.1 Jobs — lifecycle, retrieval, accounting

# VERIFIED locally (fake backend): all methods exist on RuntimeJobV2
job.job_id()      # 'ab5ccd2e-...' unique string id
job.status()      # 'QUEUED' | 'RUNNING' | 'DONE' | 'ERROR' | 'CANCELLED'
job.done()        # True when finished successfully
job.cancel()      # request cancellation
job.result()      # blocks until done, returns PrimitiveResult
# not run - requires account   (API cross-checked against docs clone: runtime-job-v2.mdx)
from qiskit_ibm_runtime import QiskitRuntimeService
service = QiskitRuntimeService()
 
old_job = service.job("d1e2f3...")            # retrieve ANY past job by id
jobs = service.jobs(limit=10,                  # verified signature: limit=10 default
                    backend_name="ibm_torino",
                    pending=True,              # True = queued/running only
                    session_id="...",
                    created_after=some_datetime)
 
job.metrics()     # dict: timestamps, usage estimation, qiskit versions...
job.usage()       # float: QPU seconds consumed  (billing / quota awareness)

Key facts:

  • Execution is asynchronousrun() returns immediately with a job object; only job.result() blocks.
  • Jobs persist server-side: reconnect any time with service.job(job_id).
  • service.jobs() filters (verified in 0.48.0 signature): limit, skip, backend_name, pending, program_id, instance, job_tags, session_id, created_after, created_before, descending.

6. §7.2 Result objects recap (V2)

  • PrimitiveResult — iterable container; len(result) = number of PUBs; has .metadata.
  • PubResult / SamplerPubResult.data (DataBin) + .metadata (dict with 'shots', ...).
  • Sampler data lives in BitArrays keyed by creg name; Estimator data is evs / stds.
  • SamplerPubResult.join_data() merges multi-register data (§3.3).

7. §7.3 Analysis — counts → probabilities → expectation values

# VERIFIED
counts = {'0': 493, '1': 531}                 # e.g. from H|0> with 1024 shots
shots  = sum(counts.values())
probs  = {k: v / shots for k, v in counts.items()}      # normalize
 
# Manual single-qubit <Z>: eigenvalue +1 for '0', -1 for '1'
expZ = (counts.get('0', 0) - counts.get('1', 0)) / shots     # (n0 - n1) / shots

Multi-qubit ⟨Z⊗Z⊗...⟩ from counts = parity: +1 if the bitstring has an even number of 1s (restricted to the measured qubits), −1 if odd:

expZZ = sum(v * (1 if b.count('1') % 2 == 0 else -1)
            for b, v in counts.items()) / shots

Shot noise: statistical error ∝ 1/√shots. Verified empirically: |⟨Z⟩| deviation for H|0⟩ ≈ 0.16 at 100 shots, ≈ 0.01 at 10 000 shots. → To cut the error in half, you need the shots. 100× more shots → 10× less error.

Common exam trap #7 — little-endian in counts math ⟨Z on qubit 0⟩ from counts uses the rightmost character: b[-1]. b[0] is the highest-index qubit.


8. Cheat sheet

PUB:            (qc,) | (qc, params) | (qc, params, shots)     — shots: PUB > run() > default_shots
Access:         job.result()[i].data.<creg_name>.get_counts()
creg names:     measure_all() -> meas   |  QuantumCircuit(n,m) -> c  |  ClassicalRegister(n,'x') -> x
BitArray:       get_counts / get_bitstrings / get_int_counts / num_shots / num_bits
Multi-creg:     pubresult.join_data([names])   first creg -> rightmost bits
Options:        default_shots (4096), dynamical_decoupling.enable + sequence_type XX/XpXm/XY4,
                twirling.enable_gates/enable_measure/num_randomizations/shots_per_randomization
NO on Sampler:  resilience_level, ZNE, PEC, TREX, observables, measurement-free circuits
Jobs:           job_id/status/done/cancel/metrics/usage; service.job(id); service.jobs(filters)
Math:           p = n/shots;  <Z> = (n0-n1)/shots;  <ZZ> = parity;  error ~ 1/sqrt(shots)

CODING LABS — predict, then run

Run with /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python. Write your predictions down BEFORE executing. Solutions in solution-bank.md.

Lab 1 — The register detective (creg traps + join_data)

from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
from qiskit.primitives import StatevectorSampler
 
q     = QuantumRegister(2, 'q')
low   = ClassicalRegister(1, 'low')     # added first
high  = ClassicalRegister(1, 'high')    # added second
qc = QuantumCircuit(q, low, high)
qc.x(0)                                 # qubit 0 -> |1>
qc.h(1)                                 # qubit 1 -> 50/50
qc.measure(0, low[0])
qc.measure(1, high[0])
 
res = StatevectorSampler().run([qc], shots=1000).result()[0]

Predict, then verify:

  1. What attribute names exist on res.data? Does res.data.c work?
  2. res.data.low.get_counts() and res.data.high.get_counts() — what (roughly)?
  3. res.join_data().get_counts() — which two keys appear, and which position holds the always-1 bit?
  4. What does res.join_data(['high', 'low']).get_counts() give instead?

Lab 2 — Estimator impersonation (manual ⟨Z⟩, ⟨ZZ⟩ from Bell counts)

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
 
bell = QuantumCircuit(2)
bell.h(0); bell.cx(0, 1)
bell.measure_all()
counts = StatevectorSampler().run([bell], shots=8192).result()[0].data.meas.get_counts()

Predict, then compute from counts:

  1. The probabilities of each bitstring (which bitstrings even appear?).
  2. ⟨Z⊗Z⟩ via parity. Exact value expected for a Bell state?
  3. ⟨Z⟩ of qubit 0 alone (use b[-1]!). Expected value?
  4. Sanity-check ⟨ZZ⟩ against qiskit.quantum_info: Statevector of the unmeasured Bell circuit, .expectation_value(SparsePauliOp('ZZ')).

Lab 3 — Shots precedence ladder (runtime SamplerV2 + Aer)

from qiskit import QuantumCircuit
from qiskit_ibm_runtime import SamplerV2
from qiskit_aer import AerSimulator
 
qc = QuantumCircuit(1); qc.h(0); qc.measure_all()
sampler = SamplerV2(mode=AerSimulator())
sampler.options.default_shots = 4096
 
job1 = sampler.run([(qc,), (qc, None, 128)], shots=512)
job2 = sampler.run([(qc,)])

Predict num_shots for: job1 pub 0, job1 pub 1, job2 pub 0. Then print each via .result()[i].data.meas.num_shots. Also: which of the three precedence levels (PUB / run / default_shots) decided each one?