573 lines
21 KiB
Python
Executable File
573 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Plot holdout ROC curves for a specific grid search run."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
from sklearn.metrics import auc, roc_curve
|
|
|
|
|
|
# ---------------------------
|
|
# Config (edit in IDE)
|
|
# ---------------------------
|
|
RUN_ID = "20251129-0312"
|
|
ANALYSIS_ROOT = Path("analysis_data/grid_search")
|
|
HEADS = ["fused", "image", "metadata"]
|
|
OUTPUT_SUBDIR = Path("plots/holdout_rocs")
|
|
BEST_OUTPUT_SUBDIR = Path("plots/best_rocs")
|
|
POSITIVE_CLASS = 1
|
|
DEBUG = True
|
|
USE_JSON_ROC = True
|
|
USE_HOLDOUT_PROBS = True
|
|
ALLOW_FALLBACK_TO_VALIDATION = False
|
|
PLOT_VALIDATION_FROM_HOLDOUT_EPOCH = True
|
|
PLOT_BEST_EPOCH = True
|
|
PLOT_HOLDOUT_FROM_BEST_EPOCH = True
|
|
PLOT_ALL_CLASSES = True
|
|
FORCE_PROBS_FOR_BEST_BINARY = True
|
|
FORCE_PROBS_FOR_HOLDOUT_BINARY = False
|
|
|
|
|
|
HEAD_FILE_KEYS = {
|
|
"fused": "fused",
|
|
"image": "img",
|
|
"metadata": "md",
|
|
}
|
|
|
|
JSON_DIR_NAMES = [
|
|
"roc_curves_holdout_best",
|
|
"roc_curves",
|
|
]
|
|
|
|
|
|
def _epoch_from_name(path: Path) -> int:
|
|
m = re.search(r"epoch(\d+)", path.name)
|
|
return int(m.group(1)) if m else -1
|
|
|
|
|
|
def _load_json(path: Path) -> Dict:
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _infer_run_info(run_dir: Path) -> Tuple[str | None, int | None, List[str] | None]:
|
|
cli = _load_json(run_dir / "cli_args.json")
|
|
summary = _load_json(run_dir / "summary.json")
|
|
payloads = [cli, summary]
|
|
eval_mode = None
|
|
num_classes = None
|
|
class_names = None
|
|
for payload in payloads:
|
|
if not payload:
|
|
continue
|
|
if eval_mode is None:
|
|
em = payload.get("eval_mode")
|
|
if isinstance(em, str):
|
|
eval_mode = em.strip().lower()
|
|
if num_classes is None:
|
|
nc = payload.get("num_classes")
|
|
if isinstance(nc, (int, float)):
|
|
num_classes = int(nc)
|
|
if class_names is None:
|
|
cn = payload.get("class_names")
|
|
if isinstance(cn, list) and cn:
|
|
class_names = [str(x) for x in cn]
|
|
if num_classes is None and eval_mode:
|
|
num_classes = 2 if eval_mode == "binary" else 3
|
|
return eval_mode, num_classes, class_names
|
|
|
|
|
|
def _collect_holdout_json_files(run_dir: Path, head: str) -> Dict[int, Path]:
|
|
fold_files: Dict[int, Path] = {}
|
|
# Fold-scoped folders
|
|
for folder_name in JSON_DIR_NAMES:
|
|
for fold_dir in run_dir.glob(f"fold*_{folder_name}"):
|
|
fold_match = re.search(r"fold(\d+)_", fold_dir.name)
|
|
if not fold_match:
|
|
continue
|
|
fold_idx = int(fold_match.group(1))
|
|
candidates = list(fold_dir.glob(f"epoch*_holdout_{head}.json"))
|
|
if not candidates:
|
|
candidates = list(fold_dir.glob(f"epoch*_{head}.json"))
|
|
if candidates:
|
|
candidates.sort(key=_epoch_from_name)
|
|
fold_files[fold_idx] = candidates[-1]
|
|
if fold_files:
|
|
return fold_files
|
|
# Fallback: unscoped roc_curves in run_dir (single-fold or in-progress)
|
|
for folder_name in JSON_DIR_NAMES:
|
|
base_dir = run_dir / folder_name
|
|
if not base_dir.exists():
|
|
continue
|
|
candidates = list(base_dir.glob(f"epoch*_holdout_{head}.json"))
|
|
if not candidates:
|
|
candidates = list(base_dir.glob(f"epoch*_{head}.json"))
|
|
if candidates:
|
|
candidates.sort(key=_epoch_from_name)
|
|
fold_files[0] = candidates[-1]
|
|
break
|
|
return fold_files
|
|
|
|
|
|
def _collect_validation_json_files(
|
|
holdout_files: Dict[int, Path], head: str
|
|
) -> Dict[int, Path]:
|
|
validation_files: Dict[int, Path] = {}
|
|
for fold_idx, holdout_path in holdout_files.items():
|
|
epoch = _epoch_from_name(holdout_path)
|
|
if epoch < 0:
|
|
continue
|
|
candidate = holdout_path.parent / f"epoch{epoch}_{head}.json"
|
|
if candidate.exists():
|
|
validation_files[fold_idx] = candidate
|
|
continue
|
|
# Fallback: try the same epoch under roc_curves (if holdout_best folder omitted it).
|
|
for folder_name in JSON_DIR_NAMES:
|
|
alt_dir = holdout_path.parent.parent / f"fold{fold_idx}_{folder_name}"
|
|
alt_candidate = alt_dir / f"epoch{epoch}_{head}.json"
|
|
if alt_candidate.exists():
|
|
validation_files[fold_idx] = alt_candidate
|
|
break
|
|
return validation_files
|
|
|
|
|
|
def _collect_holdout_from_validation_files(
|
|
validation_files: Dict[int, Path], head: str
|
|
) -> Dict[int, Path]:
|
|
holdout_files: Dict[int, Path] = {}
|
|
for fold_idx, val_path in validation_files.items():
|
|
epoch = _epoch_from_name(val_path)
|
|
if epoch < 0:
|
|
continue
|
|
candidate = val_path.parent / f"epoch{epoch}_holdout_{head}.json"
|
|
if candidate.exists():
|
|
holdout_files[fold_idx] = candidate
|
|
continue
|
|
for folder_name in ("roc_curves", "roc_curves_holdout_best"):
|
|
alt_dir = val_path.parent.parent / f"fold{fold_idx}_{folder_name}"
|
|
alt_candidate = alt_dir / f"epoch{epoch}_holdout_{head}.json"
|
|
if alt_candidate.exists():
|
|
holdout_files[fold_idx] = alt_candidate
|
|
break
|
|
return holdout_files
|
|
|
|
|
|
def _collect_best_json_files(run_dir: Path, head: str) -> Dict[int, Path]:
|
|
fold_files: Dict[int, Path] = {}
|
|
for fold_dir in run_dir.glob("fold*_roc_curves_best"):
|
|
fold_match = re.search(r"fold(\d+)_", fold_dir.name)
|
|
if not fold_match:
|
|
continue
|
|
fold_idx = int(fold_match.group(1))
|
|
candidates = list(fold_dir.glob(f"epoch*_{head}.json"))
|
|
if candidates:
|
|
candidates.sort(key=_epoch_from_name)
|
|
fold_files[fold_idx] = candidates[-1]
|
|
return fold_files
|
|
|
|
|
|
def _extract_curves(data: Dict) -> Dict[str, Tuple[List[float], List[float], float]]:
|
|
curves: Dict[str, Tuple[List[float], List[float], float]] = {}
|
|
per_class = data.get("per_class") if isinstance(data, dict) else None
|
|
if not isinstance(per_class, dict):
|
|
return curves
|
|
for cls, entry in per_class.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
fpr = entry.get("fpr")
|
|
tpr = entry.get("tpr")
|
|
auc_val = entry.get("auc")
|
|
if not isinstance(fpr, list) or not isinstance(tpr, list):
|
|
continue
|
|
try:
|
|
auc_f = float(auc_val) if auc_val is not None else float("nan")
|
|
except Exception:
|
|
auc_f = float("nan")
|
|
curves[str(cls)] = (fpr, tpr, auc_f)
|
|
return curves
|
|
|
|
|
|
def _derive_positive_from_class0(
|
|
curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]],
|
|
positive_class: int,
|
|
) -> None:
|
|
zero_key = "0"
|
|
if zero_key not in curves_by_class:
|
|
return
|
|
derived = []
|
|
for fold_idx, fpr0, tpr0, auc0 in curves_by_class.get(zero_key, []):
|
|
# The JSON for binary currently stores class-1 labels with class-0 scores,
|
|
# so invert the curve to recover the true class-1 ROC.
|
|
fpr1 = [1.0 - float(x) for x in fpr0]
|
|
tpr1 = [1.0 - float(x) for x in tpr0]
|
|
# Ensure increasing FPR for plotting.
|
|
if len(fpr1) > 1 and fpr1[0] > fpr1[-1]:
|
|
fpr1 = list(reversed(fpr1))
|
|
tpr1 = list(reversed(tpr1))
|
|
auc1 = 1.0 - auc0 if auc0 == auc0 else auc0
|
|
derived.append((fold_idx, fpr1, tpr1, auc1))
|
|
curves_by_class[str(positive_class)] = derived
|
|
|
|
|
|
def _needs_positive_derivation(
|
|
curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]],
|
|
positive_class: int,
|
|
) -> bool:
|
|
curves = curves_by_class.get(str(positive_class))
|
|
if not curves:
|
|
return True
|
|
for _, fpr, tpr, auc_val in curves:
|
|
if auc_val == auc_val and len(fpr) > 2 and len(tpr) > 2:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _collect_prob_files(run_dir: Path, suffix: str) -> Dict[int, Dict[str, Path]]:
|
|
files: Dict[int, Dict[str, Path]] = {}
|
|
for y_file in run_dir.glob(f"fold*_y_true{suffix}.npy"):
|
|
fold_str = y_file.stem.split("_")[0].replace("fold", "")
|
|
try:
|
|
fold_idx = int(fold_str)
|
|
except ValueError:
|
|
continue
|
|
files.setdefault(fold_idx, {})["y_true"] = y_file
|
|
for head, key in HEAD_FILE_KEYS.items():
|
|
for p_file in run_dir.glob(f"fold*_probs_{key}{suffix}.npy"):
|
|
fold_str = p_file.stem.split("_")[0].replace("fold", "")
|
|
try:
|
|
fold_idx = int(fold_str)
|
|
except ValueError:
|
|
continue
|
|
files.setdefault(fold_idx, {})[head] = p_file
|
|
return files
|
|
|
|
|
|
def _load_array(path: Path) -> np.ndarray | None:
|
|
try:
|
|
return np.load(path)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _compute_binary_curve(
|
|
y_true: np.ndarray, probs: np.ndarray, positive_class: int
|
|
) -> Tuple[List[float], List[float], float] | None:
|
|
if probs.ndim == 1:
|
|
scores = probs
|
|
elif probs.ndim == 2 and probs.shape[1] > positive_class:
|
|
scores = probs[:, positive_class]
|
|
else:
|
|
return None
|
|
y_bin = (y_true == positive_class).astype(int)
|
|
if y_bin.sum() == 0 or y_bin.sum() == len(y_bin):
|
|
return None
|
|
fpr, tpr, _ = roc_curve(y_bin, scores)
|
|
auc_val = float(auc(fpr, tpr))
|
|
return fpr.tolist(), tpr.tolist(), auc_val
|
|
|
|
|
|
def _compute_multiclass_curves(
|
|
y_true: np.ndarray, probs: np.ndarray
|
|
) -> Dict[str, Tuple[List[float], List[float], float]]:
|
|
curves: Dict[str, Tuple[List[float], List[float], float]] = {}
|
|
if probs.ndim != 2:
|
|
return curves
|
|
num_classes = probs.shape[1]
|
|
for cls in range(num_classes):
|
|
y_bin = (y_true == cls).astype(int)
|
|
if y_bin.sum() == 0 or y_bin.sum() == len(y_bin):
|
|
continue
|
|
fpr, tpr, _ = roc_curve(y_bin, probs[:, cls])
|
|
curves[str(cls)] = (fpr.tolist(), tpr.tolist(), float(auc(fpr, tpr)))
|
|
return curves
|
|
|
|
|
|
def _plot_overlays(
|
|
curves_by_fold: List[Tuple[int, List[float], List[float], float]],
|
|
title: str,
|
|
out_path: Path,
|
|
) -> None:
|
|
fig, ax = plt.subplots(figsize=(6, 5))
|
|
for fold_idx, fpr, tpr, auc_val in curves_by_fold:
|
|
label = (
|
|
f"fold{fold_idx} AUC={auc_val:.3f}"
|
|
if auc_val == auc_val
|
|
else f"fold{fold_idx}"
|
|
)
|
|
ax.plot(fpr, tpr, lw=1.4, label=label)
|
|
ax.plot([0, 1], [0, 1], "k--", lw=1)
|
|
ax.set_xlabel("False Positive Rate")
|
|
ax.set_ylabel("True Positive Rate")
|
|
ax.set_title(title)
|
|
ax.legend(loc="lower right", fontsize="small")
|
|
ax.grid(True, alpha=0.3, linestyle="--")
|
|
fig.tight_layout()
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fig.savefig(out_path, dpi=170)
|
|
plt.close(fig)
|
|
|
|
|
|
def main() -> None:
|
|
run_dir = ANALYSIS_ROOT / RUN_ID
|
|
if not run_dir.exists():
|
|
raise SystemExit(f"Run not found: {run_dir}")
|
|
eval_mode, num_classes, class_names = _infer_run_info(run_dir)
|
|
is_binary = eval_mode == "binary" or num_classes == 2
|
|
|
|
def _plot_set(
|
|
label: str,
|
|
head: str,
|
|
json_files: Dict[int, Path],
|
|
out_dir: Path,
|
|
paired_files: Dict[int, Path] | None,
|
|
paired_suffix: str,
|
|
class_names: List[str] | None,
|
|
) -> None:
|
|
if not json_files:
|
|
return
|
|
curves_by_class: Dict[
|
|
str, List[Tuple[int, List[float], List[float], float]]
|
|
] = {}
|
|
paired_curves_by_class: Dict[
|
|
str, List[Tuple[int, List[float], List[float], float]]
|
|
] = {}
|
|
for fold_idx, path in sorted(json_files.items()):
|
|
if DEBUG:
|
|
print(f"[debug] {label} head={head} fold={fold_idx} json={path}")
|
|
data = _load_json(path)
|
|
curves = _extract_curves(data)
|
|
for cls, (fpr, tpr, auc_val) in curves.items():
|
|
curves_by_class.setdefault(cls, []).append(
|
|
(fold_idx, fpr, tpr, auc_val)
|
|
)
|
|
if paired_files:
|
|
p_path = paired_files.get(fold_idx)
|
|
if p_path is not None:
|
|
if DEBUG:
|
|
print(
|
|
f"[debug] {label} head={head} fold={fold_idx} paired_json={p_path}"
|
|
)
|
|
p_data = _load_json(p_path)
|
|
p_curves = _extract_curves(p_data)
|
|
for cls, (fpr, tpr, auc_val) in p_curves.items():
|
|
paired_curves_by_class.setdefault(cls, []).append(
|
|
(fold_idx, fpr, tpr, auc_val)
|
|
)
|
|
if _needs_positive_derivation(curves_by_class, POSITIVE_CLASS):
|
|
_derive_positive_from_class0(curves_by_class, POSITIVE_CLASS)
|
|
if paired_curves_by_class and _needs_positive_derivation(
|
|
paired_curves_by_class, POSITIVE_CLASS
|
|
):
|
|
_derive_positive_from_class0(paired_curves_by_class, POSITIVE_CLASS)
|
|
|
|
if PLOT_ALL_CLASSES:
|
|
classes = list(curves_by_class.keys())
|
|
else:
|
|
classes = (
|
|
[str(POSITIVE_CLASS)]
|
|
if str(POSITIVE_CLASS) in curves_by_class
|
|
else list(curves_by_class.keys())
|
|
)
|
|
if not classes:
|
|
return
|
|
|
|
for cls in classes:
|
|
fold_curves = curves_by_class.get(cls, [])
|
|
if not fold_curves:
|
|
continue
|
|
class_label = cls
|
|
if class_names is not None:
|
|
try:
|
|
idx = int(cls)
|
|
if 0 <= idx < len(class_names):
|
|
class_label = f"{cls} ({class_names[idx]})"
|
|
except Exception:
|
|
pass
|
|
title = f"{RUN_ID} {label} ROC — head={head} class={class_label}"
|
|
out_path = out_dir / f"{label}_{head}_class{cls}.png"
|
|
_plot_overlays(fold_curves, title, out_path)
|
|
print(f"[ok] {out_path}")
|
|
|
|
if paired_curves_by_class:
|
|
p_curves = paired_curves_by_class.get(cls, [])
|
|
if p_curves:
|
|
p_title = f"{RUN_ID} {label} {paired_suffix} ROC — head={head} class={class_label}"
|
|
p_path = out_dir / f"{label}_{head}_class{cls}_{paired_suffix}.png"
|
|
_plot_overlays(p_curves, p_title, p_path)
|
|
print(f"[ok] {p_path}")
|
|
|
|
def _plot_from_probs(
|
|
label: str,
|
|
head: str,
|
|
out_dir: Path,
|
|
suffix: str,
|
|
class_names: List[str] | None,
|
|
) -> None:
|
|
curves_by_class: Dict[
|
|
str, List[Tuple[int, List[float], List[float], float]]
|
|
] = {}
|
|
files = _collect_prob_files(run_dir, suffix)
|
|
if not files and suffix and ALLOW_FALLBACK_TO_VALIDATION:
|
|
files = _collect_prob_files(run_dir, "")
|
|
if files:
|
|
print(
|
|
"[warn] Holdout probability dumps not found; using validation probabilities instead."
|
|
)
|
|
if not files:
|
|
return
|
|
for fold_idx in sorted(files.keys()):
|
|
fold_files = files[fold_idx]
|
|
y_path = fold_files.get("y_true")
|
|
p_path = fold_files.get(head)
|
|
if y_path is None or p_path is None:
|
|
continue
|
|
y_true = _load_array(y_path)
|
|
probs = _load_array(p_path)
|
|
if y_true is None or probs is None:
|
|
continue
|
|
curves = _compute_multiclass_curves(y_true, probs)
|
|
for cls, payload in curves.items():
|
|
curves_by_class.setdefault(cls, []).append((fold_idx, *payload))
|
|
if not curves_by_class:
|
|
return
|
|
classes = list(curves_by_class.keys())
|
|
for cls in classes:
|
|
fold_curves = curves_by_class.get(cls, [])
|
|
if not fold_curves:
|
|
continue
|
|
class_label = cls
|
|
if class_names is not None:
|
|
try:
|
|
idx = int(cls)
|
|
if 0 <= idx < len(class_names):
|
|
class_label = f"{cls} ({class_names[idx]})"
|
|
except Exception:
|
|
pass
|
|
title = f"{RUN_ID} {label} ROC — head={head} class={class_label}"
|
|
out_path = out_dir / f"{label}_{head}_class{cls}.png"
|
|
_plot_overlays(fold_curves, title, out_path)
|
|
print(f"[ok] {out_path}")
|
|
|
|
any_holdout_json = False
|
|
if USE_JSON_ROC:
|
|
out_dir = run_dir / OUTPUT_SUBDIR
|
|
for head in HEADS:
|
|
files = _collect_holdout_json_files(run_dir, head)
|
|
if files:
|
|
any_holdout_json = True
|
|
paired = (
|
|
_collect_validation_json_files(files, head)
|
|
if PLOT_VALIDATION_FROM_HOLDOUT_EPOCH
|
|
else None
|
|
)
|
|
if is_binary and FORCE_PROBS_FOR_HOLDOUT_BINARY:
|
|
_plot_from_probs("holdout", head, out_dir, "_holdout", class_names)
|
|
else:
|
|
_plot_set(
|
|
"holdout",
|
|
head,
|
|
files,
|
|
out_dir,
|
|
paired,
|
|
"validation",
|
|
class_names,
|
|
)
|
|
if not any_holdout_json and DEBUG:
|
|
print("[debug] no JSON ROC files found; falling back to probs")
|
|
|
|
if PLOT_BEST_EPOCH:
|
|
best_out_dir = run_dir / BEST_OUTPUT_SUBDIR
|
|
for head in HEADS:
|
|
if is_binary and FORCE_PROBS_FOR_BEST_BINARY:
|
|
_plot_from_probs("best", head, best_out_dir, "", class_names)
|
|
continue
|
|
best_files = _collect_best_json_files(run_dir, head)
|
|
if best_files:
|
|
paired = (
|
|
_collect_holdout_from_validation_files(best_files, head)
|
|
if PLOT_HOLDOUT_FROM_BEST_EPOCH
|
|
else None
|
|
)
|
|
_plot_set(
|
|
"best",
|
|
head,
|
|
best_files,
|
|
best_out_dir,
|
|
paired,
|
|
"holdout",
|
|
class_names,
|
|
)
|
|
|
|
# Fallback to probs for holdout plots if JSON wasn't found.
|
|
if USE_JSON_ROC and any_holdout_json:
|
|
return
|
|
|
|
out_dir = run_dir / OUTPUT_SUBDIR
|
|
for head in HEADS:
|
|
curves_by_class: Dict[
|
|
str, List[Tuple[int, List[float], List[float], float]]
|
|
] = {}
|
|
suffix = "_holdout" if USE_HOLDOUT_PROBS else ""
|
|
files = _collect_prob_files(run_dir, suffix)
|
|
if not files and USE_HOLDOUT_PROBS and ALLOW_FALLBACK_TO_VALIDATION:
|
|
suffix = ""
|
|
files = _collect_prob_files(run_dir, suffix)
|
|
if files:
|
|
print(
|
|
"[warn] Holdout probability dumps not found; using validation probabilities instead."
|
|
)
|
|
if not files:
|
|
raise SystemExit(
|
|
"No saved probability dumps found. If you want holdout ROC curves, "
|
|
"run scripts/rebuild_run_best_plots.py with --use-holdout --overwrite "
|
|
"to generate fold*_y_true_holdout.npy and fold*_probs_*_holdout.npy files."
|
|
)
|
|
for fold_idx in sorted(files.keys()):
|
|
fold_files = files[fold_idx]
|
|
y_path = fold_files.get("y_true")
|
|
p_path = fold_files.get(head)
|
|
if y_path is None or p_path is None:
|
|
continue
|
|
y_true = _load_array(y_path)
|
|
probs = _load_array(p_path)
|
|
if y_true is None or probs is None:
|
|
continue
|
|
curves = _compute_multiclass_curves(y_true, probs)
|
|
for cls, payload in curves.items():
|
|
if payload is None:
|
|
continue
|
|
fpr, tpr, auc_val = payload
|
|
curves_by_class.setdefault(cls, []).append(
|
|
(fold_idx, fpr, tpr, auc_val)
|
|
)
|
|
if not curves_by_class:
|
|
continue
|
|
classes = sorted(curves_by_class.keys(), key=lambda x: (float(x), str(x)))
|
|
for cls in classes:
|
|
fold_curves = curves_by_class.get(cls, [])
|
|
if not fold_curves:
|
|
continue
|
|
class_label = cls
|
|
if class_names is not None:
|
|
try:
|
|
idx = int(cls)
|
|
if 0 <= idx < len(class_names):
|
|
class_label = f"{cls} ({class_names[idx]})"
|
|
except Exception:
|
|
pass
|
|
title = f"{RUN_ID} holdout ROC — head={head} class={class_label}"
|
|
out_path = out_dir / f"holdout_{head}_class{cls}.png"
|
|
_plot_overlays(fold_curves, title, out_path)
|
|
print(f"[ok] {out_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|