Files
hypertower/v4/figures/F8_explainability.py
T
rpotter6298 280060db82 Add new regression and ensemble experiment configurations for V2-M and OrthoBridge
- Introduced multiple regression experiment configurations targeting vf_md, including:
  - cd_solo_reg_set.json: CD tower only regression setup.
  - img_solo_reg_set.json: Image tower only regression setup.
  - reg_head_epoch_sweep.json: Baseline regression sweeps at different epochs (50, 75, 100).
  - reg_head_set.json: Various regression setups including baseline and OrthoBridge configurations.
  - single_eye_reg.json: Single-eye regression setup for worst-eye aggregation analysis.

- Added ensemble configurations for OrthoBridge with different inner bridges:
  - ortho_alts_ensemble.json: Ensemble tests with ConcatBridge, PairwiseAdditiveBridge, and GatedAdditiveBridge.
  - ortho_alts_tritower.json: Tritower tests with the same inner bridges.

- Created V2-M specific configurations:
  - baseline_reg_nt50.json: Regression baseline with V2-M backbone.
  - geom_vec_gt.json and geom_vec_unet.json: Geometry vector injection experiments with V2-M.
  - single_l1_bridges.json: Single-eye ensemble experiments with various bridge types.
  - tritower_geom_gt.json: Tritower setup with GT contour-rasterized masks.

- Promoted existing experiments to higher repetitions for robustness.
2026-06-11 15:08:20 +02:00

1076 lines
43 KiB
Python

"""F8 - Explainability figures from the V2-M checkpointed v4 run.
Sources predictions and Grad-CAM panels exclusively from the
``experiments/explainability/ensemble_v2m_ckpt`` run (img+cd ensemble with the
refuge_efficientnet_v2_m backbone, save_checkpoints=true).
GradCAM machinery lives in ``v4.classes.accessory.explainability``; PAPILA
specific knowledge (disc contour rasterisation, OS→OD orientation flip) is
attached to ``ImageDataView`` in ``v4.classes.profiles.v4papila`` and consumed
here via getattr so future non-PAPILA profiles can opt in without changing
this file.
Usage
python -m v4.figures.F8_explainability --only-fusion
python -m v4.figures.F8_explainability --only-gradcam --run-gradcam
python -m v4.figures.F8_explainability --run-gradcam # both
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from PIL import Image
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT
OUT_DIR = Path(__file__).parent / "output"
GRADCAM_DIR = OUT_DIR / "F8_gradcam"
V4_CKPT_RUN = REPO_ROOT / "v4" / "results" / "experiments" / "explainability" / "ensemble_v2m_ckpt" / "binary"
LABEL_NAMES = {0: "Normal", 1: "Glaucoma"}
EVENT_ORDER = [
"full_correction", "img_assist", "md_assist",
"full_error", "img_drag", "md_drag",
"concordant_correct", "concordant_wrong",
]
EVENT_LABELS = {
"full_correction": "Both wrong -> fused right",
"img_assist": "Image right, MD wrong",
"md_assist": "MD right, image wrong",
"full_error": "Both right -> fused wrong",
"img_drag": "MD right, image wrong -> fused wrong",
"md_drag": "Image right, MD wrong -> fused wrong",
"concordant_correct": "All correct",
"concordant_wrong": "All wrong",
}
EVENT_COLORS = {
"full_correction": "#2f8f5b",
"img_assist": "#74b66b",
"md_assist": "#b7c85a",
"full_error": "#b23a48",
"img_drag": "#df7f5f",
"md_drag": "#d2a24c",
"concordant_correct": "#7da5c9",
"concordant_wrong": "#9b8fc2",
"net_positive": "#1f7a4d",
"net_negative": "#9f2735",
}
def _prediction_frame_from_probs(
*,
y_true: np.ndarray,
probs_fused: np.ndarray,
probs_img: np.ndarray,
probs_md: np.ndarray,
rep: str,
fold: str,
source: str,
) -> pd.DataFrame:
pred_fused = probs_fused.argmax(axis=1)
pred_img = probs_img.argmax(axis=1)
pred_md = probs_md.argmax(axis=1)
df = pd.DataFrame(
{
"idx": np.arange(len(y_true)),
"y_true": y_true.astype(int),
"pred_fused": pred_fused.astype(int),
"prob_fused_c0": probs_fused[:, 0],
"prob_fused_c1": probs_fused[:, 1],
"pred_img": pred_img.astype(int),
"prob_img_c0": probs_img[:, 0],
"prob_img_c1": probs_img[:, 1],
"pred_md": pred_md.astype(int),
"prob_md_c0": probs_md[:, 0],
"prob_md_c1": probs_md[:, 1],
"rep": rep,
"fold": fold,
"source": source,
}
)
for head in ("fused", "img", "md"):
pred = df[f"pred_{head}"].to_numpy()
y = df["y_true"].to_numpy()
df[f"tp_{head}"] = ((pred == 1) & (y == 1)).astype(int)
df[f"fp_{head}"] = ((pred == 1) & (y == 0)).astype(int)
df[f"tn_{head}"] = ((pred == 0) & (y == 0)).astype(int)
df[f"fn_{head}"] = ((pred == 0) & (y == 1)).astype(int)
return df
def _load_ckpt_predictions(split: str = "test") -> pd.DataFrame:
"""Reconstruct v4 component predictions from checkpointed fold modules.
predictions.h5 only stores the final hb head, so the image/clinical
component predictions needed for F8a are rebuilt from tower/head checkpoints.
"""
if split not in {"test", "val", "both"}:
raise ValueError(f"split must be 'test', 'val', or 'both', got {split!r}")
import importlib
import json
import torch
import torch.nn.functional as F
from v4.classes.split_manager import SplitManager
from v4.classes.v4_hypertower import load_data, build_towers, _make_loader
import v4.classes.v4_hypertower as orch
from v4.classes.heads.classifier import ClassificationHead
summary = json.loads((V4_CKPT_RUN / "summary.json").read_text())
cfg = summary["config"]
orch.cfg_ref = cfg
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
data = load_data(cfg)
label_filter = cfg.get("label_filter", None)
df_mode = data.df.copy()
if label_filter is not None:
df_mode = df_mode[df_mode[data.label_col].isin(label_filter)].reset_index(drop=True)
identity_cols = getattr(data, "identity_cols", [])
identity_level = cfg.get("split_identity_level", 1)
group_col = identity_cols[identity_level - 1] if identity_level and identity_cols else None
splits = SplitManager(group_col=group_col).build_plans(
df_mode,
label_col=data.label_col,
n_splits=cfg.get("folds", 5),
seed=cfg.get("fold_seed", 100),
)
stage_by_name = {s["name"]: s for s in cfg["stages"]}
nt_cfg = stage_by_name["nt"]
hb_cfg = stage_by_name["hb"]
rows: list[pd.DataFrame] = []
for fold_idx, split_obj in enumerate(splits):
ckpt_dir = V4_CKPT_RUN / "checkpoints" / f"fold{fold_idx}"
if not ckpt_dir.exists():
continue
towers = build_towers(cfg["towers"], data)
for name, tower in towers.items():
tower.load_state_dict(torch.load(ckpt_dir / f"tower_{name}.pt", map_location="cpu"))
tower.to(device).eval()
nt_mod = importlib.import_module(nt_cfg["module"])
nt = getattr(nt_mod, nt_cfg["class"])(
[towers[n].out_dim for n in nt_cfg["inputs"]],
**nt_cfg.get("args", {}),
).to(device)
nt.load_state_dict(torch.load(ckpt_dir / "stage_nt.pt", map_location="cpu"))
nt.eval()
hb_mod = importlib.import_module(hb_cfg["module"])
hb = getattr(hb_mod, hb_cfg["class"])(
{"a": nt.out_dim, "b": nt.out_dim},
**hb_cfg.get("args", {}),
).to(device)
hb.load_state_dict(torch.load(ckpt_dir / "stage_hb.pt", map_location="cpu"))
hb.eval()
img_aux = ClassificationHead(towers["img"].out_dim, cfg["num_classes"]).to(device)
cd_aux = ClassificationHead(towers["cd"].out_dim, cfg["num_classes"]).to(device)
hb_head = ClassificationHead(hb.out_dim, cfg["num_classes"], dropout=0.3).to(device)
img_aux.load_state_dict(torch.load(ckpt_dir / "stage_img_aux.pt", map_location="cpu"))
cd_aux.load_state_dict(torch.load(ckpt_dir / "stage_cd_aux.pt", map_location="cpu"))
hb_head.load_state_dict(torch.load(ckpt_dir / "stage_hb_head.pt", map_location="cpu"))
img_aux.eval(); cd_aux.eval(); hb_head.eval()
split_frames = []
if split in {"val", "both"}:
split_frames.append(("val", split_obj.val))
if split in {"test", "both"} and split_obj.test is not None:
split_frames.append(("test", split_obj.test))
for split_name, split_df in split_frames:
shell = data.build_shells(split_df, level="patient", label_filter=label_filter)
loader = _make_loader(
shell,
towers,
batch_size=cfg["training"].get("batch_size", 8),
shuffle=False,
)
y_all, pf_all, pi_all, pc_all, ids_all = [], [], [], [], []
with torch.no_grad():
for batch in loader:
y = batch["label"].detach().cpu().numpy()
img_a = towers["img"](batch["img"]["a"].to(device))
img_b = towers["img"](batch["img"]["b"].to(device))
cd_a = towers["cd"](batch["cd"]["a"].to(device))
cd_b = towers["cd"](batch["cd"]["b"].to(device))
z_a = nt([img_a, cd_a])
z_b = nt([img_b, cd_b])
z_hb = hb({"a": z_a, "b": z_b})
p_fused = F.softmax(hb_head(z_hb), dim=1).cpu().numpy()
p_img = 0.5 * (
F.softmax(img_aux(img_a), dim=1).cpu().numpy()
+ F.softmax(img_aux(img_b), dim=1).cpu().numpy()
)
p_cd = 0.5 * (
F.softmax(cd_aux(cd_a), dim=1).cpu().numpy()
+ F.softmax(cd_aux(cd_b), dim=1).cpu().numpy()
)
y_all.append(y)
pf_all.append(p_fused)
pi_all.append(p_img)
pc_all.append(p_cd)
ids_all.extend(batch.get("entity_id", []))
if y_all:
part = _prediction_frame_from_probs(
y_true=np.concatenate(y_all),
probs_fused=np.concatenate(pf_all, axis=0),
probs_img=np.concatenate(pi_all, axis=0),
probs_md=np.concatenate(pc_all, axis=0),
rep="v4",
fold=f"fold{fold_idx}",
source=split_name,
)
part["entity_id"] = [str(e) for e in ids_all]
rows.append(part)
del towers, nt, hb, img_aux, cd_aux, hb_head
if torch.cuda.is_available():
torch.cuda.empty_cache()
if not rows:
raise FileNotFoundError(f"No reconstructed predictions under {V4_CKPT_RUN}")
return pd.concat(rows, ignore_index=True)
def _classify_events(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
y = df["y_true"].to_numpy()
fused_ok = df["pred_fused"].to_numpy() == y
img_ok = df["pred_img"].to_numpy() == y
md_ok = df["pred_md"].to_numpy() == y
def classify(fo: bool, io: bool, mo: bool) -> str:
if fo and io and mo:
return "concordant_correct"
if not fo and not io and not mo:
return "concordant_wrong"
if fo and not io and not mo:
return "full_correction"
if fo and io and not mo:
return "img_assist"
if fo and not io and mo:
return "md_assist"
if not fo and io and mo:
return "full_error"
if not fo and not io and mo:
return "img_drag"
if not fo and io and not mo:
return "md_drag"
return "other"
df["event_type"] = [classify(fo, io, mo) for fo, io, mo in zip(fused_ok, img_ok, md_ok)]
df["fused_ok"] = fused_ok
df["img_ok"] = img_ok
df["md_ok"] = md_ok
df["tower_state"] = np.select(
[
(~img_ok) & (~md_ok),
img_ok & (~md_ok),
(~img_ok) & md_ok,
img_ok & md_ok,
],
[
"Both networks wrong",
"Image only correct",
"Clinical only correct",
"Both networks correct",
],
default="Other",
)
df["tower_mean_c1"] = 0.5 * (df["prob_img_c1"] + df["prob_md_c1"])
df["fusion_delta_c1"] = df["prob_fused_c1"] - df["tower_mean_c1"]
df["fusion_margin"] = np.where(
df["y_true"] == 1,
df["prob_fused_c1"] - df["tower_mean_c1"],
(1.0 - df["prob_fused_c1"]) - (1.0 - df["tower_mean_c1"]),
)
df["tower_gap_abs"] = (df["prob_img_c1"] - df["prob_md_c1"]).abs()
return df
def make_fusion_event_panel(split: str = "test") -> None:
df = _classify_events(_load_ckpt_predictions(split=split))
auc = roc_auc_score(df["y_true"], df["prob_fused_c1"])
fold_groups = list(df.groupby(["rep", "fold"]))
counts = df["event_type"].value_counts().reindex(EVENT_ORDER, fill_value=0)
positive_keys = EVENT_ORDER[:3]
negative_keys = EVENT_ORDER[3:6]
total_positive = int(counts[positive_keys].sum())
total_negative = int(counts[negative_keys].sum())
fig = plt.figure(figsize=(15, 8.4))
gs = fig.add_gridspec(2, 2, height_ratios=[0.9, 1.45], width_ratios=[1.05, 1.0],
wspace=0.32, hspace=0.38)
ax = fig.add_subplot(gs[0, 0])
display_counts = counts.to_dict()
display_counts["net_positive"] = total_positive
display_counts["net_negative"] = total_negative
display_labels = {
**EVENT_LABELS,
"net_positive": "Net positive",
"net_negative": "Net negative",
}
display_order = [
"full_correction", "img_assist", "md_assist",
"full_error", "img_drag", "md_drag",
"net_positive", "net_negative",
]
bars = [k for k in display_order if display_counts.get(k, 0) > 0]
y = np.arange(len(bars))
ax.barh(y, [display_counts[k] for k in bars],
color=[EVENT_COLORS[k] for k in bars], height=0.68)
ax.set_yticks(y)
ax.set_yticklabels([display_labels[k] for k in bars], fontsize=8)
ax.invert_yaxis()
ax.set_xlabel("Count")
ax.set_title("Fusion event taxonomy", fontsize=10, fontweight="bold")
for yi, k in enumerate(bars):
ax.text(display_counts[k] + 0.8, yi, str(int(display_counts[k])),
va="center", fontsize=8)
ax = fig.add_subplot(gs[0, 1])
per_fold = pd.DataFrame(
{
"fold": [f"{r}/{f}" for (r, f), _ in fold_groups],
"positive": [sum((g["event_type"] == k).sum() for k in positive_keys) for _, g in fold_groups],
"negative": [sum((g["event_type"] == k).sum() for k in negative_keys) for _, g in fold_groups],
}
)
x = np.arange(len(per_fold))
ax.bar(x, per_fold["positive"], color="#2f8f5b", width=0.72, label="positive correction")
ax.bar(x, -per_fold["negative"], color="#b23a48", width=0.72, label="negative correction")
ax.plot(x, per_fold["positive"] - per_fold["negative"], color="#222", lw=1.2,
marker="o", ms=3, label="net")
ax.axhline(0, color="#222", lw=0.8)
ax.set_xticks(x)
ax.set_xticklabels(per_fold["fold"], rotation=45, ha="right", fontsize=7)
ax.set_ylabel("Events per fold")
ax.set_title("Per-fold correction balance", fontsize=10, fontweight="bold")
ax.legend(fontsize=7, loc="upper right", frameon=False)
ax = fig.add_subplot(gs[1, :])
disagree = df[df["event_type"].isin(positive_keys + negative_keys)].copy()
concordant = df[df["event_type"].isin(["concordant_correct", "concordant_wrong"])].copy()
n_bins = 28
agreement_grid = np.zeros((n_bins, n_bins), dtype=float)
pos_grid = np.zeros((n_bins, n_bins), dtype=float)
neg_grid = np.zeros((n_bins, n_bins), dtype=float)
for _, row in concordant.iterrows():
xi = min(n_bins - 1, max(0, int(row["prob_img_c1"] * n_bins)))
yi = min(n_bins - 1, max(0, int(row["prob_md_c1"] * n_bins)))
agreement_grid[yi, xi] += 1
for _, row in disagree.iterrows():
xi = min(n_bins - 1, max(0, int(row["prob_img_c1"] * n_bins)))
yi = min(n_bins - 1, max(0, int(row["prob_md_c1"] * n_bins)))
if row["event_type"] in positive_keys:
pos_grid[yi, xi] += 1
else:
neg_grid[yi, xi] += 1
grey_rgba = np.zeros((n_bins, n_bins, 4), dtype=float)
grey_rgba[:, :, :3] = 0.52
if agreement_grid.max() > 0:
grey_rgba[:, :, 3] = 0.06 + 0.22 * np.sqrt(agreement_grid / agreement_grid.max())
grey_rgba[agreement_grid == 0, 3] = 0.0
ax.imshow(grey_rgba, extent=[0, 1, 0, 1], origin="lower", aspect="auto",
interpolation="nearest")
support = pos_grid + neg_grid
dominance = np.divide(pos_grid - neg_grid, support,
out=np.zeros_like(pos_grid), where=support > 0)
cmap = plt.get_cmap("RdYlGn")
rgba = cmap((dominance + 1.0) / 2.0)
if support.max() > 0:
rgba[:, :, 3] = 0.08 + 0.46 * np.sqrt(support / support.max())
rgba[support == 0, 3] = 0.0
ax.imshow(rgba, extent=[0, 1, 0, 1], origin="lower", aspect="auto",
interpolation="nearest")
point_handles = []
for correct, label, color in [
(True, "fused correct", "#2f8f5b"),
(False, "fused wrong", "#b23a48"),
]:
sub = df[df["fused_ok"] == correct]
h = ax.scatter(
sub["prob_img_c1"], sub["prob_md_c1"], s=18, alpha=0.45,
color=color, edgecolors="white", linewidths=0.18,
label=label,
)
point_handles.append(h)
ax.axvline(0.5, color="#222", lw=0.9, ls="--", alpha=0.75)
ax.axhline(0.5, color="#222", lw=0.9, ls="--", alpha=0.75)
ax.plot([0, 1], [0, 1], color="#222", lw=0.7, ls=":", alpha=0.55)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xlabel("Image network P(glaucoma)")
ax.set_ylabel("Clinical network P(glaucoma)")
ax.set_title(
"Network confidence space - shaded by dominant empirical fusion outcome "
"(grey=agreement, green=positive correction, red=negative correction)",
fontsize=10, fontweight="bold",
)
ax.text(0.02, 0.96, "clinical says glaucoma", transform=ax.transAxes,
ha="left", va="top", fontsize=8, color="#333")
ax.text(0.98, 0.04, "image says glaucoma", transform=ax.transAxes,
ha="right", va="bottom", fontsize=8, color="#333")
shade_handles = [
mpatches.Patch(facecolor="#858585", alpha=0.28, label="agreement density"),
mpatches.Patch(facecolor="#2f8f5b", alpha=0.34, label="positive correction shade"),
mpatches.Patch(facecolor="#b23a48", alpha=0.34, label="negative correction shade"),
]
ax.legend(handles=point_handles + shade_handles, ncol=5, fontsize=7,
loc="upper center", bbox_to_anchor=(0.5, -0.14), frameon=False)
fig.suptitle(
f"S8a - Checkpoint Fusion Events ({split}; AUC={auc:.3f}, n={len(df)})",
fontsize=12, fontweight="bold",
)
out = OUT_DIR / "S8a_comparison_panel.png"
fig.savefig(out, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"Saved: {out}")
def make_clinical_importance(n_permutations: int = 30, seed: int = 0) -> None:
"""S8e clinical permutation importance via the cd-tower → cd_aux head.
Isolates the clinical-only prediction path at the V2-M ckpt run, then
column-shuffles the encoded clinical vector to measure per-feature AUC
drop. Per-original-column grouping comes from
``ClinicalDataView.feature_groups`` (one-hot encoded dims for a
categorical column shuffle together).
Aggregates per-fold mean drops, then averages across folds.
"""
import json
import torch
import torch.nn.functional as TF_
from tqdm import tqdm
from v4.classes.accessory.explainability import permutation_importance
from v4.classes.split_manager import SplitManager
from v4.classes.v4_hypertower import load_data, _make_loader
import v4.classes.v4_hypertower as orch
summary_path = V4_CKPT_RUN / "summary.json"
if not summary_path.exists():
print(f"[F8] make_clinical_importance: no summary.json under {V4_CKPT_RUN}")
return
cfg = json.loads(summary_path.read_text())["config"]
orch.cfg_ref = cfg
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
data = load_data(cfg)
clinical_view = data.matrix
feature_names = getattr(clinical_view, "feature_names", None)
feature_groups = getattr(clinical_view, "feature_groups", None)
if feature_names is None or feature_groups is None:
print(
"[F8] make_clinical_importance: profile lacks feature_names / "
"feature_groups; skipping S8e."
)
return
label_filter = cfg.get("label_filter", None)
df_mode = data.df.copy()
if label_filter is not None:
df_mode = df_mode[df_mode[data.label_col].isin(label_filter)].reset_index(drop=True)
identity_cols = getattr(data, "identity_cols", [])
identity_level = cfg.get("split_identity_level", 1)
group_col = identity_cols[identity_level - 1] if identity_level and identity_cols else None
splits = SplitManager(group_col=group_col).build_plans(
df_mode,
label_col=data.label_col,
n_splits=cfg.get("folds", 5),
seed=cfg.get("fold_seed", 100),
)
all_results = []
for fold_idx in tqdm(range(cfg.get("folds", 5)), desc="Importance folds", unit="fold"):
ckpt_dir = V4_CKPT_RUN / "checkpoints" / f"fold{fold_idx}"
if not ckpt_dir.exists():
continue
towers, nt, hb, img_aux, cd_aux, hb_head = _build_v4_fold_modules(
fold_idx, cfg, data, device,
)
split_obj = splits[fold_idx]
if split_obj.test is None:
del towers, nt, hb, img_aux, cd_aux, hb_head
continue
shell = data.build_shells(split_obj.test, level="patient", label_filter=label_filter)
loader = _make_loader(
shell,
towers,
batch_size=cfg["training"].get("batch_size", 8),
shuffle=False,
)
X_list, y_list = [], []
with torch.no_grad():
for batch in loader:
X_list.append(batch["cd"]["a"].cpu().numpy()) # OD-side clinical
y_list.append(batch["label"].cpu().numpy())
if not X_list:
del towers, nt, hb, img_aux, cd_aux, hb_head
continue
X = np.concatenate(X_list).astype(np.float32)
y = np.concatenate(y_list).astype(int)
if len(np.unique(y)) < 2:
del towers, nt, hb, img_aux, cd_aux, hb_head
continue
def score_fn(X_in: np.ndarray) -> float:
x_t = torch.from_numpy(X_in).to(device)
with torch.no_grad():
z = towers["cd"](x_t)
logits = cd_aux(z)
probs = TF_.softmax(logits, dim=1).cpu().numpy()
try:
return roc_auc_score(y, probs[:, 1])
except Exception:
return float("nan")
result = permutation_importance(
score_fn=score_fn,
X=X,
groups=feature_groups,
n_permutations=n_permutations,
seed=seed + fold_idx,
feature_names=feature_names,
)
all_results.append(result)
del towers, nt, hb, img_aux, cd_aux, hb_head
if torch.cuda.is_available():
torch.cuda.empty_cache()
if not all_results:
print("[F8] make_clinical_importance: no folds with usable data; skipping S8e.")
return
fold_drops = np.stack([r["mean_drop"] for r in all_results])
mean_across_folds = fold_drops.mean(axis=0)
std_across_folds = fold_drops.std(axis=0)
baseline_mean = float(np.mean([r["baseline"] for r in all_results]))
names = all_results[0]["feature_names"]
order = np.argsort(mean_across_folds)[::-1]
sorted_names = [names[i] for i in order]
sorted_means = mean_across_folds[order]
sorted_stds = std_across_folds[order]
OUT_DIR.mkdir(parents=True, exist_ok=True)
fig, ax = plt.subplots(figsize=(7.5, max(3.5, 0.32 * len(sorted_names))))
y_pos = np.arange(len(sorted_names))
ax.barh(
y_pos, sorted_means, xerr=sorted_stds,
color="#3B6FB5", edgecolor="black", height=0.7,
error_kw=dict(ecolor="#444", lw=0.8, capsize=2),
)
ax.set_yticks(y_pos)
ax.set_yticklabels(sorted_names, fontsize=9)
ax.invert_yaxis()
ax.set_xlabel("Mean AUC drop on shuffling (averaged across folds)", fontsize=10)
ax.axvline(0, color="black", linewidth=0.7)
ax.set_title(
f"S8e — Clinical permutation importance (cd-only head, V2-M ckpt run)\n"
f"baseline AUC = {baseline_mean:.3f}; n_permutations = {n_permutations}",
fontsize=10, fontweight="bold",
)
ax.grid(axis="x", alpha=0.3, linestyle="--")
fig.tight_layout()
out_png = OUT_DIR / "S8e_clinical_importance.png"
out_csv = OUT_DIR / "S8e_clinical_importance.csv"
fig.savefig(out_png, dpi=180, bbox_inches="tight")
plt.close(fig)
pd.DataFrame({
"feature": sorted_names,
"mean_drop": sorted_means,
"std_drop": sorted_stds,
"baseline_auc_mean": baseline_mean,
}).to_csv(out_csv, index=False)
print(f"Saved: {out_png}")
print(f"Saved: {out_csv}")
def _build_v4_fold_modules(fold_idx: int, cfg: dict, data, device):
import importlib
import torch
from v4.classes.v4_hypertower import build_towers
from v4.classes.heads.classifier import ClassificationHead
stage_by_name = {s["name"]: s for s in cfg["stages"]}
nt_cfg = stage_by_name["nt"]
hb_cfg = stage_by_name["hb"]
ckpt_dir = V4_CKPT_RUN / "checkpoints" / f"fold{fold_idx}"
if not ckpt_dir.exists():
raise FileNotFoundError(f"No v4 checkpoint directory: {ckpt_dir}")
towers = build_towers(cfg["towers"], data)
for name, tower in towers.items():
tower.load_state_dict(torch.load(ckpt_dir / f"tower_{name}.pt", map_location="cpu"))
tower.to(device).eval()
nt_mod = importlib.import_module(nt_cfg["module"])
nt = getattr(nt_mod, nt_cfg["class"])(
[towers[n].out_dim for n in nt_cfg["inputs"]],
**nt_cfg.get("args", {}),
).to(device)
nt.load_state_dict(torch.load(ckpt_dir / "stage_nt.pt", map_location="cpu"))
nt.eval()
hb_mod = importlib.import_module(hb_cfg["module"])
hb = getattr(hb_mod, hb_cfg["class"])(
{"a": nt.out_dim, "b": nt.out_dim},
**hb_cfg.get("args", {}),
).to(device)
hb.load_state_dict(torch.load(ckpt_dir / "stage_hb.pt", map_location="cpu"))
hb.eval()
img_aux = ClassificationHead(towers["img"].out_dim, cfg["num_classes"]).to(device)
cd_aux = ClassificationHead(towers["cd"].out_dim, cfg["num_classes"]).to(device)
hb_head = ClassificationHead(hb.out_dim, cfg["num_classes"], dropout=0.3).to(device)
img_aux.load_state_dict(torch.load(ckpt_dir / "stage_img_aux.pt", map_location="cpu"))
cd_aux.load_state_dict(torch.load(ckpt_dir / "stage_cd_aux.pt", map_location="cpu"))
hb_head.load_state_dict(torch.load(ckpt_dir / "stage_hb_head.pt", map_location="cpu"))
img_aux.eval(); cd_aux.eval(); hb_head.eval()
return towers, nt, hb, img_aux, cd_aux, hb_head
def _orient_eye_array(arr: np.ndarray, eye: str) -> np.ndarray:
"""Mirror OS so temporal/nasal anatomy is aligned to OD-style orientation."""
return np.fliplr(arr) if eye.upper() == "OS" else arr
def _disc_centred_patch_array(
arr: np.ndarray,
disc_mask: np.ndarray,
*,
span: int = 5,
out: int = 96,
) -> tuple[np.ndarray | None, float | None]:
if disc_mask is None or disc_mask.sum() == 0:
return None, None
ys, xs = np.where(disc_mask)
cy, cx = ys.mean(), xs.mean()
disc_r = float(np.sqrt(disc_mask.sum() / np.pi))
half = max(1, int(round(span * disc_r / 2)))
h, w = disc_mask.shape
y0, y1 = int(round(cy)) - half, int(round(cy)) + half
x0, x1 = int(round(cx)) - half, int(round(cx)) + half
pad_spec = ((max(0, -y0), max(0, y1 - h)), (max(0, -x0), max(0, x1 - w)))
if arr.ndim == 3:
pad_spec = (*pad_spec, (0, 0))
arr_pad = np.pad(arr, pad_spec, constant_values=0)
patch = arr_pad[y0 + pad_spec[0][0]: y1 + pad_spec[0][0],
x0 + pad_spec[1][0]: x1 + pad_spec[1][0]]
if arr.ndim == 2:
pil = Image.fromarray((np.clip(patch, 0, 1) * 255).astype(np.uint8))
patch_out = np.array(pil.resize((out, out), Image.BILINEAR)) / 255.0
else:
patch_out = np.array(Image.fromarray(patch.astype(np.uint8)).resize((out, out), Image.BILINEAR))
return patch_out.astype(np.float32), out * disc_r / (2 * half)
def _annotate_nasal_temporal(ax, *, fontsize: int = 9, color: str = "white",
pad: float = 2.5) -> None:
"""Label the disc-side (nasal) and macula-side (temporal) edges of an
OD-oriented fundus axis. After OS is mirrored to OD orientation, the disc
sits on the LEFT (nasal) side and the macula on the RIGHT (temporal) side.
"""
ax.text(0.015, 0.5, "N", transform=ax.transAxes,
ha="left", va="center", fontsize=fontsize, fontweight="bold",
color=color,
bbox=dict(facecolor="black", alpha=0.55, edgecolor="none", pad=pad))
ax.text(0.985, 0.5, "T", transform=ax.transAxes,
ha="right", va="center", fontsize=fontsize, fontweight="bold",
color=color,
bbox=dict(facecolor="black", alpha=0.55, edgecolor="none", pad=pad))
def _make_oriented_disc_detail(mean_patches: dict, examples: dict, out_path: Path) -> None:
"""2x2 grid of mean Grad-CAM patches centred on the optic disc.
Rows = ground-truth class (Normal / Glaucoma)
Cols = prediction outcome relative to truth (Correct / Incorrect)
Each cell shows the per-pixel mean Grad-CAM intensity in a disc-centred,
OD-oriented patch (OS mirrored). A dashed circle traces the mean disc
boundary. Nasal (N) and Temporal (T) edges are labelled.
Sample overlays are intentionally NOT shown here; see overlay_grid_*.png
for browseable per-image overlays and hand-pick from those for any
figure that wants concrete examples.
"""
from matplotlib.patches import Circle
classes = ["Normal", "Glaucoma"]
splits = ["correct", "incorrect"]
fig, axes = plt.subplots(2, 2, figsize=(8, 7.8), constrained_layout=True)
fig.patch.set_facecolor("#f6f6f6")
# Column headers
for ci, split in enumerate(splits):
axes[0, ci].set_title(
f"{split.capitalize()} predictions",
fontsize=11, fontweight="bold", pad=10, color="#222",
)
for ri, cls in enumerate(classes):
# Row label drawn outside the leftmost cell
axes[ri, 0].text(
-0.13, 0.5, cls, transform=axes[ri, 0].transAxes,
ha="right", va="center", fontsize=12, fontweight="bold",
color="#c44e52" if cls == "Glaucoma" else "#3B6FB5", rotation=90,
)
for ci, split in enumerate(splits):
ax = axes[ri, ci]
ax.set_facecolor("#202020")
key = (cls, split)
if key in mean_patches:
patch, radius, count, *rest = mean_patches[key]
disc_frac = rest[0] if rest else None
ax.imshow(patch, cmap="jet", vmin=0, vmax=1)
ax.add_patch(Circle((48, 48), radius, fill=False,
edgecolor="white", linewidth=1.8, linestyle="--"))
_annotate_nasal_temporal(ax)
cap = f"n = {count}"
if disc_frac is not None and not np.isnan(disc_frac):
cap += f" · disc-frac = {disc_frac:.2f}"
ax.text(0.5, -0.06, cap, transform=ax.transAxes,
ha="center", va="top", fontsize=9, color="#222")
else:
ax.text(0.5, 0.5, "no data", transform=ax.transAxes,
ha="center", va="center", color="white", fontsize=9)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle(
"Mean image-tower Grad-CAM, disc-centred (OD-oriented)\n"
"rows: ground truth · columns: prediction outcome · dashed circle = mean disc boundary",
fontsize=11, fontweight="bold",
)
fig.savefig(out_path, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"Saved: {out_path}")
def _patient_id_from_entity_id(entity_id) -> int:
if isinstance(entity_id, (tuple, list)):
return int(entity_id[0])
return int(entity_id)
def _v4_gradcam_target_layer(img_tower):
blocks = getattr(img_tower, "_blocks", None)
if blocks:
return blocks[-1]
backbone = getattr(img_tower, "backbone", None)
if hasattr(backbone, "features"):
return backbone.features[-1]
children = list(backbone.children()) if backbone is not None else []
if children:
return children[-1]
raise RuntimeError(f"Could not infer GradCAM target layer for {type(img_tower).__name__}")
def _maybe(profile_view, method: str, *args, **kwargs):
"""Call profile_view.method(*args, **kwargs) if it exists, else return None."""
fn = getattr(profile_view, method, None)
return fn(*args, **kwargs) if fn is not None else None
def _orient_via_profile(profile_view, image_or_array, side: str):
"""Use the profile's orient_for_display if it provides one; else passthrough."""
return _maybe(profile_view, "orient_for_display", image_or_array, side) \
if hasattr(profile_view, "orient_for_display") else image_or_array
def _compute_image_aux_cam(
gcam,
batch: dict,
eye: str,
towers: dict,
img_aux,
device,
target_class: int | None,
) -> tuple[np.ndarray, int]:
"""Run image-tower GradCAM against the img_aux head for one (batch, eye) pair."""
for mod in [towers["img"], img_aux]:
mod.zero_grad(set_to_none=True)
side = "a" if eye.upper() == "OD" else "b"
img_t = batch["img"][side].to(device)
return gcam.compute(
forward_fn=lambda: img_aux(towers["img"](img_t)),
output_shape=img_t.shape[-2:],
target_class=target_class,
)
def _make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
target_class: int | None = None) -> None:
import json
import torch
from tqdm import tqdm
from v4.classes.accessory.explainability import GradCAM, overlay_gradcam
from v4.classes.split_manager import SplitManager
from v4.classes.v4_hypertower import load_data, _make_loader
import v4.classes.v4_hypertower as orch
summary = json.loads((V4_CKPT_RUN / "summary.json").read_text())
cfg = summary["config"]
orch.cfg_ref = cfg
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"GradCAM device: {device}")
data = load_data(cfg)
image_view = data.image # profile data view — owns dataset-specific knowledge
label_filter = cfg.get("label_filter", None)
df_mode = data.df.copy()
if label_filter is not None:
df_mode = df_mode[df_mode[data.label_col].isin(label_filter)].reset_index(drop=True)
identity_cols = getattr(data, "identity_cols", [])
identity_level = cfg.get("split_identity_level", 1)
group_col = identity_cols[identity_level - 1] if identity_level and identity_cols else None
splits = SplitManager(group_col=group_col).build_plans(
df_mode,
label_col=data.label_col,
n_splits=cfg.get("folds", 5),
seed=cfg.get("fold_seed", 100),
)
cam_sum = {0: None, 1: None}
cam_count = {0: 0, 1: 0}
overlay_items = {0: [], 1: []}
disc_patch_sum: dict[tuple[str, str], np.ndarray] = {}
disc_patch_count: dict[tuple[str, str], int] = {}
disc_radius_sum: dict[tuple[str, str], float] = {}
disc_frac_sum: dict[tuple[str, str], float] = {}
examples: dict[tuple[str, str], tuple[np.ndarray, int, str, int]] = {}
fold_range = range(cfg.get("folds", 5))
for fold_idx in tqdm(fold_range, desc="GradCAM folds", unit="fold"):
ckpt_dir = V4_CKPT_RUN / "checkpoints" / f"fold{fold_idx}"
if not ckpt_dir.exists():
continue
towers, nt, hb, img_aux, _cd_aux, hb_head = _build_v4_fold_modules(
fold_idx, cfg, data, device,
)
target_layer = _v4_gradcam_target_layer(towers["img"])
gcam = GradCAM(target_layer)
split_obj = splits[fold_idx]
if split_obj.test is None:
continue
shell = data.build_shells(split_obj.test, level="patient", label_filter=label_filter)
loader = _make_loader(shell, towers, batch_size=1, shuffle=False)
for batch in tqdm(loader, desc=f"fold{fold_idx}", leave=False, unit="pt"):
pid = _patient_id_from_entity_id(batch["entity_id"][0])
label = int(batch["label"][0].item())
for eye in ("OD", "OS"):
if not image_view.get_image_path(pid, eye).exists():
continue
pil_eval = image_view.eval_image_pil(pid, eye)
roi_mask = _maybe(image_view, "build_roi_mask",
pid, eye, target_h=pil_eval.size[1], target_w=pil_eval.size[0])
cam_np, pred = _compute_image_aux_cam(
gcam, batch, eye, towers, img_aux, device, target_class,
)
# Orient for OD-style display (profile decides; passthrough if it doesn't define)
cam_np = _orient_via_profile(image_view, cam_np, eye)
pil_oriented = _orient_via_profile(image_view, pil_eval, eye)
if roi_mask is not None:
roi_mask = _orient_via_profile(image_view, roi_mask, eye)
if cam_sum[label] is None:
cam_sum[label] = cam_np.copy()
else:
cam_sum[label] += cam_np
cam_count[label] += 1
if len(overlay_items[label]) < n_grid:
overlay_items[label].append(
(overlay_gradcam(pil_oriented, cam_np, alpha), pid, eye, pred)
)
patch, roi_r_out = _disc_centred_patch_array(cam_np, roi_mask)
if patch is not None:
key = (LABEL_NAMES[label], "correct" if pred == label else "incorrect")
disc_patch_sum[key] = disc_patch_sum.get(key, 0) + patch
disc_patch_count[key] = disc_patch_count.get(key, 0) + 1
disc_radius_sum[key] = disc_radius_sum.get(key, 0.0) + float(roi_r_out)
disc_frac_sum[key] = disc_frac_sum.get(key, 0.0) + float(
cam_np[roi_mask].sum() / (cam_np.sum() + 1e-8)
)
if key not in examples:
ov = overlay_gradcam(pil_oriented, cam_np, alpha)
ov_small = np.array(ov.resize(cam_np.shape[::-1], Image.BILINEAR))
ex_patch, _ = _disc_centred_patch_array(ov_small, roi_mask)
if ex_patch is not None:
examples[key] = (ex_patch, pid, eye, pred)
del cam_np
gcam.remove()
del towers, nt, hb, img_aux, _cd_aux, hb_head
if torch.cuda.is_available():
torch.cuda.empty_cache()
GRADCAM_DIR.mkdir(parents=True, exist_ok=True)
for cls in (0, 1):
if cam_count[cls] == 0:
continue
mean_cam = cam_sum[cls] / cam_count[cls]
mean_cam = (mean_cam - mean_cam.min()) / (mean_cam.max() - mean_cam.min() + 1e-8)
fig, ax = plt.subplots(figsize=(5, 5))
im = ax.imshow(mean_cam, cmap="jet", vmin=0, vmax=1)
ax.axis("off")
ax.set_title(f"Mean v4 image-aux GradCAM - {LABEL_NAMES[cls]} (oriented, n={cam_count[cls]})",
fontsize=10, fontweight="bold")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.savefig(GRADCAM_DIR / f"mean_cam_{LABEL_NAMES[cls].lower()}.png",
dpi=180, bbox_inches="tight")
plt.close(fig)
if cam_count[0] and cam_count[1]:
fig, axes = plt.subplots(1, 2, figsize=(11, 5.5), constrained_layout=True)
fig.patch.set_facecolor("#f6f6f6")
fig.suptitle(
"Mean image-tower Grad-CAM by ground-truth class (OD-oriented)",
fontsize=12, fontweight="bold",
)
cls_colors = {0: "#3B6FB5", 1: "#c44e52"}
for ax, cls in zip(axes, (0, 1)):
mean_cam = cam_sum[cls] / cam_count[cls]
mean_cam = (mean_cam - mean_cam.min()) / (mean_cam.max() - mean_cam.min() + 1e-8)
im = ax.imshow(mean_cam, cmap="jet", vmin=0, vmax=1)
_annotate_nasal_temporal(ax)
ax.set_title(
f"{LABEL_NAMES[cls]} n = {cam_count[cls]}",
fontsize=12, fontweight="bold", color=cls_colors[cls],
)
ax.set_xticks([]); ax.set_yticks([])
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.savefig(GRADCAM_DIR / "mean_cam_comparison.png", dpi=180, bbox_inches="tight")
plt.close(fig)
for cls in (0, 1):
items = overlay_items[cls]
if not items:
continue
items.sort(key=lambda x: x[3] == cls)
ncols = 4
nrows = int(np.ceil(len(items) / ncols))
fig, axes = plt.subplots(nrows, ncols, figsize=(ncols * 3.1, nrows * 3.1))
axes = np.array(axes).reshape(-1)
fig.suptitle(
f"Per-eye Grad-CAM samples for browsing — ground truth = {LABEL_NAMES[cls]}\n"
"(OD-oriented; green title = model agreed, red = disagreed)",
fontsize=11, fontweight="bold",
)
for i, ax in enumerate(axes):
if i < len(items):
ov, pid, eye, pred = items[i]
ax.imshow(ov)
_annotate_nasal_temporal(ax, fontsize=7, pad=1.5)
color = "#2f7d46" if pred == cls else "#b23a48"
ax.set_title(f"RET{pid:03d}{eye} -> {LABEL_NAMES[pred]}",
fontsize=7.5, color=color)
ax.axis("off")
fig.tight_layout()
fig.savefig(GRADCAM_DIR / f"overlay_grid_{LABEL_NAMES[cls].lower()}.png",
dpi=160, bbox_inches="tight")
plt.close(fig)
mean_patches = {
key: (
disc_patch_sum[key] / disc_patch_count[key],
disc_radius_sum[key] / disc_patch_count[key],
disc_patch_count[key],
disc_frac_sum.get(key, float("nan")) / disc_patch_count[key],
)
for key in disc_patch_sum
}
if mean_patches:
_make_oriented_disc_detail(mean_patches, examples, GRADCAM_DIR / "disc_attention_detail.png")
def make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
target_class: int | None = None) -> None:
_make_oriented_gradcam(n_grid=n_grid, alpha=alpha, target_class=target_class)
def main(*, run_gradcam: bool = False, n_permutations: int = 30,
fusion_split: str = "test") -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
make_fusion_event_panel(split=fusion_split)
make_clinical_importance(n_permutations=n_permutations)
if run_gradcam:
make_oriented_gradcam()
else:
print("Skipped GradCAM. Re-run with --run-gradcam to generate oriented heatmaps.")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--only-fusion", action="store_true",
help="Generate only the fusion event panel; skip clinical and GradCAM figures.")
parser.add_argument("--only-gradcam", action="store_true",
help="Generate only oriented GradCAM outputs; skip fusion and clinical figures.")
parser.add_argument("--run-gradcam", action="store_true",
help="Generate GPU-heavy oriented GradCAM outputs.")
parser.add_argument("--n-grid", type=int, default=16,
help="Max overlays per class for GradCAM grids.")
parser.add_argument("--alpha", type=float, default=0.45,
help="GradCAM overlay opacity.")
parser.add_argument("--target-class", type=int, default=None,
help="GradCAM target class; default uses predicted class.")
parser.add_argument("--n-permutations", type=int, default=30,
help="Clinical permutation repeats per feature.")
parser.add_argument("--fusion-split", choices=["test", "val", "both"], default="test",
help="Prediction split for the fusion event panel.")
args = parser.parse_args()
if args.only_fusion:
OUT_DIR.mkdir(parents=True, exist_ok=True)
make_fusion_event_panel(split=args.fusion_split)
elif args.only_gradcam:
make_oriented_gradcam(
n_grid=args.n_grid,
alpha=args.alpha,
target_class=args.target_class,
)
else:
main(
run_gradcam=args.run_gradcam,
n_permutations=args.n_permutations,
fusion_split=args.fusion_split,
)