Gyanateet Dutta
Fix Space loading: direct Streamlit, lazy imports, ReNova page, fix deps
9f8cf99
Raw
History Blame Contribute Delete
7.82 kB
"""Confidence-aware decoding utilities.
This module introduces a small data container for syndrome batches and a
weighted-MWPM decoder that can consume optional per-detector confidence values.
When confidence data is omitted, decoding behavior falls back to standard
hard-decision MWPM.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Protocol
import numpy as np
@dataclass(frozen=True)
class SyndromeBatch:
"""Container for batched detector outcomes.
Attributes:
hard_bits: Binary detector outcomes with shape ``(shots, num_detectors)``.
confidence: Optional confidence values in ``[0, 1]`` with the same shape
as ``hard_bits``. Larger values indicate higher trust in the
corresponding hard detector outcome.
"""
hard_bits: np.ndarray
confidence: Optional[np.ndarray] = None
def __post_init__(self) -> None:
hard_bits = np.asarray(self.hard_bits, dtype=np.uint8)
if hard_bits.ndim != 2:
raise ValueError("hard_bits must be a 2D array with shape (shots, num_detectors).")
if np.any((hard_bits != 0) & (hard_bits != 1)):
raise ValueError("hard_bits must contain only 0/1 values.")
object.__setattr__(self, "hard_bits", hard_bits)
if self.confidence is None:
return
confidence = np.asarray(self.confidence, dtype=np.float64)
if confidence.shape != hard_bits.shape:
raise ValueError("confidence must have the same shape as hard_bits.")
if np.any((confidence < 0.0) | (confidence > 1.0)):
raise ValueError("confidence entries must lie in [0, 1].")
object.__setattr__(self, "confidence", confidence)
class SyndromeDecoder(Protocol):
"""Decoder interface accepting hard syndromes with optional soft features."""
def decode_batch(self, syndromes: SyndromeBatch) -> np.ndarray:
"""Decode a batch and return logical predictions per shot."""
class WeightedMWPMDecoder:
"""Weighted MWPM decoder with per-shot confidence reweighting.
The decoder starts from a base matching graph extracted from a
detector-error model. If confidence is available, edge weights are adjusted
by endpoint confidence before running MWPM:
``w' = w * (1 + confidence_scale * (1 - mean_endpoint_confidence))``
so low-confidence measurements get larger effective costs.
``decode_batch`` returns an array of shape ``(shots, num_fault_ids)``
containing the predicted observable flips for every shot.
"""
def __init__(self, detector_error_model: object, *, confidence_scale: float = 1.0) -> None:
if confidence_scale < 0:
raise ValueError("confidence_scale must be non-negative.")
try:
import pymatching
except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
raise ImportError("pymatching is required for WeightedMWPMDecoder.") from exc
self._pymatching = pymatching
self._confidence_scale = float(confidence_scale)
self._base_matching = pymatching.Matching.from_detector_error_model(detector_error_model)
# Number of detector nodes; the boundary placeholder index equals this value.
self._num_detectors: int = self._base_matching.num_detectors
# Determine the number of observable fault IDs from the base matching.
_zero_syndrome = np.zeros(self._num_detectors, dtype=np.uint8)
self._num_fault_ids: int = len(self._base_matching.decode(_zero_syndrome))
# --- Precompute edge data for vectorized per-shot weight adjustment ---
# The base graph is retained for per-shot copies; this preserves all
# detector nodes (including any that have no edges in the MWPM graph).
# Edge endpoints and base weights are extracted once so the hot-path
# confidence computation can be fully vectorised with NumPy.
self._base_graph = self._base_matching.to_networkx()
boundary = self._num_detectors
edges_u: list[int] = []
edges_v: list[int] = []
base_weights: list[float] = []
for u, v, data in self._base_graph.edges(data=True):
edges_u.append(int(u))
edges_v.append(int(v))
base_weights.append(float(data.get("weight", 1.0)))
# np.intp matches NumPy's native index type, avoiding implicit casts
# during fancy-indexing into shot_confidence arrays.
self._edge_u = np.array(edges_u, dtype=np.intp)
self._edge_v = np.array(edges_v, dtype=np.intp)
self._base_weights = np.array(base_weights, dtype=np.float64)
# Ordered (u, v) pairs matching the arrays above, for O(E) weight updates.
self._edge_pairs: list[tuple[int, int]] = list(zip(edges_u, edges_v))
# Boolean masks: True when the endpoint is a real detector node.
self._u_is_det: np.ndarray = self._edge_u != boundary
self._v_is_det: np.ndarray = self._edge_v != boundary
# Safe index arrays: replace the out-of-range boundary placeholder with
# index 0 so numpy fancy-indexing never goes out of bounds; the boolean
# masks ensure those slots contribute nothing to the confidence mean.
self._edge_u_safe = np.where(self._u_is_det, self._edge_u, 0)
self._edge_v_safe = np.where(self._v_is_det, self._edge_v, 0)
def _compute_adjusted_weights(self, shot_confidence: np.ndarray) -> np.ndarray:
"""Return edge weights adjusted by per-detector confidence (vectorized).
All per-edge arithmetic is done with NumPy so no Python-level loop over
edges is needed.
"""
conf_u = np.where(self._u_is_det, shot_confidence[self._edge_u_safe], 1.0)
conf_v = np.where(self._v_is_det, shot_confidence[self._edge_v_safe], 1.0)
num_real_endpoints = self._u_is_det.astype(np.float64) + self._v_is_det.astype(np.float64)
mean_conf = np.where(
num_real_endpoints > 0,
(conf_u * self._u_is_det + conf_v * self._v_is_det) / np.maximum(num_real_endpoints, 1.0),
1.0,
)
scale = 1.0 + self._confidence_scale * (1.0 - mean_conf)
return self._base_weights * scale
def decode_batch(self, syndromes: SyndromeBatch) -> np.ndarray:
"""Decode each shot and return predictions shaped ``(shots, num_fault_ids)``.
When ``syndromes.confidence`` is ``None`` the base matching (without
any weight adjustments) is used for every shot, which is equivalent to
passing an all-ones confidence array.
"""
hard = syndromes.hard_bits
confidence = syndromes.confidence
shots = hard.shape[0]
predictions = np.zeros((shots, self._num_fault_ids), dtype=np.uint8)
if confidence is None:
for shot_ix, shot in enumerate(hard):
predictions[shot_ix] = self._base_matching.decode(shot)
return predictions
for shot_ix, (shot, shot_confidence) in enumerate(zip(hard, confidence, strict=True)):
adjusted_weights = self._compute_adjusted_weights(shot_confidence)
# Copy the base graph and apply precomputed weights. Copying rather
# than building from scratch preserves isolated detector nodes (those
# with no edges in the MWPM graph) so that syndrome arrays of full
# length num_detectors are accepted by pymatching.
graph = self._base_graph.copy()
for i, (u, v) in enumerate(self._edge_pairs):
graph[u][v]["weight"] = float(adjusted_weights[i])
matching = self._pymatching.Matching(graph)
predictions[shot_ix] = matching.decode(shot)
return predictions