lcd-qec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcd/__init__.py +4 -0
- lcd/analysis/__init__.py +4 -0
- lcd/analysis/burst_detection.py +337 -0
- lcd/analysis/circuit_peierls.py +300 -0
- lcd/analysis/circuit_peierls_geodesic.py +379 -0
- lcd/analysis/crossing.py +24 -0
- lcd/analysis/data_certificate.py +373 -0
- lcd/analysis/exchange.py +84 -0
- lcd/analysis/hardware.py +86 -0
- lcd/analysis/lattice_gap.py +194 -0
- lcd/analysis/observables.py +62 -0
- lcd/analysis/stratified.py +199 -0
- lcd/cli.py +94 -0
- lcd/codes/__init__.py +3 -0
- lcd/codes/rotated_surface.py +51 -0
- lcd/decoders/__init__.py +5 -0
- lcd/decoders/mps_ml.py +278 -0
- lcd/decoders/mwpm.py +52 -0
- lcd/decoders/tn_ml.py +142 -0
- lcd/integrations/__init__.py +1 -0
- lcd/integrations/sinter_peierls.py +80 -0
- lcd/integrations/zenodo.py +85 -0
- lcd/noise/__init__.py +3 -0
- lcd/noise/code_capacity.py +66 -0
- lcd_qec-0.1.0.dist-info/METADATA +83 -0
- lcd_qec-0.1.0.dist-info/RECORD +30 -0
- lcd_qec-0.1.0.dist-info/WHEEL +5 -0
- lcd_qec-0.1.0.dist-info/entry_points.txt +2 -0
- lcd_qec-0.1.0.dist-info/licenses/LICENSE +202 -0
- lcd_qec-0.1.0.dist-info/top_level.txt +1 -0
lcd/__init__.py
ADDED
lcd/analysis/__init__.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Burst detection from detection events: counting versus a space-time scan statistic (theory §5.4-5.5).
|
|
2
|
+
|
|
3
|
+
Theorem 5.9 says that, under the marked-Poisson model (M), a test that uses the space-time pattern of the detection
|
|
4
|
+
events has Stein exponent D_pattern = D_count + Lambda_1 D(pi_1 || pi_0) >= D_count, where D_count belongs to the test that
|
|
5
|
+
uses only the total number of events. This module provides the two tests for detector data of a 1-D (repetition) code
|
|
6
|
+
laid out on a (round, position) grid, with a common interface:
|
|
7
|
+
|
|
8
|
+
* ``CountDetector``: z-score of the total number of detection events against the baseline mean and the *empirical*
|
|
9
|
+
variance of the per-shot total (any monotone function of the total, e.g. its Poisson or binomial likelihood ratio
|
|
10
|
+
against an elevated rate, gives the same one-sided test and the same ROC once thresholds are calibrated).
|
|
11
|
+
* ``ScanDetector``: maximum over rectangles (contiguous positions x contiguous rounds, sizes from a bounded list) of the
|
|
12
|
+
Bernoulli log-likelihood ratio "every cell of the rectangle has a common rate rho > baseline" versus the per-cell
|
|
13
|
+
baseline rates p_i (rho at its maximum-likelihood value k / n). With m shots summed into the counts K, the cell counts
|
|
14
|
+
are Binomial(m, p_i) and
|
|
15
|
+
|
|
16
|
+
LLR(A) = k ln(rho) + (n - k) ln(1 - rho) - sum_{i in A} [K_i ln p_i + (m - K_i) ln(1 - p_i)], n = m |A|, rho = k / n,
|
|
17
|
+
|
|
18
|
+
set to 0 unless k exceeds its baseline expectation. All box sums come from 2-D cumulative sums (integral images),
|
|
19
|
+
so one rectangle size costs O(cells) per shot. ``model="poisson"`` gives the Kulldorff Poisson scan
|
|
20
|
+
k ln(k / mu) - (k - mu) with mu = m sum_A p_i instead.
|
|
21
|
+
|
|
22
|
+
Both detectors take an array K of shape (batch, rounds, positions) holding detection events summed over m consecutive
|
|
23
|
+
shots (m = 1: single shots), and optionally the number M of Bernoulli trials per cell; ``counts_and_trials`` builds
|
|
24
|
+
(K, M), optionally after masking leakage-like streaks (``streak_mask``: runs of >= min_run consecutive events of one
|
|
25
|
+
detector, which dominate the tail of the scan statistic on real data). Thresholds are calibrated empirically on clean shots
|
|
26
|
+
(``thresholds``): neither statistic's null distribution is taken from the model, because real detection events are
|
|
27
|
+
correlated (one fault flips two detectors) and the per-shot totals are over-dispersed.
|
|
28
|
+
|
|
29
|
+
``plant_bursts`` XORs synthetic burst events into real shots; ``marked_poisson_divergences`` and
|
|
30
|
+
``bernoulli_divergences`` give D_count and D_pattern (Lemma 5.8 / Theorem 5.9, and the independent-Bernoulli analogue)
|
|
31
|
+
for a planted burst at a known location.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import collections
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Sequence
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
from scipy.special import xlogy
|
|
42
|
+
|
|
43
|
+
DEFAULT_WIDTHS = (1, 2, 3, 4, 6, 8, 12, 16, 24)
|
|
44
|
+
DEFAULT_HEIGHTS = (1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 51)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------------------------------------- data access
|
|
48
|
+
def chain_layout(coords: dict, dem) -> np.ndarray:
|
|
49
|
+
"""Detector indices of a 1-D code on a (round, position) grid, positions ordered along the chain.
|
|
50
|
+
|
|
51
|
+
``coords``: detector -> coordinates (x, y, t) as returned by ``stim.Circuit.get_detector_coordinates``; ``dem``: a
|
|
52
|
+
``stim.DetectorErrorModel`` whose two-detector mechanisms inside one time slice give the chain's adjacency."""
|
|
53
|
+
times = sorted({float(c[-1]) for c in coords.values()})
|
|
54
|
+
tindex = {t: i for i, t in enumerate(times)}
|
|
55
|
+
sites = sorted({tuple(float(v) for v in c[:-1]) for c in coords.values()})
|
|
56
|
+
adj: dict = collections.defaultdict(set)
|
|
57
|
+
for inst in dem.flattened():
|
|
58
|
+
if inst.type != "error":
|
|
59
|
+
continue
|
|
60
|
+
ds = [t.val for t in inst.targets_copy() if t.is_relative_detector_id()]
|
|
61
|
+
if len(ds) == 2 and coords[ds[0]][-1] == coords[ds[1]][-1]:
|
|
62
|
+
a, b = (tuple(float(v) for v in coords[d][:-1]) for d in ds)
|
|
63
|
+
if a != b:
|
|
64
|
+
adj[a].add(b)
|
|
65
|
+
adj[b].add(a)
|
|
66
|
+
ends = [s for s in sites if len(adj[s]) == 1]
|
|
67
|
+
if len(ends) != 2 or any(len(adj[s]) > 2 for s in sites):
|
|
68
|
+
raise ValueError("the space-like adjacency is not a simple chain")
|
|
69
|
+
order, prev = [min(ends)], None
|
|
70
|
+
while len(order) < len(sites):
|
|
71
|
+
nxt = [s for s in adj[order[-1]] if s != prev]
|
|
72
|
+
prev = order[-1]
|
|
73
|
+
order.append(nxt[0])
|
|
74
|
+
pos = {s: i for i, s in enumerate(order)}
|
|
75
|
+
layout = np.full((len(times), len(order)), -1, dtype=np.int64)
|
|
76
|
+
for d, c in coords.items():
|
|
77
|
+
layout[tindex[float(c[-1])], pos[tuple(float(v) for v in c[:-1])]] = d
|
|
78
|
+
if (layout < 0).any():
|
|
79
|
+
raise ValueError("some (round, position) cells have no detector")
|
|
80
|
+
return layout
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class B8Reader:
|
|
84
|
+
"""Random access to a stim b8 file of detection events (one byte-aligned, little-endian record per shot)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, path: str | Path, num_detectors: int):
|
|
87
|
+
self.num_detectors = num_detectors
|
|
88
|
+
self.nbytes = (num_detectors + 7) // 8
|
|
89
|
+
self.raw = np.memmap(path, dtype=np.uint8, mode="r")
|
|
90
|
+
if self.raw.size % self.nbytes:
|
|
91
|
+
raise ValueError("file size is not a multiple of the record size")
|
|
92
|
+
self.shots = self.raw.size // self.nbytes
|
|
93
|
+
self.raw = self.raw.reshape(self.shots, self.nbytes)
|
|
94
|
+
|
|
95
|
+
def events(self, idx) -> np.ndarray:
|
|
96
|
+
"""Detection events of the shots ``idx`` (slice or index array) as a uint8 array (shots, num_detectors)."""
|
|
97
|
+
return np.unpackbits(np.asarray(self.raw[idx]), axis=1, bitorder="little")[:, :self.num_detectors]
|
|
98
|
+
|
|
99
|
+
def grid(self, idx, layout: np.ndarray) -> np.ndarray:
|
|
100
|
+
"""Detection events of the shots ``idx`` on the (round, position) grid: uint8 array (shots, rounds, positions)."""
|
|
101
|
+
return self.events(idx)[:, layout]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def window_sums(grids: np.ndarray, w: int, starts: Sequence[int] | None = None) -> np.ndarray:
|
|
105
|
+
"""Sums of ``w`` consecutive shots: out[j] = grids[starts[j]:starts[j] + w].sum(0) (all starts if None)."""
|
|
106
|
+
g = np.asarray(grids, dtype=np.int32)
|
|
107
|
+
if w == 1 and starts is None:
|
|
108
|
+
return g
|
|
109
|
+
c = np.concatenate([np.zeros((1,) + g.shape[1:], np.int32), np.cumsum(g, axis=0, dtype=np.int32)])
|
|
110
|
+
s = np.arange(len(g) - w + 1) if starts is None else np.asarray(starts)
|
|
111
|
+
return c[s + w] - c[s]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------------------------------------------------- baseline
|
|
115
|
+
def streak_mask(grids: np.ndarray, min_run: int) -> np.ndarray:
|
|
116
|
+
"""Cells (shots, rounds, positions) that belong to a run of at least ``min_run`` consecutive detection events of one
|
|
117
|
+
detector in time. Such runs are the signature of leakage (a detector firing in every round), far more frequent in
|
|
118
|
+
real data than independent rates allow; masking them makes a test insensitive to them."""
|
|
119
|
+
x = np.asarray(grids).astype(np.int16)
|
|
120
|
+
T = x.shape[1]
|
|
121
|
+
f = np.zeros_like(x)
|
|
122
|
+
b = np.zeros_like(x)
|
|
123
|
+
f[:, 0] = x[:, 0]
|
|
124
|
+
for t in range(1, T):
|
|
125
|
+
f[:, t] = (f[:, t - 1] + 1) * x[:, t]
|
|
126
|
+
b[:, T - 1] = x[:, T - 1]
|
|
127
|
+
for t in range(T - 2, -1, -1):
|
|
128
|
+
b[:, t] = (b[:, t + 1] + 1) * x[:, t]
|
|
129
|
+
return (f + b - 1 >= min_run).astype(np.uint8)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def counts_and_trials(grids: np.ndarray, min_run: int | None = None, w: int = 1,
|
|
133
|
+
starts: Sequence[int] | None = None) -> tuple[np.ndarray, np.ndarray | None]:
|
|
134
|
+
"""(K, M): detection events and Bernoulli trials per cell summed over windows of ``w`` shots, after masking streak
|
|
135
|
+
cells (``streak_mask``) if ``min_run`` is given; M is None (= w for every cell) without masking."""
|
|
136
|
+
g = np.asarray(grids, dtype=np.uint8)
|
|
137
|
+
if min_run is None:
|
|
138
|
+
return window_sums(g, w, starts), None
|
|
139
|
+
keep = 1 - streak_mask(g, min_run)
|
|
140
|
+
return window_sums(g * keep, w, starts), window_sums(keep, w, starts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Baseline:
|
|
145
|
+
"""Per-cell detection rates p (rounds, positions) and the mean / variance of the per-shot total, from clean shots.
|
|
146
|
+
With ``min_run`` the rates and totals are those of the streak-masked data and ``count_var`` is the variance of the
|
|
147
|
+
total minus its expectation given the mask."""
|
|
148
|
+
p: np.ndarray
|
|
149
|
+
count_mean: float
|
|
150
|
+
count_var: float
|
|
151
|
+
shots: int
|
|
152
|
+
min_run: int | None = None
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def fit(cls, grids: np.ndarray, min_run: int | None = None) -> "Baseline":
|
|
156
|
+
"""Estimate from grids (shots, rounds, positions); rates clipped to [1/(2n), 1 - 1/(2n)]."""
|
|
157
|
+
g = np.asarray(grids, dtype=np.uint8)
|
|
158
|
+
n = len(g)
|
|
159
|
+
if n < 2:
|
|
160
|
+
raise ValueError("need at least two shots")
|
|
161
|
+
K, M = counts_and_trials(g, min_run)
|
|
162
|
+
M = np.ones_like(K) if M is None else M
|
|
163
|
+
p = np.clip(K.sum(0) / np.maximum(M.sum(0), 1), 0.5 / n, 1 - 0.5 / n)
|
|
164
|
+
res = K.reshape(n, -1).sum(1) - (M * p).reshape(n, -1).sum(1)
|
|
165
|
+
return cls(p=p, count_mean=float(p.sum()), count_var=float(res.var(ddof=1)), shots=n, min_run=min_run)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------------------------------------------------ detectors
|
|
169
|
+
class CountDetector:
|
|
170
|
+
"""z-score of the total number of detection events of m summed shots: (N - E) / sqrt(m var), E = sum_i M_i p_i
|
|
171
|
+
(= m mu without masking)."""
|
|
172
|
+
name = "count"
|
|
173
|
+
|
|
174
|
+
def __init__(self, baseline: Baseline):
|
|
175
|
+
self.p, self.var = baseline.p, baseline.count_var
|
|
176
|
+
|
|
177
|
+
def score(self, K: np.ndarray, M: np.ndarray | None = None, m: int = 1) -> np.ndarray:
|
|
178
|
+
K = np.asarray(K)
|
|
179
|
+
N = K.reshape(len(K), -1).sum(1, dtype=np.int64).astype(np.float64)
|
|
180
|
+
E = m * self.p.sum() if M is None else (np.asarray(M) * self.p).reshape(len(K), -1).sum(1)
|
|
181
|
+
return (N - E) / np.sqrt(m * self.var)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _integral(x: np.ndarray) -> np.ndarray:
|
|
185
|
+
"""Integral image over the last two axes, with a leading row and column of zeros."""
|
|
186
|
+
c = np.cumsum(np.cumsum(x, axis=-2), axis=-1)
|
|
187
|
+
pad = [(0, 0)] * (x.ndim - 2) + [(1, 0), (1, 0)]
|
|
188
|
+
return np.pad(c, pad)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _box(c: np.ndarray, h: int, w: int) -> np.ndarray:
|
|
192
|
+
"""All h x w box sums from an integral image: shape (..., T - h + 1, Q - w + 1)."""
|
|
193
|
+
return c[..., h:, w:] - c[..., :-h, w:] - c[..., h:, :-w] + c[..., :-h, :-w]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ScanDetector:
|
|
197
|
+
"""Space-time scan statistic: max over rectangles (heights x widths) of the elevated-rate log-likelihood ratio.
|
|
198
|
+
|
|
199
|
+
Each cell i carries K_i events in M_i Bernoulli trials (M_i = m without masking). For a rectangle A with
|
|
200
|
+
k = sum K_i, n = sum M_i, rho = k / n:
|
|
201
|
+
bernoulli: LLR = k ln rho + (n - k) ln(1 - rho) - sum_A [K_i ln p_i + (M_i - K_i) ln(1 - p_i)] if k > sum_A M_i p_i
|
|
202
|
+
poisson: LLR = k ln(k / mu) - (k - mu), mu = sum_A M_i p_i if k > mu
|
|
203
|
+
and 0 otherwise."""
|
|
204
|
+
name = "pattern"
|
|
205
|
+
|
|
206
|
+
def __init__(self, baseline: Baseline, widths: Sequence[int] = DEFAULT_WIDTHS,
|
|
207
|
+
heights: Sequence[int] = DEFAULT_HEIGHTS, model: str = "bernoulli"):
|
|
208
|
+
if model not in ("bernoulli", "poisson"):
|
|
209
|
+
raise ValueError("model must be 'bernoulli' or 'poisson'")
|
|
210
|
+
p = np.asarray(baseline.p, dtype=np.float64)
|
|
211
|
+
T, Q = p.shape
|
|
212
|
+
self.p, self.model = p, model
|
|
213
|
+
self.widths = sorted({int(w) for w in widths if 1 <= w <= Q})
|
|
214
|
+
self.heights = sorted({int(h) for h in heights if 1 <= h <= T})
|
|
215
|
+
self.logit = np.log(p) - np.log1p(-p)
|
|
216
|
+
self.log1mp = np.log1p(-p)
|
|
217
|
+
|
|
218
|
+
def regions(self) -> int:
|
|
219
|
+
T, Q = self.p.shape
|
|
220
|
+
return sum((T - h + 1) * (Q - w + 1) for h in self.heights for w in self.widths)
|
|
221
|
+
|
|
222
|
+
def _integrals(self, K: np.ndarray, M: np.ndarray | None, m: int) -> dict:
|
|
223
|
+
K = np.asarray(K, dtype=np.float64)
|
|
224
|
+
Mf = np.full(K.shape[1:], float(m)) if M is None else np.asarray(M, dtype=np.float64)
|
|
225
|
+
return dict(k=_integral(K), ka=_integral(K * self.logit), n=_integral(Mf), nb=_integral(Mf * self.log1mp),
|
|
226
|
+
mu=_integral(Mf * self.p))
|
|
227
|
+
|
|
228
|
+
def llr_boxes(self, K: np.ndarray, h: int, w: int, M: np.ndarray | None = None, m: int = 1,
|
|
229
|
+
C: dict | None = None) -> np.ndarray:
|
|
230
|
+
"""LLR of every h x w rectangle: shape (batch, T - h + 1, Q - w + 1)."""
|
|
231
|
+
C = self._integrals(K, M, m) if C is None else C
|
|
232
|
+
k, mu = _box(C["k"], h, w), _box(C["mu"], h, w)
|
|
233
|
+
if self.model == "poisson":
|
|
234
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
235
|
+
llr = xlogy(k, k / mu) - (k - mu)
|
|
236
|
+
return np.where(k > mu, llr, 0.0)
|
|
237
|
+
n = _box(C["n"], h, w)
|
|
238
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
239
|
+
rho = np.where(n > 0, k / np.maximum(n, 1e-300), 0.0)
|
|
240
|
+
alt = xlogy(k, rho) + xlogy(n - k, 1.0 - rho)
|
|
241
|
+
null = _box(C["ka"], h, w) + _box(C["nb"], h, w)
|
|
242
|
+
return np.where(k > mu, alt - null, 0.0)
|
|
243
|
+
|
|
244
|
+
def score(self, K: np.ndarray, M: np.ndarray | None = None, m: int = 1, return_argmax: bool = False,
|
|
245
|
+
batch: int = 256):
|
|
246
|
+
"""Scan statistic of each row of K (batch, rounds, positions); M: trials per cell (None: m everywhere).
|
|
247
|
+
|
|
248
|
+
With ``return_argmax`` also returns an int array (batch, 4) of the maximizing (round0, pos0, height, width)."""
|
|
249
|
+
K = np.asarray(K)
|
|
250
|
+
out = np.zeros(len(K))
|
|
251
|
+
arg = np.zeros((len(K), 4), dtype=np.int64)
|
|
252
|
+
Q = self.p.shape[1]
|
|
253
|
+
for s in range(0, len(K), batch):
|
|
254
|
+
Kb = K[s:s + batch]
|
|
255
|
+
C = self._integrals(Kb, None if M is None else M[s:s + batch], m)
|
|
256
|
+
best = np.zeros(len(Kb))
|
|
257
|
+
ab = arg[s:s + batch]
|
|
258
|
+
for h in self.heights:
|
|
259
|
+
for w in self.widths:
|
|
260
|
+
L = self.llr_boxes(Kb, h, w, C=C).reshape(len(Kb), -1)
|
|
261
|
+
j = L.argmax(1)
|
|
262
|
+
v = L[np.arange(len(Kb)), j]
|
|
263
|
+
better = v > best
|
|
264
|
+
best = np.where(better, v, best)
|
|
265
|
+
if return_argmax and better.any():
|
|
266
|
+
q = Q - w + 1
|
|
267
|
+
ab[better] = np.stack([j[better] // q, j[better] % q, np.full(better.sum(), h),
|
|
268
|
+
np.full(better.sum(), w)], 1)
|
|
269
|
+
out[s:s + batch] = best
|
|
270
|
+
return (out, arg) if return_argmax else out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------------------------- calibration and planting
|
|
274
|
+
def thresholds(null_scores: np.ndarray, fars: Sequence[float]) -> dict:
|
|
275
|
+
"""Empirical thresholds: the smallest t with (#null scores > t) / n <= far, for each false-alarm rate."""
|
|
276
|
+
s = np.sort(np.asarray(null_scores))[::-1]
|
|
277
|
+
out = {}
|
|
278
|
+
for a in fars:
|
|
279
|
+
j = int(np.floor(a * len(s))) # allow j exceedances
|
|
280
|
+
out[float(a)] = float(s[j]) if j < len(s) else float(-np.inf)
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def plant_bursts(grids: np.ndarray, rng: np.random.Generator, R: int, T: int, theta: float,
|
|
285
|
+
corner: tuple[int, int] | None = None) -> tuple[np.ndarray, tuple[int, int]]:
|
|
286
|
+
"""XOR a burst into every shot of ``grids`` (shots, rounds, positions): each cell of one R-position x T-round
|
|
287
|
+
rectangle flips independently with probability theta. The rectangle is the same for all shots (a burst lasting
|
|
288
|
+
the whole window); its corner (round0, pos0) is uniformly random unless given. Returns (new grids, corner)."""
|
|
289
|
+
g = np.array(grids, dtype=np.uint8, copy=True)
|
|
290
|
+
nT, nQ = g.shape[1:]
|
|
291
|
+
if corner is None:
|
|
292
|
+
corner = (int(rng.integers(0, nT - T + 1)), int(rng.integers(0, nQ - R + 1)))
|
|
293
|
+
t0, q0 = corner
|
|
294
|
+
flips = rng.random((len(g), T, R)) < theta
|
|
295
|
+
g[:, t0:t0 + T, q0:q0 + R] ^= flips.astype(np.uint8)
|
|
296
|
+
return g, corner
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --------------------------------------------------------------------------------------------------------------- theory
|
|
300
|
+
def planted_rates(p: np.ndarray, R: int, T: int, theta: float, corner: tuple[int, int]) -> np.ndarray:
|
|
301
|
+
"""Per-cell rates after XOR-ing Bernoulli(theta) into the rectangle: p + theta (1 - 2p) inside, p outside."""
|
|
302
|
+
p1 = np.array(p, dtype=np.float64, copy=True)
|
|
303
|
+
t0, q0 = corner
|
|
304
|
+
s = p1[t0:t0 + T, q0:q0 + R]
|
|
305
|
+
p1[t0:t0 + T, q0:q0 + R] = s + theta * (1 - 2 * s)
|
|
306
|
+
return p1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def marked_poisson_divergences(lam0: np.ndarray, lam1: np.ndarray) -> dict:
|
|
310
|
+
"""Lemma 5.8 / Theorem 5.9: D_pattern, D_count, and the decomposition D_pattern = D_count + Lambda_1 D(pi_1||pi_0)."""
|
|
311
|
+
l0, l1 = np.ravel(lam0).astype(float), np.ravel(lam1).astype(float)
|
|
312
|
+
dp = float(np.sum(xlogy(l1, l1 / l0) - l1 + l0))
|
|
313
|
+
L0, L1 = l0.sum(), l1.sum()
|
|
314
|
+
dc = float(L1 * np.log(L1 / L0) - L1 + L0)
|
|
315
|
+
pi0, pi1 = l0 / L0, l1 / L1
|
|
316
|
+
dpi = float(np.sum(xlogy(pi1, pi1 / pi0)))
|
|
317
|
+
return dict(D_pattern=dp, D_count=dc, Lambda0=float(L0), Lambda1=float(L1), D_pi=dpi,
|
|
318
|
+
identity_residual=float(dp - dc - L1 * dpi), ratio=dp / dc if dc > 0 else float("inf"))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _poisson_binomial(p: np.ndarray) -> np.ndarray:
|
|
322
|
+
"""Distribution of a sum of independent Bernoulli(p_i) (exact, by convolution)."""
|
|
323
|
+
dist = np.ones(1)
|
|
324
|
+
for q in np.ravel(p):
|
|
325
|
+
dist = np.convolve(dist, [1 - q, q])
|
|
326
|
+
return dist
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def bernoulli_divergences(p0: np.ndarray, p1: np.ndarray) -> dict:
|
|
330
|
+
"""Independent-Bernoulli cells: D_pattern = sum_i KL(Bern(p1_i)||Bern(p0_i)); D_count = KL of the exact
|
|
331
|
+
Poisson-binomial laws of the totals. Per shot, H1 relative to H0."""
|
|
332
|
+
a, b = np.ravel(p0).astype(float), np.ravel(p1).astype(float)
|
|
333
|
+
dp = float(np.sum(xlogy(b, b / a) + xlogy(1 - b, (1 - b) / (1 - a))))
|
|
334
|
+
f0, f1 = _poisson_binomial(a), _poisson_binomial(b)
|
|
335
|
+
mask = f1 > 0
|
|
336
|
+
dc = float(np.sum(f1[mask] * (np.log(f1[mask]) - np.log(np.maximum(f0[mask], 1e-300)))))
|
|
337
|
+
return dict(D_pattern=dp, D_count=dc, ratio=dp / dc if dc > 0 else float("inf"))
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Circuit-level Peierls bound for minimum-weight matching on a detector error model (theory, Theorem 4.28).
|
|
2
|
+
|
|
3
|
+
Input: a stim detector error model with one logical observable, decomposed into graphlike components
|
|
4
|
+
(``circuit.detector_error_model(decompose_errors=True)``), and the decoder's edge weights (by default those of
|
|
5
|
+
``pymatching.Matching.from_detector_error_model``). Output: an upper bound on the logical failure probability of an
|
|
6
|
+
exact minimum-weight decoder with those weights, hence of the maximum-likelihood decoder.
|
|
7
|
+
|
|
8
|
+
The bound (Theorem 4.28):
|
|
9
|
+
* the observable graph is the part of the decoding graph whose components (without the boundary vertex b) carry an
|
|
10
|
+
edge that flips the observable; every mechanism is written as a set of its edges with the same detectors and the same
|
|
11
|
+
observable flip, as one edge whenever such an edge exists ("re-decomposition");
|
|
12
|
+
* if every cycle avoiding b has even observable parity (checked; the graph is "balanced"), the boundary detectors
|
|
13
|
+
split into two sides and the odd cycles are the paths between the sides closed through b;
|
|
14
|
+
* for lam >= 0 an odd simple cycle gamma contributes at most prod_{e in gamma} beta_e(lam), with
|
|
15
|
+
beta_e(lam) = exp(-lam w_e) (1 - q_e + q_e exp(2 lam w_e)) * prod_{split m on e} (1 + p_m^(1/k_m)(exp(2 lam w_e) - 1)),
|
|
16
|
+
q_e the flip probability of edge e from the mechanisms that have no other edge on any odd cycle with e, and the
|
|
17
|
+
remaining (split) mechanisms m with k_m edges shared out by the splitting lemma;
|
|
18
|
+
* pymatching minimizes rounded weights; every beta_e is multiplied by exp(lam delta), delta the rounding slack (4.28(e));
|
|
19
|
+
* summing over non-backtracking walks from one side to the other gives start^T (I - B)^{-1} end, finite when the
|
|
20
|
+
spectral radius of the weighted non-backtracking matrix B is < 1.
|
|
21
|
+
|
|
22
|
+
from lcd.analysis.circuit_peierls import peierls_bound
|
|
23
|
+
res = peierls_bound(circuit.detector_error_model(decompose_errors=True))
|
|
24
|
+
res["bound"], res["lam"], res["rho_upper"]
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import collections
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from math import sqrt
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import scipy.sparse as sp
|
|
34
|
+
import scipy.sparse.linalg as spla
|
|
35
|
+
|
|
36
|
+
Edge = tuple # (u,) for an edge to the boundary vertex, (u, v) with u < v otherwise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DemGraph:
|
|
41
|
+
edges: list # edges of the observable graph
|
|
42
|
+
flag: np.ndarray # observable flag of each edge (0/1)
|
|
43
|
+
q: np.ndarray # flip probability of each edge from its exclusive mechanisms
|
|
44
|
+
split: list # per edge: the shares p_m^(1/k_m) of mechanisms that are split
|
|
45
|
+
side: dict # boundary detector -> side 0/1 (odd cycles join the two sides)
|
|
46
|
+
stats: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def index(self) -> dict:
|
|
50
|
+
return {e: i for i, e in enumerate(self.edges)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse(dem) -> list:
|
|
54
|
+
"""[(p, [(detectors, observable parity), ...]), ...] with one entry per graphlike component."""
|
|
55
|
+
if dem.num_observables != 1:
|
|
56
|
+
raise ValueError("one logical observable expected")
|
|
57
|
+
import stim
|
|
58
|
+
out = []
|
|
59
|
+
for inst in dem.flattened():
|
|
60
|
+
if inst.type != "error":
|
|
61
|
+
continue
|
|
62
|
+
comps, dets, o = [], [], 0
|
|
63
|
+
for t in inst.targets_copy() + [stim.DemTarget.separator()]:
|
|
64
|
+
if t.is_separator():
|
|
65
|
+
comps.append((tuple(sorted(dets)), o))
|
|
66
|
+
dets, o = [], 0
|
|
67
|
+
elif t.is_logical_observable_id():
|
|
68
|
+
o ^= 1
|
|
69
|
+
else:
|
|
70
|
+
dets.append(t.val)
|
|
71
|
+
p = inst.args_copy()[0]
|
|
72
|
+
if not 0 <= p <= 0.5:
|
|
73
|
+
raise ValueError(f"mechanism probability {p} outside [0, 1/2]")
|
|
74
|
+
out.append((p, comps))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dem_graph(dem) -> DemGraph:
|
|
79
|
+
"""The observable graph of a decomposed detector error model, with re-decomposed mechanisms (Definition 4.27)."""
|
|
80
|
+
mechs = _parse(dem)
|
|
81
|
+
flag: dict = {}
|
|
82
|
+
for _, comps in mechs:
|
|
83
|
+
for e, o in comps:
|
|
84
|
+
if len(e) not in (1, 2):
|
|
85
|
+
raise ValueError(f"component {e} is not graphlike (use decompose_errors=True)")
|
|
86
|
+
if flag.setdefault(e, o) != o:
|
|
87
|
+
raise ValueError(f"edge {e} carries both observable flags")
|
|
88
|
+
parent: dict = {}
|
|
89
|
+
|
|
90
|
+
def find(x):
|
|
91
|
+
parent.setdefault(x, x)
|
|
92
|
+
while parent[x] != x:
|
|
93
|
+
parent[x] = parent[parent[x]]
|
|
94
|
+
x = parent[x]
|
|
95
|
+
return x
|
|
96
|
+
|
|
97
|
+
for e in flag:
|
|
98
|
+
find(e[0])
|
|
99
|
+
if len(e) == 2:
|
|
100
|
+
parent[find(e[0])] = find(e[1])
|
|
101
|
+
roots = {find(e[0]) for e, o in flag.items() if o}
|
|
102
|
+
in_obs = lambda e: find(e[0]) in roots # noqa: E731
|
|
103
|
+
edges = sorted(e for e in flag if in_obs(e))
|
|
104
|
+
eid = {e: i for i, e in enumerate(edges)}
|
|
105
|
+
|
|
106
|
+
# balance and sides
|
|
107
|
+
adj = collections.defaultdict(list)
|
|
108
|
+
for e in edges:
|
|
109
|
+
if len(e) == 2:
|
|
110
|
+
adj[e[0]].append((e[1], flag[e]))
|
|
111
|
+
adj[e[1]].append((e[0], flag[e]))
|
|
112
|
+
phi: dict = {}
|
|
113
|
+
for s in {x for e in edges for x in e}:
|
|
114
|
+
if s in phi:
|
|
115
|
+
continue
|
|
116
|
+
phi[s], stack = 0, [s]
|
|
117
|
+
while stack:
|
|
118
|
+
x = stack.pop()
|
|
119
|
+
for y, o in adj[x]:
|
|
120
|
+
if y not in phi:
|
|
121
|
+
phi[y] = phi[x] ^ o
|
|
122
|
+
stack.append(y)
|
|
123
|
+
elif phi[y] != phi[x] ^ o:
|
|
124
|
+
raise NotImplementedError("an odd cycle avoids the boundary vertex (unbalanced graph)")
|
|
125
|
+
side = {e[0]: flag[e] ^ phi[e[0]] for e in edges if len(e) == 1}
|
|
126
|
+
|
|
127
|
+
# re-decomposition
|
|
128
|
+
lists = collections.defaultdict(list)
|
|
129
|
+
split = [[] for _ in edges]
|
|
130
|
+
n_re = n_excl = n_split = 0
|
|
131
|
+
for p, comps in mechs:
|
|
132
|
+
zc = [(e, o) for e, o in comps if in_obs(e)]
|
|
133
|
+
if not zc:
|
|
134
|
+
continue
|
|
135
|
+
S, L = set(), 0
|
|
136
|
+
for e, o in zc:
|
|
137
|
+
S ^= set(e)
|
|
138
|
+
L ^= o
|
|
139
|
+
key = tuple(sorted(S))
|
|
140
|
+
if not S:
|
|
141
|
+
if L:
|
|
142
|
+
raise ValueError("a mechanism flips the observable without any detector")
|
|
143
|
+
continue
|
|
144
|
+
if key in eid and flag[key] == L:
|
|
145
|
+
lists[eid[key]].append(p)
|
|
146
|
+
n_re += len(zc) > 1
|
|
147
|
+
continue
|
|
148
|
+
if len(zc) == 1:
|
|
149
|
+
lists[eid[zc[0][0]]].append(p)
|
|
150
|
+
continue
|
|
151
|
+
es = [eid[e] for e, _ in zc]
|
|
152
|
+
if all(len(e) == 1 for e, _ in zc) and len({side[e[0]] for e, _ in zc}) == 1:
|
|
153
|
+
for i in es: # boundary edges on one side: never two on an odd simple cycle
|
|
154
|
+
lists[i].append(p)
|
|
155
|
+
n_excl += 1
|
|
156
|
+
else:
|
|
157
|
+
for i in es:
|
|
158
|
+
split[i].append(p ** (1 / len(es)))
|
|
159
|
+
n_split += 1
|
|
160
|
+
q = np.zeros(len(edges))
|
|
161
|
+
for i, ps in lists.items():
|
|
162
|
+
q[i] = 0.5 * (1 - np.prod([1 - 2 * x for x in ps]))
|
|
163
|
+
stats = dict(mechanisms=len(mechs), edges=len(edges), boundary_edges=sum(len(e) == 1 for e in edges),
|
|
164
|
+
redecomposed=n_re, exclusive_multi=n_excl, split_multi=n_split)
|
|
165
|
+
return DemGraph(edges=edges, flag=np.array([flag[e] for e in edges], np.int8), q=q, split=split, side=side,
|
|
166
|
+
stats=stats)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def pymatching_weights(dem, G: DemGraph) -> np.ndarray:
|
|
170
|
+
"""Weights of the observable-graph edges in pymatching's matching graph built from the same model."""
|
|
171
|
+
import pymatching
|
|
172
|
+
m = pymatching.Matching.from_detector_error_model(dem)
|
|
173
|
+
eid = G.index
|
|
174
|
+
w = np.full(len(G.edges), np.nan)
|
|
175
|
+
for u, v, attr in m.edges():
|
|
176
|
+
key = (u,) if v is None else tuple(sorted((u, v)))
|
|
177
|
+
if key in eid:
|
|
178
|
+
w[eid[key]] = attr["weight"]
|
|
179
|
+
if np.isnan(w).any() or (w < 0).any():
|
|
180
|
+
raise ValueError("an observable-graph edge has no nonnegative pymatching weight")
|
|
181
|
+
return w
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# pymatching 2.x (sparse_blossom/driver/user_graph.h, user_graph.cc) minimizes the integer weights
|
|
185
|
+
# 2 round(kappa w), kappa = (NUM_DISTINCT_WEIGHTS - 1) / max|w| over all edges (kappa = 1 if every weight is an integer).
|
|
186
|
+
PYMATCHING_NUM_DISTINCT_WEIGHTS = 1 << 24
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pymatching_rounding_slack(dem) -> float:
|
|
190
|
+
"""delta of Theorem 4.28(e): pymatching minimizes weights within delta per edge of its float weights (up to scale)."""
|
|
191
|
+
import pymatching
|
|
192
|
+
if int(pymatching.__version__.split(".")[0]) != 2:
|
|
193
|
+
raise RuntimeError(f"weight discretization checked for pymatching 2.x only, found {pymatching.__version__}")
|
|
194
|
+
ws = np.array([attr["weight"] for _, _, attr in pymatching.Matching.from_detector_error_model(dem).edges()])
|
|
195
|
+
if ws.size == 0 or np.all(np.round(ws) == ws):
|
|
196
|
+
return 0.0
|
|
197
|
+
return float(np.abs(ws).max()) / (2 * (PYMATCHING_NUM_DISTINCT_WEIGHTS - 1))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def edge_factors(G: DemGraph, w: np.ndarray, lam: float, delta: float = 0.0) -> np.ndarray:
|
|
201
|
+
"""beta_e(lam) of Theorem 4.28, times exp(lam delta) for a decoder that minimizes weights within delta of w (4.28(e))."""
|
|
202
|
+
a = np.exp(2 * lam * w)
|
|
203
|
+
beta = np.exp(lam * (delta - w)) * (1 - G.q + G.q * a)
|
|
204
|
+
for i, shares in enumerate(G.split):
|
|
205
|
+
for s in shares:
|
|
206
|
+
beta[i] *= 1 + s * (a[i] - 1)
|
|
207
|
+
return beta
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class _Arcs:
|
|
212
|
+
arc_edge: np.ndarray
|
|
213
|
+
rows: np.ndarray
|
|
214
|
+
cols: np.ndarray
|
|
215
|
+
start_bdry: np.ndarray # boundary edge index of the arc's tail if on side 0, else -1
|
|
216
|
+
end_bdry: np.ndarray # boundary edge index of the arc's head if on side 1, else -1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _arcs(G: DemGraph) -> _Arcs:
|
|
220
|
+
arcs, arc_edge = [], []
|
|
221
|
+
for i, e in enumerate(G.edges):
|
|
222
|
+
if len(e) == 2:
|
|
223
|
+
arcs += [(e[0], e[1]), (e[1], e[0])]
|
|
224
|
+
arc_edge += [i, i]
|
|
225
|
+
out = collections.defaultdict(list)
|
|
226
|
+
for a, (x, _) in enumerate(arcs):
|
|
227
|
+
out[x].append(a)
|
|
228
|
+
rows, cols = [], []
|
|
229
|
+
for a, (x, y) in enumerate(arcs):
|
|
230
|
+
for b in out[y]:
|
|
231
|
+
if arcs[b][1] != x:
|
|
232
|
+
rows.append(a)
|
|
233
|
+
cols.append(b)
|
|
234
|
+
bedge = {e[0]: i for i, e in enumerate(G.edges) if len(e) == 1}
|
|
235
|
+
sb = np.array([bedge[x] if G.side.get(x) == 0 else -1 for x, _ in arcs], dtype=np.int64)
|
|
236
|
+
eb = np.array([bedge[y] if G.side.get(y) == 1 else -1 for _, y in arcs], dtype=np.int64)
|
|
237
|
+
return _Arcs(np.array(arc_edge, np.int64), np.array(rows, np.int64), np.array(cols, np.int64), sb, eb)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def walk_sum(G: DemGraph, beta: np.ndarray, arcs: _Arcs | None = None) -> tuple[float, float]:
|
|
241
|
+
"""(bound, rho_upper): the sum over non-backtracking walks between the sides, closed by their boundary edges.
|
|
242
|
+
|
|
243
|
+
rho_upper = 1 - 1/max z with z = (I - B)^{-1} 1 is a Collatz-Wielandt upper bound on the spectral radius of B; if z
|
|
244
|
+
is not positive the radius is >= 1 and the bound is infinite.
|
|
245
|
+
"""
|
|
246
|
+
A = arcs if arcs is not None else _arcs(G)
|
|
247
|
+
n = len(A.arc_edge)
|
|
248
|
+
if n == 0:
|
|
249
|
+
return 0.0, 0.0
|
|
250
|
+
B = sp.csc_matrix((beta[A.arc_edge[A.cols]], (A.rows, A.cols)), shape=(n, n))
|
|
251
|
+
lu = spla.splu((sp.identity(n, format="csc") - B).tocsc())
|
|
252
|
+
z = lu.solve(np.ones(n))
|
|
253
|
+
if not np.all(z > 0):
|
|
254
|
+
return np.inf, np.nan
|
|
255
|
+
rho_upper = 1 - 1 / z.max()
|
|
256
|
+
start = np.where(A.start_bdry >= 0, beta[np.maximum(A.start_bdry, 0)] * beta[A.arc_edge], 0.0)
|
|
257
|
+
end = np.where(A.end_bdry >= 0, beta[np.maximum(A.end_bdry, 0)], 0.0)
|
|
258
|
+
return float(start @ lu.solve(end)), float(rho_upper)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def peierls_bound(dem, weights: np.ndarray | None = None, lams=None, G: DemGraph | None = None,
|
|
262
|
+
delta: float | None = None) -> dict:
|
|
263
|
+
"""Minimize the bound of Theorem 4.28 over lam (the bound is log-convex in lam: coarse grid, then golden section).
|
|
264
|
+
|
|
265
|
+
Default: pymatching's weights and its rounding slack delta (Theorem 4.28(e)). With explicit ``weights`` the decoder is
|
|
266
|
+
assumed to minimize exactly those weights (delta = 0) unless ``delta`` is given.
|
|
267
|
+
"""
|
|
268
|
+
G = G if G is not None else dem_graph(dem)
|
|
269
|
+
if weights is None:
|
|
270
|
+
w = pymatching_weights(dem, G)
|
|
271
|
+
delta = pymatching_rounding_slack(dem) if delta is None else delta
|
|
272
|
+
else:
|
|
273
|
+
w, delta = weights, (0.0 if delta is None else delta)
|
|
274
|
+
A = _arcs(G)
|
|
275
|
+
|
|
276
|
+
def f(lam):
|
|
277
|
+
return walk_sum(G, edge_factors(G, w, lam, delta), A)
|
|
278
|
+
|
|
279
|
+
grid = list(lams) if lams is not None else [0.1 * k for k in range(1, 10)]
|
|
280
|
+
vals = [f(l)[0] for l in grid]
|
|
281
|
+
k = int(np.argmin(vals))
|
|
282
|
+
if lams is None and np.isfinite(vals[k]):
|
|
283
|
+
lo, hi = grid[max(k - 1, 0)] if k > 0 else 0.01, grid[min(k + 1, len(grid) - 1)] if k < len(grid) - 1 else 0.99
|
|
284
|
+
g = (sqrt(5) - 1) / 2
|
|
285
|
+
x1, x2 = hi - g * (hi - lo), lo + g * (hi - lo)
|
|
286
|
+
f1, f2 = f(x1)[0], f(x2)[0]
|
|
287
|
+
for _ in range(25):
|
|
288
|
+
if f1 <= f2:
|
|
289
|
+
hi, x2, f2 = x2, x1, f1
|
|
290
|
+
x1 = hi - g * (hi - lo)
|
|
291
|
+
f1 = f(x1)[0]
|
|
292
|
+
else:
|
|
293
|
+
lo, x1, f1 = x1, x2, f2
|
|
294
|
+
x2 = lo + g * (hi - lo)
|
|
295
|
+
f2 = f(x2)[0]
|
|
296
|
+
lam = x1 if f1 <= f2 else x2
|
|
297
|
+
else:
|
|
298
|
+
lam = grid[k]
|
|
299
|
+
val, rho = f(lam)
|
|
300
|
+
return dict(bound=val, lam=float(lam), rho_upper=rho, delta=delta, stats=G.stats)
|