Add new scripts and configurations for model comparison and analysis

- Introduced `poster_model_comparison.py` for generating model comparison figures.
- Added `plot_poster_roc_comparison.py` for creating ROC comparison figures for PAPILA binary classification.
- Created new JSON configuration files for clinical solo models with and without geometry injection.
- Implemented batch dispatch updates in `batch_dispatch.py` to utilize run names from configurations.
- Added analysis scripts: `compare_grid.py`, `inspect_embeddings.py`, and `summarize_run.py` for evaluating model performance and feature embeddings.
- Created experiment configurations for various training scenarios, including warm sweeps and promoting successful runs.
- Added binary ROC comparison and model comparison figures to the results directory.
This commit is contained in:
rpotter6298
2026-05-14 13:43:30 +02:00
parent e2489a21e7
commit a721e52909
18 changed files with 1052 additions and 1 deletions
View File
View File
+112
View File
@@ -0,0 +1,112 @@
"""compare_grid — print a ranked comparison table for a folder of v4 runs.
A "grid" folder is one whose immediate children are individual run folders, e.g.
``v4/results/experiments/tri_v1/grid/`` containing ``bcd35_cw0_nt15/``,
``bcd35_cw0_nt25/``, etc. Each child must itself look like a run folder
(``repNN/.../summary.json``).
Usage:
python -m v4.scripts.analysis.compare_grid <grid_folder>
[--sort {test,val,name,reps}] [--reverse] [--csv]
Examples:
python -m v4.scripts.analysis.compare_grid \
v4/results/experiments/tri_v1/grid
python -m v4.scripts.analysis.compare_grid \
v4/results/experiments/tri_v1 --sort test
"""
from __future__ import annotations
import argparse
import csv
import sys
from pathlib import Path
from v4.scripts.analysis.summarize_run import summarise
def collect(grid_dir: Path) -> list[dict]:
rows: list[dict] = []
for child in sorted(grid_dir.iterdir()):
if not child.is_dir():
continue
s = summarise(child)
if s["n_reps"] == 0:
continue
rows.append({
"name": child.name,
"n": s["n_reps"],
"val_mean": s["val_mean"],
"val_std": s["val_std"],
"test_mean": s["test_mean"],
"test_std": s["test_std"],
})
return rows
def render_table(rows: list[dict]) -> str:
if not rows:
return "(no runs found)"
name_w = max(len("name"), max(len(r["name"]) for r in rows))
header = f"{'name':<{name_w}s} {'reps':>4s} {'val AUC':>17s} {'test AUC':>17s}"
sep = "-" * len(header)
lines = [header, sep]
for r in rows:
lines.append(
f"{r['name']:<{name_w}s} {r['n']:>4d} "
f"{r['val_mean']:.4f} ± {r['val_std']:.4f} "
f"{r['test_mean']:.4f} ± {r['test_std']:.4f}"
)
return "\n".join(lines)
def render_csv(rows: list[dict]) -> str:
buf = sys.stdout
w = csv.writer(buf)
w.writerow(["name", "reps", "val_mean", "val_std", "test_mean", "test_std"])
for r in rows:
w.writerow([r["name"], r["n"],
f"{r['val_mean']:.6f}", f"{r['val_std']:.6f}",
f"{r['test_mean']:.6f}", f"{r['test_std']:.6f}"])
return ""
_SORT_KEYS = {
"test": lambda r: r["test_mean"],
"val": lambda r: r["val_mean"],
"name": lambda r: r["name"],
"reps": lambda r: r["n"],
}
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("grid_dir", type=Path,
help="Folder whose immediate children are run folders")
ap.add_argument("--sort", choices=list(_SORT_KEYS),
default="test", help="Sort by which column (default: test)")
ap.add_argument("--reverse", action="store_true",
help="Reverse the default ordering")
ap.add_argument("--csv", action="store_true",
help="Emit CSV to stdout instead of a formatted table")
args = ap.parse_args()
if not args.grid_dir.is_dir():
raise SystemExit(f"Not a directory: {args.grid_dir}")
rows = collect(args.grid_dir)
descending = args.sort in {"test", "val", "reps"}
if args.reverse:
descending = not descending
rows.sort(key=_SORT_KEYS[args.sort], reverse=descending)
if args.csv:
render_csv(rows)
else:
print(f"Grid: {args.grid_dir} ({len(rows)} runs, sorted by {args.sort})\n")
print(render_table(rows))
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
"""inspect_embeddings — per-dim statistics on a run's saved feature embeddings.
Loads features.h5 from a run folder (must have been produced with
``save_features: true`` in the config), computes per-dimension variance,
sparsity (fraction of |z| < threshold), and useful summary stats.
Helpful for diagnosing whether a fusion stage is collapsing dimensions to
near-zero — which would silently null out information when the bridge uses
a Hadamard product.
Usage:
python -m v4.scripts.analysis.inspect_embeddings <run_folder> [--phase NAME]
[--threshold 0.05]
Examples:
python -m v4.scripts.analysis.inspect_embeddings \
v4/results/experiments/tri_v1/baseline_tri/rep00 --phase nt
"""
from __future__ import annotations
import argparse
from pathlib import Path
import h5py
import numpy as np
def load_phase(features_path: Path, phase: str | None):
with h5py.File(features_path, "r") as f:
phases = list(f.keys())
if phase is None:
phase = phases[-1]
if phase not in phases:
raise SystemExit(
f"Phase {phase!r} not in {features_path} (available: {phases})"
)
g = f[phase]
z = g["z"][:] # (n_folds, n_samples, n_dim)
split = g["split"][:].astype(str) # (n_folds, n_samples)
y_true = g["y_true"][:]
return phase, z, split, y_true, phases
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("run_dir", type=Path, help="Folder containing features.h5 (under repNN)")
ap.add_argument("--phase", default=None,
help="Bridge phase name (e.g. nt, hb, cd_fuse). Default: last in file.")
ap.add_argument("--threshold", type=float, default=0.05,
help="|z| threshold for the sparsity count (default: 0.05)")
args = ap.parse_args()
feat = next(iter(args.run_dir.rglob("features.h5")), None)
if feat is None:
raise SystemExit(f"No features.h5 found under {args.run_dir}")
phase, z, split, _, all_phases = load_phase(feat, args.phase)
val_mask = (split == "val")
z_val = z[val_mask] # (n_val_total, n_dim)
z_train = z[(split == "train")]
n_dim = z_val.shape[-1]
print(f"Run: {args.run_dir}")
print(f"File: {feat}")
print(f"Phases: {all_phases}")
print(f"Phase: {phase} (z shape: {z.shape})")
print(f"Split sizes: train={len(z_train)} val={len(z_val)}")
print()
abs_z = np.abs(z_val)
per_dim_var = z_val.var(axis=0)
per_dim_abs = abs_z.mean(axis=0)
per_dim_max = abs_z.max(axis=0)
sparsity = (abs_z < args.threshold).mean(axis=0)
print(f"Aggregate stats over val embeddings (|z| < {args.threshold} = 'near-zero'):")
print(f" global mean(|z|): {abs_z.mean():.4f}")
print(f" global var(z): {z_val.var():.4f}")
print(f" fraction near-zero (global): {(abs_z < args.threshold).mean():.4f}")
print()
print(f"Per-dimension summary ({n_dim} dims):")
print(f" variance: min={per_dim_var.min():.4f} med={np.median(per_dim_var):.4f} max={per_dim_var.max():.4f}")
print(f" mean |z|: min={per_dim_abs.min():.4f} med={np.median(per_dim_abs):.4f} max={per_dim_abs.max():.4f}")
print(f" near-zero rate: min={sparsity.min():.4f} med={np.median(sparsity):.4f} max={sparsity.max():.4f}")
# Dead dimensions: high near-zero rate
dead = np.where(sparsity > 0.9)[0]
print(f" 'dead' dims (>90% near-zero): {len(dead)}/{n_dim} "
f"{('idx: ' + str(dead.tolist())) if 0 < len(dead) <= 20 else ''}")
weak = np.where(per_dim_var < 1e-4)[0]
print(f" 'weak' dims (var < 1e-4): {len(weak)}/{n_dim} "
f"{('idx: ' + str(weak.tolist())) if 0 < len(weak) <= 20 else ''}")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""summarize_run — print cross-rep stats for one v4 run folder.
A "run" is a folder like ``v4/results/experiments/tri_v1/grid/bcd75_cw1_nt25/``
containing ``rep00/``, ``rep01/``, ... — each with a per-rep ``summary.json``
under any ``out_dir_tags`` subdir (typically ``binary/`` or ``binary/ntower/``).
Usage:
python -m v4.scripts.analysis.summarize_run <run_folder> [--per-rep] [--json]
Examples:
python -m v4.scripts.analysis.summarize_run \
v4/results/experiments/tri_v1/grid/bcd75_cw1_nt25
python -m v4.scripts.analysis.summarize_run \
v4/results/experiments/tri_v1/baseline_tri --per-rep
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
import numpy as np
def find_rep_summaries(run_dir: Path) -> list[tuple[int, Path]]:
"""Return [(rep_idx, summary_path), ...] sorted by rep_idx."""
out: list[tuple[int, Path]] = []
for rep_dir in sorted(run_dir.glob("rep*")):
if not rep_dir.is_dir():
continue
m = re.match(r"rep(\d+)$", rep_dir.name)
if not m:
continue
summary = next(iter(rep_dir.rglob("summary.json")), None)
if summary is not None:
out.append((int(m.group(1)), summary))
return out
def load_rep(summary_path: Path) -> dict:
"""Extract the fields we summarise from one rep's summary.json."""
d = json.loads(summary_path.read_text())
return {
"val_mean": float(d.get("mean_val_auc", float("nan"))),
"val_std": float(d.get("std_val_auc", float("nan"))),
"test_mean": float(d.get("mean_test_auc", float("nan"))),
"test_std": float(d.get("std_test_auc", float("nan"))),
"elapsed_s": float(d.get("elapsed_s", float("nan"))),
"eval_stage": d.get("eval_stage", "?"),
}
def summarise(run_dir: Path) -> dict:
reps = find_rep_summaries(run_dir)
if not reps:
return {"run": str(run_dir), "n_reps": 0, "reps": []}
rows = [(idx, load_rep(p)) for idx, p in reps]
val = np.array([r[1]["val_mean"] for r in rows])
test = np.array([r[1]["test_mean"] for r in rows])
elaps = np.array([r[1]["elapsed_s"] for r in rows])
out = {
"run": str(run_dir),
"n_reps": len(rows),
"eval_stage": rows[0][1]["eval_stage"],
"val_mean": float(np.mean(val)),
"val_std": float(np.std(val)),
"val_min": float(np.min(val)),
"val_max": float(np.max(val)),
"test_mean": float(np.mean(test)),
"test_std": float(np.std(test)),
"test_min": float(np.min(test)),
"test_max": float(np.max(test)),
"elapsed_total_s": float(np.sum(elaps)) if not np.isnan(elaps).any() else None,
"reps": [
{"rep": idx, **info} for idx, info in rows
],
}
return out
def render(s: dict, per_rep: bool = False) -> str:
if s["n_reps"] == 0:
return f"Run: {s['run']}\n no reps with summary.json found."
lines = [
f"Run: {s['run']}",
f"Reps: {s['n_reps']} (eval_stage={s['eval_stage']})",
f"Val AUC: {s['val_mean']:.4f} ± {s['val_std']:.4f} "
f"[min={s['val_min']:.4f} max={s['val_max']:.4f}]",
f"Test AUC: {s['test_mean']:.4f} ± {s['test_std']:.4f} "
f"[min={s['test_min']:.4f} max={s['test_max']:.4f}]",
]
if s.get("elapsed_total_s") is not None:
h = s["elapsed_total_s"] / 3600
lines.append(f"Compute: {s['elapsed_total_s']:.0f} s total ({h:.1f} h)")
if per_rep:
lines.append("")
lines.append("Per-rep breakdown:")
lines.append(f" {'rep':>4s} {'val':>7s} {'test':>7s} {'elapsed':>7s}")
for r in s["reps"]:
elapsed = (f"{r['elapsed_s']:.0f}s" if not np.isnan(r['elapsed_s']) else "-")
lines.append(
f" {r['rep']:>4d} "
f"{r['val_mean']:>7.4f} "
f"{r['test_mean']:>7.4f} "
f"{elapsed:>7s}"
)
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("run_dir", type=Path, help="Path to a run folder (contains repNN/ subdirs)")
ap.add_argument("--per-rep", action="store_true", help="Print one line per rep")
ap.add_argument("--json", action="store_true", help="Emit JSON instead of formatted text")
args = ap.parse_args()
if not args.run_dir.is_dir():
raise SystemExit(f"Not a directory: {args.run_dir}")
s = summarise(args.run_dir)
if args.json:
print(json.dumps(s, indent=2))
else:
print(render(s, per_rep=args.per_rep))
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
[
{
"_note": "Single rep of tritower default with save_features=true so we can inspect nt-stage embeddings (Hadamard-product collapse hypothesis).",
"run_name": "experiments/tri_v1/baseline_tri_features",
"reps": 1,
"overrides": {
"save_features": true
}
}
]
@@ -0,0 +1,13 @@
[
{ "_note": "cd-solo + UNet geom-vector inject; cd_warm sweep. 3 reps each.",
"run_name": "experiments/tri_v1/cd_solo_geom/warm00", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 0 } } },
{ "run_name": "experiments/tri_v1/cd_solo_geom/warm05", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 5 } } },
{ "run_name": "experiments/tri_v1/cd_solo_geom/warm10", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 10 } } },
{ "run_name": "experiments/tri_v1/cd_solo_geom/warm20", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 20 } } },
{ "run_name": "experiments/tri_v1/cd_solo_geom/warm40", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 40 } } }
]
@@ -0,0 +1,13 @@
[
{ "_note": "cd-solo, no geom; cd_warm sweep. 3 reps each. Companion to cd_solo_geom sweep.",
"run_name": "experiments/tri_v1/cd_solo/warm00", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 0 } } },
{ "run_name": "experiments/tri_v1/cd_solo/warm05", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 5 } } },
{ "run_name": "experiments/tri_v1/cd_solo/warm10", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 10 } } },
{ "run_name": "experiments/tri_v1/cd_solo/warm20", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 20 } } },
{ "run_name": "experiments/tri_v1/cd_solo/warm40", "reps": 3,
"stage_overrides": { "cd_warm": { "epochs": 40 } } }
]
@@ -0,0 +1,16 @@
[
{
"_note": "Promote grid winner bcd75_cw1_nt25 (3-rep test AUC 0.910) to 10 reps. First 3 reps will be skipped (already on disk).",
"run_name": "experiments/tri_v1/grid/bcd75_cw1_nt25",
"reps": 10,
"overrides": {
"training": {
"bcd_prob": 0.75,
"class_weighted": true
}
},
"stage_overrides": {
"nt": { "epochs": 25 }
}
}
]