287 lines
9.8 KiB
Python
Executable File
287 lines
9.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Aggregate per-fold metrics across runs and visualize AUC vs accuracy.
|
|
|
|
The script scans every `summary.json` under the provided analysis directory,
|
|
loads the per-fold macro AUC values, and combines them with per-fold
|
|
predictions to compute accuracy. Two scatter plots are produced:
|
|
|
|
1. AUC vs. fold index (with jitter) coloured by fold.
|
|
2. Accuracy (x-axis) vs. AUC (y-axis) coloured by fold.
|
|
|
|
This helps identify folds that persistently underperform across experiments.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, Iterable, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.cm import get_cmap
|
|
from matplotlib.lines import Line2D
|
|
except ImportError as exc: # pragma: no cover - forward-friendly error for runtime
|
|
raise SystemExit("matplotlib is required to run this script") from exc
|
|
|
|
|
|
@dataclass
|
|
class FoldMetric:
|
|
run_id: str
|
|
fold: int
|
|
auc: float
|
|
accuracy: float
|
|
summary_path: Path
|
|
fusion_mode: Optional[str]
|
|
plot_head: str
|
|
|
|
|
|
HEAD_SUFFIX = {
|
|
"fused": "fused",
|
|
"metadata": "md",
|
|
"metadata_only": "md",
|
|
"image": "img",
|
|
"image_only": "img",
|
|
"img": "img",
|
|
"md": "md",
|
|
}
|
|
|
|
|
|
def infer_head(summary: Dict[str, object]) -> str:
|
|
"""Return the prediction head name used for evaluation."""
|
|
plot_head = summary.get("plot_head")
|
|
if isinstance(plot_head, str) and plot_head:
|
|
key = plot_head.lower()
|
|
if key in HEAD_SUFFIX:
|
|
return key
|
|
fusion_mode = summary.get("fusion_mode")
|
|
if isinstance(fusion_mode, str):
|
|
key = fusion_mode.lower()
|
|
if key in HEAD_SUFFIX:
|
|
return key
|
|
# Fall back to fused head if nothing else matches
|
|
return "fused"
|
|
|
|
|
|
def prediction_suffix(head: str) -> str:
|
|
key = head.lower()
|
|
if key in {"metadata", "metadata_only", "md"}:
|
|
return "md"
|
|
if key in {"image", "image_only", "img"}:
|
|
return "img"
|
|
return "fused"
|
|
|
|
|
|
def compute_accuracy(probs: np.ndarray, y_true: np.ndarray) -> float:
|
|
if probs.ndim == 1:
|
|
preds = (probs >= 0.5).astype(int)
|
|
else:
|
|
preds = np.argmax(probs, axis=1)
|
|
y_int = y_true.astype(int)
|
|
return float((preds == y_int).mean()) if y_int.size else np.nan
|
|
|
|
|
|
def load_summary(path: Path) -> Optional[Dict[str, object]]:
|
|
try:
|
|
with path.open("r") as f:
|
|
return json.load(f)
|
|
except Exception as exc:
|
|
print(f"[warn] Could not parse {path}: {exc}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def collect_metrics(summary_path: Path) -> Iterable[FoldMetric]:
|
|
summary = load_summary(summary_path)
|
|
if not summary:
|
|
return []
|
|
# Only keep multiclass experiments (num_classes > 2 or eval_mode explicitly multiclass)
|
|
num_classes = summary.get("num_classes")
|
|
eval_mode = summary.get("eval_mode")
|
|
if (isinstance(num_classes, int) and num_classes <= 2) or (isinstance(eval_mode, str) and eval_mode.lower() == "binary"):
|
|
return []
|
|
|
|
head = infer_head(summary)
|
|
per_fold_auc = summary.get("per_fold_macro_ovr_auc") or summary.get("per_fold_auc")
|
|
if not isinstance(per_fold_auc, list):
|
|
# Fallback for summaries that only store fold_metrics[*].stats.
|
|
metric_key = f"auc_{prediction_suffix(head)}"
|
|
fold_metrics = summary.get("fold_metrics")
|
|
if not isinstance(fold_metrics, list):
|
|
return []
|
|
per_fold_auc = []
|
|
for entry in fold_metrics:
|
|
if not isinstance(entry, dict):
|
|
return []
|
|
stats = entry.get("stats")
|
|
if not isinstance(stats, dict):
|
|
return []
|
|
auc_val = stats.get(metric_key)
|
|
try:
|
|
per_fold_auc.append(float(auc_val))
|
|
except (TypeError, ValueError):
|
|
return []
|
|
|
|
suffix = prediction_suffix(head)
|
|
run_id = summary.get("run_id", summary_path.parent.name)
|
|
fusion_mode = summary.get("fusion_mode")
|
|
|
|
for fold_idx, auc_val in enumerate(per_fold_auc):
|
|
try:
|
|
auc = float(auc_val)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
base = summary_path.parent
|
|
probs_path = base / f"fold{fold_idx}_probs_{suffix}.npy"
|
|
y_true_path = base / f"fold{fold_idx}_y_true.npy"
|
|
if not probs_path.exists() or not y_true_path.exists():
|
|
# fall back: if fused missing for metadata mode (or vice versa), try md or img
|
|
if suffix != "fused":
|
|
alt_probs_path = base / f"fold{fold_idx}_probs_fused.npy"
|
|
if alt_probs_path.exists():
|
|
probs_path = alt_probs_path
|
|
if not probs_path.exists():
|
|
print(
|
|
f"[warn] Missing predictions for fold {fold_idx} in {base}; skipped",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
try:
|
|
probs = np.load(probs_path)
|
|
y_true = np.load(y_true_path)
|
|
except Exception as exc:
|
|
print(f"[warn] Failed loading predictions for {base}: {exc}", file=sys.stderr)
|
|
continue
|
|
accuracy = compute_accuracy(probs, y_true)
|
|
yield FoldMetric(
|
|
run_id=str(run_id),
|
|
fold=fold_idx,
|
|
auc=auc,
|
|
accuracy=accuracy,
|
|
summary_path=summary_path,
|
|
fusion_mode=fusion_mode if isinstance(fusion_mode, str) else None,
|
|
plot_head=head,
|
|
)
|
|
|
|
|
|
def build_plot(metrics: List[FoldMetric], output: Path, jitter: float, seed: int, show: bool) -> None:
|
|
rng = np.random.default_rng(seed)
|
|
folds = sorted({m.fold for m in metrics})
|
|
fold_to_color: Dict[int, tuple] = {}
|
|
cmap = get_cmap("tab10", max(len(folds), 1))
|
|
for idx, fold in enumerate(folds):
|
|
fold_to_color[fold] = cmap(idx)
|
|
|
|
# Prepare arrays for plotting
|
|
aucs = np.array([m.auc for m in metrics])
|
|
accs = np.array([m.accuracy for m in metrics])
|
|
fold_indices = np.array([m.fold for m in metrics])
|
|
colors = [fold_to_color[m.fold] for m in metrics]
|
|
jitter_offsets = rng.uniform(-jitter, jitter, size=len(metrics))
|
|
|
|
fig, axes = plt.subplots(1, 2, figsize=(13, 5), constrained_layout=True)
|
|
|
|
# Panel 1: Fold vs AUC scatter with jitter
|
|
ax0 = axes[0]
|
|
ax0.scatter(fold_indices + 1 + jitter_offsets, aucs, c=colors, edgecolor="k", linewidth=0.4, alpha=0.85)
|
|
ax0.set_xticks([f + 1 for f in folds])
|
|
ax0.set_xlabel("Fold index")
|
|
ax0.set_ylabel("Macro AUC")
|
|
ax0.set_title("Per-fold AUC across runs")
|
|
ax0.grid(True, linestyle=":", linewidth=0.5, alpha=0.4)
|
|
|
|
# Panel 2: Accuracy vs AUC scatter
|
|
ax1 = axes[1]
|
|
ax1.scatter(accs, aucs, c=colors, edgecolor="k", linewidth=0.4, alpha=0.85)
|
|
ax1.set_xlabel("Accuracy")
|
|
ax1.set_ylabel("Macro AUC")
|
|
ax1.set_title("Accuracy vs AUC by fold")
|
|
ax1.grid(True, linestyle=":", linewidth=0.5, alpha=0.4)
|
|
|
|
# Shared legend
|
|
legend_handles = [
|
|
Line2D(
|
|
[0],
|
|
[0],
|
|
marker="o",
|
|
color="w",
|
|
label=f"Fold {fold + 1}",
|
|
markerfacecolor=fold_to_color[fold],
|
|
markeredgecolor="k",
|
|
markersize=8,
|
|
)
|
|
for fold in folds
|
|
]
|
|
for ax in axes:
|
|
ax.legend(handles=legend_handles, frameon=False, loc="lower right")
|
|
|
|
fig.suptitle("Fold-level performance across experiments", fontsize=14)
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
fig.savefig(output, dpi=200)
|
|
print(f"Saved plot to {output}")
|
|
|
|
if show:
|
|
plt.show()
|
|
plt.close(fig)
|
|
|
|
|
|
def print_summary(metrics: List[FoldMetric]) -> None:
|
|
total_runs = len({m.run_id for m in metrics})
|
|
print(f"Collected {len(metrics)} fold metrics from {total_runs} runs.")
|
|
by_fold: Dict[int, List[FoldMetric]] = {}
|
|
for metric in metrics:
|
|
by_fold.setdefault(metric.fold, []).append(metric)
|
|
for fold, entries in sorted(by_fold.items()):
|
|
aucs = np.array([m.auc for m in entries])
|
|
accs = np.array([m.accuracy for m in entries])
|
|
print(
|
|
f" Fold {fold + 1}: AUC {aucs.mean():.3f} ± {aucs.std(ddof=0):.3f} | "
|
|
f"Accuracy {accs.mean():.3f} ± {accs.std(ddof=0):.3f} (n={len(entries)})"
|
|
)
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Plot per-fold AUCs and accuracies across runs.")
|
|
parser.add_argument(
|
|
"--analysis-root",
|
|
default="analysis_data",
|
|
help="Root directory that contains run folders with summary.json files (default: analysis_data)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="analysis_data/fold_auc_vs_accuracy.png",
|
|
help="Where to save the generated figure (default: analysis_data/fold_auc_vs_accuracy.png)",
|
|
)
|
|
parser.add_argument("--jitter", type=float, default=0.08, help="Horizontal jitter for fold scatter plot")
|
|
parser.add_argument("--seed", type=int, default=17, help="Random seed for jitter replication")
|
|
parser.add_argument("--show", action="store_true", help="Display the plot interactively after saving")
|
|
args = parser.parse_args(argv)
|
|
|
|
analysis_root = Path(args.analysis_root)
|
|
if not analysis_root.exists():
|
|
raise SystemExit(f"Analysis root {analysis_root} does not exist")
|
|
|
|
summary_files = sorted(analysis_root.rglob("summary.json"))
|
|
if not summary_files:
|
|
raise SystemExit(f"No summary.json files found under {analysis_root}")
|
|
|
|
metrics: List[FoldMetric] = []
|
|
for summary_path in summary_files:
|
|
metrics.extend(collect_metrics(summary_path))
|
|
|
|
if not metrics:
|
|
raise SystemExit("No fold metrics collected. Check that prediction files are present.")
|
|
|
|
print_summary(metrics)
|
|
build_plot(metrics, Path(args.output), jitter=args.jitter, seed=args.seed, show=args.show)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|