1268 lines
51 KiB
Python
1268 lines
51 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Post-hoc explainability for a full saved run (all folds).
|
||
|
||
Phase 1 — MD permutation feature importance (bar chart + CSV, per fold).
|
||
Phase 2 — GradCAM overlays on val/holdout patients (per fold).
|
||
Phase 3 — Fusion event analysis loaded entirely from saved prediction files
|
||
(no re-inference needed). Runs on both train and val splits.
|
||
|
||
Usage:
|
||
python scripts/output_analysis/explainability/explain_run.py \
|
||
--run-dir analysis_data/v2.3_single_binary_nocrop/binary/single \
|
||
[--checkpoint best_single.pt | best_holdout_single.pt] \
|
||
[--split holdout]
|
||
[--image-dir Papila/FundusImages] \
|
||
[--clinical-dir Papila/ClinicalData] \
|
||
[--n-permutations 30] \
|
||
[--seed 0] \
|
||
[--alpha 0.45]
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import matplotlib
|
||
|
||
matplotlib.use("Agg")
|
||
import matplotlib.cm as cm
|
||
import matplotlib.patches as mpatches
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
import pandas as pd
|
||
import torch
|
||
import torch.nn.functional as F
|
||
from PIL import Image
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
if str(REPO_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(REPO_ROOT))
|
||
|
||
from classes.v2.data_bundle import DataBundle
|
||
from classes.v2.papila_builders import build_papila_data
|
||
from classes.v2.profiles.papila import build_papila_profile
|
||
from classes.v2.split_manager import PatientFirstSplitManager
|
||
from classes.v2.loader_factory import filter_bilateral_samples, make_loader
|
||
from classes.v2.metrics import _score_arrays
|
||
from classes.v2.models import SingleEyeHT
|
||
from classes.v2.transforms import build_eval_transform
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Label display helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BINARY_LABELS = {0: "Normal", 1: "Glaucoma"}
|
||
MULTICLASS_LABELS = {0: "Normal", 1: "Glaucoma", 2: "Suspect"}
|
||
|
||
|
||
def label_name(label: int, eval_mode: str) -> str:
|
||
mapping = BINARY_LABELS if eval_mode == "binary" else MULTICLASS_LABELS
|
||
return mapping.get(int(label), str(label))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GradCAM
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class GradCAM:
|
||
"""Minimal GradCAM using forward/backward hooks. No extra dependencies."""
|
||
|
||
def __init__(self, target_layer: torch.nn.Module) -> None:
|
||
self._acts: torch.Tensor | None = None
|
||
self._grads: torch.Tensor | None = None
|
||
self._h1 = target_layer.register_forward_hook(self._save_acts)
|
||
self._h2 = target_layer.register_full_backward_hook(self._save_grads)
|
||
|
||
def _save_acts(self, _m, _i, output):
|
||
self._acts = output.detach()
|
||
|
||
def _save_grads(self, _m, _gi, grad_output):
|
||
self._grads = grad_output[0].detach()
|
||
|
||
def compute(
|
||
self,
|
||
img: torch.Tensor,
|
||
meta: torch.Tensor,
|
||
model: torch.nn.Module,
|
||
target_class: int | None = None,
|
||
) -> tuple[np.ndarray, int]:
|
||
model.eval()
|
||
with torch.enable_grad():
|
||
out = model(img, meta)
|
||
pred = int(out.argmax(1).item())
|
||
tc = pred if target_class is None else target_class
|
||
model.zero_grad()
|
||
out[0, tc].backward()
|
||
|
||
if self._acts is None or self._grads is None:
|
||
raise RuntimeError("GradCAM hooks did not fire — check target_layer.")
|
||
|
||
weights = self._grads.mean(dim=(2, 3), keepdim=True)
|
||
cam = F.relu((weights * self._acts).sum(dim=1, keepdim=True))
|
||
cam = F.interpolate(cam, img.shape[-2:], mode="bilinear", align_corners=False)
|
||
cam_np = cam.squeeze().cpu().numpy()
|
||
lo, hi = cam_np.min(), cam_np.max()
|
||
cam_np = (cam_np - lo) / (hi - lo + 1e-8)
|
||
return cam_np, pred
|
||
|
||
def remove(self) -> None:
|
||
self._h1.remove()
|
||
self._h2.remove()
|
||
|
||
|
||
def get_gradcam_layer(model: SingleEyeHT, backbone: str) -> torch.nn.Module:
|
||
bb = model.img_tower.backbone
|
||
key = backbone.lower()
|
||
if key in ("refugelike",) or "resnet" in key:
|
||
return bb.layer4[-1]
|
||
if "efficientnet" in key or "refuge_efficient" in key:
|
||
return bb.features[-1]
|
||
if "densenet" in key or key == "refuge_densenet":
|
||
return bb.features.denseblock4
|
||
if "mobilenet" in key:
|
||
return bb.features[-1]
|
||
if "vgg" in key:
|
||
return bb.features[-1]
|
||
raise ValueError(f"Unknown backbone for GradCAM target layer: {backbone!r}")
|
||
|
||
|
||
def overlay_gradcam(
|
||
original_pil: "Image.Image", cam: np.ndarray, alpha: float = 0.45
|
||
) -> "Image.Image":
|
||
cam_u8 = (cam * 255).astype(np.uint8)
|
||
cam_resized = (
|
||
np.array(Image.fromarray(cam_u8).resize(original_pil.size, Image.BILINEAR))
|
||
/ 255.0
|
||
)
|
||
colored = (cm.jet(cam_resized)[:, :, :3] * 255).astype(np.uint8)
|
||
return Image.blend(original_pil.convert("RGB"), Image.fromarray(colored), alpha)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature index map
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def build_feature_index_map(data: DataBundle) -> dict[str, dict]:
|
||
n_scalar = len(data.scalar_cols)
|
||
cat_expanded = sum(len(m) for m in data.cat_maps.values())
|
||
|
||
feature_map: dict[str, dict] = {}
|
||
for i, col in enumerate(data.scalar_cols):
|
||
missing_dim = n_scalar + cat_expanded + i
|
||
feature_map[col] = {"value_dims": [i], "missing_dims": [missing_dim]}
|
||
|
||
cat_offset = n_scalar
|
||
for col in data.cat_cols:
|
||
n_cats = len(data.cat_maps[col])
|
||
dims = list(range(cat_offset, cat_offset + n_cats))
|
||
feature_map[col] = {"value_dims": dims, "missing_dims": []}
|
||
cat_offset += n_cats
|
||
|
||
return feature_map
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 1 — MD permutation importance
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def run_permutation_importance(
|
||
model: SingleEyeHT,
|
||
loader,
|
||
data: DataBundle,
|
||
num_classes: int,
|
||
device: torch.device,
|
||
n_permutations: int,
|
||
seed: int,
|
||
out_dir: Path,
|
||
) -> None:
|
||
print("\n[Phase 1] MD permutation importance ...", flush=True)
|
||
|
||
img1_feats_list, img2_feats_list = [], []
|
||
md1_list, md2_list, label_list = [], [], []
|
||
model.eval()
|
||
with torch.no_grad():
|
||
for batch in loader:
|
||
img1 = batch["image_1"].to(device)
|
||
img2 = batch["image_2"].to(device)
|
||
md1 = batch["matrix_1"].to(device)
|
||
md2 = batch["matrix_2"].to(device)
|
||
labels = batch["label_1"]
|
||
img1_feats_list.append(model.img_tower(img1))
|
||
img2_feats_list.append(model.img_tower(img2))
|
||
md1_list.append(md1)
|
||
md2_list.append(md2)
|
||
if isinstance(labels, torch.Tensor):
|
||
label_list.append(labels)
|
||
else:
|
||
label_list.append(torch.tensor(labels, dtype=torch.long))
|
||
|
||
img1_feats = torch.cat(img1_feats_list)
|
||
img2_feats = torch.cat(img2_feats_list)
|
||
md1_tensor = torch.cat(md1_list)
|
||
md2_tensor = torch.cat(md2_list)
|
||
y_true = torch.cat(label_list).numpy()
|
||
N = len(y_true)
|
||
|
||
if N == 0:
|
||
print(" [Phase 1] No samples — skipping.", flush=True)
|
||
return
|
||
|
||
with torch.no_grad():
|
||
md1_feats = model.md_tower(md1_tensor)
|
||
md2_feats = model.md_tower(md2_tensor)
|
||
fused1, _, _ = model.bridge(img1_feats, md1_feats)
|
||
fused2, _, _ = model.bridge(img2_feats, md2_feats)
|
||
probs_baseline = (
|
||
0.5 * (torch.softmax(fused1, dim=1) + torch.softmax(fused2, dim=1))
|
||
).cpu().numpy()
|
||
_, baseline_auc, _ = _score_arrays(y_true, probs_baseline, num_classes)
|
||
print(f" Baseline AUC: {baseline_auc:.4f} (N={N})", flush=True)
|
||
|
||
feat_map = build_feature_index_map(data)
|
||
rng = np.random.default_rng(seed)
|
||
|
||
results = []
|
||
for feat_name, dims in feat_map.items():
|
||
all_dims = dims["value_dims"] + dims["missing_dims"]
|
||
drops = []
|
||
for _ in range(n_permutations):
|
||
perm1 = md1_tensor.clone()
|
||
perm2 = md2_tensor.clone()
|
||
perm_idx = torch.from_numpy(rng.permutation(N)).to(device)
|
||
perm1[:, all_dims] = perm1[perm_idx][:, all_dims]
|
||
perm2[:, all_dims] = perm2[perm_idx][:, all_dims]
|
||
with torch.no_grad():
|
||
md1_p = model.md_tower(perm1)
|
||
md2_p = model.md_tower(perm2)
|
||
fused1_p, _, _ = model.bridge(img1_feats, md1_p)
|
||
fused2_p, _, _ = model.bridge(img2_feats, md2_p)
|
||
probs_p = (
|
||
0.5 * (torch.softmax(fused1_p, dim=1) + torch.softmax(fused2_p, dim=1))
|
||
).cpu().numpy()
|
||
_, auc_p, _ = _score_arrays(y_true, probs_p, num_classes)
|
||
drops.append(baseline_auc - auc_p)
|
||
|
||
mean_drop = float(np.mean(drops))
|
||
std_drop = float(np.std(drops))
|
||
results.append({"feature": feat_name, "importance": mean_drop, "std": std_drop})
|
||
print(f" {feat_name:30s} Δ AUC = {mean_drop:+.4f} ± {std_drop:.4f}", flush=True)
|
||
|
||
results.sort(key=lambda r: r["importance"], reverse=True)
|
||
|
||
# total MD ablation
|
||
print(" Running total MD ablation ...", flush=True)
|
||
total_drops = []
|
||
for _ in range(n_permutations):
|
||
perm_idx = torch.from_numpy(rng.permutation(N)).to(device)
|
||
perm1_all = md1_tensor[perm_idx]
|
||
perm2_all = md2_tensor[perm_idx]
|
||
with torch.no_grad():
|
||
md1_all = model.md_tower(perm1_all)
|
||
md2_all = model.md_tower(perm2_all)
|
||
f1, _, _ = model.bridge(img1_feats, md1_all)
|
||
f2, _, _ = model.bridge(img2_feats, md2_all)
|
||
probs_all = (
|
||
0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1))
|
||
).cpu().numpy()
|
||
_, auc_all, _ = _score_arrays(y_true, probs_all, num_classes)
|
||
total_drops.append(baseline_auc - auc_all)
|
||
total_mean = float(np.mean(total_drops))
|
||
total_std = float(np.std(total_drops))
|
||
print(f" Total MD ablation Δ AUC = {total_mean:+.4f} ± {total_std:.4f}", flush=True)
|
||
|
||
# Gaussian noise ablation
|
||
print(" Running Gaussian noise ablation ...", flush=True)
|
||
noise_drops = []
|
||
for _ in range(n_permutations):
|
||
noise1 = torch.randn_like(md1_tensor)
|
||
noise2 = torch.randn_like(md2_tensor)
|
||
with torch.no_grad():
|
||
md1_noise = model.md_tower(noise1)
|
||
md2_noise = model.md_tower(noise2)
|
||
f1, _, _ = model.bridge(img1_feats, md1_noise)
|
||
f2, _, _ = model.bridge(img2_feats, md2_noise)
|
||
probs_noise = (
|
||
0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1))
|
||
).cpu().numpy()
|
||
_, auc_noise, _ = _score_arrays(y_true, probs_noise, num_classes)
|
||
noise_drops.append(baseline_auc - auc_noise)
|
||
noise_mean = float(np.mean(noise_drops))
|
||
noise_std = float(np.std(noise_drops))
|
||
print(f" Gaussian noise ablation Δ AUC = {noise_mean:+.4f} ± {noise_std:.4f}", flush=True)
|
||
print(
|
||
f" [interpretation] permutation Δ={total_mean:+.4f} noise Δ={noise_mean:+.4f} "
|
||
f"informational gain = {total_mean - noise_mean:+.4f}",
|
||
flush=True,
|
||
)
|
||
|
||
csv_path = out_dir / "md_permutation_importance.csv"
|
||
with csv_path.open("w", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=["feature", "importance", "std"])
|
||
writer.writeheader()
|
||
writer.writerows(results)
|
||
writer.writerow({"feature": "TOTAL_MD_ABLATION", "importance": total_mean, "std": total_std})
|
||
writer.writerow({"feature": "GAUSSIAN_NOISE_ABLATION", "importance": noise_mean, "std": noise_std})
|
||
|
||
names = [r["feature"] for r in results]
|
||
imps = [r["importance"] for r in results]
|
||
stds = [r["std"] for r in results]
|
||
colors = ["#e05c5c" if v >= 0 else "#5c9ee0" for v in imps]
|
||
|
||
fig, ax = plt.subplots(figsize=(9, max(4, (len(names) + 3) * 0.45)))
|
||
y_pos = np.arange(len(names))
|
||
ax.barh(y_pos, imps, xerr=stds, color=colors, ecolor="grey", capsize=3, height=0.6)
|
||
ax.axhline(len(names) - 0.25, color="grey", linewidth=0.6, linestyle="--")
|
||
ax.barh(len(names) + 0.5, total_mean, xerr=total_std,
|
||
color="#c45ce0" if total_mean >= 0 else "#5c9ee0",
|
||
ecolor="grey", capsize=3, height=0.6)
|
||
ax.barh(len(names) + 1.5, noise_mean, xerr=noise_std,
|
||
color="#e08c2a" if noise_mean >= 0 else "#5c9ee0",
|
||
ecolor="grey", capsize=3, height=0.6)
|
||
ax.set_yticks(list(y_pos) + [len(names) + 0.5, len(names) + 1.5])
|
||
ax.set_yticklabels(names + ["ALL MD (permute)", "ALL MD (noise)"], fontsize=9)
|
||
ax.invert_yaxis()
|
||
ax.axvline(0, color="black", linewidth=0.8)
|
||
ax.set_xlabel("Mean AUC drop (baseline − permuted)", fontsize=10)
|
||
ax.set_title(
|
||
f"MD Tower — Permutation Feature Importance\n"
|
||
f"baseline AUC={baseline_auc:.4f} N={N} repeats={n_permutations}",
|
||
fontsize=11,
|
||
)
|
||
fig.tight_layout()
|
||
fig.savefig(out_dir / "md_permutation_importance.png", dpi=150)
|
||
plt.close(fig)
|
||
print(f" Saved → {out_dir / 'md_permutation_importance.png'}", flush=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 2 — GradCAM overlays
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def run_gradcam(
|
||
model: SingleEyeHT,
|
||
loader,
|
||
data: DataBundle,
|
||
eval_df,
|
||
eval_mode: str,
|
||
backbone: str,
|
||
device: torch.device,
|
||
alpha: float,
|
||
out_dir: Path,
|
||
) -> None:
|
||
print("\n[Phase 2] GradCAM overlays ...", flush=True)
|
||
gradcam_dir = out_dir / "gradcam"
|
||
gradcam_dir.mkdir(exist_ok=True)
|
||
|
||
manifest_path = gradcam_dir / "gradcam_manifest.csv"
|
||
skip_overlays = manifest_path.exists()
|
||
overlay_grid_items = []
|
||
|
||
if skip_overlays:
|
||
print(" Overlays already exist — skipping computation, loading from disk.", flush=True)
|
||
manifest_df = pd.read_csv(manifest_path)
|
||
for _, row in manifest_df.iterrows():
|
||
pid = str(row["pid"])
|
||
od_path = gradcam_dir / f"gradcam_od_{pid}.png"
|
||
os_path = gradcam_dir / f"gradcam_os_{pid}.png"
|
||
od_ov = Image.open(od_path).convert("RGB") if od_path.exists() else None
|
||
os_ov = Image.open(os_path).convert("RGB") if os_path.exists() else None
|
||
overlay_grid_items.append((od_ov, os_ov, str(row["short_lbl"]), bool(row["correct"])))
|
||
else:
|
||
target_layer = get_gradcam_layer(model, backbone)
|
||
gcam = GradCAM(target_layer)
|
||
manifest_rows = []
|
||
|
||
model.eval()
|
||
for batch in loader:
|
||
img_od = batch["image_1"].to(device)
|
||
img_os = batch["image_2"].to(device)
|
||
meta_od = batch["matrix_1"].to(device)
|
||
meta_os = batch["matrix_2"].to(device)
|
||
lbl_raw = batch["label_1"][0]
|
||
label = int(lbl_raw.item() if isinstance(lbl_raw, torch.Tensor) else lbl_raw)
|
||
pid = batch["id_1"][0]
|
||
|
||
cam_od, pred = gcam.compute(img_od, meta_od, model)
|
||
cam_os, _ = gcam.compute(img_os, meta_os, model)
|
||
|
||
with torch.no_grad():
|
||
out_od = model(img_od, meta_od)
|
||
conf = float(torch.softmax(out_od, dim=1)[0, pred].item())
|
||
|
||
row_od = eval_df[
|
||
(eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OD")
|
||
]
|
||
row_os = eval_df[
|
||
(eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OS")
|
||
]
|
||
orig_od = (
|
||
Image.open(data.get_image_path(row_od.iloc[0])).convert("RGB")
|
||
if len(row_od) else None
|
||
)
|
||
orig_os = (
|
||
Image.open(data.get_image_path(row_os.iloc[0])).convert("RGB")
|
||
if len(row_os) else None
|
||
)
|
||
|
||
true_name = label_name(label, eval_mode)
|
||
pred_name = label_name(pred, eval_mode)
|
||
correct = label == pred
|
||
title = (
|
||
f"Patient {pid} | True: {true_name} | Pred: {pred_name} "
|
||
f"| conf={conf:.2f} {'✓' if correct else '✗'}"
|
||
)
|
||
|
||
fig, axes = plt.subplots(2, 2, figsize=(10, 9))
|
||
fig.suptitle(title, fontsize=11, fontweight="bold", color="green" if correct else "red")
|
||
|
||
if orig_od is not None:
|
||
axes[0, 0].imshow(orig_od)
|
||
axes[0, 0].set_title("OD — original", fontsize=9)
|
||
axes[0, 1].imshow(overlay_gradcam(orig_od, cam_od, alpha))
|
||
axes[0, 1].set_title("OD — GradCAM", fontsize=9)
|
||
else:
|
||
axes[0, 0].set_title("OD — (missing)", fontsize=9)
|
||
axes[0, 0].axis("off")
|
||
axes[0, 1].axis("off")
|
||
|
||
if orig_os is not None:
|
||
axes[1, 0].imshow(orig_os)
|
||
axes[1, 0].set_title("OS — original", fontsize=9)
|
||
axes[1, 1].imshow(overlay_gradcam(orig_os, cam_os, alpha))
|
||
axes[1, 1].set_title("OS — GradCAM", fontsize=9)
|
||
else:
|
||
axes[1, 0].set_title("OS — (missing)", fontsize=9)
|
||
axes[1, 0].axis("off")
|
||
axes[1, 1].axis("off")
|
||
|
||
fig.tight_layout()
|
||
out_path = gradcam_dir / f"patient_{pid}_OD_OS.png"
|
||
fig.savefig(out_path, dpi=120)
|
||
plt.close(fig)
|
||
print(f" Patient {pid}: {true_name} → {pred_name} ({conf:.2f}) → {out_path.name}", flush=True)
|
||
|
||
od_overlay = overlay_gradcam(orig_od, cam_od, alpha) if orig_od else None
|
||
os_overlay = overlay_gradcam(orig_os, cam_os, alpha) if orig_os else None
|
||
if od_overlay is not None:
|
||
od_overlay.save(gradcam_dir / f"gradcam_od_{pid}.png")
|
||
if os_overlay is not None:
|
||
os_overlay.save(gradcam_dir / f"gradcam_os_{pid}.png")
|
||
short_lbl = f"P{pid} {true_name[:3]}→{pred_name[:3]} {'✓' if correct else '✗'}"
|
||
overlay_grid_items.append((od_overlay, os_overlay, short_lbl, correct))
|
||
manifest_rows.append({"pid": pid, "short_lbl": short_lbl, "correct": correct})
|
||
|
||
gcam.remove()
|
||
pd.DataFrame(manifest_rows).to_csv(manifest_path, index=False)
|
||
|
||
# Always regenerate the summary grid
|
||
n = len(overlay_grid_items)
|
||
if n == 0:
|
||
print(" [Phase 2] No patients to visualise.", flush=True)
|
||
return
|
||
|
||
fig, axes = plt.subplots(n, 2, figsize=(8, n * 3.2 + 1.5))
|
||
if n == 1:
|
||
axes = axes[np.newaxis, :]
|
||
fig.suptitle("GradCAM Summary Grid — all holdout patients", fontsize=12)
|
||
|
||
for i, (od_ov, os_ov, lbl, correct) in enumerate(overlay_grid_items):
|
||
color = "green" if correct else "red"
|
||
for j in range(2):
|
||
axes[i, j].axis("off")
|
||
if od_ov is not None:
|
||
axes[i, 0].imshow(od_ov)
|
||
axes[i, 0].set_title(f"{lbl}\nOD", fontsize=7, color=color)
|
||
if os_ov is not None:
|
||
axes[i, 1].imshow(os_ov)
|
||
axes[i, 1].set_title(f"{lbl}\nOS", fontsize=7, color=color)
|
||
|
||
fig.tight_layout(rect=[0, 0, 1, 0.97])
|
||
grid_path = out_dir / "gradcam_summary_grid.png"
|
||
fig.savefig(grid_path, dpi=120)
|
||
plt.close(fig)
|
||
print(f" Summary grid → {grid_path}", flush=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 3 — Fusion event analysis (disk-based, no re-inference)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_EVENT_LABELS = [
|
||
"full correction\n(both wrong→fused right)",
|
||
"img assist\n(img wrong, md right→right)",
|
||
"md assist\n(md wrong, img right→right)",
|
||
"full error\n(both right→fused wrong)",
|
||
"img drag\n(img wrong, md right→wrong)",
|
||
"md drag\n(md wrong, img right→wrong)",
|
||
]
|
||
_EVENT_KEYS = ["full_correction", "img_assist", "md_assist",
|
||
"full_error", "img_drag", "md_drag"]
|
||
_EVENT_COLORS = ["#2ca02c", "#98df8a", "#b5d46e", "#d62728", "#ff9896", "#ffbf9b"]
|
||
|
||
|
||
def _fusion_event_stats(
|
||
y_true: np.ndarray,
|
||
pf: np.ndarray,
|
||
pi: np.ndarray,
|
||
pm: np.ndarray,
|
||
split_name: str,
|
||
out_dir: Path,
|
||
component_labels: tuple[str, str] = ("img", "md"),
|
||
) -> dict:
|
||
"""Compute, save, and plot fusion events for one split. Returns summary dict."""
|
||
N = len(y_true)
|
||
if N == 0:
|
||
print(f" [{split_name}] No samples — skipping.", flush=True)
|
||
return {}
|
||
|
||
a, b = component_labels
|
||
event_labels = [
|
||
"full correction\n(both wrong→fused right)",
|
||
f"{a} assist\n({a} wrong, {b} right→right)",
|
||
f"{b} assist\n({b} wrong, {a} right→right)",
|
||
"full error\n(both right→fused wrong)",
|
||
f"{a} drag\n({a} wrong, {b} right→wrong)",
|
||
f"{b} drag\n({b} wrong, {a} right→wrong)",
|
||
]
|
||
|
||
pred_f = pf.argmax(axis=1)
|
||
pred_i = pi.argmax(axis=1)
|
||
pred_m = pm.argmax(axis=1)
|
||
|
||
conf_f = np.take_along_axis(pf, pred_f[:, None], axis=1).squeeze(1)
|
||
conf_i = np.take_along_axis(pi, pred_i[:, None], axis=1).squeeze(1)
|
||
conf_m = np.take_along_axis(pm, pred_m[:, None], axis=1).squeeze(1)
|
||
conf_delta = conf_f - 0.5 * (conf_i + conf_m)
|
||
|
||
f_ok = pred_f == y_true
|
||
i_ok = pred_i == y_true
|
||
m_ok = pred_m == y_true
|
||
|
||
full_correction = f_ok & ~i_ok & ~m_ok
|
||
img_assist = f_ok & ~i_ok & m_ok
|
||
md_assist = f_ok & i_ok & ~m_ok
|
||
full_error = ~f_ok & i_ok & m_ok
|
||
img_drag = ~f_ok & ~i_ok & m_ok
|
||
md_drag = ~f_ok & i_ok & ~m_ok
|
||
concordant_ok = f_ok & i_ok & m_ok
|
||
concordant_bad = ~f_ok & ~i_ok & ~m_ok
|
||
|
||
event_masks = [full_correction, img_assist, md_assist, full_error, img_drag, md_drag]
|
||
counts = [int(m.sum()) for m in event_masks]
|
||
|
||
print(f"\n [{split_name}] N={N}", flush=True)
|
||
for label, count in zip(event_labels, counts):
|
||
print(f" {label.replace(chr(10), ' '):55s}: {count}", flush=True)
|
||
n_corr, n_err = counts[0], counts[3]
|
||
print(f" full correction/error ratio: {n_corr}/{n_err}", flush=True)
|
||
print(f" conf_delta mean={conf_delta.mean():+.4f} median={np.median(conf_delta):+.4f}",
|
||
flush=True)
|
||
|
||
# CSV
|
||
event_type = np.where(concordant_ok, "concordant_correct",
|
||
np.where(concordant_bad, "concordant_wrong", "other")).astype(object)
|
||
for mask, key in zip(event_masks, _EVENT_KEYS):
|
||
event_type[mask] = key
|
||
|
||
rows = [
|
||
{
|
||
"patient_idx": idx,
|
||
"y_true": int(y_true[idx]),
|
||
"pred_fused": int(pred_f[idx]),
|
||
"pred_img": int(pred_i[idx]),
|
||
"pred_md": int(pred_m[idx]),
|
||
"conf_fused": float(conf_f[idx]),
|
||
"conf_img": float(conf_i[idx]),
|
||
"conf_md": float(conf_m[idx]),
|
||
"conf_delta": float(conf_delta[idx]),
|
||
"event_type": event_type[idx],
|
||
}
|
||
for idx in range(N)
|
||
]
|
||
csv_path = out_dir / f"fusion_events_{split_name}.csv"
|
||
with csv_path.open("w", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
|
||
# Plot
|
||
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
||
fig.suptitle(f"Fusion Events — {split_name} (N={N})", fontsize=11)
|
||
|
||
for bar_x, bar_counts, bar_colors in (
|
||
(0, counts[:3], _EVENT_COLORS[:3]),
|
||
(1, counts[3:], _EVENT_COLORS[3:]),
|
||
):
|
||
bot = 0
|
||
for c, col in zip(bar_counts, bar_colors):
|
||
axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5)
|
||
if c > 0:
|
||
axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center",
|
||
fontsize=9, fontweight="bold")
|
||
bot += c
|
||
axes[0].set_xticks([0, 1])
|
||
axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"])
|
||
axes[0].set_ylabel("Count")
|
||
patches = [mpatches.Patch(color=c, label=l.replace("\n", " "))
|
||
for c, l in zip(_EVENT_COLORS, event_labels)]
|
||
axes[0].legend(handles=patches, fontsize=6, loc="upper right")
|
||
|
||
box_data = [conf_delta[m] for m in event_masks if m.sum() > 0]
|
||
box_labels = [l.split("\n")[0] for m, l in zip(event_masks, event_labels) if m.sum() > 0]
|
||
box_cols = [c for m, c in zip(event_masks, _EVENT_COLORS) if m.sum() > 0]
|
||
if box_data:
|
||
bp = axes[1].boxplot(box_data, patch_artist=True, widths=0.5)
|
||
for patch, color in zip(bp["boxes"], box_cols):
|
||
patch.set_facecolor(color)
|
||
axes[1].set_xticks(range(1, len(box_labels) + 1))
|
||
axes[1].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7)
|
||
axes[1].axhline(0, color="black", linewidth=0.8, linestyle="--")
|
||
axes[1].set_ylabel(f"conf_delta\n(fused − avg({a}, {b}))")
|
||
axes[1].set_title("Confidence delta by event type")
|
||
|
||
for mask, color, label in zip(event_masks, _EVENT_COLORS, event_labels):
|
||
if mask.sum() > 0:
|
||
axes[2].scatter(conf_i[mask], conf_m[mask], c=color,
|
||
label=label.split("\n")[0], alpha=0.85, s=45, edgecolors="none")
|
||
if concordant_ok.sum() > 0:
|
||
axes[2].scatter(conf_i[concordant_ok], conf_m[concordant_ok],
|
||
c="lightgrey", alpha=0.4, s=20, edgecolors="none", label="concordant correct")
|
||
if concordant_bad.sum() > 0:
|
||
axes[2].scatter(conf_i[concordant_bad], conf_m[concordant_bad],
|
||
c="darkgrey", alpha=0.4, s=20, edgecolors="none", label="concordant wrong")
|
||
axes[2].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4)
|
||
axes[2].set_xlabel(f"conf_{a}")
|
||
axes[2].set_ylabel(f"conf_{b}")
|
||
axes[2].set_title("Tower confidence space\ncoloured by fusion event")
|
||
axes[2].legend(fontsize=6, loc="lower right")
|
||
|
||
fig.tight_layout()
|
||
fig.savefig(out_dir / f"fusion_events_{split_name}.png", dpi=150)
|
||
plt.close(fig)
|
||
print(f" Saved → {out_dir / f'fusion_events_{split_name}.png'}", flush=True)
|
||
|
||
return {
|
||
"split": split_name,
|
||
"N": N,
|
||
**{key: cnt for key, cnt in zip(_EVENT_KEYS, counts)},
|
||
"conf_delta_mean": float(conf_delta.mean()),
|
||
"conf_delta_median": float(np.median(conf_delta)),
|
||
}
|
||
|
||
|
||
def _load_best_epoch_idx(fold_dir: Path, n_epochs: int) -> int:
|
||
"""Return 0-based array index of the best single checkpoint.
|
||
|
||
Val/train prediction arrays skip md_warmup epochs but include all other
|
||
phases (tower_warmup, fused_warmup, main). The array index is therefore
|
||
the row position within epoch_log *after* filtering out md_warmup rows.
|
||
"""
|
||
log_path = fold_dir / "epoch_log.csv"
|
||
if not log_path.exists():
|
||
return n_epochs - 1
|
||
try:
|
||
log = pd.read_csv(log_path)
|
||
if "is_best_single" not in log.columns or "phase_single" not in log.columns:
|
||
return n_epochs - 1
|
||
# Keep only phases that produce predictions (everything except md_warmup)
|
||
pred_log = log[log["phase_single"] != "md_warmup"].reset_index(drop=True)
|
||
best_rows = pred_log[pred_log["is_best_single"] == True]
|
||
if not best_rows.empty:
|
||
return int(best_rows.index[-1])
|
||
except Exception:
|
||
pass
|
||
return n_epochs - 1
|
||
|
||
|
||
def _aggregate_by_patient(
|
||
patient_ids: np.ndarray,
|
||
y_true: np.ndarray,
|
||
*prob_arrays: np.ndarray,
|
||
) -> tuple:
|
||
"""Average per-eye probs to patient level. Returns (y, *averaged_probs)."""
|
||
unique_pids = np.unique(patient_ids)
|
||
y_bilat = np.array([y_true[patient_ids == pid][0] for pid in unique_pids])
|
||
averaged = tuple(
|
||
np.array([arr[patient_ids == pid].mean(axis=0) for pid in unique_pids])
|
||
for arr in prob_arrays
|
||
)
|
||
return (y_bilat,) + averaged
|
||
|
||
|
||
def run_fusion_event_analysis(fold_dir: Path, out_dir: Path) -> list[dict]:
|
||
"""
|
||
Load pre-saved predictions from disk and run fusion event analysis
|
||
for both the train and val splits at the best checkpoint epoch.
|
||
Returns list of summary dicts (one per split).
|
||
"""
|
||
print("\n[Phase 3] Fusion event analysis (from saved predictions) ...", flush=True)
|
||
summaries = []
|
||
|
||
# ---- val: separate OD/OS per-epoch files, already patient-level ----
|
||
od_f = fold_dir / "val_probs_fused_od_epochs.npy"
|
||
os_f = fold_dir / "val_probs_fused_os_epochs.npy"
|
||
od_i = fold_dir / "val_probs_img_od_epochs.npy"
|
||
os_i = fold_dir / "val_probs_img_os_epochs.npy"
|
||
od_m = fold_dir / "val_probs_md_od_epochs.npy"
|
||
os_m = fold_dir / "val_probs_md_os_epochs.npy"
|
||
y_f = fold_dir / "val_y_true_epochs.npy"
|
||
|
||
tower_mode = fold_dir.parent.name # "single", "ensemble", "bilateral", …
|
||
is_ensemble_like = tower_mode in ("ensemble", "bilateral")
|
||
|
||
if all(p.exists() for p in [od_f, os_f, od_i, os_i, od_m, os_m, y_f]):
|
||
n_epochs = np.load(od_f).shape[0]
|
||
epoch_idx = _load_best_epoch_idx(fold_dir, n_epochs)
|
||
print(f" Val best epoch index: {epoch_idx}", flush=True)
|
||
y_val = np.load(y_f)[epoch_idx]
|
||
pf_od = np.load(od_f)[epoch_idx]
|
||
pf_os = np.load(os_f)[epoch_idx]
|
||
pi_od = np.load(od_i)[epoch_idx]
|
||
pi_os = np.load(os_i)[epoch_idx]
|
||
pm_od = np.load(od_m)[epoch_idx]
|
||
pm_os = np.load(os_m)[epoch_idx]
|
||
|
||
# Per-eye bridge (ensemble/bilateral only — redundant in single mode)
|
||
if is_ensemble_like:
|
||
summaries.append(_fusion_event_stats(y_val, pf_od, pi_od, pm_od, "val_OD", out_dir))
|
||
summaries.append(_fusion_event_stats(y_val, pf_os, pi_os, pm_os, "val_OS", out_dir))
|
||
|
||
# Ensemble: OD+OS averaged (bilateral patient-level)
|
||
pf_ens = 0.5 * (pf_od + pf_os)
|
||
pi_ens = 0.5 * (pi_od + pi_os)
|
||
pm_ens = 0.5 * (pm_od + pm_os)
|
||
summaries.append(_fusion_event_stats(y_val, pf_ens, pi_ens, pm_ens, "val", out_dir))
|
||
|
||
# Fused head (if available): learned bilateral combination vs per-eye bridge outputs
|
||
fused_head_f = fold_dir / "probs_fused_head.npy"
|
||
if fused_head_f.exists():
|
||
pf_head = np.load(fused_head_f)
|
||
summaries.append(_fusion_event_stats(
|
||
y_val, pf_head, pf_od, pf_os, "val_fused_head", out_dir,
|
||
component_labels=("OD", "OS")))
|
||
else:
|
||
print(" Val epoch files not found — skipping val.", flush=True)
|
||
|
||
# ---- train: eye-level per-epoch files, aggregate to patient level ----
|
||
tr_pf_f = fold_dir / "train_probs_fused.npy"
|
||
tr_pi_f = fold_dir / "train_probs_img.npy"
|
||
tr_pm_f = fold_dir / "train_probs_md.npy"
|
||
tr_y_f = fold_dir / "train_y_true.npy"
|
||
tr_id_f = fold_dir / "train_patient_ids.npy"
|
||
|
||
if all(p.exists() for p in [tr_pf_f, tr_pi_f, tr_pm_f, tr_y_f, tr_id_f]):
|
||
n_epochs = np.load(tr_pf_f).shape[0]
|
||
epoch_idx = _load_best_epoch_idx(fold_dir, n_epochs)
|
||
print(f" Train best epoch index: {epoch_idx}", flush=True)
|
||
pf_eyes = np.load(tr_pf_f)[epoch_idx]
|
||
pi_eyes = np.load(tr_pi_f)[epoch_idx]
|
||
pm_eyes = np.load(tr_pm_f)[epoch_idx]
|
||
y_eyes = np.load(tr_y_f)
|
||
pids = np.load(tr_id_f, allow_pickle=True).astype(str)
|
||
y_tr, pf_tr, pi_tr, pm_tr = _aggregate_by_patient(pids, y_eyes,
|
||
pf_eyes, pi_eyes, pm_eyes)
|
||
summaries.append(_fusion_event_stats(y_tr, pf_tr, pi_tr, pm_tr, "train", out_dir))
|
||
else:
|
||
print(" Train epoch files not found — skipping train.", flush=True)
|
||
|
||
return summaries
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cross-fold MD importance summary plot
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _plot_run_md_importance_summary(run_dir: Path, folds: list) -> None:
|
||
"""Aggregate per-fold MD permutation importance CSVs into a run-level summary plot."""
|
||
all_dfs = []
|
||
for fold_idx, fold_dir, _ in folds:
|
||
csv_path = fold_dir / "explainability" / "md_permutation_importance.csv"
|
||
if csv_path.exists():
|
||
df = pd.read_csv(csv_path)
|
||
df["fold"] = fold_idx
|
||
all_dfs.append(df)
|
||
|
||
if not all_dfs:
|
||
print(" [MD summary] No per-fold importance CSVs found — skipping.", flush=True)
|
||
return
|
||
|
||
combined = pd.concat(all_dfs, ignore_index=True)
|
||
_SPECIAL = {"TOTAL_MD_ABLATION", "GAUSSIAN_NOISE_ABLATION"}
|
||
feature_rows = combined[~combined["feature"].isin(_SPECIAL)]
|
||
special_rows = combined[combined["feature"].isin(_SPECIAL)]
|
||
|
||
agg = (
|
||
feature_rows.groupby("feature")["importance"]
|
||
.agg(["mean", "std"])
|
||
.reset_index()
|
||
.rename(columns={"mean": "mean_importance", "std": "std_importance"})
|
||
.sort_values("mean_importance", ascending=False)
|
||
.reset_index(drop=True)
|
||
)
|
||
special_agg = (
|
||
special_rows.groupby("feature")["importance"]
|
||
.agg(["mean", "std"])
|
||
.reset_index()
|
||
)
|
||
|
||
names = agg["feature"].tolist()
|
||
imps = agg["mean_importance"].tolist()
|
||
stds = agg["std_importance"].fillna(0).tolist()
|
||
colors = ["#e05c5c" if v >= 0 else "#5c9ee0" for v in imps]
|
||
|
||
fig, ax = plt.subplots(figsize=(9, max(4, (len(names) + 3) * 0.45)))
|
||
y_pos = np.arange(len(names))
|
||
ax.barh(y_pos, imps, xerr=stds, color=colors, ecolor="grey", capsize=3, height=0.6)
|
||
ax.axhline(len(names) - 0.25, color="grey", linewidth=0.6, linestyle="--")
|
||
|
||
special_label_map = {
|
||
"TOTAL_MD_ABLATION": "ALL MD (permute)",
|
||
"GAUSSIAN_NOISE_ABLATION": "ALL MD (noise)",
|
||
}
|
||
special_colors = {
|
||
"TOTAL_MD_ABLATION": "#c45ce0",
|
||
"GAUSSIAN_NOISE_ABLATION": "#e08c2a",
|
||
}
|
||
extra_ytick_pos = []
|
||
extra_ytick_labels = []
|
||
for i, feat in enumerate(["TOTAL_MD_ABLATION", "GAUSSIAN_NOISE_ABLATION"]):
|
||
row = special_agg[special_agg["feature"] == feat]
|
||
if row.empty:
|
||
continue
|
||
offset = len(names) + 0.5 + i
|
||
val, err = float(row["mean"].iloc[0]), float(row["std"].iloc[0])
|
||
ax.barh(offset, val, xerr=err,
|
||
color=special_colors[feat] if val >= 0 else "#5c9ee0",
|
||
ecolor="grey", capsize=3, height=0.6)
|
||
extra_ytick_pos.append(offset)
|
||
extra_ytick_labels.append(special_label_map[feat])
|
||
|
||
ax.set_yticks(list(y_pos) + extra_ytick_pos)
|
||
ax.set_yticklabels(names + extra_ytick_labels, fontsize=9)
|
||
ax.invert_yaxis()
|
||
ax.axvline(0, color="black", linewidth=0.8)
|
||
ax.set_xlabel("Mean AUC drop (baseline − permuted)", fontsize=10)
|
||
ax.set_title(
|
||
f"MD Tower — Permutation Feature Importance ({len(all_dfs)}-fold summary)\n"
|
||
f"error bars = std across folds",
|
||
fontsize=11,
|
||
)
|
||
fig.tight_layout()
|
||
out_path = run_dir / "explainability_md_importance_summary.png"
|
||
fig.savefig(out_path, dpi=150)
|
||
plt.close(fig)
|
||
print(f" MD importance summary → {out_path}", flush=True)
|
||
|
||
agg.to_csv(run_dir / "explainability_md_importance_summary.csv", index=False)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cross-fold fusion summary plot
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _plot_cross_fold_fusion_summary(df_sum: pd.DataFrame, run_dir: Path) -> None:
|
||
"""Generate aggregate fusion event plots from the cross-fold summary DataFrame.
|
||
|
||
Loads per-fold fusion_events_*.csv files to get sample-level data for the
|
||
conf_delta and tower-confidence-space panels.
|
||
"""
|
||
for split_name, grp in df_sum.groupby("split"):
|
||
grp = grp.sort_values("fold").reset_index(drop=True)
|
||
n_folds = len(grp)
|
||
fold_ids = grp["fold"].values
|
||
|
||
# For the fused_head split the two comparators are OD/OS bridges, not img/md towers
|
||
comp_a, comp_b = ("OD", "OS") if "fused_head" in split_name else ("img", "md")
|
||
summary_event_labels = [
|
||
"full correction\n(both wrong→fused right)",
|
||
f"{comp_a} assist\n({comp_a} wrong, {comp_b} right→right)",
|
||
f"{comp_b} assist\n({comp_b} wrong, {comp_a} right→right)",
|
||
"full error\n(both right→fused wrong)",
|
||
f"{comp_a} drag\n({comp_a} wrong, {comp_b} right→wrong)",
|
||
f"{comp_b} drag\n({comp_b} wrong, {comp_a} right→wrong)",
|
||
]
|
||
|
||
# Load all per-fold CSVs for this split to get sample-level data
|
||
sample_dfs = []
|
||
for fold_idx in fold_ids:
|
||
csv_path = (run_dir / f"fold{fold_idx}" / "explainability"
|
||
/ f"fusion_events_{split_name}.csv")
|
||
if csv_path.exists():
|
||
sample_dfs.append(pd.read_csv(csv_path))
|
||
sample_df = pd.concat(sample_dfs, ignore_index=True) if sample_dfs else pd.DataFrame()
|
||
|
||
fig, axes = plt.subplots(1, 5, figsize=(25, 4))
|
||
fig.suptitle(f"Fusion Events — {split_name} (all {n_folds} folds combined)", fontsize=11)
|
||
|
||
# Panel 1: stacked bar of totals (positive vs negative)
|
||
for bar_x, keys, colors in (
|
||
(0, _EVENT_KEYS[:3], _EVENT_COLORS[:3]),
|
||
(1, _EVENT_KEYS[3:], _EVENT_COLORS[3:]),
|
||
):
|
||
bot = 0
|
||
for key, col in zip(keys, colors):
|
||
c = int(grp[key].sum())
|
||
axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5)
|
||
if c > 0:
|
||
axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center",
|
||
fontsize=9, fontweight="bold")
|
||
bot += c
|
||
axes[0].set_xticks([0, 1])
|
||
axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"])
|
||
axes[0].set_ylabel("Count (all folds)")
|
||
patches = [mpatches.Patch(color=c, label=l.replace("\n", " "))
|
||
for c, l in zip(_EVENT_COLORS, summary_event_labels)]
|
||
axes[0].legend(handles=patches, fontsize=6, loc="upper right")
|
||
|
||
# Panel 2: per-fold stacked bar (fold variance)
|
||
x = np.arange(n_folds)
|
||
pos_bot = np.zeros(n_folds)
|
||
neg_bot = np.zeros(n_folds)
|
||
for key, color in zip(_EVENT_KEYS[:3], _EVENT_COLORS[:3]):
|
||
vals = grp[key].values.astype(float)
|
||
axes[1].bar(x, vals, bottom=pos_bot, color=color, width=0.6)
|
||
pos_bot += vals
|
||
for key, color in zip(_EVENT_KEYS[3:], _EVENT_COLORS[3:]):
|
||
vals = grp[key].values.astype(float)
|
||
axes[1].bar(x + 0.65, vals, bottom=neg_bot, color=color, width=0.6)
|
||
neg_bot += vals
|
||
axes[1].set_xticks(x + 0.325)
|
||
axes[1].set_xticklabels([f"fold {f}" for f in fold_ids], fontsize=8)
|
||
axes[1].set_ylabel("Count")
|
||
axes[1].set_title("Per-fold breakdown\n(left=positive, right=negative)")
|
||
|
||
# Panel 3: conf_delta_mean per fold (bar + mean line)
|
||
cd = grp["conf_delta_mean"].values
|
||
bar_colors = ["#2ca02c" if v >= 0 else "#d62728" for v in cd]
|
||
axes[2].bar(x, cd, color=bar_colors, width=0.6, alpha=0.8)
|
||
axes[2].axhline(cd.mean(), color="black", linewidth=1.2, linestyle="--",
|
||
label=f"mean={cd.mean():+.4f}")
|
||
axes[2].axhline(0, color="grey", linewidth=0.7)
|
||
axes[2].set_xticks(x)
|
||
axes[2].set_xticklabels([f"fold {f}" for f in fold_ids], fontsize=8)
|
||
axes[2].set_ylabel(f"conf_delta mean\n(fused − avg({comp_a}, {comp_b}))")
|
||
axes[2].set_title("Confidence delta per fold")
|
||
axes[2].legend(fontsize=8)
|
||
|
||
# Panel 4: conf_delta boxplot by event type (all folds combined)
|
||
if not sample_df.empty and "event_type" in sample_df.columns:
|
||
key_order = [k for k in _EVENT_KEYS if k in sample_df["event_type"].values]
|
||
box_data = [sample_df.loc[sample_df["event_type"] == k, "conf_delta"].values
|
||
for k in key_order]
|
||
box_labels = [l.split("\n")[0]
|
||
for k, l in zip(_EVENT_KEYS, summary_event_labels) if k in key_order]
|
||
box_cols = [c for k, c in zip(_EVENT_KEYS, _EVENT_COLORS) if k in key_order]
|
||
if box_data:
|
||
bp = axes[3].boxplot(box_data, patch_artist=True, widths=0.5)
|
||
for patch, color in zip(bp["boxes"], box_cols):
|
||
patch.set_facecolor(color)
|
||
axes[3].set_xticks(range(1, len(box_labels) + 1))
|
||
axes[3].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7)
|
||
axes[3].axhline(0, color="black", linewidth=0.8, linestyle="--")
|
||
axes[3].set_ylabel(f"conf_delta\n(fused − avg({comp_a}, {comp_b}))")
|
||
axes[3].set_title("Confidence delta by event type\n(all folds)")
|
||
|
||
# Panel 5: tower confidence space scatter (all folds combined)
|
||
if not sample_df.empty:
|
||
event_color_map = dict(zip(_EVENT_KEYS, _EVENT_COLORS))
|
||
for key, color in zip(_EVENT_KEYS, _EVENT_COLORS):
|
||
sub = sample_df[sample_df["event_type"] == key]
|
||
if len(sub):
|
||
label = next(l.split("\n")[0] for k, l in zip(_EVENT_KEYS, summary_event_labels)
|
||
if k == key)
|
||
axes[4].scatter(sub["conf_img"], sub["conf_md"], c=color,
|
||
label=label, alpha=0.7, s=30, edgecolors="none")
|
||
for conc_key, conc_color, conc_label in [
|
||
("concordant_correct", "lightgrey", "concordant correct"),
|
||
("concordant_wrong", "darkgrey", "concordant wrong"),
|
||
]:
|
||
sub = sample_df[sample_df["event_type"] == conc_key]
|
||
if len(sub):
|
||
axes[4].scatter(sub["conf_img"], sub["conf_md"], c=conc_color,
|
||
alpha=0.3, s=15, edgecolors="none", label=conc_label)
|
||
axes[4].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4)
|
||
axes[4].set_xlabel(f"conf_{comp_a}")
|
||
axes[4].set_ylabel(f"conf_{comp_b}")
|
||
axes[4].legend(fontsize=6, loc="lower right")
|
||
axes[4].set_title("Tower confidence space\n(all folds)")
|
||
|
||
fig.tight_layout()
|
||
out_path = run_dir / f"explainability_fusion_summary_{split_name}.png"
|
||
fig.savefig(out_path, dpi=150)
|
||
plt.close(fig)
|
||
print(f" Summary plot → {out_path}", flush=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fold discovery
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def find_folds(run_dir: Path, checkpoint: str) -> list[tuple[int, Path, Path]]:
|
||
"""Return sorted list of (fold_idx, fold_dir, ckpt_path) for existing folds."""
|
||
folds = []
|
||
for fold_dir in sorted(run_dir.glob("fold*")):
|
||
if not fold_dir.is_dir():
|
||
continue
|
||
try:
|
||
fold_idx = int(fold_dir.name.replace("fold", ""))
|
||
except ValueError:
|
||
continue
|
||
ckpt = fold_dir / checkpoint
|
||
if not ckpt.exists():
|
||
print(f" [WARN] Checkpoint not found: {ckpt} — skipping fold {fold_idx}",
|
||
flush=True)
|
||
continue
|
||
folds.append((fold_idx, fold_dir, ckpt))
|
||
return folds
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Argument parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def parse_args():
|
||
ap = argparse.ArgumentParser(
|
||
description="Post-hoc explainability for all folds of a saved run."
|
||
)
|
||
ap.add_argument(
|
||
"--run-dir",
|
||
type=Path,
|
||
required=True,
|
||
help="Path to the tower-mode run directory, e.g. "
|
||
"analysis_data/v2.3_single_binary_nocrop/binary/single",
|
||
)
|
||
ap.add_argument(
|
||
"--checkpoint",
|
||
default="best_single.pt",
|
||
help="Checkpoint filename inside each fold_dir (default: best_single.pt)",
|
||
)
|
||
ap.add_argument(
|
||
"--split",
|
||
choices=["holdout", "val"],
|
||
default="holdout",
|
||
help="Which patient set to use for Phases 1 & 2 (default: holdout, falls back to val)",
|
||
)
|
||
ap.add_argument("--image-dir", default="Papila/FundusImages")
|
||
ap.add_argument("--clinical-dir", default="Papila/ClinicalData")
|
||
ap.add_argument("--label-col", default="Diagnosis")
|
||
ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"])
|
||
ap.add_argument("--fold-seed", type=int, default=42)
|
||
ap.add_argument("--holdout-seed", type=int, default=123)
|
||
ap.add_argument("--holdout-per-class", type=int, default=5)
|
||
ap.add_argument("--n-splits", type=int, default=5)
|
||
ap.add_argument("--n-permutations", type=int, default=30)
|
||
ap.add_argument("--seed", type=int, default=0)
|
||
ap.add_argument("--alpha", type=float, default=0.45)
|
||
ap.add_argument("--batch-size", type=int, default=1)
|
||
ap.add_argument("--no-phase1", action="store_true", help="Skip MD importance")
|
||
ap.add_argument("--no-phase2", action="store_true", help="Skip GradCAM")
|
||
ap.add_argument("--no-phase3", action="store_true", help="Skip fusion event analysis")
|
||
return ap.parse_args()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
run_dir = args.run_dir.resolve()
|
||
if not run_dir.is_dir():
|
||
sys.exit(f"[ERROR] run_dir does not exist: {run_dir}")
|
||
|
||
summary_path = run_dir / "summary.json"
|
||
if not summary_path.exists():
|
||
sys.exit(f"[ERROR] summary.json not found: {summary_path}")
|
||
summary = json.loads(summary_path.read_text())
|
||
backbone = summary["backbone"]
|
||
eval_mode = summary["eval_mode"]
|
||
tower_mode = summary.get("tower_mode", "single")
|
||
print(f"[explain_run] backbone={backbone} eval_mode={eval_mode} tower_mode={tower_mode}")
|
||
|
||
if tower_mode not in ("single", "ensemble"):
|
||
sys.exit(
|
||
f"[ERROR] explain_run supports single/ensemble tower modes, got: {tower_mode!r}"
|
||
)
|
||
|
||
folds = find_folds(run_dir, args.checkpoint)
|
||
if not folds:
|
||
sys.exit(f"[ERROR] No fold directories with checkpoint '{args.checkpoint}' found in {run_dir}")
|
||
print(f"[explain_run] Found {len(folds)} fold(s): {[f[0] for f in folds]}")
|
||
|
||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||
print(f"[explain_run] device={device}", flush=True)
|
||
|
||
# Build data bundle once (shared across folds)
|
||
print("[explain_run] Loading clinical data ...", flush=True)
|
||
data = build_papila_data(
|
||
image_dir=args.image_dir,
|
||
clinical_dir=args.clinical_dir,
|
||
label_col=args.label_col,
|
||
cat_cols=args.cat_cols,
|
||
n_splits=args.n_splits,
|
||
random_seed=args.fold_seed,
|
||
)
|
||
df_mode = data.df.copy()
|
||
if eval_mode == "binary":
|
||
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
|
||
num_classes = 2 if eval_mode == "binary" else int(df_mode[args.label_col].nunique())
|
||
|
||
# Reconstruct all splits once
|
||
splitter = PatientFirstSplitManager(
|
||
patient_col="Patient ID", label_col=args.label_col
|
||
)
|
||
split_args = SimpleNamespace(
|
||
eval_mode=eval_mode,
|
||
holdout_per_class=args.holdout_per_class,
|
||
holdout_seed=args.holdout_seed,
|
||
n_splits=args.n_splits,
|
||
fold_seed=args.fold_seed,
|
||
)
|
||
clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col)
|
||
plans = splitter.build_plans(clinical=clinical_ns, args=split_args, profile=None)
|
||
|
||
profile_patient = build_papila_profile(
|
||
patient_col="Patient ID", label_col=args.label_col, sample_mode="patient"
|
||
)
|
||
eval_transform = build_eval_transform(backbone)
|
||
|
||
all_phase3_summaries = []
|
||
|
||
for fold_idx, fold_dir, ckpt_path in folds:
|
||
print(f"\n{'='*60}", flush=True)
|
||
print(f"[explain_run] === Fold {fold_idx} ===", flush=True)
|
||
|
||
out_dir = fold_dir / "explainability"
|
||
out_dir.mkdir(exist_ok=True)
|
||
|
||
# Phase 3 needs no model — run first so the model load can be skipped
|
||
# if only phase3 is requested
|
||
if not args.no_phase3:
|
||
p3_summaries = run_fusion_event_analysis(fold_dir=fold_dir, out_dir=out_dir)
|
||
for s in p3_summaries:
|
||
s["fold"] = fold_idx
|
||
all_phase3_summaries.extend(p3_summaries)
|
||
|
||
if args.no_phase1 and args.no_phase2:
|
||
continue
|
||
|
||
# ---- Phases 1 & 2 require model + loader ----
|
||
if fold_idx >= len(plans):
|
||
print(f" [WARN] fold_idx={fold_idx} >= n_plans={len(plans)} — skipping Phases 1/2",
|
||
flush=True)
|
||
continue
|
||
split = plans[fold_idx]
|
||
|
||
if args.split == "holdout" and split.holdout is not None and not split.holdout.empty:
|
||
eval_df = split.holdout
|
||
split_name = "holdout"
|
||
else:
|
||
if args.split == "holdout":
|
||
print(" [WARN] No holdout set; falling back to val.", flush=True)
|
||
eval_df = split.val
|
||
split_name = "val"
|
||
print(f" Phases 1/2 using {split_name}: {eval_df['Patient ID'].nunique()} patients",
|
||
flush=True)
|
||
|
||
samples = filter_bilateral_samples(
|
||
profile_patient.build_samples(df=eval_df, clinical=data)
|
||
)
|
||
if not samples:
|
||
print(f" [WARN] No bilateral samples for fold {fold_idx} — skipping Phases 1/2",
|
||
flush=True)
|
||
continue
|
||
|
||
loader = make_loader(
|
||
samples,
|
||
profile_patient.slot_descriptors(),
|
||
image_transform=eval_transform,
|
||
image_preprocessor=None,
|
||
batch_size=args.batch_size,
|
||
shuffle=False,
|
||
num_workers=0,
|
||
)
|
||
|
||
print(f" Loading model from {ckpt_path} ...", flush=True)
|
||
model = SingleEyeHT(
|
||
backbone=backbone,
|
||
freeze_ratio=0.0,
|
||
augment=False,
|
||
clinical_data=data,
|
||
num_classes=num_classes,
|
||
).to(device)
|
||
state = torch.load(ckpt_path, map_location=device)
|
||
model.load_state_dict(state)
|
||
model.eval()
|
||
|
||
if not args.no_phase1:
|
||
run_permutation_importance(
|
||
model=model,
|
||
loader=loader,
|
||
data=data,
|
||
num_classes=num_classes,
|
||
device=device,
|
||
n_permutations=args.n_permutations,
|
||
seed=args.seed,
|
||
out_dir=out_dir,
|
||
)
|
||
|
||
if not args.no_phase2:
|
||
run_gradcam(
|
||
model=model,
|
||
loader=loader,
|
||
data=data,
|
||
eval_df=eval_df,
|
||
eval_mode=eval_mode,
|
||
backbone=backbone,
|
||
device=device,
|
||
alpha=args.alpha,
|
||
out_dir=out_dir,
|
||
)
|
||
|
||
# free GPU memory between folds
|
||
del model
|
||
torch.cuda.empty_cache()
|
||
|
||
# ---- Cross-fold MD importance summary ----
|
||
if not args.no_phase1:
|
||
print(f"\n{'='*60}", flush=True)
|
||
print("[explain_run] === Cross-fold MD importance summary ===", flush=True)
|
||
_plot_run_md_importance_summary(run_dir, folds)
|
||
|
||
# ---- Cross-fold Phase 3 summary ----
|
||
if all_phase3_summaries and not args.no_phase3:
|
||
print(f"\n{'='*60}", flush=True)
|
||
print("[explain_run] === Cross-fold fusion event summary ===", flush=True)
|
||
df_sum = pd.DataFrame(all_phase3_summaries)
|
||
for split_name, grp in df_sum.groupby("split"):
|
||
print(f"\n [{split_name}]", flush=True)
|
||
for col in _EVENT_KEYS:
|
||
vals = grp[col].values
|
||
print(f" {col:20s} mean={vals.mean():.1f} total={vals.sum()}", flush=True)
|
||
cd = grp["conf_delta_mean"].values
|
||
print(f" conf_delta_mean mean={cd.mean():+.4f} std={cd.std():.4f}", flush=True)
|
||
summary_csv = run_dir / "explainability_fusion_summary.csv"
|
||
df_sum.to_csv(summary_csv, index=False)
|
||
print(f"\n Cross-fold summary → {summary_csv}", flush=True)
|
||
_plot_cross_fold_fusion_summary(df_sum, run_dir)
|
||
|
||
print("\n[explain_run] Done.", flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|