60c2ca1985
New "model" family in the gallery: router_gating (mean soft gate weight vs. pre-step energy, showing the router's soft decision boundaries) and router_share_by_pdg/router_share_by_process (stacked top-1 dispatch share by species / true physics process). Needs a live checkpoint's Router, so it's a documented exception to the rest of the package's polars/numpy-only contract; gracefully degrades to a placeholder for non-MoE checkpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
340 lines
11 KiB
Python
340 lines
11 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"
|
|
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.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 {}
|
|
router_cfg = model_cfg.get("router")
|
|
if not router_cfg or not router_cfg.get("enabled"):
|
|
return None
|
|
|
|
stage1, _ = build_models(model_cfg)
|
|
stage1.load_state_dict(ckpt["model"])
|
|
stage1.eval()
|
|
|
|
return _RouterHandle(
|
|
router=stage1.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"]),
|
|
conditioning=model_cfg.get("conditioning", "embedding"),
|
|
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,
|
|
conditioning=handle.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},
|
|
},
|
|
)
|