File size: 10,683 Bytes
9f8cf99 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | """Helpers for quickly comparing nested surface-code policies.
The helpers in this module run tiny Stim simulations for both the original
static surface code builder and one of the dynamic builders. They return simple
tables of logical error rates that can be consumed by notebooks or tests
without requiring long simulation times.
"""
from __future__ import annotations
from importlib.util import find_spec
import multiprocessing as mp
from typing import Any, Callable, Dict, Iterable, TYPE_CHECKING
import numpy as np
from surface_code_in_stem.dynamic import hexagonal_surface_code
from surface_code_in_stem.decoders import DecoderMetadata, DecoderProtocol, MWPMDecoder
from surface_code_in_stem.surface_code import surface_code_circuit_string
from surface_code_in_stem.rl_control.nested_agent import NestedLearningAgent
if TYPE_CHECKING:
import stim
CircuitArtifact = Any
StimBuilder = Callable[[int, int, float], CircuitArtifact]
def _logical_error_rate(
circuit_string: str,
shots: int,
seed: int | None,
decoder: DecoderProtocol | None = None,
) -> float:
"""Estimate post-decoding logical-observable-0 error probability."""
try:
import stim
except ModuleNotFoundError as exc: # pragma: no cover - handled by tests
raise ImportError("Stim is required to sample logical error rates.") from exc
circuit = stim.Circuit(circuit_string)
if circuit.num_observables == 0:
raise ValueError("Circuit must define observable 0 to estimate logical error rate.")
sampler = circuit.compile_detector_sampler(seed=seed)
detector_samples, observable_samples = sampler.sample(shots, separate_observables=True)
active_decoder = decoder or MWPMDecoder()
dem = None
if isinstance(active_decoder, MWPMDecoder) and find_spec("pymatching") is not None:
dem = circuit.detector_error_model(decompose_errors=True)
metadata = DecoderMetadata(
num_observables=circuit.num_observables,
detector_error_model=dem,
circuit=circuit,
seed=seed,
)
decoded = active_decoder.decode(detector_samples, metadata=metadata)
# Post-decoding logical error is the residual mismatch between decoder
# predictions and the sampled observable values.
logical_predictions = np.asarray(decoded.logical_predictions)
if logical_predictions.shape != observable_samples.shape:
raise ValueError(
f"Decoder returned logical_predictions with shape {logical_predictions.shape}, "
f"but expected {observable_samples.shape} to match observable_samples."
)
if logical_predictions.dtype != observable_samples.dtype:
logical_predictions = logical_predictions.astype(observable_samples.dtype, copy=False)
logical_mismatch = np.logical_xor(logical_predictions, observable_samples)
return float(np.mean(logical_mismatch[:, 0]))
def _coerce_circuit_string(circuit: CircuitArtifact) -> str:
if isinstance(circuit, str):
return circuit
return str(circuit)
def _evaluate_policy_task(
task: tuple[str, StimBuilder, int, int, float, int, int | None],
) -> tuple[str, Dict[str, float | int | str | None] | None, str | None]:
name, builder, distance, rounds, p, shots, seed = task
builder_name = getattr(builder, "__name__", builder.__class__.__name__)
try:
circuit_string = _coerce_circuit_string(builder(distance, rounds, p))
result: Dict[str, float | int | str | None] = {
"builder": builder_name,
"distance": distance,
"rounds": rounds,
"p": p,
"shots": shots,
"seed": seed,
"logical_error_rate": _logical_error_rate(circuit_string, shots, seed),
}
return name, result, None
except ImportError as exc:
return name, None, str(exc)
def _run_policy_tasks(
tasks: list[tuple[str, StimBuilder, int, int, float, int, int | None]],
) -> list[tuple[str, Dict[str, float | int | str | None] | None, str | None]]:
if len(tasks) <= 1:
return [_evaluate_policy_task(task) for task in tasks]
processes = min(len(tasks), max(1, mp.cpu_count() or 1))
try:
with mp.get_context("spawn").Pool(processes=processes) as pool:
return pool.map(_evaluate_policy_task, tasks)
except (AttributeError, OSError, RuntimeError, TypeError):
# Custom builders defined in interactive sessions are often not
# picklable. Preserve the public API by falling back to serial
# execution instead of failing before any simulation runs.
return [_evaluate_policy_task(task) for task in tasks]
def compare_nested_policies(
*,
distance: int,
rounds: int,
p: float,
shots: int,
seed: int | None = None,
static_builder: StimBuilder = surface_code_circuit_string,
dynamic_builder: StimBuilder = hexagonal_surface_code,
) -> Dict[str, Dict[str, float | int | str | None]]:
"""Run small simulations for static and dynamic builders.
Returns a dictionary keyed by policy name containing the logical error rate
and the simulation metadata used to generate it. Independent policy
builders are evaluated concurrently with `multiprocessing.Pool` when
possible.
"""
if not isinstance(distance, int):
raise ValueError("distance must be an integer.")
if distance < 3 or distance % 2 == 0:
raise ValueError("distance must be an odd integer >= 3.")
if not isinstance(rounds, int) or rounds <= 0:
raise ValueError("rounds must be a positive integer.")
if not isinstance(shots, int) or shots <= 0:
raise ValueError("shots must be a positive integer.")
if not isinstance(p, float) or not 0.0 <= p <= 1.0:
raise ValueError("p must be a float between 0 and 1 (inclusive).")
if not callable(static_builder):
raise ValueError("static_builder must be callable.")
if not callable(dynamic_builder):
raise ValueError("dynamic_builder must be callable.")
if find_spec("stim") is None:
raise ImportError("Stim is required to sample logical error rates.")
policies: Dict[str, StimBuilder] = {
"static": static_builder,
"dynamic": dynamic_builder,
}
tasks = [(name, builder, distance, rounds, p, shots, seed) for name, builder in policies.items()]
results: Dict[str, Dict[str, float | int | str | None]] = {}
for name, metrics, error in _run_policy_tasks(tasks):
if error is not None:
raise ImportError(error)
if metrics is None:
raise RuntimeError(f"Policy '{name}' did not return metrics.")
results[name] = metrics
return results
def tabulate_comparison(comparison: Dict[str, Dict[str, float | int | str | None]]) -> Iterable[Dict[str, float | int | str | None]]:
"""Flatten a comparison dictionary into a list of rows."""
for policy, metrics in comparison.items():
yield {"policy": policy, **metrics}
def train_nested_agent(
distance: int,
rounds: int,
p: float,
shots: int,
epochs: int = 10,
seed: int | None = None
) -> NestedLearningAgent:
"""
Train a NestedLearningAgent using actual Stim simulations for the inner loop.
The inner loop trains the agent to predict logical errors (or corrections) from syndromes.
The outer loop adapts the agent's memory/hyperparameters based on overall logical error rate.
"""
import torch
import stim
# 1. Setup Environment (Circuit)
# We use the dynamic builder (e.g., hexagonal) as the environment
circuit_str = _coerce_circuit_string(hexagonal_surface_code(distance, rounds, p))
circuit = stim.Circuit(circuit_str)
# 2. Initialize Agent
# State dim: Number of detectors (syndrome bits)
# Action dim: 2 (Predict logical observable flip: 0 or 1)
num_detectors = circuit.num_detectors
state_dim = num_detectors
action_dim = 2
agent = NestedLearningAgent(state_dim=state_dim, action_dim=action_dim)
# Sampler for generating experience
sampler = circuit.compile_detector_sampler(seed=seed)
for epoch in range(epochs):
# --- Inner Loop: Train on Batches of Syndromes ---
# Generate a batch of experience
batch_size = shots
detector_samples, observable_samples = sampler.sample(batch_size, separate_observables=True)
# Convert to PyTorch tensors
# detectors: (batch, num_detectors) -> Float for NN input
states = torch.from_numpy(detector_samples).float()
# observables: (batch, num_observables) -> We care about observable 0
# actions: The "correct" action is the actual logical flip (0 or 1)
# If the agent predicts this correctly, it "decodes" the logical error state.
target_actions = torch.from_numpy(observable_samples[:, 0].astype(np.int64))
# Rewards: +1 for correct prediction, -1 for incorrect
# We calculate this inside inner_loop implicitly via CrossEntropy,
# but let's provide explicit rewards for the interface.
# Here we just pass 1.0s because CrossEntropy handles the "supervised" signal.
rewards = torch.ones(batch_size)
# Run inner loop update
inner_loss = agent.inner_loop(states, target_actions, rewards)
# --- Outer Loop: Evaluate & Adapt ---
# Evaluate performance using the standard comparison tool
# This runs a separate validation set (potentially parallelized)
comparison = compare_nested_policies(
distance=distance,
rounds=rounds,
p=p,
shots=shots, # Validation shots
seed=seed + epoch if seed else None # Vary seed for validation
)
# Get the logical error rate of the dynamic policy (baseline)
# In a full RL setup, we'd use the agent's own performance,
# but here we use the environment's difficulty as a proxy for "outer" adaptation needs.
dynamic_error_rate = comparison.get("dynamic", {}).get("logical_error_rate", 1.0)
# Performance metric for outer loop:
# If error rate is high, we might want to update memory more aggressively.
# Let's use (1 - error_rate) as "performance".
performance_metric = 1.0 - float(dynamic_error_rate)
outer_loss = agent.outer_loop(states, performance_metric)
print(f"Epoch {epoch+1}/{epochs} | Inner Loss: {inner_loss:.4f} | Outer Loss: {outer_loss:.4f} | Val Error Rate: {dynamic_error_rate:.4f}")
return agent
|