Files
wilds-ijepa/tools/aggregate_seeds.py
YannAhlgrim 1a3916ce1a min seeds
2026-07-07 09:43:28 +02:00

471 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Aggregate multi-seed WILDS-iWildCam supervised runs into mean +/- std.
For each model configuration (grouped across seeds), this reads the per-seed
evaluation metrics for both splits:
- ID (in-distribution): iwildcam_id_test_metrics.json (split "id_test")
- OOD (out-of-distribution): iwildcam_test_metrics.json (split "test")
and computes, across seeds, the mean and std of every WILDS metric plus the
training time and epochs recorded during training. It also reports the
generalization gap (ID - OOD) on the headline metric.
Leaderboard columns reported:
Test ID Macro F1 | Test ID Avg Acc | Test OOD Macro F1 | Test OOD Avg Acc
Outputs:
experiment_logs/seed-runs/<model>/summary.json (per-seed rows + mean/std)
experiment_logs/seed-runs/summary_all.csv (one row per model)
Usage:
python3 tools/aggregate_seeds.py --root experiment_logs/eval-wilds
python3 tools/aggregate_seeds.py --primary F1-macro_all --acc acc_avg
python3 tools/aggregate_seeds.py --root experiment_logs/eval-wilds --min-seeds 5
"""
import argparse
import csv
import json
import math
import os
import re
ID_METRICS_FILE = "iwildcam_id_test_metrics.json"
OOD_METRICS_FILE = "iwildcam_test_metrics.json"
# Trailing "-seedN" (and any leftover separators) so runs of the same config
# collapse into one group.
_SEED_SUFFIX_RE = re.compile(r"-seed\d+$")
def _find_metric(metrics, key):
"""Recursively search a nested dict/list for `key`."""
if isinstance(metrics, dict):
if key in metrics:
return metrics[key]
for value in metrics.values():
found = _find_metric(value, key)
if found is not None:
return found
elif isinstance(metrics, list):
for item in metrics:
found = _find_metric(item, key)
if found is not None:
return found
return None
def _strip_seed(run_name):
return _SEED_SUFFIX_RE.sub("", run_name)
def _extract_run_info(*metrics_objs):
"""Find a run_info dict inside any metrics object (dict- or list-form).
Eval-only runs write metrics as a list ([{metrics}, "summary"]) with no
run_info; training runs write a dict with a top-level run_info. Search both.
"""
def _search(obj):
if isinstance(obj, dict):
ri = obj.get("run_info")
if isinstance(ri, dict):
return ri
for v in obj.values():
found = _search(v)
if found is not None:
return found
elif isinstance(obj, list):
for item in obj:
found = _search(item)
if found is not None:
return found
return None
for m in metrics_objs:
found = _search(m)
if found is not None:
return found
return {}
def _seed_from_name(run_name):
"""Parse a trailing -seedN from a run folder name; None if absent."""
m = re.search(r"-seed(\d+)$", run_name)
return int(m.group(1)) if m else None
def _seed_from_params(run_dir):
"""Read `seed:` from params-eval.yaml without requiring PyYAML."""
path = os.path.join(run_dir, "params-eval.yaml")
try:
with open(path, "r") as f:
for line in f:
m = re.match(r"\s*seed\s*:\s*(\d+)\s*$", line)
if m:
return int(m.group(1))
except OSError:
pass
return None
def _resolve_seed(run_info, run_name, run_dir):
"""Seed detection chain: run_info -> folder name -> params-eval.yaml."""
seed = run_info.get("seed")
if seed is not None:
return seed
seed = _seed_from_name(run_name)
if seed is not None:
return seed
return _seed_from_params(run_dir)
def _load_json(path):
try:
with open(path, "r") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def _mean_std(values):
vals = [v for v in values if v is not None and not _is_nan(v)]
if not vals:
return None, None, 0
n = len(vals)
mean = sum(vals) / n
if n > 1:
var = sum((v - mean) ** 2 for v in vals) / (n - 1) # sample std
std = math.sqrt(var)
else:
std = 0.0
return mean, std, n
def _is_nan(v):
try:
return math.isnan(float(v))
except (TypeError, ValueError):
return False
def _collect_metric_keys(metrics_obj):
"""All scalar metric keys in a WILDS metrics dict (excludes our extras)."""
keys = set()
if isinstance(metrics_obj, dict):
for k, v in metrics_obj.items():
if k in ("run_info", "split"):
continue
if isinstance(v, (int, float)) and not isinstance(v, bool):
keys.add(k)
return keys
def _fmt(mean, std):
if mean is None:
return ""
if std is None:
return f"{mean:.4f}"
return f"{mean:.4f} +/- {std:.4f}"
def main():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__,
)
parser.add_argument(
"--root",
default="experiment_logs/eval-wilds",
help="root folder holding per-run eval subfolders (default: %(default)s)",
)
parser.add_argument(
"--out",
default="experiment_logs/seed-runs",
help="output folder for aggregated summaries (default: %(default)s)",
)
parser.add_argument(
"--primary",
default="F1-macro_all",
help="headline metric key (default: %(default)s)",
)
parser.add_argument(
"--acc",
default="acc_avg",
help="average-accuracy metric key (default: %(default)s)",
)
parser.add_argument(
"--min-seeds",
type=int,
default=1,
help="only report models with at least this many seeds (default: %(default)s)",
)
args = parser.parse_args()
if not os.path.isdir(args.root):
print(f"Root folder not found: {args.root}")
return
# group_name -> list of per-seed records
groups = {}
for entry in sorted(os.listdir(args.root)):
run_dir = os.path.join(args.root, entry)
if not os.path.isdir(run_dir):
continue
id_metrics = _load_json(os.path.join(run_dir, ID_METRICS_FILE))
ood_metrics = _load_json(os.path.join(run_dir, OOD_METRICS_FILE))
if id_metrics is None and ood_metrics is None:
continue
run_info = _extract_run_info(ood_metrics, id_metrics)
group = _strip_seed(entry)
groups.setdefault(group, []).append(
{
"run_name": entry,
"seed": _resolve_seed(run_info, entry, run_dir),
"id_metrics": id_metrics,
"ood_metrics": ood_metrics,
"run_info": run_info,
}
)
if not groups:
print(f"No metrics found under {args.root}")
return
if args.min_seeds > 1:
dropped = {
name: len(recs)
for name, recs in groups.items()
if len(recs) < args.min_seeds
}
groups = {
name: recs for name, recs in groups.items() if len(recs) >= args.min_seeds
}
if dropped:
print(
f"Skipping {len(dropped)} model(s) with fewer than "
f"{args.min_seeds} seed(s):"
)
for name in sorted(dropped):
print(f" {name} ({dropped[name]} seed(s))")
if not groups:
print(f"No models have >= {args.min_seeds} seeds under {args.root}")
return
os.makedirs(args.out, exist_ok=True)
csv_rows = []
csv_fields = [
"model",
"num_seeds",
"seeds",
"id_macro_f1_mean",
"id_macro_f1_std",
"id_avg_acc_mean",
"id_avg_acc_std",
"ood_macro_f1_mean",
"ood_macro_f1_std",
"ood_avg_acc_mean",
"ood_avg_acc_std",
"gen_gap_macro_f1_mean", # ID - OOD on primary metric
"train_time_seconds_mean",
"train_time_seconds_std",
"epochs_run_mean",
"epochs_run_std",
"peak_host_ram_gb_mean",
"peak_host_ram_gb_std",
"peak_gpu_alloc_gb_mean",
"peak_gpu_alloc_gb_std",
"peak_gpu_reserved_gb_mean",
"peak_gpu_reserved_gb_std",
]
for group_name in sorted(groups.keys()):
records = sorted(groups[group_name], key=lambda r: (r["seed"] is None, r["seed"]))
seeds = [r["seed"] for r in records]
# Determine the union of metric keys present on either split.
all_metric_keys = set()
for r in records:
all_metric_keys |= _collect_metric_keys(r["id_metrics"])
all_metric_keys |= _collect_metric_keys(r["ood_metrics"])
def metric_values(split_key, mkey):
return [_find_metric(r[split_key], mkey) for r in records]
# Per-seed rows for the JSON summary.
per_seed = []
for r in records:
per_seed.append(
{
"seed": r["seed"],
"run_name": r["run_name"],
"id": {
"macro_f1": _find_metric(r["id_metrics"], args.primary),
"avg_acc": _find_metric(r["id_metrics"], args.acc),
},
"ood": {
"macro_f1": _find_metric(r["ood_metrics"], args.primary),
"avg_acc": _find_metric(r["ood_metrics"], args.acc),
},
"train_time_seconds": r["run_info"].get("train_time_seconds"),
"train_time_hms": r["run_info"].get("train_time_hms"),
"epochs_run": r["run_info"].get("epochs_run"),
"configured_epochs": r["run_info"].get("configured_epochs"),
"best_epoch": r["run_info"].get("best_epoch"),
"early_stopped": r["run_info"].get("early_stopped"),
"peak_host_ram_gb": r["run_info"].get("peak_host_ram_gb"),
"peak_gpu_alloc_gb": r["run_info"].get("peak_gpu_alloc_gb"),
"peak_gpu_reserved_gb": r["run_info"].get("peak_gpu_reserved_gb"),
}
)
# Aggregate every metric for both splits.
def agg_all(split_key):
out = {}
for mkey in sorted(all_metric_keys):
mean, std, n = _mean_std(metric_values(split_key, mkey))
if n > 0:
out[mkey] = {"mean": mean, "std": std, "n": n}
return out
id_agg = agg_all("id_metrics")
ood_agg = agg_all("ood_metrics")
time_vals = [r["run_info"].get("train_time_seconds") for r in records]
epoch_vals = [r["run_info"].get("epochs_run") for r in records]
host_ram_vals = [r["run_info"].get("peak_host_ram_gb") for r in records]
gpu_alloc_vals = [r["run_info"].get("peak_gpu_alloc_gb") for r in records]
gpu_reserved_vals = [r["run_info"].get("peak_gpu_reserved_gb") for r in records]
time_mean, time_std, _ = _mean_std(time_vals)
epoch_mean, epoch_std, _ = _mean_std(epoch_vals)
host_ram_mean, host_ram_std, host_ram_n = _mean_std(host_ram_vals)
gpu_alloc_mean, gpu_alloc_std, _ = _mean_std(gpu_alloc_vals)
gpu_reserved_mean, gpu_reserved_std, _ = _mean_std(gpu_reserved_vals)
if host_ram_n == 0:
# No usable peak_host_ram_gb in any seed's run_info. This usually means
# the metrics JSONs were (re)generated by a standalone eval run that did
# not fold in train_supervised.py's run_info, or run_info is absent.
missing_run_info = sum(1 for r in records if not r["run_info"])
print(
f"[warn] {group_name}: RAM was not recorded for these eval runs "
f"(no peak_host_ram_gb across {len(records)} seed(s); "
f"{missing_run_info} missing run_info entirely). RAM column stays blank."
)
# Headline (leaderboard) numbers.
id_f1_mean, id_f1_std, _ = _mean_std(metric_values("id_metrics", args.primary))
id_acc_mean, id_acc_std, _ = _mean_std(metric_values("id_metrics", args.acc))
ood_f1_mean, ood_f1_std, _ = _mean_std(metric_values("ood_metrics", args.primary))
ood_acc_mean, ood_acc_std, _ = _mean_std(metric_values("ood_metrics", args.acc))
gen_gap = None
if id_f1_mean is not None and ood_f1_mean is not None:
gen_gap = id_f1_mean - ood_f1_mean
summary = {
"model": group_name,
"primary_metric": args.primary,
"acc_metric": args.acc,
"num_seeds": len(records),
"seeds": seeds,
"leaderboard": {
"test_id_macro_f1": {"mean": id_f1_mean, "std": id_f1_std},
"test_id_avg_acc": {"mean": id_acc_mean, "std": id_acc_std},
"test_ood_macro_f1": {"mean": ood_f1_mean, "std": ood_f1_std},
"test_ood_avg_acc": {"mean": ood_acc_mean, "std": ood_acc_std},
"generalization_gap_macro_f1": gen_gap,
},
"training": {
"train_time_seconds": {"mean": time_mean, "std": time_std},
"epochs_run": {"mean": epoch_mean, "std": epoch_std},
},
"resources": {
"peak_host_ram_gb": {"mean": host_ram_mean, "std": host_ram_std},
"peak_gpu_alloc_gb": {"mean": gpu_alloc_mean, "std": gpu_alloc_std},
"peak_gpu_reserved_gb": {
"mean": gpu_reserved_mean,
"std": gpu_reserved_std,
},
},
"id_metrics_aggregated": id_agg,
"ood_metrics_aggregated": ood_agg,
"per_seed": per_seed,
}
model_out_dir = os.path.join(args.out, group_name)
os.makedirs(model_out_dir, exist_ok=True)
with open(os.path.join(model_out_dir, "summary.json"), "w") as f:
json.dump(summary, f, indent=2, sort_keys=True)
csv_rows.append(
{
"model": group_name,
"num_seeds": len(records),
"seeds": " ".join(str(s) for s in seeds),
"id_macro_f1_mean": id_f1_mean,
"id_macro_f1_std": id_f1_std,
"id_avg_acc_mean": id_acc_mean,
"id_avg_acc_std": id_acc_std,
"ood_macro_f1_mean": ood_f1_mean,
"ood_macro_f1_std": ood_f1_std,
"ood_avg_acc_mean": ood_acc_mean,
"ood_avg_acc_std": ood_acc_std,
"gen_gap_macro_f1_mean": gen_gap,
"train_time_seconds_mean": time_mean,
"train_time_seconds_std": time_std,
"epochs_run_mean": epoch_mean,
"epochs_run_std": epoch_std,
"peak_host_ram_gb_mean": host_ram_mean,
"peak_host_ram_gb_std": host_ram_std,
"peak_gpu_alloc_gb_mean": gpu_alloc_mean,
"peak_gpu_alloc_gb_std": gpu_alloc_std,
"peak_gpu_reserved_gb_mean": gpu_reserved_mean,
"peak_gpu_reserved_gb_std": gpu_reserved_std,
}
)
csv_path = os.path.join(args.out, "summary_all.csv")
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=csv_fields)
writer.writeheader()
for row in csv_rows:
writer.writerow(row)
# Terminal table.
print(f"\nAggregated {len(csv_rows)} model(s). Primary metric: {args.primary}\n")
header = (
f"{'model':<40} {'seeds':>5} "
f"{'ID MacroF1':>18} {'ID AvgAcc':>18} "
f"{'OOD MacroF1':>18} {'OOD AvgAcc':>18} {'gap':>8} "
f"{'peakRAM_GB':>14} {'peakVRAM_GB':>14}"
)
print(header)
print("-" * len(header))
for row in csv_rows:
gap = row["gen_gap_macro_f1_mean"]
gap_str = "" if gap is None else f"{gap:.4f}"
model_str = row["model"][:40]
print(
f"{model_str:<40} {row['num_seeds']:>5} "
f"{_fmt(row['id_macro_f1_mean'], row['id_macro_f1_std']):>18} "
f"{_fmt(row['id_avg_acc_mean'], row['id_avg_acc_std']):>18} "
f"{_fmt(row['ood_macro_f1_mean'], row['ood_macro_f1_std']):>18} "
f"{_fmt(row['ood_avg_acc_mean'], row['ood_avg_acc_std']):>18} "
f"{gap_str:>8} "
f"{_fmt(row['peak_host_ram_gb_mean'], row['peak_host_ram_gb_std']):>14} "
f"{_fmt(row['peak_gpu_alloc_gb_mean'], row['peak_gpu_alloc_gb_std']):>14}"
)
print(f"\nWrote per-model summaries to: {args.out}/<model>/summary.json")
print(f"Wrote combined CSV to: {csv_path}")
if __name__ == "__main__":
main()