Files
giant/giant/analysis/router_gating.py
T
lars 818c380fd0 Extract predict/rollout's duplicated inference bootstrap into giant.checkpoint_io (issues.md Issue 5)
giant predict and giant rollout each carried a ~65-line, independently
drifting copy of "load checkpoint -> validate -> resolve conditioning axes
-> restore normalizers/vocab maps -> build models -> load weights", plus a
third partial copy of _conditioning_axes in analysis/router_gating.py. A
silent divergence there doesn't crash, it makes the two commands run
different physics from the same checkpoint with no test coverage anywhere
along that path.

giant/checkpoint_io.py now holds the single implementation:
load_for_inference() + an InferenceContext dataclass, raising
CheckpointCompatibilityError (verbatim message text preserved) instead of
calling typer directly, so it can be unit-tested and imported from
non-Typer code. router_gating.py's load_router imports conditioning_axes
from it lazily, keeping its "no torch at module scope" contract intact.

Adds 17 direct unit tests for load_for_inference/conditioning_axes/stage_cfg
plus CLI smoke tests confirming the error surfaces as typer.Exit(1) through
predict and rollout — previously zero coverage on this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 15:31:14 +02:00

334 lines
12 KiB
Python

"""Router gating diagnostic: where a MoE checkpoint's decision boundaries sit.
Unlike everything else in this package, this reduction needs a live PyTorch
model — soft expert gate weights aren't columns in a rollout/predict parquet,
they only exist by calling `Router.gate(cond_cont, cond_cat)` (see
`giant.model.network.Router`) against the checkpoint that produced the
rollout. That's a deliberate, narrow exception to the rest of the catalog's
"polars/numpy only" contract; it still runs fine as a `compute-one` HTCondor
job since torch is already installed there (the same env trains checkpoints).
The routing axis is fixed to pre-step energy: every router type at least
indirectly depends on it (`EnergyRouter` reads it directly; `PdgRouter` and
`ProcessRouter` correlate with it through the physics), and it's the one axis
a reader can interpret without knowing the checkpoint's specific router
config. `x` is binned into equal-population (quantile) bins rather than
equal-width ones, since energy is heavy-tailed and equal-width bins would
leave the upper end almost empty. Mean gate weight per bin is stacked as
filled areas per expert — since `gate` rows are a partition of unity, the
stack always fills exactly to 1, and the crossover bands are the router's
soft decision boundaries (where two experts' means cross ~0.5).
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
import polars as pl
from giant.analysis.grouping import pdg_label
from giant.analysis.reduced import Reduced
if TYPE_CHECKING:
import torch
from giant.data.transforms import Normalizer
_SAMPLE_ROWS = 200_000
_N_BINS = 40
_TOP_K_PROCESS = 8
_COLS = (
"pre_x",
"pre_y",
"pre_z",
"pre_E",
"pre_dx",
"pre_dy",
"pre_dz",
"layer_id",
"pdg",
"material",
)
@dataclass
class _RouterHandle:
router: "torch.nn.Module"
pdg_map: dict[int, int]
mat_map: dict[str, int]
cond_normalizer: "Normalizer"
particle_conditioning: str
material_conditioning: str
router_type: str
def load_router(checkpoint: str | Path) -> _RouterHandle | None:
"""Load a checkpoint's Stage-1 router, or None if it isn't a MoE checkpoint."""
import torch
from giant.checkpoint_io import conditioning_axes
from giant.data.transforms import Normalizer
from giant.model.network import build_models
ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
model_cfg = ckpt.get("model_config") or {}
# New nested shape (has a "stage1_model" key) vs. a v0.2 checkpoint's
# flat model_config.
router_cfg = (
(model_cfg.get("stage1_model") or {}).get("router") if "stage1_model" in model_cfg else model_cfg.get("router")
)
if not router_cfg or not router_cfg.get("enabled"):
return None
built = build_models(model_cfg)
stage1 = built["stage1"]
if stage1 is None:
return None
stage1.load_state_dict(ckpt["model"])
stage1.eval()
router = stage1.trunk.router
if router is None:
return None
particle_conditioning, material_conditioning = conditioning_axes(model_cfg)
return _RouterHandle(
router=router,
pdg_map={int(k): v for k, v in ckpt["pdg_map"].items()},
mat_map={str(k): v for k, v in ckpt["mat_map"].items()},
cond_normalizer=Normalizer.from_dict(ckpt["normalizer"]["cond"]),
particle_conditioning=particle_conditioning,
material_conditioning=material_conditioning,
router_type=router_cfg["type"],
)
def _subsample(lf: pl.LazyFrame, n: int, seed: int, extra_cols: tuple = ()) -> pl.DataFrame:
total = lf.select(pl.len()).collect(engine="streaming").item()
if total > n:
threshold = int(n / total * 2**32)
lf = lf.filter((pl.col("pre_E").hash(seed=seed) % 2**32) < threshold)
return lf.select(*_COLS, *extra_cols).collect(engine="streaming")
def _gate_for_df(handle: _RouterHandle, df: pl.DataFrame) -> tuple[pl.DataFrame, np.ndarray]:
"""(filtered df, gate_weights) for rows in ``df`` with a known pdg/material.
Rows whose species or material never appeared in the checkpoint's
training vocab can't be embedded — dropped here the same way
`giant.rollout`'s own known-pdg gate drops them at inference. The
returned df keeps every original column (filtered to the same rows), so
callers can key gate weights by any of them (energy, pdg, process, ...).
"""
import torch
from giant.data.transforms import build_cond_features
known = np.array(
[
int(p) in handle.pdg_map and str(m) in handle.mat_map
for p, m in zip(df["pdg"].to_list(), df["material"].to_list())
]
)
if not known.any():
return df.clear(), np.zeros((0, handle.router.n_experts))
df = df.filter(pl.Series(known, dtype=pl.Boolean))
data = {
"pre_pos": np.column_stack([df["pre_x"].to_numpy(), df["pre_y"].to_numpy(), df["pre_z"].to_numpy()]),
"pre_E": df["pre_E"].to_numpy(),
"pre_dir": np.column_stack([df["pre_dx"].to_numpy(), df["pre_dy"].to_numpy(), df["pre_dz"].to_numpy()]),
"layer_id": df["layer_id"].to_numpy(),
"pdg": df["pdg"].to_numpy(),
"material": df["material"].to_numpy(),
}
cond_cont, cond_cat = build_cond_features(
data,
handle.pdg_map,
handle.mat_map,
cond_normalizer=handle.cond_normalizer,
particle_conditioning=handle.particle_conditioning,
material_conditioning=handle.material_conditioning,
)
with torch.no_grad():
gate = handle.router.gate(torch.from_numpy(cond_cont).float(), torch.from_numpy(cond_cat).long()).numpy()
return df, gate
def _quantile_bins(x: np.ndarray, gate: np.ndarray, n_bins: int) -> dict:
order = np.argsort(x)
x_sorted, g_sorted = x[order], gate[order]
edges = np.quantile(x_sorted, np.linspace(0, 1, n_bins + 1))
edges[-1] = np.nextafter(edges[-1], np.inf) # include the max value
bin_idx = np.clip(np.digitize(x_sorted, edges[1:-1]), 0, n_bins - 1)
n_experts = gate.shape[1]
centers = np.full(n_bins, np.nan)
means = np.full((n_bins, n_experts), np.nan)
for b in range(n_bins):
mask = bin_idx == b
if mask.any():
centers[b] = x_sorted[mask].mean()
means[b] = g_sorted[mask].mean(axis=0)
valid = ~np.isnan(centers)
return {"centers": centers[valid].tolist(), "means": means[valid].tolist()}
def _top1_shares(categories: np.ndarray, idx: np.ndarray, order: list, n_experts: int) -> dict[str, list[float]]:
"""Fraction of each category's rows hard-dispatched to each expert.
Uses `Router.top1` (argmax), not the soft `gate` mean — grouped top-1
dispatch is what `_route_forward` actually runs in eval mode (rollout,
predict), so this answers "which expert does a photon/Compton step
actually go through", not just its average soft weight.
"""
shares: dict[str, list[float]] = {}
for key in order:
mask = categories == key
total = int(mask.sum())
if total == 0:
shares[str(key)] = [0.0] * n_experts
continue
counts = np.bincount(idx[mask], minlength=n_experts)
shares[str(key)] = (counts / total).tolist()
return shares
_NOTE_NOT_MOE = "checkpoint has no enabled MoE router (model.router.enabled is false/absent) — nothing to show"
_TITLES = {
"router_gating": "Router gating (mixture-of-experts decision boundaries)",
"router_share_by_pdg": "Router expert share by particle species",
"router_share_by_process": "Router expert share by physics process",
}
def _unavailable(spec_id: str) -> Reduced:
return Reduced(
id=spec_id,
family="model",
kind="unavailable",
title=_TITLES[spec_id],
xlabel="n/a",
payload={"note": _NOTE_NOT_MOE},
)
def compute_router_gating(
checkpoint: str | Path | None,
r_phys: pl.LazyFrame,
t_phys: pl.LazyFrame,
seed: int = 0,
) -> Reduced:
"""`Reduced` for the router-gating figure, or an explanatory note if n/a."""
handle = load_router(checkpoint) if checkpoint else None
if handle is None:
return _unavailable("router_gating")
sides: dict[str, dict] = {}
for name, lf in (("rollout", r_phys), ("reference", t_phys)):
df = _subsample(lf, _SAMPLE_ROWS, seed)
df, gate = _gate_for_df(handle, df)
x = df["pre_E"].to_numpy()
sides[name] = _quantile_bins(x, gate, _N_BINS) if len(x) else {"centers": [], "means": []}
return Reduced(
id="router_gating",
family="model",
kind="router_gating",
title=_TITLES["router_gating"],
xlabel="pre-step energy [MeV]",
payload={
"router_type": handle.router_type,
"n_experts": handle.router.n_experts,
"log_x": True,
**sides,
},
)
def compute_router_share_by_pdg(
checkpoint: str | Path | None,
r_phys: pl.LazyFrame,
t_phys: pl.LazyFrame,
top_pdgs: list[int],
seed: int = 0,
) -> Reduced:
"""Stacked-bar share of each particle species dispatched to each expert."""
handle = load_router(checkpoint) if checkpoint else None
if handle is None:
return _unavailable("router_share_by_pdg")
labels = [pdg_label(p) for p in top_pdgs]
sides: dict[str, dict] = {}
for name, lf in (("rollout", r_phys), ("reference", t_phys)):
df = _subsample(lf, _SAMPLE_ROWS, seed)
df, gate = _gate_for_df(handle, df)
if len(df):
idx = gate.argmax(axis=1)
shares = _top1_shares(df["pdg"].to_numpy(), idx, top_pdgs, handle.router.n_experts)
else:
shares = {str(p): [0.0] * handle.router.n_experts for p in top_pdgs}
sides[name] = {labels[i]: shares[str(p)] for i, p in enumerate(top_pdgs)}
return Reduced(
id="router_share_by_pdg",
family="model",
kind="router_share",
title=_TITLES["router_share_by_pdg"],
xlabel="particle species",
payload={
"router_type": handle.router_type,
"n_experts": handle.router.n_experts,
"categories": labels,
**sides,
},
)
def compute_router_share_by_process(
checkpoint: str | Path | None,
t_phys: pl.LazyFrame,
seed: int = 0,
top_k: int = _TOP_K_PROCESS,
) -> Reduced:
"""Stacked-bar share of each physics process dispatched to each expert.
Reference-only: ``process`` is the true post-step physics process — a
label the rollout side has no equivalent of (see
`giant.model.network.ProcessRouter`, which predicts it from pre-step
conditioning alone, never observes it at eval time). This plot instead
checks *after the fact*, on real data, how well the router's conditioning
-based dispatch lines up with the true process.
"""
handle = load_router(checkpoint) if checkpoint else None
if handle is None:
return _unavailable("router_share_by_process")
df = _subsample(t_phys, _SAMPLE_ROWS, seed, extra_cols=("process",))
df, gate = _gate_for_df(handle, df)
if len(df):
counts = df["process"].value_counts().sort("count", descending=True)
order = counts["process"].to_list()[:top_k]
idx = gate.argmax(axis=1)
shares = _top1_shares(df["process"].to_numpy(), idx, order, handle.router.n_experts)
else:
order, shares = [], {}
return Reduced(
id="router_share_by_process",
family="model",
kind="router_share",
title=_TITLES["router_share_by_process"],
xlabel="physics process",
payload={
"router_type": handle.router_type,
"n_experts": handle.router.n_experts,
"categories": order,
"reference": {p: shares[p] for p in order},
},
)