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
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
176 lines
7.0 KiB
Python
176 lines
7.0 KiB
Python
"""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,
|
||
)
|