Files
foxhunt/scripts/aggregate-norm-stats.py
jgrusewski 47c8b783c4 plan5(task1B): metric aggregation across multi-seed runs
Companion to Plan 5 Task 1A (multi-seed Argo DAG). Adds the post-run
aggregation pipeline:

- scripts/gather-multi-seed-metrics.sh: thin wrapper that pulls Argo logs
  for every workflow tagged foxhunt-tag=<tag>, concatenates them into
  /tmp/all-logs-<tag>.txt, then dispatches to the Python aggregator.

- scripts/aggregate-multi-seed-metrics.py: stdlib-only HEALTH_DIAG parser.
  Recognises both bare and JSON-envelope-wrapped HEALTH_DIAG[<epoch>]
  lines, parses every <block>[<key=val> ...] segment, and emits per-
  (epoch, metric_name) mean / std / median / min / max across streams
  (where each stream is one (seed, fold) training run, attributed by
  pod-name prefix when present, else by epoch-rewind detection — naive
  epoch=0 trigger over-segments the multi-line per-epoch HEALTH_DIAG
  output, fixed by requiring epoch < last_epoch to start a new stream).
  Output JSON schema matches the plan example (top-level: tag,
  multi_seed, folds, warmup_end_epoch, streams_seen, aggregates;
  per-entry: epoch, mean, std, median, min, max, n_samples).

- scripts/aggregate-norm-stats.py: stdlib-only merger for
  norm_stats_foldN_seedM.json files. Per-fold output collapses N seeds
  into mean/median/std/std_dispersion arrays consumed by the
  `evaluate` step's inference normaliser.

- scripts/requirements.txt: declares numpy/scipy/matplotlib for downstream
  Plan 5 T4-T5 tier-validation/plotting scripts. The aggregators
  themselves are stdlib-only (statistics module).

Smoke-validated on /tmp/p4t6-cleanroom-smoke.log: detects 3 streams
(matching the 3 fold runs in that log), 90 unique metrics, n_samples=3
per (epoch, metric), schema matches plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:58:42 +02:00

157 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Merge per-(fold, seed) norm_stats JSON files into per-fold aggregates.
Plan 5 Task 1B.3 — input is a directory of `norm_stats_foldN_seedM.json`
files written by training. Each file contains per-feature normalisation
statistics (mean, std) computed on that (fold, seed)'s training window.
Across seeds for the same fold, those stats should be near-identical (same
training window), but seed-driven sampling order can introduce small
variation; this script collapses N seeds into one per-fold stat for the
`evaluate` step's inference normaliser.
Output: per-fold JSON files named `norm_stats_foldN.json` with mean and
median across seeds for each feature dimension.
Input file schema (one per fold/seed):
{
"fold": 0,
"seed": 0,
"feature_dim": 42,
"mean": [<float>, ...], # per-dim mean
"std": [<float>, ...], # per-dim std (sample stddev)
"n_bars": 12345 # samples used to compute stats
}
Output file schema (one per fold):
{
"fold": N,
"feature_dim": int,
"n_seeds": int,
"mean": [<float>, ...], # mean of per-seed means
"median": [<float>, ...], # median of per-seed means
"std": [<float>, ...], # mean of per-seed stds
"std_dispersion": [<float>, ...], # cross-seed stddev of the means
"n_bars_total": int
}
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
from statistics import mean, median, pstdev
NORM_STATS_RE = re.compile(r"norm_stats_fold(\d+)_seed(\d+)\.json")
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Merge norm_stats_foldN_seedM.json files across seeds into "
"per-fold aggregates for inference normalisation."
),
)
ap.add_argument(
"--input-dir",
type=Path,
required=True,
help="Directory containing norm_stats_foldN_seedM.json files",
)
ap.add_argument(
"--output-dir",
type=Path,
required=True,
help="Directory for output norm_stats_foldN.json files",
)
args = ap.parse_args()
if not args.input_dir.is_dir():
print(f"ERROR: input dir not found: {args.input_dir}", file=sys.stderr)
return 1
by_fold: dict[int, list[Path]] = defaultdict(list)
for entry in args.input_dir.iterdir():
m = NORM_STATS_RE.match(entry.name)
if m:
by_fold[int(m.group(1))].append(entry)
if not by_fold:
print(
f"ERROR: no norm_stats_foldN_seedM.json files found in {args.input_dir}",
file=sys.stderr,
)
return 1
args.output_dir.mkdir(parents=True, exist_ok=True)
for fold, paths in sorted(by_fold.items()):
loaded = []
for p in sorted(paths):
try:
with p.open("r", encoding="utf-8") as fh:
loaded.append(json.load(fh))
except (OSError, json.JSONDecodeError) as e:
print(f"WARN: skipping {p}: {e}", file=sys.stderr)
if not loaded:
print(f"WARN: no readable seeds for fold {fold}", file=sys.stderr)
continue
# Validate consistent feature_dim — abort if not (norm-stats files
# for the same fold must agree on dimensionality).
feature_dims = {entry.get("feature_dim") for entry in loaded}
if len(feature_dims) != 1 or None in feature_dims:
print(
f"ERROR: fold {fold} has inconsistent feature_dim: {feature_dims}",
file=sys.stderr,
)
return 1
feature_dim = feature_dims.pop()
# Per-dim aggregation: collect across seeds, then reduce.
means_by_dim: list[list[float]] = [[] for _ in range(feature_dim)]
stds_by_dim: list[list[float]] = [[] for _ in range(feature_dim)]
for entry in loaded:
entry_mean = entry["mean"]
entry_std = entry["std"]
if len(entry_mean) != feature_dim or len(entry_std) != feature_dim:
print(
f"ERROR: fold {fold} seed entry has mismatched dim",
file=sys.stderr,
)
return 1
for d in range(feature_dim):
means_by_dim[d].append(float(entry_mean[d]))
stds_by_dim[d].append(float(entry_std[d]))
out = {
"fold": fold,
"feature_dim": feature_dim,
"n_seeds": len(loaded),
"mean": [mean(vals) for vals in means_by_dim],
"median": [median(vals) for vals in means_by_dim],
"std": [mean(vals) for vals in stds_by_dim],
"std_dispersion": [
pstdev(vals) if len(vals) > 1 else 0.0 for vals in means_by_dim
],
"n_bars_total": sum(int(entry.get("n_bars", 0)) for entry in loaded),
}
out_path = args.output_dir / f"norm_stats_fold{fold}.json"
with out_path.open("w", encoding="utf-8") as fh:
json.dump(out, fh, indent=2)
print(
f"fold {fold}: merged {len(loaded)} seeds → {out_path}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())