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
StatevectorSamplerand runtimeSamplerV2emit aUserWarning("…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 likecircuit.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.measvs.data.cmeasure_all()⇒.meas.QuantumCircuit(n, m)⇒.c. Named register ⇒ that name. Wrong name =AttributeErrorat runtime. Exam questions love pairingQuantumCircuit(2,2)with.data.meas(wrong) ormeasure_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 # 2All 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 itCommon 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 = 1raises a ValidationError ("Object has no attribute 'resilience_level'") andsampler.options.resilienceraisesAttributeError. Sampler has NO resilience / error mitigation — only suppression (DD + twirling).- Valid
sequence_typeliterals are exactly'XX','XpXm','XY4'(anything else → ValidationError; confirmed in docs:Literal['XX', 'XpXm', 'XY4']). SamplerOptionsfields: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 settingsampler.options.resilience_levelis 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 asynchronous —
run()returns immediately with a job object; onlyjob.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 isevs/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) / shotsMulti-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()) / shotsShot 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 4× 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:
- What attribute names exist on
res.data? Doesres.data.cwork? res.data.low.get_counts()andres.data.high.get_counts()— what (roughly)?res.join_data().get_counts()— which two keys appear, and which position holds the always-1 bit?- 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:
- The probabilities of each bitstring (which bitstrings even appear?).
- ⟨Z⊗Z⟩ via parity. Exact value expected for a Bell state?
- ⟨Z⟩ of qubit 0 alone (use
b[-1]!). Expected value? - Sanity-check ⟨ZZ⟩ against
qiskit.quantum_info:Statevectorof 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?