Files
giant/giant/analysis/context.py
T
lars f4c2545e8b
CI / Lint (ruff check) (push) Failing after 4s
CI / Format (ruff format) (push) Failing after 4s
CI / Type check (ty) (push) Failing after 3s
CI / Tests (push) Failing after 4s
CI / Bump version, build & publish wheel (push) Has been skipped
Rewrite analysis as streaming rollout-vs-reference plotting pipeline
Replace the monolithic giant/analysis.py (predict-local + RolloutVsTruth
diagnostics) with a lean giant/analysis/ package that compares one
autoregressive `giant rollout` for a checkpoint against a held-out
miniCaloSim reference file, and generates publication-styled plots in
parallel on HTCondor.

Rollout output and a raw reference file share a world-frame physical
column subset under identical names, so the old ALR/local-frame decode
machinery is gone — everything is world-frame mm/MeV.

- sources.py: canonical LazyFrames, synthetic-termination-row filtering,
  the secondary view (rollout generation>0 tracks vs reference sec_*_list).
- reduce.py: streaming primitives — a single hist1d group_by pass, per-event
  scalars, edep-weighted depth/transverse profiles, species share, leakage.
- context.py/grouping.py: prep resolves fixed bin edges + energy/pdg/material
  group sets once into shared.json, so each compute job is one pass, no range
  scan (histogram efficiency).
- catalog.py: declarative PlotSpec registry — marginals x {overall,energy,pdg,
  material}, per-event totals, shower profiles, species/leakage, secondaries.
- render.py: the only plotstyle/LaTeX importer; PDFs + gallery metadata.
- condor.py + `giant analyze` CLI (prep/compute-one/list/render/submit):
  one job per plot, compute/render split (workers polars-only, no LaTeX).

Styling via ETPlot's plotstyle (added to the analysis extra). New tests cover
the reduce primitives, catalog id uniqueness + compute, condor submit, and a
guarded render smoke test. Delete the two predict-diagnostics notebooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 17:38:09 +02:00

200 lines
7.1 KiB
Python

"""Shared analysis context (the ``prep`` step): fixed bin edges + group sets.
Every histogram in the catalog bins against **fixed** edges so each compute job
is a single streaming pass with no min/max range scan. Those edges — plus the
energy-bin quantiles, the top PDG species and the material list to stratify by,
and the shower depth/transverse ranges — are resolved *once* here, on the submit
node, from a hash-subsample plus a few cheap exact ``group_by`` passes, and shipped
in ``shared.json``. Tiny and self-describing; no per-event arrays.
plotstyle-free (runs on the submit node, but also importable by workers).
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
import numpy as np
import polars as pl
from giant.analysis.grouping import energy_bin_edges
from giant.analysis.reduce import (
attach_entry_axis,
depth_expr,
entry_axis,
transverse_expr,
)
from giant.analysis.sources import Side, open_side, physical_steps, secondaries
from giant.analysis.variables import RANGED_VARS
@dataclass
class Context:
"""Resolved bin edges and grouping sets shared by every compute job."""
n_marginal_bins: int
var_ranges: dict[str, tuple[float, float]] # ranged var -> (lo, hi)
energy_edges: list[float]
top_pdgs: list[int]
materials: list[str]
depth_edges: list[float]
transverse_edges: list[float]
sec_energy_range: tuple[float, float]
n_sec_bins: int
n_events: dict[str, int] = field(default_factory=dict)
# -- (de)serialization -------------------------------------------------
def save(self, path: str | Path) -> None:
Path(path).write_text(json.dumps(asdict(self), indent=2))
@classmethod
def load(cls, path: str | Path) -> "Context":
d = json.loads(Path(path).read_text())
d["var_ranges"] = {k: tuple(v) for k, v in d["var_ranges"].items()}
d["sec_energy_range"] = tuple(d["sec_energy_range"])
return cls(**d)
# -- convenience -------------------------------------------------------
def marginal_edges(self, var: str) -> np.ndarray:
lo, hi = self.var_ranges[var]
return np.linspace(lo, hi, self.n_marginal_bins + 1)
_LO_Q, _HI_Q = 0.001, 0.999
def _row_subsample(lf: pl.LazyFrame, sample_rows: int, seed: int) -> pl.LazyFrame:
"""Hash-subsample ~``sample_rows`` rows (for range estimation only)."""
n_total = lf.select(pl.len()).collect(engine="streaming").item()
if n_total <= sample_rows:
return lf
threshold = int(sample_rows / n_total * 2**32)
return lf.filter((pl.col("pre_E").hash(seed=seed) % 2**32) < threshold)
def _combined_quantiles(
r_vals: np.ndarray, t_vals: np.ndarray, lo_q: float, hi_q: float
) -> tuple[float, float]:
"""Robust (lo_q, hi_q) range over the union of two value samples."""
both = np.concatenate([r_vals, t_vals])
lo, hi = float(np.quantile(both, lo_q)), float(np.quantile(both, hi_q))
if not (hi - lo > 1e-6 * max(abs(hi), 1.0)):
lo, hi = lo - 0.5, hi + 0.5
return lo, hi
def build_context(
rollout: str | Path | pl.LazyFrame,
reference: str | Path | pl.LazyFrame,
*,
n_energy_bins: int = 4,
n_marginal_bins: int = 50,
n_sec_bins: int = 40,
top_k_pdg: int = 6,
sample_rows: int = 1_000_000,
seed: int = 0,
) -> Context:
"""Resolve the shared context from the two files (the ``prep`` step)."""
r_all = open_side(rollout, Side.rollout)
t_all = open_side(reference, Side.reference)
r_lf = physical_steps(r_all, Side.rollout)
t_lf = physical_steps(t_all, Side.reference)
# Ranged marginal variables: robust ranges over a shared row subsample.
exprs = [e.alias(n) for n, (_, e) in RANGED_VARS.items()]
r_s = (
_row_subsample(r_lf, sample_rows, seed)
.select(exprs)
.collect(engine="streaming")
)
t_s = (
_row_subsample(t_lf, sample_rows, seed)
.select(exprs)
.collect(engine="streaming")
)
var_ranges = {
name: _combined_quantiles(
r_s[name].to_numpy(), t_s[name].to_numpy(), _LO_Q, _HI_Q
)
for name in RANGED_VARS
}
# Energy-bin edges from exact per-event incident energies (cheap group_by).
def _incident(lf: pl.LazyFrame) -> np.ndarray:
return (
lf.group_by("event_id")
.agg(pl.col("pre_E").max())
.collect(engine="streaming")["pre_E"]
.to_numpy()
)
r_inc, t_inc = _incident(r_lf), _incident(t_lf)
energy_edges = energy_bin_edges(np.concatenate([r_inc, t_inc]), n_energy_bins)
# Top PDG species and material list (cheap single-column group_bys).
def _counts(lf: pl.LazyFrame, col: str) -> pl.DataFrame:
return lf.group_by(col).agg(pl.len().alias("n")).collect(engine="streaming")
pdg_counts = (
pl.concat([_counts(r_lf, "pdg"), _counts(t_lf, "pdg")])
.group_by("pdg")
.agg(pl.col("n").sum())
.sort("n", descending=True)
)
top_pdgs = [int(x) for x in pdg_counts["pdg"].to_list()[:top_k_pdg]]
materials = sorted(
set(_counts(r_lf, "material")["material"].to_list())
| set(_counts(t_lf, "material")["material"].to_list())
)
# Shower depth / transverse ranges from a subsampled proxy.
def _proxy(lf: pl.LazyFrame) -> tuple[np.ndarray, np.ndarray]:
ea = entry_axis(lf)
sub = (
attach_entry_axis(_row_subsample(lf, sample_rows, seed), ea)
.select(depth_expr().alias("d"), transverse_expr().alias("t"))
.collect(engine="streaming")
)
return sub["d"].to_numpy(), sub["t"].to_numpy()
r_d, r_t = _proxy(r_lf)
t_d, t_t = _proxy(t_lf)
d_lo, d_hi = _combined_quantiles(r_d, t_d, _LO_Q, _HI_Q)
depth_edges = np.linspace(d_lo, d_hi, n_marginal_bins + 1)
t_hi = max(float(np.quantile(np.concatenate([r_t, t_t]), _HI_Q)), 1e-6)
transverse_edges = np.linspace(0.0, t_hi, n_marginal_bins + 1)
# Secondary energy range.
r_se = secondaries(r_lf, Side.rollout).select("energy")
t_se = secondaries(t_all, Side.reference).select("energy")
r_se = _row_sample_col(r_se, sample_rows, seed)
t_se = _row_sample_col(t_se, sample_rows, seed)
sec_energy_range = _combined_quantiles(r_se, t_se, _LO_Q, _HI_Q)
return Context(
n_marginal_bins=n_marginal_bins,
var_ranges=var_ranges,
energy_edges=[float(x) for x in energy_edges],
top_pdgs=top_pdgs,
materials=materials,
depth_edges=[float(x) for x in depth_edges],
transverse_edges=[float(x) for x in transverse_edges],
sec_energy_range=sec_energy_range,
n_sec_bins=n_sec_bins,
n_events={
"rollout": len(r_inc),
"reference": len(t_inc),
},
)
def _row_sample_col(lf: pl.LazyFrame, sample_rows: int, seed: int) -> np.ndarray:
"""Collect a subsample of a single-column ``energy`` LazyFrame to numpy."""
vals = lf.collect(engine="streaming")["energy"].to_numpy()
if len(vals) > sample_rows:
rng = np.random.default_rng(seed)
vals = vals[rng.choice(len(vals), size=sample_rows, replace=False)]
return vals