Onset of ergodicity across scales
This notebook walks through how to run and analyze the experiments in Onset of Ergodicity Across Scales on a Digital Quantum Processor [arXiv:2603.12236]: from the model to the circuits, from bitstrings to entropies, from noisy data to mitigated results, and from a 16-qubit simulation to a 64-qubit run on an IBM Quantum® Nighthawk processor. All the code is here, and each function is defined where it is first needed.
Time | Section |
|---|---|
| 0–1 min | Setup |
| 1–6 min | 1. From the model to a circuit |
| 6–12 min | 2. What to measure and how to estimate it |
| 12–19 min | 3. Noise, and how to undo it |
| 19–24 min | 4. Run at scale |
| 24–33 min | 5. The hardware data |
| 33–35 min | Wrap-up |
Following along. Run the cells from top to bottom. No IBM Quantum account is needed; the cells that submit hardware jobs are switched off, and section 5 analyzes a saved hardware run, which the setup cell downloads to data/. Questions for discussion are marked Question; try to answer before you open the answer.
Setup
Requires Python 3.12 or later and the following packages:
- Qiskit SDK v2.5 or later, with visualization support
- Qiskit Runtime v0.49 or later (
pip install qiskit-ibm-runtime) - Qiskit Aer v0.17 or later (
pip install qiskit-aer) - Samplomatic v0.21 or later (
pip install samplomatic) - NumPy and Matplotlib
The setup cell also downloads the saved hardware run and MPS references (about 9 MB) to data/, unless they are already there.
from math import comb
from pathlib import Path
from urllib.request import urlretrieve
import matplotlib.pyplot as plt
import numpy as np
from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister
from qiskit.primitives import StatevectorSampler
from qiskit.quantum_info import (
PauliLindbladMap,
QubitSparsePauli,
Statevector,
)
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel, ReadoutError, depolarizing_error
from qiskit_aer.primitives import SamplerV2 as AerSampler
RUN_ON_HARDWARE = (
False # True submits the 8x8 jobs in section 4 (needs Nighthawk access)
)
COMPUTE_MPS_REFERENCES = False # True recomputes the MPS references in section 5 (about 18 minutes)
# Download the saved hardware run and MPS references, unless they are already in data/
data_url = "https://raw.githubusercontent.com/Qiskit/documentation/main/datasets/tutorials/onset-of-ergodicity"
Path("data").mkdir(exist_ok=True)
for name in ("hardware_run.npz", "mps_references_all.npz"):
if not Path("data", name).exists():
urlretrieve(f"{data_url}/{name}", Path("data", name))1. From the model to a circuit
The two-qubit gate
Every bond of the lattice gets the same two-qubit gate, made of a Heisenberg entangler followed by random fields:
with drawn uniformly from for every gate.
Question. How would you decompose into gates the hardware can run?
The fields are easy. is a single-qubit rotation on each qubit.
The entangler is the hard part. , and commute, so it factors into three rotations, , and . Each takes two CNOTs, which makes six CNOTs per gate.
There is a better way. Any two-qubit gate can be built from at most three CNOTs and single-qubit rotations. The optimal circuit starts from the form , and this entangler already has that form with . So it maps straight onto three CNOTs, and the single-qubit angles are linear in . The experiment code builds the gate this way.
On hardware, every two-qubit gate adds noise, so halving the CNOT count matters.
def xxyyzz_gate(circuit, coupling, i, j):
# exp(iJ(XX + YY + ZZ)) with three CNOTs; only the rotation angles depend on J
circuit.rz(-np.pi / 2, j)
circuit.cx(j, i)
circuit.rz(np.pi / 2 - 2 * coupling, i)
circuit.ry(2 * coupling - np.pi / 2, j)
circuit.cx(i, j)
circuit.ry(np.pi / 2 - 2 * coupling, j)
circuit.cx(j, i)
circuit.rz(np.pi / 2, i)
def heisenberg_gate(circuit, coupling, i, j, h_i, h_j):
# The entangler first, then the random fields exp(i h Z)
xxyyzz_gate(circuit, coupling, i, j)
circuit.rz(-2 * h_i, i)
circuit.rz(-2 * h_j, j)
h_i, h_j = 0.3, -0.7
gate = QuantumCircuit(2)
heisenberg_gate(gate, 0.05 * np.pi, 0, 1, h_i, h_j)
gate.draw("mpl")Output:
The brickwork and the Floquet cycle
The gates tile the square lattice in four sublayers of non-overlapping bonds, a brickwork: horizontal bonds starting on even columns, then odd columns, then vertical bonds starting on even rows, then odd rows. Qubit sits at site .
def brickwork_layers(num_cols, num_rows):
# Bonds of the square lattice, in the four sublayers of the brickwork
def qubit(x, y):
return y * num_cols + x
layers = []
for p in (0, 1): # horizontal bonds starting on even, then odd, columns
layers.append(
[
(qubit(x, y), qubit(x + 1, y))
for y in range(num_rows)
for x in range(p, num_cols - 1, 2)
]
)
for p in (0, 1): # vertical bonds starting on even, then odd, rows
layers.append(
[
(qubit(x, y), qubit(x, y + 1))
for y in range(p, num_rows - 1, 2)
for x in range(num_cols)
]
)
return layers
brickwork_layers(4, 4)Output:
[[(0, 1), (2, 3), (4, 5), (6, 7), (8, 9), (10, 11), (12, 13), (14, 15)],
[(1, 2), (5, 6), (9, 10), (13, 14)],
[(0, 4), (1, 5), (2, 6), (3, 7), (8, 12), (9, 13), (10, 14), (11, 15)],
[(4, 8), (5, 9), (6, 10), (7, 11)]]
The four sublayers make one Floquet cycle. The cycle repeats times with the same fields, so the dynamics are random in space and periodic in time. The circuit starts from the Néel state, and is half the side length of the lattice.
The next cell draws one Floquet cycle of the hardware lattice, sublayer by sublayer, with the fields of the hardware run, in the style of Figure 5 of the paper. Thick bonds are the gates of each sublayer. Each qubit a gate acts on is colored by the random field it then receives, and idle qubits are white. The arrows show the Néel initial state, with for . The paper's figure applies the vertical sublayers first; neither order is expected to change the physics.
# circuit structure, 8x8 system:
side = 8
all_bonds = [bond for layer in brickwork_layers(side, side) for bond in layer]
# get the disorder parameters for a given system:
def sample_disorder(num_cols, num_rows, rng):
# One pair of fields (h_i, h_j) per gate, reused in every Floquet cycle
return [
[
(bond, tuple(rng.uniform(-np.pi / 2, np.pi / 2, 2)))
for bond in layer
]
for layer in brickwork_layers(num_cols, num_rows)
]
# build the structure for the 8x8 system
example_disorder = sample_disorder(
side, side, np.random.default_rng(7)
) # the hardware fields (section 4)
titles = [
r"$U^H_\mathrm{even}$",
r"$U^H_\mathrm{odd}$",
r"$U^V_\mathrm{even}$",
r"$U^V_\mathrm{odd}$",
]
norm = plt.Normalize(-np.pi / 2, np.pi / 2)
cmap = plt.get_cmap("RdBu_r")
fig, axes = plt.subplots(1, 4, figsize=(16, 4.8), layout="constrained")
for ax, title, sublayer in zip(axes, titles, example_disorder):
for i, j in all_bonds:
ax.plot(
[i % side, j % side],
[i // side, j // side],
color="0.75",
lw=1,
zorder=0,
)
fields = {}
for (i, j), (h_i, h_j) in sublayer:
ax.plot(
[i % side, j % side],
[i // side, j // side],
color="0.2",
lw=4,
zorder=1,
)
fields[i], fields[j] = h_i, h_j
for q in range(side * side):
x, y = q % side, q // side
ax.scatter(
x,
y,
s=260,
color=cmap(norm(fields[q])) if q in fields else "white",
edgecolor="black",
zorder=2,
)
ax.text(
x,
y,
"↓" if (x + y) % 2 else "↑",
ha="center",
va="center",
fontsize=9,
zorder=3,
)
ax.set_aspect("equal")
ax.set_xlim(-1, side) # padding, so the panels read as separate steps
ax.set_ylim(side, -1)
ax.axis("off")
ax.set_title(title, y=-0.1, fontsize=13)
fig.colorbar(
plt.cm.ScalarMappable(norm=norm, cmap=cmap),
ax=axes,
shrink=0.7,
label="Field $h$ after the gate",
)
plt.show()Output:
Define the initial state.
# initial state
def neel_state(circuit, num_cols, num_rows, layout):
for y in range(num_rows):
for x in range(num_cols):
if (x + y) % 2 == 1:
circuit.x(layout[y * num_cols + x])# The initial Néel state: |1⟩ (black, ↓) on odd sites, |0⟩ (white, ↑) on even sites
fig, ax = plt.subplots(figsize=(4, 4.4))
for i, j in all_bonds:
ax.plot(
[i % side, j % side],
[i // side, j // side],
color="0.75",
lw=1,
zorder=0,
)
for q in range(side * side):
x, y = q % side, q // side
excited = (x + y) % 2 == 1
ax.scatter(
x,
y,
s=150,
color="black" if excited else "white",
edgecolor="black",
zorder=2,
)
ax.text(
x,
y,
"↓" if excited else "↑",
ha="center",
va="center",
fontsize=7,
color="white" if excited else "black",
zorder=3,
)
ax.set_aspect("equal")
ax.set_xlim(-1, side)
ax.set_ylim(side, -1)
ax.axis("off")
ax.set_title("Initial state: Néel", fontsize=13)
plt.show()Output:
# actual circuit construction:
def heisenberg_floquet_circuit(
num_cols, num_rows, coupling, num_cycles, disorder, measure=True
):
num_qubits = num_cols * num_rows
circuit = QuantumCircuit(num_qubits)
neel_state(circuit, num_cols, num_rows, list(range(num_qubits)))
cycle = QuantumCircuit(num_qubits)
for sublayer in disorder:
for (i, j), (h_i, h_j) in sublayer:
heisenberg_gate(cycle, coupling, i, j, h_i, h_j)
for _ in range(num_cycles):
circuit.compose(cycle, inplace=True)
if measure:
circuit.measure_all()
return circuitA example
The simulations use a lattice with two cycles, and the couplings studied in the paper. One set of fields is reused for every coupling.
# simulation parameters:
num_cols = num_rows = 4
num_qubits = num_cols * num_rows
num_cycles = 2
couplings = (
np.array([0.005, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10, 0.125])
* np.pi
)
disorder = sample_disorder(num_cols, num_rows, np.random.default_rng(42))
# circuit construction:
logical_circuits = [
heisenberg_floquet_circuit(
num_cols, num_rows, coupling, num_cycles, disorder
)
for coupling in couplings
]
logical_circuits[5].draw(output="mpl", fold=-1)Output:
# transpiling the circuit:
pass_manager = generate_preset_pass_manager(
optimization_level=3,
basis_gates=["cz", "rz", "sx", "x"],
seed_transpiler=7,
)
circuits = [pass_manager.run(circuit) for circuit in logical_circuits]
def two_qubit_stats(circuit):
count = sum(1 for inst in circuit.data if inst.operation.num_qubits == 2)
depth = circuit.depth(lambda inst: inst.operation.num_qubits == 2)
return count, depth
stats = {two_qubit_stats(circuit) for circuit in circuits}
assert len(stats) == 1, "the CZ structure should not depend on J"
num_cz, depth = stats.pop()
print(
f"{num_qubits} qubits: {num_cz} CZ gates in {depth} layers, for every coupling"
)
circuits[5].draw("mpl", fold=-1, scale=0.4)Output:
16 qubits: 144 CZ gates in 24 layers, for every coupling
A clever compiler is not needed, because the circuits (and experiment) were designed for IBM Nighthawk.
Simulate the lattice
For the lattice, you can do both: compute the exact state at every coupling, and sample every circuit with 10,000 shots, as an experiment would. The shots are stored with shape (batches, shots, qubits), the same layout as the randomizations in the hardware data. Here all 10,000 shots form a single batch, so the bootstrap over batches adds no shot noise, and the error bars in the next plot show only the spread between marginal positions.
exact_states = [
Statevector(
heisenberg_floquet_circuit(
num_cols, num_rows, c, num_cycles, disorder, measure=False
)
)
for c in couplings
]
shots = 10_000
results = (
StatevectorSampler(default_shots=shots, seed=1234).run(circuits).result()
)
# the output of the simulation for the 10 couplings
bits = [
result.data.meas.to_bool_array(order="little").reshape(
1, 10_000, num_qubits
)
for result in results
]
print(bits[0])Output:
[[[False True False ... False True False]
[False True False ... False True False]
[False True False ... False True False]
...
[False True False ... False True False]
[False True False ... False True False]
[False True False ... False True False]]]
2. What to measure and how to estimate it
The IPR and the collision entropy
For a marginal of qubits with bitstring distribution , the quantity to estimate is the inverse participation ratio (IPR) of the marginal distribution, and from it the collision entropy:
is also the probability that two shots agree on the marginal. A product state has , and a random state has , so climbs from 0 to about bits as the marginal becomes random.
Estimator 1: count pairs of shots
Question. How do you estimate this quantity from your shots?
The naive estimator computes each from the shots as , then sums . This estimator is biased.
counts every shot as agreeing with itself, which pushes the estimate up: on average it gives instead of . Since is the probability that two shots agree, count only pairs of different shots:
This is unbiased, and it is what
ipr_from_shotsbelow computes.
# IPR expectation values:
# refactor this function cause it does not scale
def ipr_from_shots(bits):
# Pair-counting estimate of the IPR of a marginal: the fraction of pairs of different shots
# that agree on it. bits holds the marginal's qubits only, with shape (randomizations, shots,
# qubits), and all randomizations are pooled.
n = bits.shape[-1]
outcomes = bits @ 2 ** np.arange(
n
) # each shot's marginal bitstring, read as an integer
counts = np.bincount(outcomes.ravel()) # how often each outcome occurs
total = counts.sum()
return float((counts * (counts - 1)).sum() / (total * (total - 1)))
def collision_entropy(ipr):
return -np.log2(ipr)Estimator 2: Parseval expansion
The IPR can also be written through the parities of all nonempty subsets of the marginal:
This form matters because error mitigation methods such as PEA work on expectation values, not on probabilities.
One qubit: The outcome probabilities are and , so
A marginal: With its qubits labeled , the marginal has parities: 4 single qubits, 6 pairs, 4 triples, and the whole marginal, so
def parity_supports(patch):
# All nonempty subsets S of the marginal
return sorted(
[
tuple(q for k, q in enumerate(patch) if mask & (1 << k))
for mask in range(1, 1 << len(patch))
],
key=len,
)
def parity_expectation_values(bits, supports):
# Shot-averaged <Z_S> for every support, per randomization
signs = 1 - 2 * bits.astype(np.int8)
return np.stack(
[signs[..., list(s)].prod(axis=-1).mean(axis=-1) for s in supports],
axis=-1,
)
# Example: the parities of a 3-qubit marginal made of lattice qubits 5, 6, and 9
print(parity_supports([5, 6, 9]))
print(f"A 3×3 marginal has {len(parity_supports(range(9)))} parities")Output:
[(5,), (6,), (9,), (5, 6), (5, 9), (6, 9), (5, 6, 9)]
A 3×3 marginal has 511 parities
Average over marginals
Additional useful functions: Haar values, selecting marginals, bootstrapping, and averaging.
# functions for the Haar values on plots:
def haar_ipr(num_patch, num_qubits):
# Mean IPR of a marginal for Haar-random states in the sector with num_qubits // 2 ones
weight = num_qubits // 2
dimension = comb(num_qubits, weight)
total = 0
for k in range(num_patch + 1): # k ones on the marginal
rest = (
comb(num_qubits - num_patch, weight - k) if weight - k >= 0 else 0
)
total += comb(num_patch, k) * rest * (rest + 1)
return total / (dimension * (dimension + 1))
def haar_entropy(num_patch, num_qubits):
return collision_entropy(haar_ipr(num_patch, num_qubits))
# definitions of positions in the lattice:
def patch_qubits(anchor, shape, num_cols):
# Lattice indices of a rectangular marginal with its top-left corner at anchor
(x0, y0), (width, height) = anchor, shape
return [
(y0 + dy) * num_cols + (x0 + dx)
for dy in range(height)
for dx in range(width)
]
def all_positions(shape, num_cols, num_rows):
# Every placement of a (width, height) marginal on the lattice
width, height = shape
return [
patch_qubits((x, y), shape, num_cols)
for y in range(num_rows - height + 1)
for x in range(num_cols - width + 1)
]
# bootstrapping for error bars:
bootstrap_rng = np.random.default_rng(0)
def bootstrap_error(statistic, *samples, axis=0, num_resamples=50):
# Shot noise of a statistic, from resampling the randomizations (along axis of every
# sample) with replacement
values = []
for _ in range(num_resamples):
resampled = [
np.take(
x,
bootstrap_rng.integers(0, x.shape[axis], x.shape[axis]),
axis=axis,
)
for x in samples
]
values.append(statistic(*resampled))
return float(np.std(values, ddof=1))
# averaging the ipr over marginals:
def average_ipr(iprs, shot_errors):
# iprs and shot_errors have shape (positions, couplings). The mean IPR over positions, and its
# error: the spread between positions plus the shot noise of each (law of total variance)
iprs, shot_errors = np.asarray(iprs), np.asarray(shot_errors)
count = len(iprs)
variance = (
iprs.var(axis=0, ddof=1) / count
+ (shot_errors**2).sum(axis=0) / count**2
)
return iprs.mean(axis=0), np.sqrt(variance)# Calculating exact simulation entropies:
patch_shapes = {
"1×1": (1, 1),
"1×2": (2, 1),
"2×2": (2, 2),
"2×3": (3, 2),
"3×3": (3, 3),
}
# calculate the exact entropies:
sim_positions = {
label: all_positions(shape, num_cols, num_rows)
for label, shape in patch_shapes.items()
}
exact_iprs = { # shape (positions, couplings)
label: np.array(
[
[(s.probabilities(qargs=q) ** 2).sum() for s in exact_states]
for q in qubit_lists
]
)
for label, qubit_lists in sim_positions.items()
}
exact_entropies = {
label: collision_entropy(v.mean(axis=0))
for label, v in exact_iprs.items()
}
# Average IPR of every shape over its positions, from the sampled shots, then the entropy
iprs_from_shots = {} # per shape: average IPR and its error, at every coupling
entropies_from_shots = {} # per shape: average entropy and its error, at every coupling
for label, qubit_lists in sim_positions.items():
iprs = [[ipr_from_shots(b[:, :, q]) for b in bits] for q in qubit_lists]
# bootstrapping over subsampling for error bars:
errors = [
[bootstrap_error(ipr_from_shots, b[:, :, q]) for b in bits]
for q in qubit_lists
]
ipr, ipr_error = average_ipr(iprs, errors)
iprs_from_shots[label] = (ipr, ipr_error)
entropies_from_shots[label] = (
collision_entropy(ipr),
ipr_error / (ipr * np.log(2)),
)# Now plot the average IPR on the left, and the same averages as entropies on the right
colors = plt.cm.plasma(np.linspace(0.05, 0.8, len(patch_shapes)))
fig, (ax_ipr, ax_entropy) = plt.subplots(1, 2, figsize=(12.8, 4.2))
for (label, qubit_lists), color in zip(sim_positions.items(), colors):
n = len(qubit_lists[0])
ipr, ipr_error = iprs_from_shots[label]
entropy, entropy_error = entropies_from_shots[label]
# Left: the average IPR, with its exact value and its random-state value
ax_ipr.axhline(haar_ipr(n, num_qubits), color=color, ls="dashdot", lw=0.9)
ax_ipr.plot(
couplings / np.pi, exact_iprs[label].mean(axis=0), color=color
)
ax_ipr.errorbar(
couplings / np.pi,
ipr,
yerr=ipr_error,
fmt="o",
color=color,
mec="white",
ms=7,
capsize=3,
label=f"{label} ({len(qubit_lists)} positions)",
)
# Right: the average collision entropy
ax_entropy.axhline(
haar_entropy(n, num_qubits), color=color, ls="dashdot", lw=0.9
)
ax_entropy.plot(couplings / np.pi, exact_entropies[label], color=color)
ax_entropy.errorbar(
couplings / np.pi,
entropy,
yerr=entropy_error,
fmt="o",
color=color,
mec="white",
ms=7,
capsize=3,
label=f"{label} ({len(qubit_lists)} positions)",
)
ax_ipr.set_yscale("log")
ax_ipr.set_ylabel("Average IPR")
ax_entropy.set_ylabel("Average collision entropy (bits)")
for ax in (ax_ipr, ax_entropy):
ax.set_xlabel("Coupling $J/\\pi$")
fig.suptitle("Exact (lines) and sampled (dots), 4×4 lattice, 2 cycles")
ax_entropy.legend(title="Marginal", loc="upper left")
fig.tight_layout()
plt.show()Output:
Every marginal shape rises smoothly toward its random-state value, and larger marginals get there at larger .
3. Noise, and how to undo it
A noisy simulation
Now add noise: 1% two-qubit depolarizing noise on every CZ gate and a 3% readout error on every qubit, sampled with the Qiskit Aer sampler. With 144 CZ gates in the circuit, this is much less noise than the 8×8 hardware circuits see, but enough to see what it does. The simulation uses six couplings and 4,000 shots each, and takes about 1.5 minutes.
Question. Before you run it: will noise make the system look more thermalized or less thermalized?
More. Noise scrambles the outcomes, which pushes every marginal distribution toward uniform, so the raw IPR drops and the entropy rises. Without mitigation, noise looks like thermalization that is not there, and the crossovers come out too early.
# define the noise model
noise_model = NoiseModel()
# two qubit depolarizing noise:
noise_model.add_all_qubit_quantum_error(depolarizing_error(0.01, 2), ["cz"])
# symmetric readout noise:
noise_model.add_all_qubit_readout_error(
ReadoutError([[0.97, 0.03], [0.03, 0.97]])
)
# simulation is done using trajectories
noisy_sampler = AerSampler(
default_shots=4000,
seed=1,
options={"backend_options": {"noise_model": noise_model}},
)
# choose a subset of J values for speed:
noisy_indices = [
0,
2,
3,
5,
7,
9,
] # J/π = 0.005, 0.02, 0.03, 0.05, 0.08, 0.125
noisy_results = noisy_sampler.run(
[circuits[k] for k in noisy_indices]
).result()
# No twirling here, so the 20 "randomizations" are just batches of 200 shots
noisy_bits = {
k: result.data.meas.to_bool_array(order="little").reshape(
20, 200, num_qubits
)
for k, result in zip(noisy_indices, noisy_results)
}# Calculating noisy entropies
# Average IPR of every shape over its positions, from the noisy shots, then the entropy
noisy_iprs = {} # per shape: average IPR and its error, at every noisy coupling
noisy_entropies = {} # per shape: average entropy and its error, at every noisy coupling
for label, qubit_lists in sim_positions.items():
iprs = [
[ipr_from_shots(noisy_bits[k][:, :, q]) for k in noisy_indices]
for q in qubit_lists
]
# bootstrapping over subsampling for error bars:
errors = [
[
bootstrap_error(ipr_from_shots, noisy_bits[k][:, :, q])
for k in noisy_indices
]
for q in qubit_lists
]
ipr, ipr_error = average_ipr(iprs, errors)
noisy_iprs[label] = (ipr, ipr_error)
noisy_entropies[label] = (
collision_entropy(ipr),
ipr_error / (ipr * np.log(2)),
)fig, (ax_ipr, ax_entropy) = plt.subplots(1, 2, figsize=(12.8, 4.2))
for (label, qubit_lists), color in zip(sim_positions.items(), colors):
n = len(qubit_lists[0])
ipr, ipr_error = noisy_iprs[label]
entropy, entropy_error = noisy_entropies[label]
# Left: the average IPR, with its exact value and its random-state value
ax_ipr.axhline(haar_ipr(n, num_qubits), color=color, ls="dashdot", lw=0.9)
ax_ipr.plot(
couplings / np.pi, exact_iprs[label].mean(axis=0), color=color
)
ax_ipr.errorbar(
couplings[noisy_indices] / np.pi,
ipr,
yerr=ipr_error,
fmt="o",
color=color,
mec="white",
ms=7,
capsize=3,
label=label,
)
# Right: the same averages as entropies
ax_entropy.axhline(
haar_entropy(n, num_qubits), color=color, ls="dashdot", lw=0.9
)
ax_entropy.plot(couplings / np.pi, exact_entropies[label], color=color)
ax_entropy.errorbar(
couplings[noisy_indices] / np.pi,
entropy,
yerr=entropy_error,
fmt="o",
color=color,
mec="white",
ms=7,
capsize=3,
label=label,
)
ax_ipr.set_yscale("log")
ax_ipr.set_ylabel("Average IPR")
ax_entropy.set_ylabel("Average collision entropy (bits)")
for ax in (ax_ipr, ax_entropy):
ax.set_xlabel("Coupling $J/\\pi$")
fig.suptitle("Exact (lines) and noisy raw estimates (dots)")
ax_entropy.legend(title="Marginal", loc="lower right")
fig.tight_layout()
plt.show()Output:
Brainstorm: how would you undo this?
At small , the state is barely entangled, so the ideal answer there is cheap to compute classically, even for 64 qubits. That suggests measuring the effect of the noise at a small calibration coupling and correcting every other coupling with it. There are two natural ways to do that, called low-entanglement calibration (LEC):
LEC on the IPR. Rescale the excess of the IPR over its value for a uniform distribution, , by one factor per marginal:
LEC on the expectation values. Rescale every parity of the marginal by its own factor, and rebuild the IPR with the Parseval formula:
In the code, each squared rescaled parity is debiased by subtracting its variance, as in section 2.
Both are the same if every equals .
Question. What are the pros and cons of these methods?
Pros
- Cheap: no extra data is needed from the device, only a classical simulation at .
- Uses the structure of the circuit: the circuit depth and the CZ layers do not change with , so the noise at is the same noise as at every other .
Cons
- Assumes the effect of the noise on the observables does not depend on : the noise itself is the same, but how it damps the IPR or each can change as grows.
Both methods on the noisy simulation
# function to mitigate the IPR signal:
def mitigate_ipr_w_lec(
ipr_raw, ipr_raw_calibration, ipr_ideal_calibration, num_patch
):
# LEC on the IPR: one factor R0 for the excess over the uniform value
uniform = 2.0**-num_patch
factor = (ipr_ideal_calibration - uniform) / (
ipr_raw_calibration - uniform
)
return uniform + factor * (ipr_raw - uniform)
# function used to calculate the target <Z> expectation values
def parities_from_probabilities(probabilities):
# Ideal <Z_S> from a marginal distribution, in parity_supports order: subsets
# by size, and in bitmask order within each size
outcomes = np.arange(len(probabilities))
masks = np.array(sorted(range(1, len(probabilities)), key=int.bit_count))
signs = (-1.0) ** np.bitwise_count(outcomes[:, None] & masks[None, :])
return np.asarray(probabilities) @ signs
# calculate boosting factors:
def lec_z_factors(calibration_parities, ideal_parities):
# LEC on <Z>: one factor r_S per parity, from data with shape (randomizations, supports).
return ideal_parities / calibration_parities.mean(axis=0)
# function to mitigate the IPR from <Z> expectation values:
def mitigate_ipr_w_lec_on_parities(
parities, calibration_parities, ideal_calibration_parities, num_patch
):
# LEC on <Z>: rescale each parity by its factor r_S = ideal / raw at J0, then rebuild the IPR
# with Parseval. parities and calibration_parities have shape (randomizations, supports).
# Each squared, rescaled parity is debiased by subtracting its variance, which to first
# order combines the shot noise at J. The shot noise of r_S from J0 is omitted; including it
# does not change the results.
factors = lec_z_factors(calibration_parities, ideal_calibration_parities)
mean = parities.mean(axis=0) # mean over the twirling instances
variance = parities.var(axis=0, ddof=1) / len(
parities
) # variance of the mean
rescaled = factors * mean
rescaled_variance = factors**2 * variance
return float((1 + np.sum(rescaled**2 - rescaled_variance)) / 2**num_patch)def lec_estimates(patch_bits, parities, ideal, calibration, num_patch):
# LEC on the IPR and on <Z> for one marginal position, at every coupling: values and shot
# noise. patch_bits (the bits of the marginal only) and parities are per coupling, with
# randomizations first.
q_ideal = (ideal**2).sum()
ideal_parities = parities_from_probabilities(ideal)
def ipr_statistic(b, c):
return mitigate_ipr_w_lec(
ipr_from_shots(b), ipr_from_shots(c), q_ideal, num_patch
)
def z_statistic(p, c):
return mitigate_ipr_w_lec_on_parities(p, c, ideal_parities, num_patch)
values = {"LEC on IPR": [], "LEC on ⟨Z⟩": []}
errors = {"LEC on IPR": [], "LEC on ⟨Z⟩": []}
for k in range(len(patch_bits)):
if (
k == calibration
): # both match the ideal value at J0 by construction
for method in values:
values[method].append(q_ideal)
errors[method].append(0.0)
continue
values["LEC on IPR"].append(
ipr_statistic(patch_bits[k], patch_bits[calibration])
)
errors["LEC on IPR"].append(
bootstrap_error(
ipr_statistic, patch_bits[k], patch_bits[calibration]
)
)
values["LEC on ⟨Z⟩"].append(
z_statistic(parities[k], parities[calibration])
)
errors["LEC on ⟨Z⟩"].append(
bootstrap_error(z_statistic, parities[k], parities[calibration])
)
return values, errors
# getting all the data:
calibration_index = 0 # index of J0 = 0.005π in noisy_indices
mitigated = {
"LEC on IPR": {},
"LEC on ⟨Z⟩": {},
} # per shape: (values, errors), each (positions, couplings)
for label, qubit_lists in sim_positions.items():
n = len(qubit_lists[0])
per_position = []
for qubits in qubit_lists:
supports = parity_supports(qubits)
parities = [
parity_expectation_values(noisy_bits[k], supports)
for k in noisy_indices
]
patch_bits = [noisy_bits[k][:, :, qubits] for k in noisy_indices]
ideal = exact_states[noisy_indices[calibration_index]].probabilities(
qargs=qubits
)
per_position.append(
lec_estimates(patch_bits, parities, ideal, calibration_index, n)
)
for method in mitigated:
mitigated[method][label] = (
[p[0][method] for p in per_position],
[p[1][method] for p in per_position],
)
# Average over positions, then the entropy, for both methods
mitigated_entropies = {
method: {} for method in mitigated
} # per method and shape: entropy and its error
for method, by_shape in mitigated.items():
for label, (values, errors) in by_shape.items():
ipr, ipr_error = average_ipr(values, errors)
mitigated_entropies[method][label] = (
collision_entropy(ipr),
ipr_error / (ipr * np.log(2)),
) # log here comes from error propagation# Plot the data mitigated with both methods:
fig, axes = plt.subplots(1, 2, figsize=(12.8, 4.2), sharey=True)
for ax, (method, by_shape) in zip(axes, mitigated_entropies.items()):
for (label, qubit_lists), color in zip(sim_positions.items(), colors):
entropy, error = by_shape[label]
ax.axhline(
haar_entropy(len(qubit_lists[0]), num_qubits),
color=color,
ls="dashdot",
lw=0.9,
)
ax.plot(couplings / np.pi, exact_entropies[label], color=color)
ax.errorbar(
couplings[noisy_indices] / np.pi,
entropy,
yerr=error,
fmt="o",
color=color,
mec="white",
ms=7,
capsize=3,
label=label,
)
ax.set_title(f"{method} (lines: exact)")
ax.set_xlabel("Coupling $J/\\pi$")
axes[0].set_ylabel("Average collision entropy (bits)")
axes[0].legend(title="Marginal", loc="lower right")
fig.tight_layout()
plt.show()
# add the noisy data here tooOutput:
Both methods remove most of the damage: the raw estimates in the previous plot are far too random, and the mitigated ones follow the exact curves. The differences show up for the larger marginals.
For the small marginals both methods work. For the marginal, LEC on is clearly closer.
Why LEC on the IPR gives too little entropy. Too little entropy means the IPR is too big: LEC on the IPR over-inflates it. It assumes that noise damps the contribution of every to the IPR by the same factor. At , every is close to , so all strings contribute equally, and most strings have medium weight: of the 511 strings of a marginal, 252 have weight 4 or 5, and only nine have weight 1. Noise damps a string more the higher its weight (readout error alone damps a weight- string by ), so the single factor is set mainly by the damping of medium-weight strings. As grows, the higher-weight decay fastest, and the IPR is carried more and more by low-weight strings, which noise damps less. Boosting them with a factor learned mainly from more heavily damped strings over-inflates the IPR. The effect grows with the size of the marginal, since a larger marginal has a wider range of weights. It is largest at intermediate , after the mix of strings has shifted but while the excess of the IPR over its uniform value still matters.
Why LEC on errs the other way. It rescales each string by its own factor, so the shift between weights does not affect it. It only assumes that each string keeps its damping. As grows, each spreads into higher-weight operators, which pick up more noise than at , so the factors slightly under-correct and the entropy comes out slightly too high.
So the prediction to test on hardware is: the larger the marginal, the more LEC on should win. On hardware, the noise is stronger and not of a known form.
4. Run at scale
The hardware experiment runs an lattice with four cycles: 64 qubits and 1,344 CZ gates in 48 layers, at nine couplings. Five things change compared with the simulation:
- The noise has to be learned, which only works if the CZ layers are fixed.
- Twirling turns the noise of each layer into a Pauli channel, which can be learned and amplified.
- Dynamical decoupling (DD) protects idle qubits.
- Noise amplification for PEA runs in the same job as the main data.
- MPS simulations at small provide the classical references for LEC-based error mitigation.
The cells that need a backend only run when RUN_ON_HARDWARE is True. MPS simulations can be rerun with COMPUTE_MPS_REFERENCES set to True. The other cells run here.
# hardware run parameters:
num_cols_hw = num_rows_hw = 8
num_qubits_hw = num_cols_hw * num_rows_hw
num_cycles_hw = 4
couplings_hw = (
np.array([0.005, 0.01, 0.02, 0.03, 0.05, 0.07, 0.09, 0.11, 0.125]) * np.pi
)
calibration_hw = 0 # J0 = 0.005π
disorder_hw = sample_disorder(
num_cols_hw, num_rows_hw, np.random.default_rng(7)
)
# if running PEA:
noise_factors = np.array([1.0, 1.5, 2.0]) # PEA noise-amplification factorsFixed CZ layers
def compile_by_sublayer(
num_cols,
num_rows,
coupling,
num_cycles,
disorder,
pass_manager,
layout=None,
num_physical_qubits=None,
):
# Compile each sublayer once and repeat it with barriers, so the CZ layers are the
# same in every cycle and at every coupling. The registers are named explicitly
# because register names enter Samplomatic's box hashes. Bits land in lattice order.
num_qubits = num_cols * num_rows
if layout is None:
layout, num_physical_qubits = list(range(num_qubits)), num_qubits
blocks = []
for sublayer in disorder:
block = QuantumCircuit(num_qubits)
for (i, j), (h_i, h_j) in sublayer:
heisenberg_gate(block, coupling, i, j, h_i, h_j)
blocks.append(pass_manager.run(block))
circuit = QuantumCircuit(
QuantumRegister(num_physical_qubits, "q"),
ClassicalRegister(num_qubits, "meas"),
)
neel_state(circuit, num_cols, num_rows, layout)
for _ in range(num_cycles):
for block in blocks:
circuit.compose(block, inplace=True)
circuit.barrier(layout)
circuit.measure(layout, range(num_qubits))
return circuitBoxes for noise learning
The Samplomatic boxing pass manager then groups each circuit into boxes: one per CZ layer and one for the measurements. Each box carries a reference to the noise model it needs, so you can check that all couplings share the same boxes. The next cell does this for the circuits of the hardware run, compiled to CZ gates without a backend, and prints the boxes that noise learning will characterize.
from samplomatic import build
from samplomatic.transpiler import generate_boxing_pass_manager
from samplomatic.utils import find_unique_box_instructions
boxing_pass_manager = generate_boxing_pass_manager(
enable_gates=True, # twirl the CZ layers
enable_measures=True, # twirl the measurements
inject_noise_targets="gates", # PEA amplifies the noise of the CZ layers only
inject_noise_strategy="uniform_modification",
)
def noise_refs(boxed_circuit):
return sorted(
annotation.ref
for box in find_unique_box_instructions(boxed_circuit.data)
for annotation in box.operation.annotations
if hasattr(annotation, "ref")
)
# The 8x8 hardware circuits, compiled to CZ gates on the lattice qubits (no backend needed)
compiled_8x8 = [
compile_by_sublayer(
num_cols_hw, num_rows_hw, c, num_cycles_hw, disorder_hw, pass_manager
)
for c in couplings_hw
]
boxed_8x8 = [boxing_pass_manager.run(circuit) for circuit in compiled_8x8]
assert all(
noise_refs(b) == noise_refs(boxed_8x8[0]) for b in boxed_8x8
), "layers differ between couplings"
num_cz, depth = two_qubit_stats(compiled_8x8[0])
print(
f"{num_qubits_hw} qubits: {num_cz} CZ gates in {depth} layers, for every coupling"
)
for box in find_unique_box_instructions(boxed_8x8[0].data):
ops = box.operation.blocks[0].count_ops()
print(
f"{box.operation.num_qubits}-qubit box: {ops.get('cz', 0)} CZ, {ops.get('measure', 0)} measurements"
)
print(
f"All {len(boxed_8x8)} couplings share these {len(find_unique_box_instructions(boxed_8x8[0].data))} boxes"
)Output:
64 qubits: 1344 CZ gates in 48 layers, for every coupling
64-qubit box: 32 CZ, 0 measurements
64-qubit box: 24 CZ, 0 measurements
64-qubit box: 32 CZ, 0 measurements
64-qubit box: 24 CZ, 0 measurements
64-qubit box: 0 CZ, 64 measurements
All 9 couplings share these 5 boxes
Twirl the circuits
In every randomization, Samplomatic puts random Pauli gates before each box and the compensating gates after it, merged into the neighboring single-qubit gates. Averaged over randomizations, the noise of each layer becomes a Pauli channel, which NoiseLearnerV3 can learn as a sparse Pauli-Lindblad model and the PEA step can amplify. Measurement twirling randomly flips bits before readout. The flips are undone in software, which makes the readout noise symmetric and is the basis of the readout correction (TREX).
The hardware workflow
Choose the qubits, compile the circuits, then learn the noise and run the sweep. These cells are switched off here.
Choose the qubits. Nighthawk's square grid holds the lattice without SWAP gates. The cell scores every rectangle of the grid by its reported CZ and readout errors and takes the best.
def square_layouts(backend, side):
# Every side x side rectangle of the square qubit grid that supports all bonds
target = backend.target
def has_cz(a, b):
return (a, b) in target["cz"] or (b, a) in target["cz"]
stride = next(
s
for s in (10, 12)
if all(has_cz(q, q + s) for q in range(backend.num_qubits - s))
)
layouts = []
for y0 in range(backend.num_qubits // stride - side + 1):
for x0 in range(stride - side + 1):
layout = [
stride * (y0 + y) + x0 + x
for y in range(side)
for x in range(side)
]
bonds = [
bond
for layer in brickwork_layers(side, side)
for bond in layer
]
if all(has_cz(layout[i], layout[j]) for i, j in bonds):
layouts.append(((x0, y0), layout))
return layouts
def layout_score(backend, layout, side, uses_per_bond):
# Minus the summed log-fidelity of the bonds (each used uses_per_bond times) and readouts
properties, target = backend.properties(), backend.target
cz_errors = []
for i, j in (
bond for layer in brickwork_layers(side, side) for bond in layer
):
pair = (
(layout[i], layout[j])
if (layout[i], layout[j]) in target["cz"]
else (layout[j], layout[i])
)
cz_errors.append(properties.gate_error("cz", pair))
readout_errors = [properties.readout_error(q) for q in layout]
if max(cz_errors) >= 0.5 or max(readout_errors) >= 0.25:
return np.inf # an unusable qubit or bond
return float(
-uses_per_bond * np.log1p(-np.array(cz_errors)).sum()
- np.log1p(-np.array(readout_errors)).sum()
)
if RUN_ON_HARDWARE:
from qiskit_ibm_runtime import QiskitRuntimeService
def holds_lattice(backend):
try:
return bool(square_layouts(backend, num_cols_hw))
except (KeyError, StopIteration):
return False
service = QiskitRuntimeService()
candidates = [
b
for b in service.backends(operational=True, simulator=False)
if holds_lattice(b)
]
backend = min(candidates, key=lambda b: b.status().pending_jobs)
score, origin, initial_layout = min(
(
layout_score(backend, layout, num_cols_hw, 3 * num_cycles_hw),
origin,
layout,
)
for origin, layout in square_layouts(backend, num_cols_hw)
)
print(f"{backend.name}: origin {origin}, score {score:.2f}")Compile. The same steps as above on the chosen qubits, followed by build, which turns each boxed circuit into a template circuit and a samplex, the recipe for drawing the twirling gates and the injected noise. DD is added to the templates.
if RUN_ON_HARDWARE:
from qiskit_ibm_runtime.executor.dynamical_decoupling import (
generate_dd_pass_manager,
)
from qiskit_ibm_runtime.options import DynamicalDecouplingOptions
# PEA structure functions:
target_pass_manager = generate_preset_pass_manager(
target=backend.target,
optimization_level=3,
initial_layout=initial_layout,
routing_method="none",
seed_transpiler=7,
)
boxed_hw = [
boxing_pass_manager.run(
compile_by_sublayer(
num_cols_hw,
num_rows_hw,
coupling,
num_cycles_hw,
disorder_hw,
target_pass_manager,
layout=initial_layout,
num_physical_qubits=backend.num_qubits,
)
)
for coupling in couplings_hw
]
assert all(noise_refs(b) == noise_refs(boxed_hw[0]) for b in boxed_hw)
unique_boxes_hw = find_unique_box_instructions(boxed_hw[0].data)
# DD pass:
dd_pass_manager = generate_dd_pass_manager(
backend,
DynamicalDecouplingOptions(
enable=True,
sequence_type="XY4",
scheduling_method="alap",
extra_slack_distribution="middle",
skip_reset_qubits=False,
),
)
templates = []
for circuit in boxed_hw:
template, samplex = build(circuit)
templates.append((dd_pass_manager.run(template), samplex))Learn the noise and run the sweep. One NoiseLearnerV3 job learns all five boxes: a Pauli-Lindblad model for each of the four CZ layers, and the readout noise of the measurement box. Then one QuantumProgram holds all nine couplings. Each item gets its template, its samplex, the learned noise models, and three noise scales. A scale of 0 is the unamplified data for both LEC methods. Scales of 0.5 and 1 add 50% and 100% of the learned noise for PEA, which extrapolates back to zero noise. This uses about 40 seconds of QPU time.
if RUN_ON_HARDWARE:
from qiskit_ibm_runtime import Executor, NoiseLearnerV3, QuantumProgram
# Learn the noise of the five boxes
learner = NoiseLearnerV3(backend)
learner.options.num_randomizations = 32
learner.options.shots_per_randomization = 128
# Up to 24 layer pairs, the depth of the 48-layer circuits
learner.options.layer_pair_depths = [0, 1, 2, 4, 16, 24]
learner_job = learner.run(unique_boxes_hw)
print("Noise learning job:", learner_job.job_id())
# Run the sweep, with the learned noise amplified by each of the noise factors
noise_maps = learner_job.result().to_dict(
instructions=unique_boxes_hw, require_refs=False
)
program = QuantumProgram(shots=200)
for template, samplex in templates:
program.append_samplex_item(
template,
samplex=samplex,
samplex_arguments={
"pauli_lindblad_maps": noise_maps,
**{
f"noise_scales.{ref}": (noise_factors - 1).reshape(-1, 1)
for ref in noise_maps
},
},
shape=(len(noise_factors), 200), # noise scales x randomizations
)
executor_job = Executor(backend).run(program)
print("Executor job:", executor_job.job_id())
# Section 5 converts the results of these two jobs and saves them5. The hardware data
This section explores how the previous methods perform on real hardware data from a Nighthawk processor.
Load the run
With RUN_ON_HARDWARE set to True, the next cell converts the results of the two jobs from section 4 and saves them to data/hardware_run_new.npz, in the same format as the saved run, so the new run can be analyzed again later. Otherwise it loads the saved run from data/hardware_run.npz. The bits are packed along the qubit axis to save space.
If the kernel restarts while the jobs run, do not submit them again. Rerun the cells before the last call in section 4, check that the same qubits are chosen, and set learner_job and executor_job to service.job(job_id) with the job IDs that were printed out in section 4.
if RUN_ON_HARDWARE:
def unflip(item_result):
# Undo the measurement twirling of an Executor item: flip back the bits that were flipped
return item_result["meas"] ^ item_result["measurement_flips.meas"]
learner_result = learner_job.result()
raw_bits = [unflip(item) for item in executor_job.result()]
# The learned readout map indexes the measured qubits in ascending physical order;
# relabel it to lattice order
measurement_box_index = next(
k
for k, box in enumerate(unique_boxes_hw)
if "measure" in box.operation.blocks[0].count_ops()
)
measurement_box = next(
inst
for inst in boxed_hw[0].data
if inst.operation.name == "box"
and "measure" in inst.operation.blocks[0].count_ops()
)
measured = sorted(
boxed_hw[0].find_bit(q).index for q in measurement_box.qubits
)
lattice_index = {physical: q for q, physical in enumerate(initial_layout)}
readout_map = (
learner_result[measurement_box_index]
.to_pauli_lindblad_map()
.apply_layout([lattice_index[p] for p in measured], num_qubits_hw)
)
# Save the run in the same format as the saved run, so it can be analyzed again later
terms = readout_map.to_sparse_list()
np.savez_compressed(
"data/hardware_run_new.npz",
bits=np.packbits(np.stack(raw_bits), axis=-1, bitorder="little"),
labels=np.array([label for label, _, _ in terms]),
indices=np.concatenate(
[np.asarray(support) for _, support, _ in terms]
),
rates=np.array([rate for _, _, rate in terms]),
physical_qubits=np.array(initial_layout),
couplings_over_pi=couplings_hw / np.pi,
noise_factors=noise_factors,
)
else:
saved = np.load(
"data/hardware_run.npz"
) # or data/hardware_run_new.npz, for a new run
raw_bits = list(
np.unpackbits(
saved["bits"], axis=-1, count=num_qubits_hw, bitorder="little"
).astype(bool)
)
labels = [str(label) for label in saved["labels"]]
indices = np.split(
saved["indices"], np.cumsum([len(label) for label in labels])[:-1]
)
readout_map = PauliLindbladMap.from_sparse_list(
list(zip(labels, indices, saved["rates"])), num_qubits_hw
)
print(f"{len(raw_bits)} couplings, bits per coupling: {raw_bits[0].shape}")
print(
f"Readout noise: {readout_map.num_terms} terms on {readout_map.num_qubits} qubits"
)Output:
9 couplings, bits per coupling: (3, 200, 200, 64)
Readout noise: 64 terms on 64 qubits
Classical references
Both LEC methods need the ideal marginal distributions at , and testing them needs ideal values at the held-out couplings and . These come from matrix product state (MPS) simulations, saved for every placement of each marginal shape on the lattice, as in the paper, and for every non-overlapping block at . At they converge at bond dimension . At and they use , and at they still change by up to about 13% from for marginals, so treat them as approximate there. The next cell computes them if COMPUTE_MPS_REFERENCES is True, which takes about 18 minutes, and otherwise loads the saved results, keyed by coupling index, bond dimension, and marginal.
# Every placement of every shape on the 8x8 lattice
positions = {
label: all_positions(shape, num_cols_hw, num_rows_hw)
for label, shape in patch_shapes.items()
}
reference_settings = [
(calibration_hw, 32),
(calibration_hw, 64),
(1, 128),
(2, 64),
(2, 128),
] # (coupling, χ)
if COMPUTE_MPS_REFERENCES:
def mps_patch_probabilities(coupling, patches, bond_dimension):
# Distributions of the given marginals after the 8x8 circuit, from an MPS simulation
simulator = AerSimulator(
method="matrix_product_state",
matrix_product_state_max_bond_dimension=bond_dimension,
matrix_product_state_truncation_threshold=1e-12,
)
circuit = heisenberg_floquet_circuit(
num_cols_hw,
num_rows_hw,
coupling,
num_cycles_hw,
disorder_hw,
measure=False,
)
circuit = generate_preset_pass_manager(
optimization_level=1, basis_gates=["cx", "u"], seed_transpiler=7
).run(circuit)
for label, qubits in patches.items():
circuit.save_probabilities(qubits, label=label)
data = simulator.run(circuit, shots=1).result().data(0)
return {label: np.asarray(data[label]) for label in patches}
placements = {
f"{label} {k}": qubits
for label, patch_list in positions.items()
for k, qubits in enumerate(patch_list)
}
tiles = {
f"site {q}": [q] for q in range(num_qubits_hw)
} | { # for the lattice map at J0
f"block {x},{y}": patch_qubits((2 * x, 2 * y), (2, 2), num_cols_hw)
for y in range(num_rows_hw // 2)
for x in range(num_cols_hw // 2)
}
computed = {}
for index, chi in reference_settings:
targets = (
placements | tiles
if (index, chi) == (calibration_hw, 64)
else placements
)
for key, p in mps_patch_probabilities(
couplings_hw[index], targets, chi
).items():
computed[f"{index}/{chi}/{key}"] = p
np.savez_compressed("data/mps_references_all.npz", **computed)
saved = computed
else:
saved = np.load("data/mps_references_all.npz")
references = {} # (coupling index, bond dimension) -> {marginal key: distribution}
for name in saved:
index, chi, key = name.split("/", 2)
references.setdefault((int(index), int(chi)), {})[key] = saved[name]Data quality: faulty couplers / qubits
First, look at the lowest coupling, , where the expectation values should be close to the Néel checkerboard.
# <Z> of every site at J0: the MPS reference, and the hardware data at noise factor 1
z_reference = np.array(
[
parities_from_probabilities(
references[(calibration_hw, 64)][f"site {q}"]
)[0]
for q in range(num_qubits_hw)
]
)
z_measured = 1 - 2 * raw_bits[calibration_hw][0].mean(axis=(0, 1))fig, axes = plt.subplots(1, 2, figsize=(11, 4.6), layout="constrained")
fig.get_layout_engine().set(wspace=0.12) # white space between the two panels
edges = np.arange(num_cols_hw + 1) - 0.5
for ax, values, title in zip(
axes, (z_reference, z_measured), ("MPS reference", "Hardware")
):
mesh = ax.pcolormesh(
edges,
edges,
values.reshape(num_rows_hw, num_cols_hw),
cmap="RdBu_r",
vmin=-1,
vmax=1,
edgecolors="white",
)
ax.set_aspect("equal")
ax.invert_yaxis()
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(
f"{title}, $J_0 = {couplings_hw[calibration_hw] / np.pi:g}\\pi$"
)
fig.colorbar(
mesh, ax=axes, shrink=0.8, label=r"$\langle Z \rangle$ of each site"
)
plt.show()Output:
Question. Looking at the hardware map, what stands out?
The pale sites, which have lost almost all of their signal although the state at is close to the Néel state. They matter because LEC divides by whatever signal survives at : a qubit with almost no signal gives a huge, noisy correction factor, and a few such qubits can dominate every marginal that contains them.
A rule that uses only the data: A qubit is faulty if its single-qubit IPR at is within of , the value for a fully mixed qubit. Since the IPR of one qubit is , this flags qubits with . Marginals that contain a flagged site are left out below.
site_iprs = np.array(
[
ipr_from_shots(raw_bits[calibration_hw][0][:, :, [q]])
for q in range(num_qubits_hw)
]
)
# |IPR - 1/2| < 0.25**2 / 2 is the same as |<Z>| < 0.25
flagged = {
q for q in range(num_qubits_hw) if abs(site_iprs[q] - 0.5) < 0.25**2 / 2
}
print(
f"Median single-qubit IPR at J0: {np.median(site_iprs):.2f}; flagged lattice sites {sorted(flagged)}"
)Output:
Median single-qubit IPR at J0: 0.77; flagged lattice sites [19, 24, 25, 27, 32]
Select the marginals to average
The next cell shows which placements of each shape enter the averages below: every placement in the bulk of the lattice without a flagged site (red crosses). Placements that touch the edge (shaded) are left out. Edge qubits have fewer bonds, so they scramble more slowly. Averaged on their own, they cross over a full step of later than bulk placements, for every shape and method. Each averaged placement has its own box, colored by placement.
def touches_edge(qubits):
# Whether a placement includes a site on the edge of the lattice
return any(
q % num_cols_hw in (0, num_cols_hw - 1)
or q // num_cols_hw in (0, num_rows_hw - 1)
for q in qubits
)
# The placements of each shape that enter the averages: in the bulk, and without a flagged site
kept_placements = {
label: [
k
for k, qubits in enumerate(patch_list)
if not touches_edge(qubits) and not set(qubits) & flagged
]
for label, patch_list in positions.items()
}placement_cmap = plt.get_cmap("viridis")
fig, axes = plt.subplots(
1, len(positions), figsize=(17, 4.4), layout="constrained"
)
for ax, (label, patch_list) in zip(axes, positions.items()):
width, height = patch_shapes[label]
for q in range(num_qubits_hw):
x, y = q % num_cols_hw, q // num_cols_hw
if touches_edge([q]): # shade the edge of the lattice
ax.add_patch(
plt.Rectangle(
(x - 0.5, y - 0.5), 1, 1, color="0.92", zorder=0
)
)
ax.plot(x, y, "o", color="0.7", ms=4, zorder=1)
kept = kept_placements[label]
for i, k in enumerate(kept):
qubits = patch_list[k]
x0, y0 = (
qubits[0] % num_cols_hw,
qubits[0] // num_cols_hw,
) # top-left site of the placement
ax.add_patch(
plt.Rectangle(
(x0 - 0.4, y0 - 0.4),
width - 0.2,
height - 0.2,
fill=False,
lw=1.2,
ec=placement_cmap(i / max(len(kept) - 1, 1)),
zorder=2,
)
)
for q in flagged:
ax.plot(
q % num_cols_hw,
q // num_cols_hw,
"x",
color="#d62728",
ms=11,
mew=3,
zorder=3,
)
ax.set_xlim(-0.6, num_cols_hw - 0.4)
ax.set_ylim(num_rows_hw - 0.4, -0.6)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(f"{label}: {len(kept)} placements averaged")
plt.show()Output:
PEA
The third method needs no classical reference. For each parity, take the values at noise factors 1, 1.5, and 2, divide out the learned readout damping (TREX), fit an exponential in the noise factor, and extrapolate to zero noise. Parities that change sign or are nearly zero get a straight-line fit. Subtracting the variance needs a reliable estimate of it, which is easy for raw and rescaled parities but not for the output of a nonlinear fit with clipping and fallbacks. PEA therefore uses the other standard way to remove the bias of a square. Extrapolate each half of the randomizations separately and multiply the two independent estimates.
def readout_fidelity(support):
# Damping of <Z_S> by the learned readout noise
pauli = QubitSparsePauli.from_sparse_label(
("Z" * len(support), list(support)), num_qubits_hw
)
return readout_map.pauli_fidelity(pauli)
def extrapolate_to_zero(values):
# Fit a * exp(b * factor) to each column of values (noise factors first), or a line
# where the values change sign or come within 1e-3 of zero
result = np.polynomial.polynomial.polyfit(noise_factors, values, 1)[0]
sign = np.sign(values[0])
usable = np.all(np.sign(values) == sign, axis=0) & (
np.abs(values).min(axis=0) > 1e-3
)
if usable.any():
log_fit = np.polynomial.polynomial.polyfit(
noise_factors, np.log(np.abs(values[:, usable])), 1
)
result[usable] = sign[usable] * np.exp(log_fit[0])
return result
def pea_ipr(parities, fidelities, num_patch):
# parities has shape (noise factors, randomizations, supports). Extrapolate the two
# halves of the randomizations separately; their errors are independent, so the
# product of the two estimates of each parity has no shot-noise bias
def zero_noise(p):
# A parity cannot exceed 1 in magnitude, so cap the extrapolated values there
return np.clip(
extrapolate_to_zero(p.mean(axis=1) / fidelities), -1, 1
)
half = parities.shape[1] // 2
first, second = (
zero_noise(parities[:, :half]),
zero_noise(parities[:, half:]),
)
return float((1 + np.dot(first, second)) / 2**num_patch)All four estimates, and the crossovers
The next function computes the raw, LEC-on-IPR, LEC-on-, and PEA estimates of the IPR of one marginal position at every coupling. It also estimates the shot noise of each, by bootstrapping the randomizations. The cell runs it for every placement that enters the averages, which takes about a minute and a half.
def estimate_iprs(qubits, key):
# Raw, LEC-on-IPR, LEC-on-<Z>, and PEA IPRs of one marginal position at every coupling,
# with the shot noise of each from a bootstrap over the randomizations
n = len(qubits)
supports = parity_supports(qubits)
# Parities with shape (noise factors, randomizations, supports), per coupling
parities = [
np.stack([parity_expectation_values(b, supports) for b in bits])
for bits in raw_bits
]
patch_bits = [
bits[0][:, :, qubits] for bits in raw_bits
] # noise factor 1
values, errors = lec_estimates(
patch_bits,
[p[0] for p in parities],
references[(calibration_hw, 64)][key],
calibration_hw,
n,
)
fidelities = np.array([readout_fidelity(s) for s in supports])
def pea_statistic(p):
return pea_ipr(p, fidelities, n)
values["raw"] = [ipr_from_shots(b) for b in patch_bits]
errors["raw"] = [bootstrap_error(ipr_from_shots, b) for b in patch_bits]
values["PEA"] = [pea_statistic(p) for p in parities]
errors["PEA"] = [
bootstrap_error(pea_statistic, p, axis=1) for p in parities
] # randomizations are axis 1
return {m: np.array(v) for m, v in values.items()}, {
m: np.array(e) for m, e in errors.items()
}
methods = ["LEC on IPR", "LEC on ⟨Z⟩", "PEA"]
method_colors = {
"LEC on IPR": "#2a78d6",
"LEC on ⟨Z⟩": "#1baf7a",
"PEA": "#eb6834",
}
iprs, ipr_errors = {}, {}
for label, patch_list in positions.items():
for k in kept_placements[label]:
iprs[label, k], ipr_errors[label, k] = estimate_iprs(
patch_list[k], f"{label} {k}"
)
print(f"{len(iprs)} clean marginal positions")Output:
93 clean marginal positions
def crossover_coupling(couplings, entropies, haar, tolerance=0.1):
# Smallest coupling whose entropy is within tolerance of the random-state value
for coupling, entropy in zip(couplings, entropies):
if entropy >= haar - tolerance:
return coupling
return None
def reference_ipr(index, chi, key):
# IPR of the MPS distribution of a marginal
return float((references[(index, chi)][key] ** 2).sum())
mps_settings = [
(calibration_hw, 64),
(1, 128),
(2, 128),
] # (coupling index, bond dimension)
panels = ["raw", *methods]
# Average IPR of every shape over its clean placements, then the entropy and the crossover
hw_entropies = {
method: {} for method in panels
} # per method and shape: average entropy and its error
crossovers = {method: {} for method in panels}
mps_entropies = {} # per shape: MPS entropy, averaged over the same placements, at mps_settings
for label, patch_list in positions.items():
haar = haar_entropy(len(patch_list[0]), num_qubits_hw)
keys = [(shape, k) for (shape, k) in iprs if shape == label]
mps_entropies[label] = [
collision_entropy(
np.mean([reference_ipr(j, chi, f"{label} {k}") for _, k in keys])
)
for j, chi in mps_settings
]
for method in panels:
ipr, ipr_error = average_ipr(
[iprs[key][method] for key in keys],
[ipr_errors[key][method] for key in keys],
)
entropy = collision_entropy(ipr)
hw_entropies[method][label] = (entropy, ipr_error / (ipr * np.log(2)))
crossovers[method][label] = crossover_coupling(
couplings_hw, entropy, haar
)fig, axes = plt.subplots(
1, len(panels), figsize=(5 * len(panels), 4.2), sharey=True
)
for (label, patch_list), color in zip(positions.items(), colors):
haar = haar_entropy(len(patch_list[0]), num_qubits_hw)
for ax, method in zip(axes, panels):
entropy, error = hw_entropies[method][label]
ax.errorbar(
couplings_hw / np.pi,
entropy,
yerr=error,
fmt="o-",
color=color,
mec="white",
ms=7,
lw=1,
capsize=3,
label=label,
)
ax.plot(
couplings_hw[[j for j, _ in mps_settings]] / np.pi,
mps_entropies[label],
"s--",
color="black",
ms=4,
lw=1,
label="MPS" if label == "3×3" else None,
zorder=4,
)
ax.axhline(haar, color=color, ls="dashdot", lw=0.9)
if crossovers[method][label]:
ax.plot(
crossovers[method][label] / np.pi,
haar,
"*",
ms=14,
color=color,
mec="black",
zorder=3,
)
for ax, method in zip(axes, panels):
ax.set_title(f"{'Raw' if method == 'raw' else method} (stars: $J^*$)")
ax.set_xlabel("Coupling $J/\\pi$")
axes[0].set_ylabel("Average collision entropy (bits)")
axes[0].legend(title="Marginal", loc="lower right")
fig.tight_layout()
plt.show()Output:
Wrap-up
What it took to run an experiment of this size:
- Circuits whose structure does not depend on , compiled one sublayer at a time, so one noise model serves the whole sweep.
- Estimators that work from bitstrings: pair counting for the IPR of a marginal, and the Parseval form, with the variance subtracted from each squared parity, when the inputs are mitigated expectation values.
- A cheap classical anchor: the ideal state at small calibrates the noise, and held-out couplings test the calibration. Calibrating each separately, rather than the IPR as a whole, is what makes the large marginals agree.
- IBM tools for the heavy lifting: Samplomatic boxes and twirling,
NoiseLearnerV3, dynamical decoupling, and oneExecutorjob with noise amplification for PEA. - Data-quality checks that use only the data.
Next steps. To go further with mitigation, see the probabilistic error amplification and PEC with shaded lightcones tutorials.