Case Study: Building a Reproducible Quantum Experiment Harness
Executive Summary
Six months after publishing, a reviewer asks you to reproduce Figure 3. The backend has been recalibrated two hundred times, Qiskit has had a major release, the transpiler's stochastic routing has changed, and your script hardcodes least_busy(). You cannot reproduce your own result, and neither can anyone else.
Quantum experiments have more reproducibility hazards than ordinary software: the hardware itself is a time-varying instrument. This case study builds a harness that records enough to make a run reconstructible — and, where exact reproduction is impossible in principle, makes that explicit rather than pretending otherwise.
Skills applied
- Identifying every source of run-to-run variation (§8.14).
- Pinning transpilation with seeds and explicit layouts (§8.10).
- Capturing backend calibration state alongside results (§8.13).
- Reporting statistical and systematic uncertainty separately.
Background: seven sources of variation
| # | Source | Reproducible? | Mitigation |
|---|---|---|---|
| 1 | Shot noise | No (statistical) | Report error bars; fix RNG seed for simulation |
| 2 | Transpiler stochasticity | Yes | seed_transpiler, or save the transpiled circuit |
| 3 | Qubit layout choice | Yes | Explicit initial_layout |
| 4 | Backend calibration drift | No (physical) | Record properties + timestamp |
| 5 | Which backend least_busy picked |
Yes | Pin the backend name |
| 6 | Qiskit / provider version | Yes | Pin versions, record them |
| 7 | Device retirement | No | Archive the transpiled circuit and raw counts |
Rows 1, 4, and 7 are irreducible: shot noise is physics, drift is physics, and hardware gets decommissioned. The harness cannot eliminate them, so its job is to record them precisely enough that a later reader can tell whether a discrepancy is expected.
Phase 1: Pin everything pinnable
import json, hashlib, sys
from datetime import datetime, timezone
from qiskit import transpile, qpy
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler
CONFIG = {
"backend_name": "ibm_brisbane", # pinned, not least_busy()
"initial_layout": [12, 13, 14], # pinned
"optimization_level": 3,
"seed_transpiler": 20260727,
"shots": 8192,
}
service = QiskitRuntimeService()
backend = service.backend(CONFIG["backend_name"])
isa = transpile(circuit, backend,
initial_layout=CONFIG["initial_layout"],
optimization_level=CONFIG["optimization_level"],
seed_transpiler=CONFIG["seed_transpiler"])
Three of the seven hazards are closed by this block alone. The most important is the pinned backend: least_busy() is convenient and makes a script non-reproducible by construction.
Phase 2: Archive the transpiled circuit
The transpiled circuit is the actual experiment. Transpiler behavior changes between releases, so re-transpiling later is not guaranteed to reproduce it — even at a fixed seed.
with open("run_isa_circuit.qpy", "wb") as f:
qpy.dump(isa, f) # exact, version-stable serialization
circuit_hash = hashlib.sha256(
qpy_bytes_of(isa)).hexdigest()[:16] # identity you can cite
QPY is Qiskit's binary circuit format and survives across versions. Archiving it means a future reader can execute exactly what you executed, rather than an approximation of it.
Phase 3: Snapshot the instrument
This is the step that distinguishes quantum experiments from ordinary software runs. The device is part of the experiment.
props = backend.properties()
snapshot = {
"last_calibrated": props.last_update_date.isoformat(),
"qubits": {
q: {
"T1_us": props.t1(q) * 1e6,
"T2_us": props.t2(q) * 1e6,
"readout_error": props.readout_error(q),
"frequency_GHz": props.frequency(q) / 1e9,
} for q in CONFIG["initial_layout"]
},
"two_qubit_gate_errors": {
f"{a}_{b}": props.gate_error("ecr", [a, b])
for a, b in backend.coupling_map
if a in CONFIG["initial_layout"] and b in CONFIG["initial_layout"]
},
}
Without this, a reader who reruns your script in a year and gets a different number cannot tell whether your result was wrong or the device simply has different $T_2$ today. With it, they can check.
Phase 4: Run, and record provenance
sampler = Sampler(mode=backend)
job = sampler.run([isa], shots=CONFIG["shots"])
counts = job.result()[0].data.c.get_counts()
record = {
"config": CONFIG,
"circuit_sha256": circuit_hash,
"circuit_depth": isa.depth(),
"two_qubit_gates": sum(1 for i in isa.data if i.operation.num_qubits == 2),
"backend_snapshot": snapshot,
"job_id": job.job_id(),
"submitted_utc": datetime.now(timezone.utc).isoformat(),
"versions": {
"python": sys.version.split()[0],
"qiskit": __import__("qiskit").__version__,
"qiskit_ibm_runtime": __import__("qiskit_ibm_runtime").__version__,
},
"raw_counts": counts,
}
json.dump(record, open(f"run_{circuit_hash}.json", "w"), indent=2)
Raw counts are the primary datum. Store them always, unprocessed. Mitigated, normalized, or fitted values are derived products and should be recomputable from the raw counts by anyone.
The job_id is worth keeping: providers retain job records, so a reader can often retrieve the original result server-side independently of your file.
Phase 5: Report uncertainty in two parts
A single error bar hides the distinction that matters.
Statistical — shot noise, shrinks as $1/\sqrt N$, and is fully under your control:
$$\sigma_{\text{stat}} = \sqrt{\frac{p(1-p)}{N}}$$
Systematic — readout bias, coherent gate errors, drift. Does not shrink with more shots. Estimate it by repeating the identical experiment across several calibration cycles and taking the spread of the means.
# same circuit, five separate days
means = [0.812, 0.798, 0.834, 0.805, 0.789]
import statistics
print(f"systematic spread: {statistics.stdev(means):.3f}") # ~0.017
A result quoted as $0.808 \pm 0.004$ (statistical only) when the day-to-day spread is $0.017$ is misleading by a factor of four. Report both: $0.808 \pm 0.004\ (\text{stat}) \pm 0.017\ (\text{sys})$.
This is the most common reporting failure in the field. Shot-noise error bars are easy to compute and are routinely presented as though they were the whole uncertainty.
Phase 6: The reproducibility statement
Publish, alongside the figure:
Circuits were transpiled for
ibm_brisbanewithoptimization_level=3,seed_transpiler=20260727, andinitial_layout=[12,13,14]; the exact transpiled circuits are archived in QPY format (SHA-256a3f2…). Backend calibration data at submission time is included in the record. Raw counts are provided; all derived quantities are recomputable from them. Results were collected across five calibration cycles between 2026-07-14 and 2026-07-21; the day-to-day spread of the mean is reported as a systematic uncertainty. Exact reproduction is not possible in principle, as device parameters drift and the calibration state of a given day cannot be restored.
That last sentence is the honest one, and including it costs nothing.
Discussion Questions
- Three of the seven variation sources are irreducible. Does that make quantum experiments unreproducible, or does it change what reproducibility means?
- Why archive the transpiled circuit rather than the source circuit plus a seed?
- A colleague reports $0.91 \pm 0.003$ from 100,000 shots in one session. What is your first question?
- What is lost if only mitigated results are published, and not raw counts?
Your Turn: Extensions
- Implement the harness and run one circuit through it; inspect the JSON record.
- Run the same circuit on five separate days; plot the means and compute the systematic spread.
- Reload an archived QPY circuit and confirm it matches the recorded hash.
- Compare the transpiled output of the same source circuit under two Qiskit versions.
Key Takeaways
- The hardware is part of the experiment: record calibration data and timestamps with every result.
- Pin the backend, layout, seed, and versions; archive the transpiled circuit in QPY, since that is what actually ran.
- Raw counts are the primary datum. Everything else must be recomputable from them.
- Separate statistical from systematic uncertainty. Shot-noise bars alone routinely understate total error several-fold.
- Where exact reproduction is physically impossible, say so explicitly instead of leaving the reader to discover it.