Across the series I kept saying the simulator was small: a qubit is an array, a
gate is a matrix, the whole thing is a few hundred lines. This appendix makes good
on that the literate way. We build the statevector engine, the part every algorithm
in the series ran on, in one sitting, with nothing hidden. Every line below is the
actual qfs library code, presented in the order you would write it. By the end we
will have a working quantum computer simulator and we will use it to make a Bell
state, having written every operation it rests on ourselves.
There are exactly three pieces: the gates (data), the embedding (how a small gate acts on a big register), and the state (the array, plus apply and measure).
import numpy as np
1. Gates are constant matrices
A single-qubit gate is a 2x2 complex matrix, and the standard ones are just numbers you can write down. This is the core of the gate library; there is no cleverness to it, which is the point. The Pauli matrices X, Y, Z, the Hadamard H that builds superposition, and the phase gates S and T.
I = np.array([[1, 0], [0, 1]], dtype=np.complex128)
X = np.array([[0, 1], [1, 0]], dtype=np.complex128)
Y = np.array([[0, -1j], [1j, 0]], dtype=np.complex128)
Z = np.array([[1, 0], [0, -1]], dtype=np.complex128)
H = np.array([[1, 1], [1, -1]], dtype=np.complex128) / np.sqrt(2)
S = np.array([[1, 0], [0, 1j]], dtype=np.complex128)
T = np.array([[1, 0], [0, np.exp(1j * np.pi / 4)]], dtype=np.complex128)
The continuous rotations are the only gates that need to be functions, because they
take an angle. Ry rotates a qubit’s Bloch vector around the y axis; it is the one
we use below to make an arbitrary state.
def Ry(theta):
c, s = np.cos(theta / 2), np.sin(theta / 2)
return np.array([[c, -s], [s, c]], dtype=np.complex128)
def phase(lam):
return np.array([[1, 0], [0, np.exp(1j * lam)]], dtype=np.complex128)
The only property a gate must have is that it is unitary, $U^\dagger U = I$, which is exactly the condition that keeps a normalized state normalized. We can check it.
for name, U in [("X", X), ("H", H), ("S", S), ("Ry(0.7)", Ry(0.7))]:
print(f"{name:8s} unitary:", np.allclose(U.conj().T @ U, I))
X unitary: True
H unitary: True
S unitary: True
Ry(0.7) unitary: True
2. Embedding a small gate into a big register
Here is the first real idea. A gate is 2x2, but an $n$-qubit state lives in
$2^n$ dimensions. To apply a single-qubit gate to qubit target of an $n$-qubit
register, we need the full $2^n \times 2^n$ operator that acts as the gate on that
one qubit and as the identity on every other. The textbook way to write it is a
Kronecker product: identity on each untouched wire, the gate on the target wire.
def kron_embed(U, target, n):
"""Embed single-qubit U on `target` via Kronecker products (big-endian)."""
op = np.array([[1.0 + 0j]])
for q in range(n):
op = np.kron(op, U if q == target else np.eye(2, dtype=np.complex128))
return op
That is correct and clear, and it is how you should first understand gate
application. But it cannot do a controlled gate (where the gate fires only when some
other qubit is 1), and those are half of every interesting circuit. So the library
uses a slightly lower-level version that builds the operator column by column. For
each basis state (each column), it checks whether all the control qubits are 1; if
so it applies the gate to the target bit, otherwise it leaves the column alone. With
no controls this does exactly what kron_embed does; with controls it is a
controlled gate. This one function is the engine’s true workhorse.
def embed(U, target, n, controls=()):
"""Full 2^n operator: apply single-qubit U to `target` when all `controls` are 1.
Big-endian: qubit q lives at bit position (n - 1 - q). With controls=() this
is the plain single-qubit embedding; with controls it is a controlled-U.
"""
dim = 2 ** n
op = np.zeros((dim, dim), dtype=np.complex128)
tpos = n - 1 - target
cpos = [n - 1 - c for c in controls]
for col in range(dim):
if all((col >> p) & 1 for p in cpos):
tbit = (col >> tpos) & 1
for out in (0, 1):
amp = U[out, tbit]
if amp == 0:
continue
row = (col & ~(1 << tpos)) | (out << tpos)
op[row, col] += amp
else:
op[col, col] = 1.0
return op
The two agree when there are no controls, which is the sanity check that the clever version did not break anything.
print("embed == kron_embed (no controls):", np.allclose(embed(H, 1, 3), kron_embed(H, 1, 3)))
embed == kron_embed (no controls): True
And the controlled case gives a CNOT: control qubit 0, target qubit 1, it flips the target exactly when the control is set. On the two-qubit basis that is the permutation that sends $|10\rangle$ to $|11\rangle$ and back.
cnot = embed(X, target=1, n=2, controls=(0,))
print("CNOT is the expected 4x4 permutation:")
print(cnot.real.astype(int))
CNOT is the expected 4x4 permutation:
[[1 0 0 0]
[0 1 0 0]
[0 0 0 1]
[0 0 1 0]]
3. The state, and the two things you do to it
Now the state itself. An $n$-qubit pure state is a length-$2^n$ array of complex
amplitudes, starting in $|00\ldots0\rangle$. There are exactly two operations: apply
a gate (multiply by the embedded operator), and measure (sample by the Born rule and
collapse). That is the heart of the class; the library version adds a few
conveniences you have already met, like sample and from_amplitudes, but nothing
structural.
class StateVector:
def __init__(self, n, rng=None):
self.n = n
self.amps = np.zeros(2 ** n, dtype=np.complex128)
self.amps[0] = 1.0
self.rng = rng if rng is not None else np.random.default_rng()
def apply(self, U, target, controls=()):
self.amps = embed(U, target, self.n, controls) @ self.amps
return self
def probabilities(self):
return np.abs(self.amps) ** 2
def measure(self, qubit):
pos = self.n - 1 - qubit
idx = np.arange(2 ** self.n)
is_one = ((idx >> pos) & 1).astype(bool)
p_one = float(np.sum(np.abs(self.amps[is_one]) ** 2))
outcome = 1 if self.rng.random() < p_one else 0
keep = is_one if outcome == 1 else ~is_one
self.amps = np.where(keep, self.amps, 0.0)
self.amps = self.amps / np.linalg.norm(self.amps)
return outcome
Look at how little is there. apply is one matrix multiply. probabilities is the
squared magnitudes, the Born rule in one line. measure does the only subtle thing
in the whole engine: it computes the probability that the chosen qubit is 1 by
summing the squared amplitudes of the basis states where that bit is set, flips a
weighted coin, then zeroes out the amplitudes inconsistent with the outcome and
renormalizes. Collapse is just deleting the part of the array you did not see.
4. A quantum computer, used
That is the entire engine: three matrices, one embedding, one small class. Everything
the series did, Grover, the Fourier transform, Shor, ran on top of this, plus one
extension you met in post 5, controlled_operator, which lets a control qubit gate
a whole register at once.
Let us prove it works by building a Bell state, the canonical piece of entanglement,
from the parts we just wrote: put qubit 0 in superposition with a Hadamard, then
entangle qubit 1 with a CNOT.
bell = StateVector(2).apply(H, 0).apply(X, 1, controls=(0,))
print("Bell state amplitudes:", np.round(bell.amps, 3))
print("equal weight on |00> and |11>, nothing on |01> or |10>")
Bell state amplitudes: [0.707+0.j 0. +0.j 0. +0.j 0.707+0.j]
equal weight on |00> and |11>, nothing on |01> or |10>
And the signature of entanglement, that measuring one qubit instantly fixes the
other. We built measure ourselves a moment ago; here it is enforcing the
correlation. Across many runs the two qubits always agree.
rng = np.random.default_rng(0)
agree = 0
for _ in range(1000):
sv = StateVector(2, rng=rng).apply(H, 0).apply(X, 1, controls=(0,))
if sv.measure(0) == sv.measure(1):
agree += 1
print(f"the two qubits agreed in {agree}/1000 measurements")
the two qubits agreed in 1000/1000 measurements
Where this leaves us
Fifty lines, give or take, and we have a quantum computer simulator that entangles
qubits and measures them correctly. The rest of the qfs library is more of the
same shape: the algorithms are circuits built from these gates, the Fourier transform
is a particular pattern of controlled phases, the density matrix swaps the length
$2^n$ vector for a $2^n \times 2^n$ matrix and the noise channels act on that. But the
beating heart is what you just read in full. There was never anything behind the
curtain, because we wrote the curtain. That was the whole idea of building it from
scratch: not to have a simulator, but to have no mysteries left in it.
Discussion