Files
giant/giant/data/scan.py
T
larsandClaude Sonnet 5 e24907862f
CI / Sync project version with tag (hand-pushed tags only) (push) Skipped
CI / Publish package to Gitea package registry (push) Skipped
CI / Lint (ruff check) (push) Successful in 1m7s
CI / Format (ruff format) (push) Successful in 1m8s
CI / Type check (ty) (push) Successful in 1m12s
CI / Tests (push) Successful in 2m41s
CI / Release (bump, changelog, badges, tag) on merge to master (push) Successful in 8s
fix(deps): silence polars explode() empty_as_null deprecation warnings
pytest emitted 113 DeprecationWarnings, all from the same source: polars
2.0 changes explode()'s default handling of empty lists from "explode to
null" to "drop the row". Every explode() call site in this repo already
follows the explode with drop_nulls() (or otherwise excludes empty
lists), so the new behavior is what we always wanted — pass
empty_as_null=False explicitly rather than suppressing the warning.

Bump the polars floor from >=1.0 to >=1.43, since the empty_as_null
kwarg doesn't exist before ~1.35.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dE2r7cNo9Lbthh1JUW1NF
2026-09-07 16:25:19 +02:00

176 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fused metadata scan over one or more parquet files.
`giant.pipeline.run_setup_stage` needs several distinct frequency summaries
before training can start — the event-id → row-count index (for the train/val
split), the pdg/material vocabularies, an optional physics-process count, and
a pooled pdg count (primary + secondary species, for onehot conditioning).
Each of those used to be its own full `pd.read_parquet(path, columns=[...])`
per file (`giant.data.loader`'s old `_rank_by_frequency_from_files` /
`build_index_maps_from_files` / `build_pdg_topn_map_from_files`) — up to five
separate reads of the same file. `scan_metadata` answers all of them in one
`pl.collect_all` per file instead, sharing the file open/decompress cost.
Every requested count comes back keyed by value, as a `ValueStat(count,
first_seen)`. `first_seen` is the value's row ordinal — file order (as given
in `files`), then row order within a file — via `row_index_name` on the
per-file lazy scan plus a running row offset across files. This is what
`giant.data.loader._topn_plus_other_map`'s frequency-ranking tie-break keys
on: among equally-frequent values, whichever was scanned first wins its own
class slot. That is an explicit, documented contract (this module is where
it's implemented), not an accident of iteration order.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import polars as pl
from giant.data.loader import _offset_event_id, event_id_offset
@dataclass(frozen=True)
class ScanRequest:
"""Which aggregations to compute. Every field defaults off so a caller
only pays for what it actually needs."""
event_index: bool = False
pdg: bool = False
material: bool = False
process: bool = False
pooled_pdg: bool = False
"""pdg exploded sec_pdg_list — both roles a PDG code plays (primary
species and secondary species), pooled into one count per code. See
`giant.data.loader.build_pdg_topn_map_from_files`'s docstring for why."""
@dataclass(frozen=True)
class ValueStat:
count: int
first_seen: int
@dataclass
class MetadataScan:
event_index: tuple[np.ndarray, np.ndarray] | None = None
"""(unique_ids, counts), ids ascending — matches
`setup_cache.compute_event_index_from_files`'s return shape."""
pdg: dict[int, ValueStat] | None = None
material: dict[str, ValueStat] | None = None
process: dict[str, ValueStat] | None = None
pooled_pdg: dict[int, ValueStat] | None = None
def _group_lazy(path: Path, column: str) -> pl.LazyFrame:
return (
pl.scan_parquet(path, row_index_name="__row")
.select(column, "__row")
.group_by(column)
.agg(pl.len().alias("__count"), pl.col("__row").min().alias("__first_row"))
)
def _pooled_pdg_lazy(path: Path, has_sec_pdg_list: bool) -> pl.LazyFrame:
lf = pl.scan_parquet(path, row_index_name="__row")
parts = [lf.select(pl.col("pdg").alias("__val"), "__row")]
if has_sec_pdg_list:
parts.append(
lf.select(pl.col("sec_pdg_list").alias("__val"), "__row")
.explode("__val", empty_as_null=False)
.drop_nulls("__val")
)
combined = pl.concat(parts)
return combined.group_by("__val").agg(pl.len().alias("__count"), pl.col("__row").min().alias("__first_row"))
def _merge_counts(acc: dict, df: pl.DataFrame, column: str, row_offset: int, cast) -> None:
for key, count, first_row in zip(
df[column].to_list(), df["__count"].to_list(), df["__first_row"].to_list(), strict=True
):
key = cast(key)
first_seen = row_offset + int(first_row)
if key in acc:
prev_count, prev_first = acc[key]
acc[key] = (prev_count + int(count), min(prev_first, first_seen))
else:
acc[key] = (int(count), first_seen)
def scan_metadata(files: list[Path], request: ScanRequest) -> MetadataScan:
"""Scan `files` once (one `pl.collect_all` per file) and return every
aggregation `request` asks for. Files with zero rows contribute nothing
but still advance nothing (no row_offset change, nothing to merge)."""
event_id_parts: list[tuple[np.ndarray, np.ndarray]] = []
pdg_acc: dict[int, tuple[int, int]] = {}
material_acc: dict[str, tuple[int, int]] = {}
process_acc: dict[str, tuple[int, int]] = {}
pooled_pdg_acc: dict[int, tuple[int, int]] = {}
row_offset = 0
for file_idx, path in enumerate(files):
keys: list[str] = []
lazies: list[pl.LazyFrame] = []
if request.event_index:
keys.append("event_id")
lazies.append(_group_lazy(path, "event_id"))
if request.pdg:
keys.append("pdg")
lazies.append(_group_lazy(path, "pdg"))
if request.material:
keys.append("material")
lazies.append(_group_lazy(path, "material"))
if request.process:
keys.append("process")
lazies.append(_group_lazy(path, "process"))
if request.pooled_pdg:
has_sec = "sec_pdg_list" in pl.scan_parquet(path).collect_schema().names()
keys.append("pooled_pdg")
lazies.append(_pooled_pdg_lazy(path, has_sec))
keys.append("__n")
lazies.append(pl.scan_parquet(path).select(pl.len().alias("__n")))
results = dict(zip(keys, pl.collect_all(lazies, engine="streaming"), strict=True))
n_rows = int(results["__n"].item()) if len(results["__n"]) else 0
if request.event_index:
df = results["event_id"]
ids = _offset_event_id(df["event_id"].to_numpy(), event_id_offset(file_idx))
counts = df["__count"].to_numpy().astype(np.int64)
if ids.size:
event_id_parts.append((ids, counts))
if request.pdg:
_merge_counts(pdg_acc, results["pdg"], "pdg", row_offset, int)
if request.material:
_merge_counts(material_acc, results["material"], "material", row_offset, str)
if request.process:
_merge_counts(process_acc, results["process"], "process", row_offset, str)
if request.pooled_pdg:
_merge_counts(pooled_pdg_acc, results["pooled_pdg"], "__val", row_offset, int)
row_offset += n_rows
event_index = None
if request.event_index:
if event_id_parts:
all_ids = np.concatenate([p[0] for p in event_id_parts])
all_counts = np.concatenate([p[1] for p in event_id_parts])
order = np.argsort(all_ids, kind="stable")
event_index = (all_ids[order], all_counts[order])
else:
event_index = (np.empty(0, dtype=np.int64), np.empty(0, dtype=np.int64))
def _to_stats(acc: dict) -> dict:
return {k: ValueStat(*v) for k, v in acc.items()}
return MetadataScan(
event_index=event_index,
pdg=_to_stats(pdg_acc) if request.pdg else None,
material=_to_stats(material_acc) if request.material else None,
process=_to_stats(process_acc) if request.process else None,
pooled_pdg=_to_stats(pooled_pdg_acc) if request.pooled_pdg else None,
)