Education / Quantum Programming

Quantum Programming · Chapter 02

The Qiskit Environment

How do we make a quantum program reproducible and executable locally?

In this chapter · 3 sections
  1. 2.1 Python and Environments
  2. 2.2 The Qiskit Ecosystem
  3. 2.3 A First Program
Reading guide · preparation and approach

Starting point: Earlier programming chapters; relevant constructions from Quantum Computing and Quantum Algorithms.

Anchor example: Construct and run a one-qubit circuit in a clean Python environment.

This lesson includes explanations, worked examples, and problems with solutions. Advanced results whose proofs are outside the chapter are identified explicitly.

We have read a first program: a function that builds the Bell circuit and a check that its amplitudes match our prediction. That check passed on one computer. A program is useful only if someone else can run it and get the same answer, and quantum programs add a difficulty of their own, because the outcomes of measurement are random.

So how do we make a quantum program reproducible and executable locally? We need a little more Python, so we learn it on the simplest experiment with a predictable result, a one-qubit coin. Running that program on another machine means installing the same software, so we fix the versions in an isolated environment. The software comes as several packages, and we ask which of them a local experiment needs. Finally we run the coin and decide which random outputs count as correct.

By the end we have a program whose output we can predict, reproduce and test.

2.1 Python and Environments

Our experiment starts from \(\ket0\), applies H, and measures, many times. Since \(H\ket0=(\ket0+\ket1)/\sqrt2\), each outcome has probability one half, so we expect a fair coin. Writing this program needs a few Python features beyond the Bell function, and running it elsewhere needs the same software. We take the language first and the software second.

2.1.1 Python through circuit examples

How do we tell the computer to build \(H\ket0\), measure it many times, and count the outcomes? The building part uses only what we already know. We name a one-qubit circuit and append gates with method calls:

circuit = QuantumCircuit(1)
circuit.h(0)
circuit.measure_all()

The method measure_all() measures every qubit. It also adds a classical register, a row of classical bits that holds the results, and names it meas.

Running the circuit is the job of a sampler, an object that runs circuits and returns their measured outcomes. A sampler is built to run many circuits in one request, so it expects a list of circuits, even when we have only one. Square brackets make a list: [circuit] is a list with one item, and [a, b] has two. Inputs can also be given by name, as in shots=4096, which asks for 4096 repetitions.

What comes back is a tally: how many times each bit string appeared. Python stores such a tally as a dictionary, a table that maps each key to a value. A run might return {'1': 2030, '0': 2066}, where the keys are the bit strings and the values are their counts. We look up a value with counts.get("1", 0). The second argument is what we get if the key is missing, which happens when a string never occurred. The expression sum(counts.values()) adds all the counts.

Finally, we turn our prediction into checks with assert, as in the Bell program. The test == asks whether two values are equal, < asks whether one is smaller, and abs gives an absolute value.

2.1.2 Packages, versions, and isolated environments

Suppose the coin program runs on our computer but fails on a friend’s, with an error saying some name does not exist. How can the same text behave differently?

The program is not the only code involved. Its import lines pull in packages, bundles of code written by others and installed separately, such as qiskit. Each package exists in many versions, and between versions names change and behaviour can change. A package also relies on other packages, its dependencies, which have versions of their own. So the friend’s machine runs our text with different code underneath it.

The remedy has two parts. First we record the exact versions we tested. The file requirements.txt lists them as lines such as qiskit==2.6.0, which pins that package to one version. A second file, requirements-lock.txt, records every package that was installed, including the dependencies of dependencies.

Second, we install those versions in a virtual environment, a folder holding its own Python and its own packages, separate from the rest of the computer. Other projects then cannot change our versions, and we cannot change theirs. From the repository root, the first command below creates the environment, and the second switches the terminal to it. The third uses pip, Python’s package installer, to install the pinned versions. The fourth runs the companion program. The accompanying TESTING.md records the Python version and platform on which the companions were tested.

python3 -m venv .venv-quantum
source .venv-quantum/bin/activate
python -m pip install -r education/programming/requirements.txt
python education/programming/examples/chapter_02.py

2.2 The Qiskit Ecosystem

The requirements file installs three Qiskit packages, not one. Why three, and which do we need to flip a simulated coin? Once the software is installed, we must also decide where the program lives: in a script or in a notebook. The packages come first, because they decide what can run locally.

2.2.1 SDK, Aer, and Runtime

Which pieces do we need to run the coin on our own computer, and which only to reach real hardware? Each package answers a different need.

The package qiskit is the SDK itself. It builds circuits and computes quantum-information objects such as Statevector. It contains the compiler that rewrites circuits for a particular device. It also supplies reference primitives: standard interfaces for two basic requests, “sample these circuits” and “estimate these expectation values,” carried out by exact classical simulation. Our coin needs nothing more, because its sampler is one of these primitives.

Exact simulation of an ideal circuit is not always enough. When we later want faster simulators, or simulators that imitate a noisy device, we use the separate package qiskit-aer, called Aer. To send circuits to IBM’s remote devices we use qiskit-ibm-runtime, which needs an account. Importing that package contacts no one: only an explicit submission does. So none of the local examples needs an account or a token.

2.2.2 Notebooks and executable scripts

Suppose we develop the coin in a notebook, where code sits in cells that we run one at a time. We set shots = 100 in one cell and run it. Later we change that cell to shots = 4096, run a different cell, and delete the first one. Can a reader reproduce our result from the notebook they receive?

Not reliably. A notebook keeps every variable from the cells run so far, in whatever order we ran them. The result can depend on a cell that no longer exists. A script is a plain file that Python runs from top to bottom in a fresh process, so nothing hidden survives from before. If we use a notebook, we restart it and run all cells in order before trusting a result. The tested version of each experiment in this course is a script.

What else must we write down? The versions, the parameters, the number of shots, and the seed. A simulator does not use true randomness. It uses a pseudorandom number generator, a fixed algorithm whose outputs look random and are determined by a starting number called the seed. The same seed with the same implementation gives the same sequence of outcomes. A new version may consume the generator differently, so a seed reproduces a run only within one implementation.

Problem 2.1. What does changing the seed change?

Worked solution

It changes the pseudorandom shot sequence, not the ideal quantum probability. A changed probability indicates a different circuit, model, or execution setting.

2.3 A First Program

We now have the language, a pinned environment and the packages. We can run the coin. Two questions remain: what exactly does the program get back, and which outputs should we accept as correct? The first is about reading results, and the second about the randomness that every quantum program must allow for.

2.3.1 Build, run, and inspect

What happens between handing the sampler our circuit and holding a dictionary of counts? The program below writes each step on its own line, so that we can name it.

The first two imports let the program report the Python and Qiskit versions it ran with, the record we decided to keep. The next two bring in the circuit class and StatevectorSampler, a reference sampler.

After building the circuit, sampler = StatevectorSampler(seed=7) creates a sampler with a fixed seed. Its method run does not hand back counts directly. It returns a job, a handle to work that may still be in progress. This matters for remote devices, where a job can wait in a queue. Calling job.result() waits until the work is done and fetches the result.

The result holds one entry for each circuit in our list. Square brackets after it select an entry by position, starting from zero, so result[0] is the entry for our only circuit. Inside it, .data.meas is the record of the register meas, and get_counts() turns that record into the dictionary.

"""Runnable companion for Quantum Programming, Chapter 2."""
import sys
from importlib.metadata import version
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

circuit = QuantumCircuit(1)
circuit.h(0)
circuit.measure_all()

sampler = StatevectorSampler(seed=7)
job = sampler.run([circuit], shots=4096)
result = job.result()
counts = result[0].data.meas.get_counts()

assert sum(counts.values()) == 4096
assert abs(counts.get("1", 0) / 4096 - 0.5) < 0.05
print("Python", sys.version.split()[0], "Qiskit", version("qiskit"))
print(counts)

Companion script: chapter_02.py.

The sampler computed the amplitudes \((1/\sqrt2,1/\sqrt2)\) internally to generate its samples. What we received, though, is only a classical record of outcomes, the same kind of record a physical device would give.

Problem 2.2. Where are amplitudes measured in this example?

Worked solution

They are not physically measured. The reference simulator computes them classically to generate samples. Hardware can provide measurement outcomes, not its complete internal state vector.

2.3.2 Predictions and reproducible results

Which counts should our test accept? The obvious attempt demands exactly 2048 ones out of \(R=4096\) shots. Our run gave 2030, and the circuit is correct. So how far from 2048 may a correct run land?

Let \(b_k\) be 1 if shot \(k\) gives one and 0 otherwise, with probability \(p=\tfrac12\) of a one. A single \(b_k\) has mean \(p\). Its variance is \(\mathbb E[b_k^2]-p^2=p-p^2=p(1-p)\), because \(b_k^2=b_k\). The frequency is \(f=(b_1+\dots+b_R)/R\). For independent shots the variances add, and dividing by \(R\) divides the variance by \(R^2\), so \[\operatorname{Var} f=\frac{p(1-p)}{R},\qquad \text{standard deviation}=\sqrt{\frac{p(1-p)}{R}}=\frac1{2\sqrt R}=\frac1{128}.\]

The test accepts any frequency within 0.05 of one half. That is \(0.05\times128=6.4\) standard deviations. For so many shots the frequency is very nearly normally distributed, and a correct circuit lands outside that window with probability about \(2\times10^{-10}\). Our run lies \(18/4096\approx0.0044\) from one half, well inside. A second check confirms that all 4096 shots are present.

What does a pass establish? Only that the outcome distribution looks fair. The circuit that prepares \((\ket0-\ket1)/\sqrt2\) would pass too, and so would a classical coin, so a pass is not evidence that we applied H rather than something else. The fixed seed adds a different kind of value. With the same seed and versions, the program prints the same counts every time, so a change in the output tells us that something in the code or the environment changed. Re-running a fixed program to detect such changes is called regression testing.

Problem 2.3. Why should a fair coin not be tested by demanding exactly 2048 ones?

Worked solution

Even an ideal fair source fluctuates. Exact equality would reject many correct executions. Test a distribution with a justified tolerance and test the unitary separately.

Our coin had a single qubit, so each outcome was one character. With two qubits a sampler returns strings such as 01. Which character belongs to qubit 0?

Sources and further study: IBM, SDK primitive simulation.

Summary

Questions and worked problems

These exercises draw on the whole chapter. Attempt each question before opening a hint, explain your reasoning, then compare it with the worked solution.

1. Quick check §

For \(1024\) fair-coin shots, find the expected count of 1 and the standard deviations of the count and of the frequency. Would \(540\) ones be surprising?

Hint 1

Count standard deviation \(\sqrt{Rp(1-p)}\).

Hint 2

\(540-512=28\).

Worked solution

Expected count \(512\); count standard deviation \(\sqrt{1024/4}=16\); frequency standard deviation \(1/(2\sqrt{1024})=1/64\approx0.016\). A count of \(540\) is \(28/16\approx1.75\) standard deviations high: unusual but not alarming. Tests should use tolerances of several standard deviations, chosen in advance.

Revisit the relevant explanation

2. Quick check §

A circuit is built with its own classical register: QuantumCircuit(1, 1), H, then measure(0, 0). The sampler result has no field named meas. Under which name are the counts stored, and why?

Hint 1

measure_all creates a register named meas.

Hint 2

QuantumCircuit(1, 1) creates a classical register named c.

Worked solution

The counts are under data.c. QuantumCircuit(1, 1) creates a default classical register named c, and the sampler’s result fields are named after the circuit’s classical registers. measure_all adds a new register named meas, which is why the chapter’s example used data.meas. Code that hard-codes the name breaks when the circuit is built differently.

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

circuit = QuantumCircuit(1, 1)
circuit.h(0)
circuit.measure(0, 0)
result = StatevectorSampler(seed=7).run([circuit], shots=100).result()[0]
assert list(result.data.keys()) == ["c"]
print(result.data.c.get_counts())

Revisit the relevant explanation

3. Quick check §

Which package is needed for each task: (a) sampling an ideal circuit locally with StatevectorSampler; (b) simulating with a noise model; (c) submitting a job to an IBM device? Does importing the remote client submit anything?

Hint 1

Section 2.2.1 separates the SDK, Aer, and Runtime.

Hint 2

Submission needs an account and an explicit call.

Worked solution

(a) The Qiskit SDK. (b) Qiskit Aer, the separate simulation package with noise models. (c) IBM Runtime. Importing the Runtime client only loads code; submitting requires credentials, a chosen backend, and an explicit run call, which is why the course keeps such calls behind explicit options.

Revisit the relevant explanation

4. Worked problem §

Prepare a qubit with ry(\(\theta\)) so that it reads 1 with probability \(0.75\). (a) Find \(\theta\) using \(P(1)=\sin^2(\theta/2)\). (b) For \(4096\) shots, find the expected count and a five-standard-deviation tolerance on the frequency. (c) Write a test that checks the sampled frequency against this tolerance.

Hint 1

\(\sin^2(\theta/2)=3/4\) gives \(\theta=2\pi/3\).

Hint 2

Frequency standard deviation \(\sqrt{0.75\times0.25/4096}\approx0.0068\).

Worked solution

(a) \(\theta/2=\pi/3\), so \(\theta=2\pi/3\). (b) Expected count \(3072\); the frequency standard deviation is \(\sqrt{0.1875/4096}\approx0.0068\), so a five-sigma tolerance is about \(0.034\). (c) The test below asserts that the observed frequency lies within that tolerance of \(0.75\); with a fixed seed it is also a reproducible regression test.

import numpy as np
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

shots = 4096
circuit = QuantumCircuit(1)
circuit.ry(2 * np.pi / 3, 0)
circuit.measure_all()
counts = StatevectorSampler(seed=11).run([circuit], shots=shots).result()[0].data.meas.get_counts()
tolerance = 5 * np.sqrt(0.75 * 0.25 / shots)
assert abs(counts.get("1", 0) / shots - 0.75) < tolerance

Revisit the relevant explanation

5. Worked problem §

The chapter’s test asserts \(|f-0.5|<0.05\) for \(4096\) fair-coin shots. (a) How many standard deviations is \(0.05\)? Estimate how often a correct circuit fails the test. (b) A buggy circuit has \(P(1)=0.45\). How often does it pass? (c) What does this say about choosing shots and tolerance together?

Hint 1

Frequency standard deviation \(1/128\approx0.0078\).

Hint 2

For \(p=0.45\), the mean sits exactly at the tolerance edge.

Worked solution

(a) \(0.05/0.0078\approx6.4\) standard deviations; a correct circuit fails with probability around \(10^{-10}\). (b) For \(p=0.45\), the observed frequency is centred on \(0.45\), exactly at the boundary, so the bug passes about half the time. (c) A wide tolerance avoids false alarms but misses moderate bugs. Choosing the smallest error that must be detected, and then the shots needed to resolve it at the desired confidence, gives a test that is both reliable and sensitive; checking exact probabilities with Statevector avoids sampling altogether when possible.

Revisit the relevant explanation

6. Worked problem §

Run a single sampler call on two circuits: H then measure, and X then measure, with \(1000\) shots and a fixed seed. How do you access each circuit’s counts, and what should each contain?

Hint 1

result() returns one entry per submitted circuit, in order.

Hint 2

X then measure always gives 1.

Worked solution

The result is indexed in submission order: result()[0] for the H circuit and result()[1] for the X circuit, each with its own data.meas. The H counts are near \(500/500\); the X counts are exactly {'1': 1000}. Batching circuits in one call is efficient, but the index ties each result to its circuit, so the order must be preserved.

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

h = QuantumCircuit(1)
h.h(0)
h.measure_all()
x = QuantumCircuit(1)
x.x(0)
x.measure_all()
results = StatevectorSampler(seed=3).run([h, x], shots=1000).result()
assert results[1].data.meas.get_counts() == {"1": 1000}
assert abs(results[0].data.meas.get_counts().get("1", 0) - 500) < 80

Revisit the relevant explanation

7. Worked problem §

Show what a seed does. (a) Run the fair-coin circuit twice with seed 7 and once with seed 8. Which counts agree? (b) What does a fixed seed guarantee, and what does it not guarantee?

Hint 1

A seed fixes the pseudorandom sequence of one implementation.

Hint 2

Different library versions may consume random numbers differently.

Worked solution

(a) The two seed-7 runs give identical counts; the seed-8 run gives different counts, still near \(2048\). (b) A seed makes a particular sampling run repeatable with the same software and settings, which is valuable for regression tests. It does not make the result “correct,” it does not guarantee identical counts across library versions or simulators, and it does not remove statistical uncertainty: a seeded sample is still one sample.

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

circuit = QuantumCircuit(1)
circuit.h(0)
circuit.measure_all()

def counts(seed):
    return StatevectorSampler(seed=seed).run([circuit], shots=4096).result()[0].data.meas.get_counts()

assert counts(7) == counts(7)
print(counts(7), counts(8))

Revisit the relevant explanation

8. Misconception §

A student increases the shots from \(4096\) to \(40960\) with the same seed and expects every count to be exactly ten times larger. Why is this wrong, and how do the frequency fluctuations change?

Hint 1

A new shot count changes the sampled sequence.

Hint 2

The frequency standard deviation scales as \(1/\sqrt R\).

Worked solution

Counts are random samples: with more shots the sampler draws a different and longer sequence, so the new counts are not a scaled copy of the old ones. What changes predictably is the precision: the frequency standard deviation shrinks by \(\sqrt{10}\approx3.2\), from about \(0.0078\) to \(0.0025\). Expect the frequency to move closer to \(0.5\) on average, not the counts to scale exactly.

Revisit the relevant explanation

9. Extension §

Write a reusable test helper that decides whether counts from a fair-coin circuit are consistent with probability \(\frac12\), using a tolerance of \(z\) standard deviations of the frequency. Apply it to a seeded run. How would you choose \(z\) for a suite of \(100\) such tests?

Hint 1

Tolerance \(z/(2\sqrt{R})\) for a fair coin.

Hint 2

With many tests, the chance that some correct test fails adds up.

Worked solution

The helper below computes the tolerance \(z/(2\sqrt R)\) and compares the observed frequency. With \(z=5\) a correct test fails with probability under \(10^{-6}\), so even \(100\) tests rarely raise a false alarm (about \(10^{-4}\) in total); with \(z=2\), about \(5\%\) of correct tests would fail somewhere in every run of the suite. The number of tests, the acceptable false-alarm rate, and the smallest bug to catch together determine \(z\) and the shots.

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

def consistent_with_fair(counts, shots, z=5):
    tolerance = z / (2 * shots ** 0.5)
    return abs(counts.get("1", 0) / shots - 0.5) < tolerance

circuit = QuantumCircuit(1)
circuit.h(0)
circuit.measure_all()
counts = StatevectorSampler(seed=7).run([circuit], shots=4096).result()[0].data.meas.get_counts()
assert consistent_with_fair(counts, 4096)

Revisit the relevant explanation

10. Extension §

For deterministic properties, sampling is unnecessary. Use Statevector to obtain the exact outcome probabilities of the ry(\(2\pi/3\)) circuit (after removing its final measurements), and test them against \(0.25\) and \(0.75\). When is sampling still the right test?

Hint 1

remove_final_measurements(inplace=False) returns a unitary circuit.

Hint 2

probabilities_dict gives exact probabilities keyed by bit string.

Worked solution

Statevector computes the exact probabilities \(0.25\) and \(0.75\), so the test can use a tight numerical tolerance and has no statistical failures. Sampling remains the right test when the behavior being checked is statistical or involves classical processing of samples: estimator code, mid-circuit measurements with feedback, noise simulations, or the sampler interface itself. Use exact methods for the mathematics and sampling for the pipeline.

import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Statevector

circuit = QuantumCircuit(1)
circuit.ry(2 * np.pi / 3, 0)
circuit.measure_all()
probabilities = Statevector.from_instruction(circuit.remove_final_measurements(inplace=False)).probabilities_dict()
assert abs(probabilities["0"] - 0.25) < 1e-12 and abs(probabilities["1"] - 0.75) < 1e-12

Revisit the relevant explanation

Search this book

Search the course.