#!/usr/bin/env python3 """ Per-fold and mean OVR ROC plots for a single v2 HyperTower run. Reads the saved .npy artifacts from a completed run and produces: - One per-fold ROC figure per class (all folds as individual lines) - One mean ± SD OVR ROC figure (all classes on the same axes) Outputs are written to {mode_dir}/plots/. v2 directory layout expected ----------------------------- {run_dir}/{eval_mode}/{tower_mode}/ fold0/ y_true.npy probs_fused.npy | probs_bilat.npy | probs_classic.npy | probs_fused_head.npy fold1/ ... Usage examples -------------- # Ensemble binary run python scripts/output_analysis/visualizations/plot_run_roc_v2.py \\ --run-dir analysis_data/v2_modes_full_40ep_5fold_roi_unet_perimage_refugebuild_holdout \\ --eval-mode binary --tower-mode ensemble # Bilateral multiclass run python scripts/output_analysis/visualizations/plot_run_roc_v2.py \\ --run-dir analysis_data/v2_modes_full_40ep_5fold_roi_unet_perimage_refugebuild_holdout \\ --eval-mode multiclass --tower-mode bilateral # Fused head — explicitly select probs file python scripts/output_analysis/visualizations/plot_run_roc_v2.py \\ --run-dir analysis_data/v2_ensemble_fused_binary_unet_40ep_5fold_v1 \\ --eval-mode binary --tower-mode ensemble --probs probs_fused_head """ from __future__ import annotations import argparse from pathlib import Path import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from sklearn.metrics import roc_curve, auc as sk_auc # --------------------------------------------------------------------------- # Probs auto-detection # --------------------------------------------------------------------------- _PROBS_PRIORITY: dict[str, list[str]] = { "ensemble": ["probs_fused_head", "probs_fused"], "bilateral": ["probs_bilat"], "single": ["probs_classic"], "classic": ["probs_classic"], } _ALL_PROBS = ["probs_fused_head", "probs_fused", "probs_bilat", "probs_classic"] def detect_probs_stems(fold_dir: Path, tower_mode: str | None) -> list[str]: """Return all present probs stems (in priority order) for this fold dir.""" priority = _PROBS_PRIORITY.get(tower_mode, _ALL_PROBS) if tower_mode else _ALL_PROBS return [stem for stem in priority if (fold_dir / f"{stem}.npy").exists()] # --------------------------------------------------------------------------- # Data loading # --------------------------------------------------------------------------- def find_fold_dirs(mode_dir: Path) -> list[Path]: return sorted( [p for p in mode_dir.iterdir() if p.is_dir() and p.name.startswith("fold")], key=lambda p: int(p.name.replace("fold", "")), ) def _find_y_true(fold_dir: Path) -> Path | None: """ Return path to y_true.npy for this fold. If missing from fold_dir (can happen for bilateral-only old runs), fall back to the same fold index under sibling tower-mode directories (ensemble → single → classic). Labels are shared across tower modes within the same fold. """ local = fold_dir / "y_true.npy" if local.exists(): return local fold_name = fold_dir.name # e.g. "fold0" tower_dir = fold_dir.parent # e.g. .../binary/bilateral eval_dir = tower_dir.parent # e.g. .../binary for fallback in ("ensemble", "single", "classic"): candidate = eval_dir / fallback / fold_name / "y_true.npy" if candidate.exists(): return candidate return None def load_fold( fold_dir: Path, probs_stem: str, eval_mode: str, ) -> tuple[np.ndarray, np.ndarray] | None: y_path = _find_y_true(fold_dir) p_path = fold_dir / f"{probs_stem}.npy" if y_path is None or not p_path.exists(): return None y = np.load(y_path) p = np.load(p_path) if eval_mode == "binary": mask = np.isin(y, [0, 1]) y, p = y[mask], p[mask] if p.shape[1] > 2: p = p[:, :2] return y, p # --------------------------------------------------------------------------- # ROC helpers # --------------------------------------------------------------------------- def per_class_roc(y: np.ndarray, p: np.ndarray) -> dict[int, dict]: """OVR ROC for each class. Returns {k: {fpr, tpr, auc}}.""" out: dict[int, dict] = {} for k in range(p.shape[1]): yb = (y == k).astype(np.uint8) if yb.sum() == 0 or yb.sum() == len(yb): continue fpr, tpr, _ = roc_curve(yb, p[:, k]) out[k] = {"fpr": fpr, "tpr": tpr, "auc": sk_auc(fpr, tpr)} return out # --------------------------------------------------------------------------- # Plotting # --------------------------------------------------------------------------- def plot_perfold( per_fold: list[tuple[int, dict]], out_dir: Path, class_names: list[str], probs_stem: str, eval_mode: str, ) -> None: """One figure per class: each fold as a separate line.""" all_classes = sorted({k for _, curves in per_fold for k in curves}) if eval_mode == "binary": all_classes = [k for k in all_classes if k == 1] out_dir.mkdir(parents=True, exist_ok=True) for k in all_classes: cname = class_names[k] if k < len(class_names) else f"class_{k}" fig, ax = plt.subplots(figsize=(9, 7)) ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") for fold_idx, curves in per_fold: if k not in curves: continue c = curves[k] auc_val = c["auc"] ax.plot(c["fpr"], c["tpr"], linewidth=1.5, label=f"Fold {fold_idx} (AUC {auc_val:.3f})") ax.set_xlabel("False Positive Rate") ax.set_ylabel("True Positive Rate") ax.set_title(f"Per-fold ROC — {cname} [{probs_stem}]") ax.legend(loc="lower right") fig.tight_layout() safe = cname.replace(" ", "_") fig.savefig(out_dir / f"roc_{probs_stem}_{safe}_perfold.png", dpi=160) plt.close(fig) print(f" Saved per-fold ROC ({cname})") def plot_mean_ovr( per_fold: list[tuple[int, dict]], out_dir: Path, class_names: list[str], probs_stem: str, eval_mode: str, ) -> None: """Mean ± SD OVR ROC — all classes on one figure.""" all_classes = sorted({k for _, curves in per_fold for k in curves}) if eval_mode == "binary": all_classes = [k for k in all_classes if k == 1] grid = np.linspace(0, 1, 501) out_dir.mkdir(parents=True, exist_ok=True) fig, ax = plt.subplots(figsize=(9, 7)) ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") for k in all_classes: cname = class_names[k] if k < len(class_names) else f"class_{k}" tprs, aucs = [], [] for _, curves in per_fold: if k not in curves: continue c = curves[k] tprs.append(np.interp(grid, c["fpr"], c["tpr"])) aucs.append(c["auc"]) if not tprs: continue tprs_arr = np.vstack(tprs) mean = tprs_arr.mean(axis=0) std = tprs_arr.std(axis=0) label = f"{cname} (AUC {np.nanmean(aucs):.3f} ± {np.nanstd(aucs):.3f})" line, = ax.plot(grid, mean, linewidth=2, label=label) ax.fill_between(grid, np.clip(mean - std, 0, 1), np.clip(mean + std, 0, 1), alpha=0.15, color=line.get_color()) ax.set_xlabel("False Positive Rate") ax.set_ylabel("True Positive Rate") ax.set_title(f"Mean OVR ROC (± 1 SD) [{probs_stem}]") ax.legend(loc="lower right") fig.tight_layout() out_path = out_dir / f"roc_{probs_stem}_mean_ovr.png" fig.savefig(out_path, dpi=160) plt.close(fig) print(f" Saved mean OVR ROC: {out_path}") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> None: ap = argparse.ArgumentParser( description="Per-fold and mean OVR ROC plots for a single v2 run.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) ap.add_argument("--run-dir", required=True, type=Path, help="Top-level run directory (e.g. analysis_data/v2_my_run).") ap.add_argument("--eval-mode", required=True, choices=["binary", "multiclass"]) ap.add_argument("--tower-mode", required=True, choices=["single", "classic", "ensemble", "bilateral"], help="Tower mode subdirectory to read from.") ap.add_argument("--probs", default=None, help="Probs file stem to use (e.g. probs_fused, probs_bilat, " "probs_fused_head). Auto-detected if omitted.") ap.add_argument("--class-names", nargs="*", default=["Healthy", "Glaucoma", "Suspect"]) args = ap.parse_args() mode_dir = args.run_dir / args.eval_mode / args.tower_mode if not mode_dir.exists(): raise SystemExit(f"Directory not found: {mode_dir}") fold_dirs = find_fold_dirs(mode_dir) if not fold_dirs: raise SystemExit(f"No fold subdirectories found in {mode_dir}") # Determine probs stems to plot if args.probs is not None: stems_to_plot = [args.probs] else: # Collect all stems present across any fold dir seen: list[str] = [] for fd in fold_dirs: for s in detect_probs_stems(fd, args.tower_mode): if s not in seen: seen.append(s) stems_to_plot = seen if not stems_to_plot: raise SystemExit(f"Could not detect any probs file in {mode_dir}/fold*/") print(f"Probs stems to plot: {stems_to_plot}") out_dir = mode_dir / "plots" for probs_stem in stems_to_plot: print(f"\n--- {probs_stem} ---") per_fold: list[tuple[int, dict]] = [] for fd in fold_dirs: fold_idx = int(fd.name.replace("fold", "")) result = load_fold(fd, probs_stem, args.eval_mode) if result is None: print(f" [skip] fold {fold_idx}: missing y_true or {probs_stem}.npy") continue y, p = result curves = per_class_roc(y, p) per_fold.append((fold_idx, curves)) auc_str = " ".join(f"class{k}={v['auc']:.3f}" for k, v in curves.items()) print(f" fold {fold_idx}: {auc_str}") if not per_fold: print(f" No usable folds for {probs_stem}, skipping.") continue plot_perfold(per_fold, out_dir, args.class_names, probs_stem, args.eval_mode) plot_mean_ovr(per_fold, out_dir, args.class_names, probs_stem, args.eval_mode) print(f"\nPlots written to {out_dir}") if __name__ == "__main__": main()