Files
giant/giant/cli.py
T
lars 917835e182 Fold --to/--gen dataset-versioning flags into the dwarf CLI
origin/energy-conservation-poc grew bump-gen/bump-schema --to and
update-manifest --gen flags (091b23a) plus a train output-dir date
prefix (305e436) after the dwarf unification was written locally.
Reconcile: bring plan_bump_gen/plan_bump_schema/plan_update_manifest's
target/target_gen support into the plain-function (argparse-free) form,
thread --to/--gen through scripts/dwarf.py's bump-gen/bump-schema/
update-manifest commands, and take giant/cli.py's date-prefix change
and the associated tests as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 09:57:17 +02:00

553 lines
18 KiB
Python

from collections import Counter
from datetime import date, datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
import uuid as uuid_mod
import numpy as np
import yaml
import torch
import typer
from typing_extensions import Annotated
import pyarrow as pa
import pyarrow.parquet as pq
from tqdm import tqdm
from giant import config as gconfig
from giant.constants import (
LOCAL_TARGET_NAMES,
PREDICT_COORD_METADATA_KEY,
PREDICT_SCHEMA_VERSION,
PREDICT_SCHEMA_VERSION_KEY,
)
from giant.data.loader import (
find_parquet_files,
iter_file_chunks,
iter_cond_chunks,
)
from giant.data.transforms import (
build_features,
build_cond_features,
energy_simplex_decode,
inv_local_frame_rotation,
inv_log_transform,
reconstruct_post_pos,
Normalizer,
)
from giant.model.network import DenoisingMLP
from giant.pipeline import run_train_job
from giant.sample import sample_flow
app = typer.Typer(no_args_is_help=True)
_CEPH_PREDICTIONS = Path("/ceph/lbogner/geant_steps/predictions")
def _resolve_prediction_output(
data: Path, out: Path | None
) -> tuple[Path, Path, str]:
"""Return (out_path, resolved_dataset_path, pred_uuid).
When *out* is None the output path is derived from *data*:
- under /ceph/ → fixed central store with a UUID filename
- elsewhere → sibling of *data* with a UUID filename
"""
dataset_path = data.resolve()
pred_uuid = str(uuid_mod.uuid4())
if out is None:
if str(dataset_path).startswith("/ceph/"):
out = _CEPH_PREDICTIONS / f"{pred_uuid}.parquet"
else:
out = data.parent / f"{pred_uuid}.parquet"
return out, dataset_path, pred_uuid
def _write_prediction_ref(
checkpoint: Path,
pred_uuid: str,
out: Path,
dataset_path: Path,
) -> Path:
"""Write a YAML sidecar in the checkpoint directory and return its path."""
ref = {
"prediction_id": pred_uuid,
"output": str(out),
"dataset": str(dataset_path),
"checkpoint": str(checkpoint.resolve()),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
ref_path = checkpoint.parent / f"{pred_uuid}.yaml"
ref_path.write_text(yaml.dump(ref, default_flow_style=False, sort_keys=False))
return ref_path
@app.callback()
def _main() -> None:
"""GIANT — Geant4 step-function surrogate."""
class Mode(str, Enum):
flow = "flow"
ddpm = "ddpm"
class Coord(str, Enum):
global_ = "global"
local = "local"
@app.command()
def train(
data: Annotated[
Path, typer.Argument(help="Parquet file or directory of parquet files")
],
config: Annotated[
Optional[Path],
typer.Option(
"--config", "-c", help="TOML config file (overridden by explicit flags)"
),
] = None,
mode: Annotated[
Optional[Mode],
typer.Option("--mode", "-m", help="Generative model: flow matching or DDPM"),
] = None,
epochs: Annotated[Optional[int], typer.Option("--epochs", "-e")] = None,
batch_size: Annotated[
Optional[str],
typer.Option(
"--batch-size",
"-b",
help="Integer, or 'auto' to estimate from free GPU memory "
"(cuda devices only)",
),
] = None,
lr: Annotated[Optional[float], typer.Option("--lr", "-l")] = None,
warmup_epochs: Annotated[
Optional[int], typer.Option("--warmup-epochs", "-w")
] = None,
hidden_dim: Annotated[Optional[int], typer.Option("--hidden-dim", "-H")] = None,
n_blocks: Annotated[Optional[int], typer.Option("--n-blocks", "-n")] = None,
emb_dim: Annotated[Optional[int], typer.Option("--emb-dim", "-E")] = None,
dropout: Annotated[
Optional[float],
typer.Option(
"--dropout", "-d", help="Dropout probability in ResBlocks (default: 0.1)"
),
] = None,
val_fraction: Annotated[
Optional[float], typer.Option("--val-fraction", "-f")
] = None,
seed: Annotated[
Optional[int],
typer.Option("--seed", "-s", help="Random seed for reproducibility"),
] = None,
validate_every: Annotated[
Optional[int],
typer.Option(
"--validate-every",
"-v",
help="Run marginal+KL validation every N epochs (0 disables)",
),
] = None,
validate_steps: Annotated[
Optional[int],
typer.Option(
"--validate-steps",
"-t",
help="Flow matching ODE steps used during marginal validation "
"(ignored in ddpm mode, which always runs the full schedule)",
),
] = None,
shuffle_buffer: Annotated[
int,
typer.Option(
"--shuffle-buffer", "-B", help="Rows held in RAM per worker for shuffling"
),
] = 65536,
out: Annotated[
Optional[Path],
typer.Option(
"--out", "-o", help="Checkpoint dir (default: auto from hyperparams)"
),
] = None,
device: Annotated[
Optional[str],
typer.Option("--device", "-D", help="cpu | cuda | mps (default: auto)"),
] = None,
num_workers: Annotated[Optional[int], typer.Option("--num-workers", "-j")] = None,
resume: Annotated[
Optional[Path],
typer.Option("--resume", "-r", help="Checkpoint .pt to resume training from"),
] = None,
) -> None:
"""Train the GIANT surrogate model."""
batch_size_auto = False
batch_size_value: Optional[int] = None
if batch_size is not None:
if batch_size.strip().lower() == "auto":
batch_size_auto = True
else:
try:
batch_size_value = int(batch_size)
except ValueError:
typer.echo(
f"error: --batch-size must be an integer or 'auto', "
f"got {batch_size!r}",
err=True,
)
raise typer.Exit(1)
cli_train = {
k: v
for k, v in {
"mode": mode.value if mode is not None else None,
"epochs": epochs,
"batch_size": batch_size_value,
"lr": lr,
"warmup_epochs": warmup_epochs,
"val_fraction": val_fraction,
"num_workers": num_workers,
"seed": seed,
"validate_every": validate_every,
"validate_steps": validate_steps,
}.items()
if v is not None
}
cli_model = {
k: v
for k, v in {
"hidden_dim": hidden_dim,
"n_blocks": n_blocks,
"emb_dim": emb_dim,
"dropout": dropout,
}.items()
if v is not None
}
cfg = gconfig.merge_cli_overrides(
gconfig.DEFAULT_CONFIG, config, cli_train, cli_model
)
t, m = cfg["train"], cfg["model"]
_device = torch.device(device) if device else gconfig.auto_device()
if batch_size_auto:
try:
t["batch_size"] = gconfig.estimate_batch_size(
m["hidden_dim"], m["n_blocks"], _device
)
except ValueError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(1)
typer.echo(
f"batch_size: {t['batch_size']} (auto-estimated from free GPU memory)"
)
out_dir = out or Path(
f"checkpoints/{date.today().strftime('%Y%m%d')}"
f"_{t['mode']}"
f"_h{m['hidden_dim']}"
f"_b{m['n_blocks']}"
f"_e{m['emb_dim']}"
f"_lr{t['lr']}"
f"_bs{t['batch_size']}"
)
typer.echo(f"device: {_device}")
typer.echo(f"out_dir: {out_dir}")
run_train_job(
data=data,
cfg=cfg,
out_dir=out_dir,
device=_device,
shuffle_buffer=shuffle_buffer,
num_workers=t["num_workers"],
resume=resume,
echo=typer.echo,
)
@app.command()
def predict(
data: Annotated[
Path, typer.Argument(help="Parquet file or directory of parquet files")
],
checkpoint: Annotated[
Path,
typer.Option(
"--checkpoint",
"-c",
help="Path to checkpoint .pt file (best.pt or last.pt)",
),
],
coord: Annotated[
Coord,
typer.Option(
"--coord",
"-C",
help="global: full physical units, world frame (default). "
"local: raw 9D model output (denormalised only, local frame, "
"log-scaled scalars) alongside the matching ground-truth target "
"for the same input file — requires post-step columns.",
),
] = Coord.global_,
out: Annotated[
Optional[Path],
typer.Option(
"--out",
"-o",
help="Output parquet path (default: <data>_predicted[_local].parquet)",
),
] = None,
batch_size: Annotated[
str,
typer.Option(
"--batch-size",
"-b",
help="Inference batch size, or 'auto' to estimate from free GPU "
"memory (cuda devices only)",
),
] = "4096",
steps: Annotated[
int, typer.Option("--steps", "-s", help="Flow matching ODE steps")
] = 10,
device: Annotated[
Optional[str],
typer.Option("--device", "-d", help="cpu | cuda | mps (default: auto)"),
] = None,
) -> None:
"""Run trained model on a parquet file and save predictions."""
batch_size_auto = False
batch_size_value: Optional[int] = None
if batch_size.strip().lower() == "auto":
batch_size_auto = True
else:
try:
batch_size_value = int(batch_size)
except ValueError:
typer.echo(
f"error: --batch-size must be an integer or 'auto', got {batch_size!r}",
err=True,
)
raise typer.Exit(1)
_device = torch.device(device) if device else gconfig.auto_device()
typer.echo(f"device: {_device}")
# --- Load checkpoint ---
ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
if "model_config" not in ckpt:
typer.echo(
"error: checkpoint has no model_config — retrain with the current code",
err=True,
)
raise typer.Exit(1)
model_cfg = ckpt["model_config"]
if batch_size_auto:
try:
batch_size_value = gconfig.estimate_batch_size(
model_cfg["hidden_dim"],
model_cfg["n_blocks"],
_device,
training=False,
)
except ValueError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(1)
typer.echo(
f"batch_size: {batch_size_value} (auto-estimated from free GPU memory)"
)
assert batch_size_value is not None
bs = batch_size_value
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_norm = Normalizer.from_dict(ckpt["normalizer"]["cond"])
tgt_norm = Normalizer.from_dict(ckpt["normalizer"]["target"])
model = DenoisingMLP(**model_cfg)
model.load_state_dict(ckpt["model"])
model.to(_device).eval()
typer.echo(f"loaded checkpoint: {checkpoint}")
gconfig.warn_if_checkpoint_config_mismatch(checkpoint)
# --- Output path ---
out, dataset_path, pred_uuid = _resolve_prediction_output(data, out)
out.parent.mkdir(parents=True, exist_ok=True)
typer.echo(f"output: {out}")
# --- Stream input, generate predictions, write output ---
files = find_parquet_files(data)
typer.echo(f"found {len(files)} parquet file(s)")
writer: pq.ParquetWriter | None = None
total = 0
skipped = 0
unknown_pdg_counts: Counter[int] = Counter()
total_rows = sum(pq.ParquetFile(path).metadata.num_rows for path in files)
chunk_iter = iter_file_chunks if coord == Coord.local else iter_cond_chunks
def _concat(
a: dict[str, np.ndarray], b: dict[str, np.ndarray]
) -> dict[str, np.ndarray]:
return {k: np.concatenate([a[k], b[k]], axis=0) for k in a}
def _process(piece: dict[str, np.ndarray]) -> None:
nonlocal writer, total
if coord == Coord.local:
cond_cont, cond_cat, target_raw, _, _ = build_features(
piece, pdg_map, mat_map
)
cond_cont = cond_norm.transform(cond_cont)
else:
cond_cont, cond_cat = build_cond_features(
piece, pdg_map, mat_map, cond_norm
)
cc = torch.from_numpy(cond_cont).float().to(_device)
ck = torch.from_numpy(cond_cat).long().to(_device)
pred = sample_flow(model, cc, ck, steps=steps).cpu().numpy() # normalised
# Inverse-normalise → local frame, log-scaled scalars
raw = tgt_norm.inverse_transform(pred)
if coord == Coord.local:
table = pa.table(
{
"event_id": piece["event_id"],
"pdg": piece["pdg"],
"pre_x": piece["pre_pos"][:, 0],
"pre_y": piece["pre_pos"][:, 1],
"pre_z": piece["pre_pos"][:, 2],
"pre_E": piece["pre_E"],
"pre_dx": piece["pre_dir"][:, 0],
"pre_dy": piece["pre_dir"][:, 1],
"pre_dz": piece["pre_dir"][:, 2],
"material": piece["material"],
"layer_id": piece["layer_id"],
"n_sec": piece["n_sec"],
**{
f"pred_{name}": raw[:, j]
for j, name in enumerate(LOCAL_TARGET_NAMES)
},
**{
f"true_{name}": target_raw[:, j]
for j, name in enumerate(LOCAL_TARGET_NAMES)
},
}
)
else:
step_length = inv_log_transform(raw[:, 0])
# Columns 1:3 are ALR coords of the deposit/secondary/post energy
# simplex; decode them against pre_E so edep + e_sec + post_E == pre_E
# (hence delta_e == edep + e_sec) holds by construction.
edep, _e_sec, _post_E, delta_e = energy_simplex_decode(
raw[:, 1:3], piece["pre_E"]
)
# Normalise predicted direction then rotate back to world frame
post_dir_local = raw[:, 3:6].copy()
norms = np.linalg.norm(post_dir_local, axis=1, keepdims=True)
post_dir_local /= np.where(norms < 1e-8, 1.0, norms)
post_dir_world = inv_local_frame_rotation(piece["pre_dir"], post_dir_local)
# Same for the travel direction, then reconstruct post_pos from
# the single shared step_length so the two stay consistent.
travel_dir_local = raw[:, 6:9].copy()
norms = np.linalg.norm(travel_dir_local, axis=1, keepdims=True)
travel_dir_local /= np.where(norms < 1e-8, 1.0, norms)
post_pos_world = reconstruct_post_pos(
piece["pre_pos"], piece["pre_dir"], step_length, travel_dir_local
)
table = pa.table(
{
"event_id": piece["event_id"],
"pdg": piece["pdg"],
"pre_x": piece["pre_pos"][:, 0],
"pre_y": piece["pre_pos"][:, 1],
"pre_z": piece["pre_pos"][:, 2],
"pre_E": piece["pre_E"],
"pre_dx": piece["pre_dir"][:, 0],
"pre_dy": piece["pre_dir"][:, 1],
"pre_dz": piece["pre_dir"][:, 2],
"material": piece["material"],
"layer_id": piece["layer_id"],
"n_sec": piece["n_sec"],
"step_length": step_length,
"delta_e": delta_e,
"edep": edep,
"post_dx": post_dir_world[:, 0],
"post_dy": post_dir_world[:, 1],
"post_dz": post_dir_world[:, 2],
"post_x": post_pos_world[:, 0],
"post_y": post_pos_world[:, 1],
"post_z": post_pos_world[:, 2],
}
)
table = table.replace_schema_metadata(
{
PREDICT_COORD_METADATA_KEY: coord.value,
PREDICT_SCHEMA_VERSION_KEY: PREDICT_SCHEMA_VERSION,
}
)
if writer is None:
writer = pq.ParquetWriter(out, table.schema)
writer.write_table(table)
total += len(piece["event_id"])
# Buffer rows across row-group boundaries so the inference batch size
# isn't capped by however the source file happens to be chunked.
buffer: dict[str, np.ndarray] | None = None
bar = tqdm(total=total_rows, desc="predict", unit="row", dynamic_ncols=True)
for path in files:
for chunk in chunk_iter(path):
N_in = len(chunk["event_id"])
pdg_mask = np.array([int(p) in pdg_map for p in chunk["pdg"]])
if not pdg_mask.all():
unknown_pdg_counts.update(int(p) for p in chunk["pdg"][~pdg_mask])
chunk = {k: v[pdg_mask] for k, v in chunk.items()}
skipped += N_in - len(chunk["event_id"])
bar.update(N_in)
if len(chunk["event_id"]) == 0:
continue
buffer = chunk if buffer is None else _concat(buffer, chunk)
while len(buffer["event_id"]) >= bs:
piece = {k: v[:bs] for k, v in buffer.items()}
buffer = {k: v[bs:] for k, v in buffer.items()}
_process(piece)
if buffer is not None and len(buffer["event_id"]) > 0:
_process(buffer)
bar.close()
if writer is not None:
writer.close()
ref_path = _write_prediction_ref(checkpoint, pred_uuid, out, dataset_path)
typer.echo(f"reference: {ref_path}")
if skipped:
codes = ", ".join(
f"{pdg} ({count})" for pdg, count in sorted(unknown_pdg_counts.items())
)
typer.echo(
f"warning: skipped {skipped:,} row(s) with unknown PDG code(s): {codes}",
err=True,
)
typer.echo(f"wrote {total:,} rows → {out}")
if __name__ == "__main__":
app()