Files
giant/giant/analysis/reduce.py
T
lars ffb7c0cc2a
CI / Format (ruff format) (push) Successful in 30s
CI / Lint (ruff check) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 32s
CI / Lint (ruff check) (pull_request) Successful in 33s
CI / Format (ruff format) (pull_request) Successful in 31s
CI / Type check (ty) (pull_request) Successful in 35s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Tests (push) Successful in 5m51s
CI / Tests (pull_request) Successful in 5m5s
CI / Bump version, tag, and update changelog on merge to master (push) Has been skipped
CI / Bump version, tag, and update changelog on merge to master (pull_request) Has been skipped
Add rollout-quality distance, confusion, containment and router plots (gitea #76)
Picks 4 of the 7 catalog additions the issue proposed (the smaller-lift
ones; 2D joint plots, PIT calibration, and the throughput/accuracy scatter
are left for follow-up issues):

- marginal_distance_summary: a var x grouping-axis KS-statistic heatmap,
  reusing the existing marginal hist1d compute and just adding a finalize —
  a single at-a-glance regression scorecard instead of N overlay plots.
- n_sec_confusion: predicted (rollout) vs true (reference) secondary count
  per event, paired by event_id since a rollout is seeded from the same
  events as its reference file. Needed a new zero-filling primitive
  (reduce.sec_count_by_event) since a plain group_by over secondary rows
  silently drops zero-secondary events.
- shower_containment_depth_{90,95}: per-event depth containing 90%/95% of
  deposited energy, derived from the same per-event depth-bin matrix the
  longitudinal profile already computes.
- router_specialization: max gate weight vs energy per side, summarizing
  router_gating's full stacked area into the one trend line the roadmap's
  MoE writeup describes (the ~60-65% ceiling), to make a future
  lambda_balance>0 retrain's effect on specialization checkable at a glance.

Both new heatmap-shaped plots (distance summary, confusion matrix) share one
new "heatmap" Reduced kind/renderer rather than two near-identical ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:12:16 +02:00

291 lines
12 KiB
Python

"""Streaming compute primitives — the reduce half of the analysis.
Everything here turns a (possibly larger-than-RAM) LazyFrame into a *compact*
numpy/DataFrame artifact in bounded memory, and never imports plotstyle so it can
run on an HTCondor worker. Efficiency rules (see the plan's "Histogram
efficiency" section):
* ``hist1d`` is a single streaming ``group_by([group, bin]).len()`` pass against
**fixed** edges (no min/max range pass) with a strict column projection — only
the columns the value/group expressions reference are read from the parquet.
* the multi-quantity reductions (``event_scalars``, profiles, ``species_share``,
``leakage_fraction``) each emit *all* their outputs from one ``group_by``.
* per-event -> per-row lookups (shower entry/axis) use ``replace_strict`` (a hash
map applied as an expression, bounded memory), never a streaming join.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import polars as pl
from giant.constants import TERM_ESCAPED
# ---------------------------------------------------------------------------
# 1-D histogram primitive
# ---------------------------------------------------------------------------
def _bin_expr(value: pl.Expr, lo: float, hi: float, nbins: int) -> pl.Expr:
"""Uniform bin index of ``value`` over ``[lo, hi]`` into ``nbins`` bins.
Out-of-range values clamp into the edge bins, and the clamp deliberately
happens in f64 *before* the integer cast: a rollout is free to emit a wildly
out-of-range outlier (a step_length of 1e10 mm, say) or an inf, whose
unclamped bin index overflows i32 and makes the cast fail outright. NaN has
no edge to clamp to, so it becomes null and is dropped by the callers below
— the same thing ``np.histogram`` does with it.
"""
idx = ((value - lo) / (hi - lo) * nbins).floor().clip(0, nbins - 1)
return pl.when(idx.is_nan()).then(None).otherwise(idx).cast(pl.Int32)
def hist1d(
lf: pl.LazyFrame,
value: pl.Expr,
edges: np.ndarray,
group: pl.Expr | None = None,
) -> dict[object, np.ndarray]:
"""Streaming histogram of ``value`` over fixed uniform ``edges``, by ``group``.
Returns ``{group_key: counts}`` (counts is an ``int64`` array of length
``len(edges)-1``). One hash pass; runtime is independent of group cardinality,
so every pdg/material/energy stratum falls out together. Only the tiny
``(n_groups x nbins)`` result is materialized.
"""
lo, hi, nbins = float(edges[0]), float(edges[-1]), len(edges) - 1
group = pl.lit(0, dtype=pl.Int64) if group is None else group
res = (
lf.select(group.alias("_g"), _bin_expr(value, lo, hi, nbins).alias("_b"))
.drop_nulls("_b")
.group_by("_g", "_b")
.agg(pl.len().alias("_n"))
.collect(engine="streaming")
)
out: dict[object, np.ndarray] = {}
for g, b, n in res.iter_rows():
out.setdefault(g, np.zeros(nbins, dtype=np.int64))[b] = n
return out
def sum_merge(dicts: list[dict[str, Any]]) -> dict[str, Any]:
"""Elementwise-sum a list of sum-mergeable count/total dicts (JSON-safe keys).
Used to merge chunked ``hist1d``/``species_share``-style partials, whose
values bin/group against edges or keys fixed by ``Context`` — a chunk's raw
count dict is exactly a partial sum, so merging is a plain elementwise sum
over the union of keys (a key absent from some chunk is all-zero there).
Values may be per-bin count lists or plain scalar totals; both round-trip
through ``np.asarray``/``.tolist()`` unchanged in shape.
"""
out: dict[str, np.ndarray] = {}
for d in dicts:
for k, v in d.items():
arr = np.asarray(v)
out[k] = arr.copy() if k not in out else out[k] + arr
return {k: v.tolist() for k, v in out.items()}
# ---------------------------------------------------------------------------
# Per-event scalar observables (one bounded group_by pass)
# ---------------------------------------------------------------------------
def event_scalars(lf: pl.LazyFrame) -> pl.DataFrame:
"""One row per event: total/mean deposited energy, path length, step count.
Columns: ``event_id, total_edep, total_length, n_steps, mean_length,
incident_E`` (incident = ``max(pre_E)``, the primary). The caller chooses
whether ``lf`` includes the rollout's synthetic termination rows — pass the
full scan for energy totals (they carry the deposited remainder), physical
steps only for step-count / mean-length.
"""
return (
lf.group_by("event_id")
.agg(
pl.col("edep").sum().alias("total_edep"),
pl.col("step_length").sum().alias("total_length"),
pl.len().alias("n_steps"),
pl.col("pre_E").max().alias("incident_E"),
)
.with_columns((pl.col("total_length") / pl.col("n_steps")).alias("mean_length"))
.collect(engine="streaming")
)
# ---------------------------------------------------------------------------
# Shower shape: entry/axis + edep-weighted longitudinal / transverse profiles
# ---------------------------------------------------------------------------
def entry_axis(lf: pl.LazyFrame) -> pl.DataFrame:
"""Per-event shower entry point and axis (from the highest-``pre_E`` step).
One bounded ``group_by``: the primary's ``pre_pos`` becomes the entry point
and its ``pre_dir`` the shower axis.
"""
return (
lf.group_by("event_id")
.agg(
pl.col("pre_x").get(pl.col("pre_E").arg_max()).alias("entry_x"),
pl.col("pre_y").get(pl.col("pre_E").arg_max()).alias("entry_y"),
pl.col("pre_z").get(pl.col("pre_E").arg_max()).alias("entry_z"),
pl.col("pre_dx").get(pl.col("pre_E").arg_max()).alias("axis_x"),
pl.col("pre_dy").get(pl.col("pre_E").arg_max()).alias("axis_y"),
pl.col("pre_dz").get(pl.col("pre_E").arg_max()).alias("axis_z"),
)
.collect(engine="streaming")
.sort("event_id")
)
_ENTRY_AXIS_COLS = ("entry_x", "entry_y", "entry_z", "axis_x", "axis_y", "axis_z")
def attach_entry_axis(lf: pl.LazyFrame, entry: pl.DataFrame) -> pl.LazyFrame:
"""Broadcast each event's entry/axis onto its rows via ``replace_strict``.
A hash map applied as an expression — streams in bounded memory, unlike a
join which would buffer the whole file-sized left side.
"""
ids = entry["event_id"].to_numpy()
return lf.with_columns(
pl.col("event_id").replace_strict(ids, entry[col].to_numpy(), return_dtype=pl.Float64).alias(col)
for col in _ENTRY_AXIS_COLS
)
def depth_expr() -> pl.Expr:
"""Signed distance of ``post_pos`` from the entry point along the shower axis."""
dx = pl.col("post_x") - pl.col("entry_x")
dy = pl.col("post_y") - pl.col("entry_y")
dz = pl.col("post_z") - pl.col("entry_z")
return dx * pl.col("axis_x") + dy * pl.col("axis_y") + dz * pl.col("axis_z")
def transverse_expr() -> pl.Expr:
"""Perpendicular distance of ``post_pos`` from the shower axis."""
dx = pl.col("post_x") - pl.col("entry_x")
dy = pl.col("post_y") - pl.col("entry_y")
dz = pl.col("post_z") - pl.col("entry_z")
depth = dx * pl.col("axis_x") + dy * pl.col("axis_y") + dz * pl.col("axis_z")
tx = dx - depth * pl.col("axis_x")
ty = dy - depth * pl.col("axis_y")
tz = dz - depth * pl.col("axis_z")
return (tx**2 + ty**2 + tz**2).sqrt()
def profile_partial(
lf: pl.LazyFrame,
coord: pl.Expr,
edges: np.ndarray,
weight: pl.Expr,
) -> tuple[np.ndarray, np.ndarray]:
"""One chunk's per-event x bin ``weight``-sum matrix: ``(event_ids, matrix)``.
One streaming ``group_by(event_id, bin)`` sums ``weight`` per (event, bin).
A chunk's matrix rows are only the events present in that chunk, so chunks'
matrices stack cleanly with no cross-chunk lookup — this requires chunking
to be event-disjoint (every row of an event lands in one chunk).
``coord``/``weight`` require the entry/axis columns attached.
"""
lo, hi, nbins = float(edges[0]), float(edges[-1]), len(edges) - 1
grid = (
lf.select(
"event_id",
_bin_expr(coord, lo, hi, nbins).alias("_b"),
weight.alias("_w"),
)
.drop_nulls("_b")
.group_by("event_id", "_b")
.agg(pl.col("_w").sum().alias("_ws"))
.collect(engine="streaming")
)
ev = grid["event_id"].to_numpy()
uniq, inv = np.unique(ev, return_inverse=True)
mat = np.zeros((len(uniq), nbins), dtype=np.float64)
np.add.at(mat, (inv, grid["_b"].to_numpy()), grid["_ws"].to_numpy())
return uniq, mat
def profile_finalize(mats: list[np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
"""Collapse per-chunk per-event x bin matrices into the final mean/std profile.
Chunks are event-disjoint, so row-wise concatenation of their matrices
reconstructs the full per-event matrix; the mean/event-RMS collapse must
happen once over that full matrix — an average of per-chunk means/stds
would be wrong (chunks generally hold different numbers of events).
Returns ``(mean, std)``, each length ``nbins``.
"""
full = np.concatenate(mats, axis=0)
return full.mean(axis=0), full.std(axis=0)
def weighted_profile(
lf: pl.LazyFrame,
coord: pl.Expr,
edges: np.ndarray,
weight: pl.Expr,
) -> tuple[np.ndarray, np.ndarray]:
"""Single-pass profile of ``coord`` (mean +/- event-RMS band over events).
Convenience wrapper for the unchunked (whole-dataset) case; ``mean_std +
profile_partial`` is what a chunked compute/finalize split uses instead.
"""
_, mat = profile_partial(lf, coord, edges, weight)
return profile_finalize([mat])
# ---------------------------------------------------------------------------
# Species contribution and leakage
# ---------------------------------------------------------------------------
def species_share(lf: pl.LazyFrame) -> pl.DataFrame:
"""Total deposited energy per PDG species (``pdg, total_edep``), one pass."""
return (
lf.group_by("pdg")
.agg(pl.col("edep").sum().alias("total_edep"))
.collect(engine="streaming")
.sort("total_edep", descending=True)
)
def leakage_fraction(lf: pl.LazyFrame) -> np.ndarray:
"""Per-event escaped-energy fraction (rollout only), one bounded pass.
Escaped rows carry the leaked energy in ``pre_E`` (``edep`` is 0 there); the
fraction is ``escaped / (deposited + escaped)`` per event.
"""
per_event = (
lf.group_by("event_id")
.agg(
pl.col("edep").sum().alias("deposited"),
pl.col("pre_E").filter(pl.col("termination_reason") == TERM_ESCAPED).sum().alias("escaped"),
)
.collect(engine="streaming")
)
deposited = per_event["deposited"].to_numpy()
escaped = per_event["escaped"].fill_null(0.0).to_numpy()
total = deposited + escaped
return np.where(total > 0, escaped / total, 0.0)
def sec_count_by_event(lf_all: pl.LazyFrame, sec_lf: pl.LazyFrame) -> tuple[np.ndarray, np.ndarray]:
"""Per-event secondary count, zero-filled for events that produced none.
Two bounded per-event ``group_by``s — the full event set (from ``lf_all``)
and the secondary counts (from ``sec_lf``, see ``sources.secondaries``) —
merged in Python via a dict. Both results are event-granularity (not
per-row), so this stays in the same bounded-memory budget as
``event_scalars``; a plain ``group_by`` on ``sec_lf`` alone would silently
drop zero-secondary events instead of zero-filling them.
"""
ev = lf_all.select("event_id").unique().collect(engine="streaming")["event_id"].to_numpy()
cnt_df = sec_lf.group_by("event_id").agg(pl.len().alias("n")).collect(engine="streaming")
cnt = dict(zip(cnt_df["event_id"].to_list(), cnt_df["n"].to_list()))
counts = np.array([cnt.get(int(e), 0) for e in ev], dtype=np.int64)
return ev, counts