Add analysis scripts and experiment configurations for bridge attention and sensitivity studies

- Introduced `bridge_attention_ceiling_check.py` for variance decomposition analysis on bridge attention configurations.
- Added `bridge_attention_readout.py` to perform per-tower gate and contribution readouts, including AUC sanity checks.
- Created multiple JSON configuration files for backbone replication experiments, including anonymous CV variants and basic backbones.
- Implemented sensitivity experiments to evaluate the impact of axial length inclusion and EfficientNetV2-M performance at higher resolutions.
- Added a memory probe script to assess GPU memory usage during training with EfficientNetV2-M.
This commit is contained in:
rpotter6298
2026-07-03 08:51:44 +02:00
parent 3d954a4606
commit 708fbc70ce
52 changed files with 2223 additions and 218 deletions
@@ -1,50 +1,56 @@
"""F2 Backbone selection panel.
"""F2 - Backbone selection panel.
Box plot in the style of v3/figures/phase2_analysis.png (black-bordered boxes,
red median lines, baseline median reference). Three left-to-right sections:
Block 1 (blue) Basic backbones (img-only, single-eye, ImageNet pretraining):
Block 1 (blue) -- Basic backbones (img-only, single-eye, ImageNet pretraining):
VGG16, MobileNetV2, DenseNet121, InceptionV3, ResNet50
Sourced from v3 phase 1 / phase 2 fold AUCs. Will be refined with v4
10x5 runs later; means should not move much.
Sourced from v4 experiments/backbone_replication/basic_* (10x5 = 50 fold-rep
AUCs each, img-only single-eye, patient-grouped CV).
Block 2 (blue) ResNet50 preprocessing/CV variations:
leaky CV, GT crop, U-Net crop (all 2.5x scale; 1.1x dropped from labels)
Sourced from v3 phase 2 'classic_test_auc' (single-mode image-only).
Block 2 (blue) -- ResNet50 preprocessing/CV variations:
Anonymous CV, GT crop, U-Net crop (disc crops use margin 2.5x)
"Anonymous CV" = patient-identity-agnostic cross-validation: fold
assignment ignores PAPILA's patient IDs, allowing the same patient's
OD/OS pair to be split across train and test. Reflects the standard
protocol in benchmark reports that do not have patient-level labels
(or do not respect them).
Sourced from v4 experiments/backbone_replication/{anonymous_cv,gtcrop,
unetcrop}_refugelike.
Block 3 (orange) Baseline reference:
"Baseline (fine-tuned ResNet50)" — what we previously called refugelike.
Sourced from v3 phase 2 imageonly_refugelike_proper.
Block 3 (orange) -- Baseline reference:
"Baseline (fine-tuned ResNet50)" -- REFUGE-pretrained R50 image-only,
sourced from v4 experiments/refuge_v2m_baseline/img_solo_single_refugelike.
Each non-baseline box is labelled with a Wilcoxon two-sided p-value comparing
its fold AUCs to the baseline.
its fold-rep AUCs to the baseline fold-rep AUCs.
Re-run anytime:
python -m v4.figures.F2_papila_replication_and_single_mode
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import wilcoxon
from v4.figures.util.loaders import REPO_ROOT
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F2_backbones.png"
# ── Colors / styling (mirrors v3 phase2_analysis) ────────────────────────────
C_VAR = "#4c72b0" # blue non-baseline boxes (basic backbones + variants)
C_BASE = "#dd8452" # orange baseline reference box
C_MEDIAN = "#c44e52" # red median line inside boxes
# Colors / styling (mirrors v3 phase2_analysis)
C_VAR = "#4c72b0" # blue - non-baseline boxes
C_BASE = "#dd8452" # orange - baseline reference box
C_MEDIAN = "#c44e52" # red - median line inside boxes
ALPHA = 0.82
V3_PHASE1_DIR = REPO_ROOT / "v3" / "results" / "phase1"
V3_PHASE2_DIR = REPO_ROOT / "v3" / "results" / "phase2"
# Stage key for img-only single-eye fusion
STAGE_KEY = "img_fuse_test_auc"
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
@@ -57,67 +63,64 @@ def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
return float("nan")
def _load_phase1_fold_aucs(subdir: str) -> np.ndarray:
fp = V3_PHASE1_DIR / subdir / "fold_metrics.csv"
if not fp.exists():
return np.array([])
df = pd.read_csv(fp)
return df["auc"].dropna().astype(float).values
def _load_phase2_classic_aucs(run_name: str) -> np.ndarray:
"""Collect classic_test_auc across all rep×fold for a phase 2 run folder."""
root = V3_PHASE2_DIR / run_name
def _load_fold_aucs(rel: str) -> np.ndarray:
"""Collect STAGE_KEY across all rep x fold for a v4 results subdirectory."""
root = RESULTS_ROOT / rel
if not root.exists():
return np.array([])
out: list[float] = []
for rep in sorted(root.glob("rep*")):
fp = rep / "binary" / "single" / "fold_results.csv"
if not fp.exists(): continue
df = pd.read_csv(fp)
if "classic_test_auc" not in df.columns: continue
out.extend(df["classic_test_auc"].dropna().astype(float).tolist())
for s in sorted(root.glob("rep*/binary/summary.json")):
d = json.loads(s.read_text())
for fr in d.get("fold_results", []):
v = fr.get(STAGE_KEY)
if v is None or not np.isfinite(v):
continue
out.append(float(v))
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Each entry: (label, loader_fn, *args)
# Per-section data definitions (label, results-subdir under RESULTS_ROOT)
BASIC_BACKBONES = [
("VGG16", _load_phase1_fold_aucs, "cnn_vgg16"),
("MobileNetV2", _load_phase1_fold_aucs, "cnn_mobilenet_v2"),
("DenseNet121", _load_phase1_fold_aucs, "cnn_densenet121"),
("InceptionV3", _load_phase1_fold_aucs, "cnn_inception_v3"),
# Use phase 2 ResNet50 (50 fold AUCs) for tighter statistics on the
# backbone that we sweep variations of in block 2.
("ResNet50", _load_phase2_classic_aucs, "imageonly_resnet50_proper"),
("VGG16", "backbone_replication/basic_vgg16"),
("MobileNetV2", "backbone_replication/basic_mobilenet_v2"),
("DenseNet121", "backbone_replication/basic_densenet121"),
("InceptionV3", "backbone_replication/basic_inception_v3"),
("ResNet50", "backbone_replication/basic_resnet50"),
]
RESNET_VARIATIONS = [
("leaky CV", _load_phase2_classic_aucs, "imageonly_resnet50_leaky"),
("GT crop", _load_phase2_classic_aucs, "imageonly_resnet50_gtcrop_2.5"),
("U-Net crop", _load_phase2_classic_aucs, "imageonly_resnet50_unetcrop_2.5"),
("Anonymous CV", "backbone_replication/anonymous_cv_refugelike"),
("GT crop", "backbone_replication/gtcrop_refugelike"),
("U-Net crop", "backbone_replication/unetcrop_refugelike"),
]
BASELINE_LABEL = "baseline\n(fine-tuned ResNet50)"
BASELINE_DATA = (_load_phase2_classic_aucs, "imageonly_refugelike_proper")
BASELINE_REL = "refuge_v2m_baseline/img_solo_single_refugelike"
def render() -> None:
# Load everything
block1 = [(lbl, fn(arg)) for lbl, fn, arg in BASIC_BACKBONES]
block2 = [(lbl, fn(arg)) for lbl, fn, arg in RESNET_VARIATIONS]
base_fn, base_arg = BASELINE_DATA
base_aucs = base_fn(base_arg)
block1 = [(lbl, _load_fold_aucs(rel)) for lbl, rel in BASIC_BACKBONES]
block2 = [(lbl, _load_fold_aucs(rel)) for lbl, rel in RESNET_VARIATIONS]
base_aucs = _load_fold_aucs(BASELINE_REL)
print("Block 1 Basic backbones:")
print("Block 1 - Basic backbones (ImageNet pretraining):")
for lbl, a in block1:
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}" if len(a) else f" {lbl:<14s} no data")
print("Block 2 — ResNet50 variations:")
if len(a):
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.4f} +/- {a.std():.4f}")
else:
print(f" {lbl:<14s} no data")
print("Block 2 - ResNet50 (REFUGE) variations:")
for lbl, a in block2:
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}" if len(a) else f" {lbl:<14s} no data")
print(f"Block 3 — Baseline: n={len(base_aucs)} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}" if len(base_aucs) else "Block 3 — no baseline data")
if len(a):
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.4f} +/- {a.std():.4f}")
else:
print(f" {lbl:<14s} no data")
if len(base_aucs):
print(f"Block 3 - Baseline: n={len(base_aucs)} "
f"mean={base_aucs.mean():.4f} +/- {base_aucs.std():.4f}")
else:
print("Block 3 - no baseline data")
# Lay out positions
gap = 0.7
@@ -135,7 +138,6 @@ def render() -> None:
section3_left = p
pos.append(p)
section3_right = p
total_w = p + 0.6
fig, ax = plt.subplots(figsize=(13, 5.8))
fig.suptitle("Backbone Selection", fontsize=13, fontweight="bold")
@@ -156,10 +158,10 @@ def render() -> None:
all_labels.append(lbl); all_aucs.append(a); all_colors.append(C_VAR)
all_labels.append(BASELINE_LABEL); all_aucs.append(base_aucs); all_colors.append(C_BASE)
# Draw boxes
for x, aucs, color in zip(pos, all_aucs, all_colors):
if not len(aucs): continue
bp = ax.boxplot(
if not len(aucs):
continue
ax.boxplot(
aucs, positions=[x], widths=box_w, patch_artist=True, manage_ticks=False,
boxprops=dict(facecolor=color, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops,
@@ -168,31 +170,27 @@ def render() -> None:
flierprops=flierprops,
)
# Baseline median reference line spanning the variant blocks
if len(base_aucs):
ax.axhline(np.median(base_aucs),
color=C_BASE, linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median")
# Dividers between sections (vertical light lines)
div1 = (section1_right + section2_left) / 2
div2 = (section2_right + section3_left) / 2
for d in (div1, div2):
ax.axvline(d, color="#aaa", linewidth=0.7, alpha=0.65, linestyle="-")
# Section labels just above each block
y_band = 1.02
section_centers = [
((pos[0] + section1_right) / 2, "Basic backbones (img-only, single)"),
((section2_left + section2_right) / 2, "ResNet50 variations"),
((section3_left + section3_right) / 2, "Baseline"),
((pos[0] + section1_right) / 2, "Basic backbones (img-only, single)"),
((section2_left + section2_right) / 2, "ResNet50 variations"),
((section3_left + section3_right) / 2, "Baseline"),
]
for cx, txt in section_centers:
ax.text(cx, y_band, txt, ha="center", va="bottom",
fontsize=10, color="#333", fontweight="bold",
transform=ax.get_xaxis_transform())
# X-tick labels (with p-values vs baseline beneath each variant box)
tick_labels = []
for lbl, aucs, color in zip(all_labels, all_aucs, all_colors):
if color == C_BASE or not len(aucs) or not len(base_aucs):
+3 -3
View File
@@ -58,9 +58,9 @@ SEV_COLORS = {
"unknown": C_UNKNOWN,
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {"normal": 0.40, "unknown": 0.35, "early": 0.55, "moderate": 0.70, "severe": 0.85}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
SEV_ORDER = ["severe", "moderate", "unknown", "early", "normal"]
SEV_ALPHA = {"normal": 0.55, "unknown": 0.55, "early": 0.55, "moderate": 0.55, "severe": 0.55}
SEV_SIZE = {"normal": 8, "unknown": 8, "early": 8, "moderate": 8, "severe": 8}
# ── Per-panel definitions: (label, results dir, eval_stage) ──────────────────
# Top row: single-modality reference runs
+155
View File
@@ -0,0 +1,155 @@
"""F3b - Hadamard L1 fusion ROC: patient-grouped vs anonymous CV (overlay).
Single panel overlaying the ROC of the same single-eye img+cd Hadamard L1
fusion configuration evaluated under patient-grouped 5-fold CV (the headline
protocol) and patient-anonymous 5-fold CV (the prevailing benchmark protocol).
Both runs use matched seeds, matched backbones, matched training schedules;
only the fold-grouping rule changes.
For each configuration the figure shows:
- per-fold-rep ROC curves as faint coloured lines (50 curves per condition)
- mean ROC across fold-reps with a shaded SD band
- the rep-mean test AUC ± SD in a corner annotation
Re-run:
python -m v4.figures.F3b_anonymous_cv_roc
"""
from __future__ import annotations
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import roc_auc_score, roc_curve
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F3b_anonymous_cv_roc.png"
CONDITIONS = [
("Patient-grouped CV",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike",
"nt", "#1f6fb0"),
("Anonymous CV",
RESULTS_ROOT / "backbone_replication" / "anonymous_cv_ensemble_single_refugelike",
"nt", "#c44e52"),
]
# Common FPR grid for per-fold-rep ROC interpolation
FPR_GRID = np.linspace(0.0, 1.0, 201)
def collect_per_foldrep(run_dir: Path, eval_stage: str):
"""Return list of (y_true, y_score) tuples, one per (rep, fold)."""
per: list[tuple[np.ndarray, np.ndarray]] = []
for rep in sorted(run_dir.glob("rep*")):
fp = next(iter(rep.rglob("predictions.h5")), None)
if fp is None:
continue
with h5py.File(fp, "r") as f:
if eval_stage not in f:
continue
grp = f[eval_stage]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(int)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, n_outputs = logits.shape
if n_outputs != 2:
continue
ep, head = n_epochs - 1, n_heads - 1
for fold in range(n_folds):
labels = np.array(
[s.decode() if isinstance(s, bytes) else str(s) for s in split[fold]]
)
test_mask = labels == "test"
if not test_mask.any():
continue
lg = logits[fold, ep, test_mask, head, :]
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
y = y_true[test_mask]
s = p[:, 1]
per.append((y, s))
return per
def interp_tpr(y: np.ndarray, s: np.ndarray) -> np.ndarray:
if len(np.unique(y)) < 2:
return np.full_like(FPR_GRID, np.nan, dtype=float)
fpr, tpr, _ = roc_curve(y, s)
return np.interp(FPR_GRID, fpr, tpr)
def render() -> None:
fig, ax = plt.subplots(figsize=(7.4, 6.4))
# Diagonal reference first so it sits behind everything
ax.plot([0, 1], [0, 1], color="#aaa", linewidth=0.8, linestyle=":", zorder=1)
annotations = []
for label, run_dir, stage, color in CONDITIONS:
per = collect_per_foldrep(run_dir, stage)
if not per:
continue
tprs = np.array([interp_tpr(y, s) for (y, s) in per])
valid = ~np.isnan(tprs).any(axis=1)
tprs = tprs[valid]
per_aucs = np.array(
[roc_auc_score(y, s) for (y, s) in per if len(np.unique(y)) >= 2]
)
rep_means = (per_aucs.reshape(-1, 5).mean(axis=1)
if len(per_aucs) % 5 == 0 else per_aucs)
# Per-fold-rep curves
for (y, s) in per:
if len(np.unique(y)) < 2:
continue
fpr, tpr, _ = roc_curve(y, s)
ax.plot(fpr, tpr, color=color, linewidth=0.4, alpha=0.13, zorder=2)
# Mean ± SD band
mean_tpr = tprs.mean(axis=0)
sd_tpr = tprs.std(axis=0)
ax.fill_between(
FPR_GRID, np.clip(mean_tpr - sd_tpr, 0, 1),
np.clip(mean_tpr + sd_tpr, 0, 1),
color=color, alpha=0.20, zorder=3,
)
ax.plot(
FPR_GRID, mean_tpr, color=color, linewidth=2.2, zorder=4,
label=f"{label} (AUC = {rep_means.mean():.3f} ± {rep_means.std():.3f})",
)
annotations.append((label, rep_means))
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xlabel("False Positive Rate", fontsize=11)
ax.set_ylabel("True Positive Rate", fontsize=11)
ax.set_title(
"Hadamard L1 fusion ROC under matched architecture,\n"
"patient-grouped vs anonymous cross-validation",
fontsize=12, fontweight="bold",
)
ax.grid(alpha=0.25, linestyle="--")
ax.legend(loc="lower right", fontsize=10, framealpha=0.94)
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+6 -6
View File
@@ -58,15 +58,15 @@ SEV_LABELS = {
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ORDER = ["severe", "moderate", "unknown", "early", "normal"]
SEV_ALPHA = {
"normal": 0.40,
"unknown": 0.35,
"normal": 0.55,
"unknown": 0.55,
"early": 0.55,
"moderate": 0.70,
"severe": 0.85,
"moderate": 0.55,
"severe": 0.55,
}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
SEV_SIZE = {"normal": 8, "unknown": 8, "early": 8, "moderate": 8, "severe": 8}
# Panel grid: [row][col] = (label, run_dir, eval_stage)
GRID = [
+4 -1
View File
@@ -27,7 +27,10 @@ from sklearn.metrics import roc_curve, roc_auc_score
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F6_regression.png"
RUN_DIR = RESULTS_ROOT / "reg_head" / "baseline_reg_nt50"
# Points at the post-fix run that stores VF_MD as float64. The earlier
# baseline_reg_nt50 run stored y_true as int64, silently rounding the
# regression targets; do not mix the two.
RUN_DIR = RESULTS_ROOT / "reg_head" / "baseline_reg_nt50_floaty"
# Prediction-side bin boundaries
NP_THRESH = -1.097 # mean of measured-healthy MD
+72
View File
@@ -0,0 +1,72 @@
"""F8 combined explainability panel: disc-centred attention + quadrant breakdown.
Composes a single A/B figure from two existing renderings:
A: ``F8_gradcam/disc_attention_detail.png`` — 2x2 grid of mean Grad-CAM
heatmaps for {correct, incorrect} x {Normal, Glaucoma} cells, with the
mean disc boundary annotated as a dashed circle.
B: ``F8_quadrant_attention.png`` — grouped-bar chart of mean full-image
Grad-CAM fraction per optic-disc quadrant, by cell.
Both source panels are produced by ``v4.figures.F8_explainability`` and
``v4.figures.F8_quadrant_plot`` respectively; this script just stitches the
two PNGs into a single combined figure with A/B subfigure labels.
Re-run:
python -m v4.figures.F8_attention_combined
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image
SRC_A = Path(__file__).parent / "output" / "F8_gradcam" / "disc_attention_detail.png"
SRC_B = Path(__file__).parent / "output" / "F8_quadrant_attention.png"
OUT = Path(__file__).parent / "output" / "F8_attention_combined.png"
def render() -> None:
for p in (SRC_A, SRC_B):
if not p.exists():
raise SystemExit(
f"Source panel missing: {p}\n"
"Run F8_explainability (for A) and F8_quadrant_plot (for B) first."
)
img_a = Image.open(SRC_A)
img_b = Image.open(SRC_B)
# Stack vertically: A on top (square), B below (wider).
fig = plt.figure(figsize=(13.0, 13.6))
gs = fig.add_gridspec(
2, 1,
height_ratios=[img_a.size[1] / img_a.size[0],
img_b.size[1] / img_b.size[0] * 13.0 / 13.0],
hspace=0.06,
)
ax_a = fig.add_subplot(gs[0])
ax_a.imshow(img_a)
ax_a.axis("off")
ax_a.text(-0.01, 1.01, "A", transform=ax_a.transAxes,
ha="left", va="bottom", fontsize=22, fontweight="bold")
ax_b = fig.add_subplot(gs[1])
ax_b.imshow(img_b)
ax_b.axis("off")
ax_b.text(-0.01, 1.01, "B", transform=ax_b.transAxes,
ha="left", va="bottom", fontsize=22, fontweight="bold")
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+104 -15
View File
@@ -1,8 +1,10 @@
"""F8 - Explainability figures from the V2-M checkpointed v4 run.
"""F8 - Explainability figures from the R50 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).
``experiments/explainability/ensemble_refugelike_ckpt`` run (img+cd ensemble
with the refugelike R50 backbone, save_checkpoints=true). rep00 (seed=1234)
is the single rep used for the figure; matches the headline configuration in
section 3.
GradCAM machinery lives in ``v4.classes.accessory.explainability``; PAPILA
specific knowledge (disc contour rasterisation, OS→OD orientation flip) is
@@ -33,7 +35,7 @@ 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"
V4_CKPT_RUN = REPO_ROOT / "v4" / "results" / "experiments" / "explainability" / "ensemble_refugelike_ckpt" / "rep00" / "binary"
LABEL_NAMES = {0: "Normal", 1: "Glaucoma"}
EVENT_ORDER = [
@@ -43,11 +45,11 @@ EVENT_ORDER = [
]
EVENT_LABELS = {
"full_correction": "Both wrong -> fused right",
"img_assist": "Image right, MD wrong",
"md_assist": "MD right, image wrong",
"img_assist": "Image right, clinical wrong",
"md_assist": "Clinical 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",
"img_drag": "Clinical right, image wrong -> fused wrong",
"md_drag": "Image right, clinical wrong -> fused wrong",
"concordant_correct": "All correct",
"concordant_wrong": "All wrong",
}
@@ -339,6 +341,8 @@ def make_fusion_event_panel(split: str = "test") -> None:
ax.invert_yaxis()
ax.set_xlabel("Count")
ax.set_title("Fusion event taxonomy", fontsize=10, fontweight="bold")
max_count = max(display_counts[k] for k in bars) if bars else 0
ax.set_xlim(0, max_count * 1.10 + 1)
for yi, k in enumerate(bars):
ax.text(display_counts[k] + 0.8, yi, str(int(display_counts[k])),
va="center", fontsize=8)
@@ -346,7 +350,7 @@ def make_fusion_event_panel(split: str = "test") -> None:
ax = fig.add_subplot(gs[0, 1])
per_fold = pd.DataFrame(
{
"fold": [f"{r}/{f}" for (r, f), _ in fold_groups],
"fold": [str(f) for (_, 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],
}
@@ -437,10 +441,6 @@ def make_fusion_event_panel(split: str = "test") -> None:
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)
@@ -450,7 +450,7 @@ def make_fusion_event_panel(split: str = "test") -> None:
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
Isolates the clinical-only prediction path at the R50 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
@@ -590,7 +590,7 @@ def make_clinical_importance(n_permutations: int = 30, seed: int = 0) -> None:
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"S8e — Clinical permutation importance (cd-only head, R50 ckpt run)\n"
f"baseline AUC = {baseline_mean:.3f}; n_permutations = {n_permutations}",
fontsize=10, fontweight="bold",
)
@@ -692,6 +692,60 @@ def _disc_centred_patch_array(
return patch_out.astype(np.float32), out * disc_r / (2 * half)
QUAD_ORDER = ("ST", "SN", "IT", "IN") # superotemporal, superonasal, inferotemporal, inferonasal
def _quadrant_fractions(
cam: np.ndarray,
disc_mask: np.ndarray,
*,
peri_inner: float = 1.0,
peri_outer: float = 2.0,
) -> tuple[dict[str, float], dict[str, float], dict[str, float]] | tuple[None, None, None]:
"""Per-quadrant Grad-CAM fractions in OD-oriented coordinates, for three
region scopes.
Quadrant boundaries are the disc-mask centroid (cx, cy). In the OD-oriented
frame nasal is left (x < cx) and temporal is right (x > cx); superior is
top (y < cy) and inferior is bottom (y > cy):
ST = x > cx, y < cy
SN = x < cx, y < cy
IT = x > cx, y > cy
IN = x < cx, y > cy
Three region scopes are returned:
disc_q : fractions of CAM intensity that fall inside the GT disc mask
peri_q : fractions inside a peri-disc annulus of disc-radius units
(peri_inner to peri_outer, default 1x-2x), excluding the disc
full_q : fractions over the entire image
Each dict sums to 1 (within floating-point error). Returns (None, None, None)
if the disc mask is empty.
"""
if disc_mask is None or disc_mask.sum() == 0:
return None, None, None
ys, xs = np.where(disc_mask)
cy = float(ys.mean()); cx = float(xs.mean())
disc_r = float(np.sqrt(disc_mask.sum() / np.pi))
h, w = cam.shape
yy, xx = np.mgrid[0:h, 0:w]
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
peri_mask = (dist >= peri_inner * disc_r) & (dist <= peri_outer * disc_r) & ~disc_mask
quads = {
"ST": (xx > cx) & (yy < cy),
"SN": (xx < cx) & (yy < cy),
"IT": (xx > cx) & (yy > cy),
"IN": (xx < cx) & (yy > cy),
}
disc_total = float(cam[disc_mask].sum()) + 1e-8
peri_total = float(cam[peri_mask].sum()) + 1e-8
full_total = float(cam.sum()) + 1e-8
disc_q = {k: float(cam[disc_mask & q].sum()) / disc_total for k, q in quads.items()}
peri_q = {k: float(cam[peri_mask & q].sum()) / peri_total for k, q in quads.items()}
full_q = {k: float(cam[q].sum()) / full_total for k, q in quads.items()}
return disc_q, peri_q, full_q
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
@@ -868,6 +922,10 @@ def _make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
disc_patch_count: dict[tuple[str, str], int] = {}
disc_radius_sum: dict[tuple[str, str], float] = {}
disc_frac_sum: dict[tuple[str, str], float] = {}
# Per-eye quadrant fractions for three region scopes; aggregated per cell.
quad_disc_list: dict[tuple[str, str], list[dict[str, float]]] = {}
quad_peri_list: dict[tuple[str, str], list[dict[str, float]]] = {}
quad_full_list: dict[tuple[str, str], list[dict[str, float]]] = {}
examples: dict[tuple[str, str], tuple[np.ndarray, int, str, int]] = {}
fold_range = range(cfg.get("folds", 5))
@@ -927,6 +985,11 @@ def _make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
disc_frac_sum[key] = disc_frac_sum.get(key, 0.0) + float(
cam_np[roi_mask].sum() / (cam_np.sum() + 1e-8)
)
disc_q, peri_q, full_q = _quadrant_fractions(cam_np, roi_mask)
if disc_q is not None:
quad_disc_list.setdefault(key, []).append(disc_q)
quad_peri_list.setdefault(key, []).append(peri_q)
quad_full_list.setdefault(key, []).append(full_q)
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))
@@ -1020,6 +1083,32 @@ def _make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
if mean_patches:
_make_oriented_disc_detail(mean_patches, examples, GRADCAM_DIR / "disc_attention_detail.png")
# Per-quadrant CAM fractions (within-disc and full-image scopes).
# One CSV row per (class, outcome, scope, quadrant) cell, with mean and SD
# computed over the per-eye fractions in that cell.
rows = []
for key in sorted(quad_disc_list.keys()):
cls_name, outcome = key
n_eyes = len(quad_disc_list[key])
for scope_label, scope_list in (("disc", quad_disc_list[key]),
("peri", quad_peri_list[key]),
("full", quad_full_list[key])):
for q in QUAD_ORDER:
vals = np.array([d[q] for d in scope_list], dtype=np.float64)
rows.append({
"class": cls_name,
"outcome": outcome,
"scope": scope_label,
"quadrant": q,
"n_eyes": n_eyes,
"mean": float(vals.mean()),
"sd": float(vals.std(ddof=1)) if n_eyes > 1 else float("nan"),
})
if rows:
out_csv = OUT_DIR / "F8_quadrant_fractions.csv"
pd.DataFrame(rows).to_csv(out_csv, index=False)
print(f"saved quadrant fractions: {out_csv}")
def make_oriented_gradcam(n_grid: int = 16, alpha: float = 0.45,
target_class: int | None = None) -> None:
+123
View File
@@ -0,0 +1,123 @@
"""F8 quadrant attention chart (full-image scope).
Reads the per-cell quadrant fractions produced by
``v4.figures.F8_explainability`` (see CSV at
``output/F8_quadrant_fractions.csv``) and renders a single-panel grouped bar
chart of full-image Grad-CAM intensity by optic-disc quadrant. Within-disc
and peri-disc scopes are present in the CSV but are not plotted here; see
the CSV for the per-cell numbers if you need them.
Re-run:
python -m v4.figures.F8_quadrant_plot
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
CSV = Path(__file__).parent / "output" / "F8_quadrant_fractions.csv"
OUT = Path(__file__).parent / "output" / "F8_quadrant_attention.png"
QUAD_ORDER = ("ST", "SN", "IT", "IN")
QUAD_LABEL = {
"ST": "Superotemporal",
"SN": "Superonasal",
"IT": "Inferotemporal",
"IN": "Inferonasal",
}
CELL_ORDER = [
("Normal", "correct"),
("Normal", "incorrect"),
("Glaucoma", "correct"),
("Glaucoma", "incorrect"),
]
CELL_COLOR = {
("Normal", "correct"): "#3B6FB5",
("Normal", "incorrect"): "#8BB0DA",
("Glaucoma", "correct"): "#c44e52",
("Glaucoma", "incorrect"): "#e6a3a4",
}
CELL_LABEL = {
("Normal", "correct"): "Normal correct",
("Normal", "incorrect"): "Normal incorrect",
("Glaucoma", "correct"): "Glaucoma correct",
("Glaucoma", "incorrect"): "Glaucoma incorrect",
}
def render() -> None:
if not CSV.exists():
raise SystemExit(
f"CSV {CSV} not found. Run `python -m v4.figures.F8_explainability "
"--only-gradcam --run-gradcam` first."
)
df = pd.read_csv(CSV)
df_full = df[df["scope"] == "full"]
fig, ax = plt.subplots(figsize=(9.6, 6.0))
n_quad = len(QUAD_ORDER)
n_cell = len(CELL_ORDER)
bar_w = 0.18
x = np.arange(n_quad, dtype=float)
for i, cell in enumerate(CELL_ORDER):
means, sds = [], []
n_eyes = None
for q in QUAD_ORDER:
row = df_full[
(df_full["class"] == cell[0])
& (df_full["outcome"] == cell[1])
& (df_full["quadrant"] == q)
]
means.append(float(row["mean"].iloc[0]) if len(row) else float("nan"))
sds.append(float(row["sd"].iloc[0]) if len(row) else float("nan"))
if n_eyes is None and len(row):
n_eyes = int(row["n_eyes"].iloc[0])
offsets = (i - (n_cell - 1) / 2.0) * bar_w
bars = ax.bar(
x + offsets, means, width=bar_w,
yerr=sds, capsize=2,
color=CELL_COLOR[cell], alpha=0.92,
edgecolor="black", linewidth=0.6,
label=f"{CELL_LABEL[cell]} (n = {n_eyes})",
error_kw=dict(ecolor="#444", linewidth=0.8, capthick=0.8),
)
for rect, m in zip(bars, means):
if np.isnan(m):
continue
ax.text(rect.get_x() + rect.get_width() / 2, m + 0.005,
f"{m:.2f}", ha="center", va="bottom",
fontsize=8.5, color="#222")
ax.set_xticks(x)
ax.set_xticklabels([QUAD_LABEL[q] for q in QUAD_ORDER], fontsize=11)
ax.set_ylabel("Mean fraction of full-image Grad-CAM intensity", fontsize=11)
ax.set_title(
"Image-tower Grad-CAM by optic-disc quadrant (OD-oriented; centroid-split)",
fontsize=12.5, fontweight="bold",
)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.set_ylim(0, max(ax.get_ylim()[1], 0.7))
ax.axhline(0.25, color="#888", linestyle=":", linewidth=0.8, alpha=0.6, zorder=0)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.10), ncol=4,
framealpha=0.94, fontsize=10)
fig.tight_layout(rect=(0, 0.04, 1, 0.98))
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+277
View File
@@ -0,0 +1,277 @@
"""S2 - Variance decomposition of the four-bridge L1 fusion comparison.
Two-panel supplementary figure summarising the variance decomposition
reported alongside section 3.2 of the manuscript.
Panel A (left): paired-line plot.
x-axis : the 4 bridge variants (Concat, Pairwise, Gated, Hadamard)
y-axis : eye-level test AUC
each line : one fold-rep, connecting that fold-rep's 4 bridge AUCs
overlay : per-bridge boxplot showing the marginal AUC distribution
annotation : pooled across-architecture and across-fold-rep SDs,
and the SD ratio
Panel B (right): centered-offset KDEs.
x-axis : AUC offset from grouping mean (centered at 0)
y-axis : density
four coloured curves : per-bridge fold-rep distributions (50 fold-reps
per bridge, centered by subtracting each bridge's
own mean)
dashed dark curve : architectural offset distribution (200 values,
centered by subtracting each fold-rep's mean)
annotation : variance ratio
Read directly from v4/results/experiments/{phase3_v4,refuge_v2m_baseline}.
Re-run:
python -m v4.figures.S2_variance_decomposition
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import gaussian_kde
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "S2_variance_decomposition.png"
# Bridge label, results dir relative to experiments/, and stage key
BRIDGES = [
("Concat", "phase3_v4/single_bcd_concat"),
("Pairwise", "phase3_v4/single_bcd_pairwise"),
("Gated", "phase3_v4/single_bcd_gated"),
("Hadamard", "refuge_v2m_baseline/ensemble_single_refugelike"),
]
STAGE_KEY = "nt_test_auc"
# Panel-A colour palette (paired lines + boxplots)
C_LINE = "#1f6fb0" # single blue for all paired-cell lines
C_LINE_ALPHA = 0.22
C_MARKER = "#1f6fb0"
C_MEDIAN = "#c44e52" # red box median line
C_BOX_FILL = "#dbe6f0" # pale blue box fill
# Panel-B colour palette (per-bridge KDEs)
C_ARCH = "#222" # dark grey for the architectural curve
BRIDGE_COLORS = {
"Concat": "#7f7f7f",
"Pairwise": "#ff7f0e",
"Gated": "#2ca02c",
"Hadamard": "#1f77b4",
}
# ── Data loading + decomposition ────────────────────────────────────────────
def collect() -> pd.DataFrame:
rows = []
for label, rel in BRIDGES:
root = RESULTS_ROOT / rel
for s in sorted(root.glob("rep*/binary/summary.json")):
rep = int(s.parents[1].name.replace("rep", ""))
d = json.loads(s.read_text())
for fr in d.get("fold_results", []):
v = fr.get(STAGE_KEY)
if v is None or not np.isfinite(v):
continue
rows.append({
"bridge": label,
"rep": rep,
"fold": int(fr["fold"]),
"auc": float(v),
})
return pd.DataFrame(rows)
def decompose(df: pd.DataFrame) -> tuple[float, float, float, dict[str, float]]:
"""Return (arch_sd_pooled, fold_sd_pooled, ratio, per_bridge_sd)."""
cell_var = df.groupby(["rep", "fold"])["auc"].var(ddof=1)
bridge_var = df.groupby("bridge")["auc"].var(ddof=1)
arch_sd = float(np.sqrt(cell_var.mean()))
fold_sd = float(np.sqrt(bridge_var.mean()))
ratio = fold_sd / arch_sd if arch_sd > 0 else float("inf")
per_bridge_sd = {b: float(np.sqrt(v)) for b, v in bridge_var.items()}
return arch_sd, fold_sd, ratio, per_bridge_sd
# ── Panel A: paired-line plot ───────────────────────────────────────────────
def draw_panel_a(ax, df: pd.DataFrame,
arch_sd: float, fold_sd: float, ratio: float,
n_cells: int) -> None:
bridge_order = [b for b, _ in BRIDGES]
pivot = df.pivot_table(
index=["rep", "fold"], columns="bridge", values="auc"
)[bridge_order]
x_positions = np.arange(len(bridge_order), dtype=float)
# Paired cell lines: one per fold-rep
for _, row in pivot.iterrows():
if row.isna().any():
continue
ax.plot(
x_positions, row.values, color=C_LINE, alpha=C_LINE_ALPHA,
linewidth=0.9, marker="o", markersize=2.0,
markerfacecolor=C_MARKER, markeredgecolor="none", zorder=2,
)
# Per-bridge boxplot
box_data = [pivot[b].dropna().values for b in bridge_order]
ax.boxplot(
box_data,
positions=x_positions,
widths=0.32,
patch_artist=True,
manage_ticks=False,
zorder=3,
boxprops=dict(facecolor=C_BOX_FILL, edgecolor="black",
linewidth=1.0, alpha=0.85),
whiskerprops=dict(color="black", linewidth=0.9),
capprops=dict(color="black", linewidth=0.9),
medianprops=dict(color=C_MEDIAN, linewidth=1.8),
flierprops=dict(marker="", markersize=0),
)
ax.set_xticks(x_positions)
ax.set_xticklabels(bridge_order, fontsize=11)
ax.set_xlabel("L1 fusion bridge", fontsize=11)
ax.set_ylabel("Eye-level test AUC", fontsize=11)
ax.set_xlim(-0.5, len(bridge_order) - 0.5)
ax.grid(axis="y", alpha=0.3, linestyle="--")
txt = (
f"n = {n_cells} fold-rep AUC values | variance ratio {ratio:.2f}\n"
f" across-architecture SD = {arch_sd:.3f}\n"
f" across-fold-rep SD = {fold_sd:.3f}"
)
ax.text(
0.985, 0.025, txt, transform=ax.transAxes,
ha="right", va="bottom", fontsize=9.5, family="monospace",
bbox=dict(boxstyle="round,pad=0.5", facecolor="white",
edgecolor="#888", alpha=0.92),
)
# ── Panel B: centered-offset KDE curves ─────────────────────────────────────
def draw_panel_b(ax, df: pd.DataFrame,
arch_sd: float, fold_sd: float, ratio: float,
per_bridge_sd: dict[str, float]) -> None:
df = df.copy()
fold_rep_mean = df.groupby(["rep", "fold"])["auc"].transform("mean")
bridge_mean = df.groupby("bridge")["auc"].transform("mean")
df["arch_offset"] = df["auc"] - fold_rep_mean
df["fold_offset"] = df["auc"] - bridge_mean
all_offsets = np.concatenate(
[df["arch_offset"].values, df["fold_offset"].values]
)
x_max = float(np.abs(all_offsets).max()) * 1.10
xs = np.linspace(-x_max, x_max, 600)
# Per-bridge fold-rep offset curves
for b in [name for name, _ in BRIDGES]:
offs = df.loc[df["bridge"] == b, "fold_offset"].values
kde = gaussian_kde(offs)
y = kde(xs)
sd = per_bridge_sd[b]
ax.plot(xs, y, color=BRIDGE_COLORS[b], linewidth=1.6, alpha=0.92,
zorder=3,
label=f"{b} SD = {sd:.3f}")
# Architectural offset curve (pooled)
arch_offsets = df["arch_offset"].values
kde_arch = gaussian_kde(arch_offsets)
y_arch = kde_arch(xs)
ax.fill_between(xs, y_arch, color=C_ARCH, alpha=0.18, zorder=2)
ax.plot(xs, y_arch, color=C_ARCH, linewidth=2.2, linestyle="--", zorder=4,
label=f"Architectural SD = {arch_sd:.3f}")
ax.axvline(0, color="#666", linewidth=0.8, linestyle=":",
alpha=0.6, zorder=0)
ax.set_xlabel("AUC offset from grouping mean", fontsize=11)
ax.set_ylabel("Probability density", fontsize=11)
ax.set_xlim(-x_max, x_max)
ax.set_yticklabels([])
ax.tick_params(axis="y", which="both", left=True, labelleft=False)
ax.grid(axis="y", alpha=0.25, linestyle="--")
# Headroom on the y-axis so the variance-ratio box does not crowd the
# architectural-curve peak.
ymin, ymax = ax.get_ylim()
ax.set_ylim(0, ymax * 1.10)
# Legend below the top so it clears the variance-ratio annotation.
ax.legend(
loc="upper left", bbox_to_anchor=(0.0, 0.82),
fontsize=9, framealpha=0.92,
)
txt = f"variance ratio fold-SD / arch-SD = {ratio:.2f}"
ax.text(
0.985, 0.975, txt, transform=ax.transAxes,
ha="right", va="top", fontsize=10.5, family="monospace",
bbox=dict(boxstyle="round,pad=0.45", facecolor="white",
edgecolor="#888", alpha=0.92),
)
# ── Combined render ─────────────────────────────────────────────────────────
def render() -> None:
df = collect()
if df.empty:
print("No data collected; check the source paths.")
return
n_cells = df.groupby(["rep", "fold"]).ngroups
n_bridges = df["bridge"].nunique()
arch_sd, fold_sd, ratio, per_bridge_sd = decompose(df)
print(f"Collected {len(df)} observations ({n_bridges} bridges x {n_cells} cells)")
print(f" across-architecture SD (within cell) : {arch_sd:.4f}")
print(f" across-fold-rep SD (within bridge) : {fold_sd:.4f}")
print(f" ratio fold-SD / arch-SD : {ratio:.2f}x")
print("Per-bridge fold-rep SDs:")
for b in [name for name, _ in BRIDGES]:
print(f" {b:<10s} SD = {per_bridge_sd[b]:.4f}")
fig, (axA, axB) = plt.subplots(
nrows=1, ncols=2, figsize=(16.0, 6.0),
gridspec_kw=dict(wspace=0.22),
)
draw_panel_a(axA, df, arch_sd, fold_sd, ratio, n_cells)
draw_panel_b(axB, df, arch_sd, fold_sd, ratio, per_bridge_sd)
# Subfigure labels
for ax, label in ((axA, "A"), (axB, "B")):
ax.text(
-0.07, 1.03, label, transform=ax.transAxes,
ha="left", va="bottom", fontsize=15, fontweight="bold",
)
fig.suptitle(
"Variance decomposition: fold-assignment noise vs L1 bridge choice",
fontsize=13.0, fontweight="bold", y=1.00,
)
fig.tight_layout(rect=(0, 0, 1, 0.97))
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+162
View File
@@ -0,0 +1,162 @@
"""S3 - Centered-offset distributions of architectural vs fold-rep variance.
Companion to S2, presenting the same variance decomposition as two
overlapping KDE curves on a common centered axis.
For each of the 200 (fold-rep, bridge) AUC observations, compute two
mean-centered offsets:
architectural offset = AUC - mean(AUC over the 4 bridges in that fold-rep)
fold-rep offset = AUC - mean(AUC over the 50 fold-reps for that bridge)
Both sets have 200 values, both are centered at 0 by construction, and the
spread of each distribution corresponds directly to one of the two SDs in
the variance decomposition. Plotted as KDE curves on a shared x-axis, the
ratio of their widths is the variance ratio reported in S2.
Re-run:
python -m v4.figures.S3_variance_distributions
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import gaussian_kde
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "S3_variance_distributions.png"
BRIDGES = [
("Concat", "phase3_v4/single_bcd_concat"),
("Pairwise", "phase3_v4/single_bcd_pairwise"),
("Gated", "phase3_v4/single_bcd_gated"),
("Hadamard", "refuge_v2m_baseline/ensemble_single_refugelike"),
]
STAGE_KEY = "nt_test_auc"
C_ARCH = "#222" # dark grey for the architectural curve
BRIDGE_COLORS = {
"Concat": "#7f7f7f", # grey (underperformer)
"Pairwise": "#ff7f0e", # orange
"Gated": "#2ca02c", # green
"Hadamard": "#1f77b4", # blue (default)
}
def collect() -> pd.DataFrame:
rows = []
for label, rel in BRIDGES:
root = RESULTS_ROOT / rel
for s in sorted(root.glob("rep*/binary/summary.json")):
rep = int(s.parents[1].name.replace("rep", ""))
d = json.loads(s.read_text())
for fr in d.get("fold_results", []):
v = fr.get(STAGE_KEY)
if v is None or not np.isfinite(v):
continue
rows.append({
"bridge": label,
"rep": rep,
"fold": int(fr["fold"]),
"auc": float(v),
})
return pd.DataFrame(rows)
def render() -> None:
df = collect()
if df.empty:
print("No data collected.")
return
# Compute centering offsets per (fold-rep, bridge) cell
fold_rep_mean = df.groupby(["rep", "fold"])["auc"].transform("mean")
bridge_mean = df.groupby("bridge")["auc"].transform("mean")
df["arch_offset"] = df["auc"] - fold_rep_mean
df["fold_offset"] = df["auc"] - bridge_mean
# Use the same SD formula as S2 (within-group SD averaged across groups)
# so the two figures report identical pooled numbers.
cell_var = df.groupby(["rep", "fold"])["auc"].var(ddof=1)
bridge_var = df.groupby("bridge")["auc"].var(ddof=1)
arch_sd_pooled = float(np.sqrt(cell_var.mean()))
fold_sd_pooled = float(np.sqrt(bridge_var.mean()))
ratio = fold_sd_pooled / arch_sd_pooled if arch_sd_pooled > 0 else float("inf")
# Per-bridge fold-rep SDs (50 fold-reps per bridge)
per_bridge_sd = {b: float(np.sqrt(v)) for b, v in bridge_var.items()}
print(f"n cells = {len(df)}")
print(f"architectural SD (pooled, within-fold-rep avg) = {arch_sd_pooled:.4f}")
print(f"fold-rep SD (pooled, within-bridge avg) = {fold_sd_pooled:.4f}")
print(f"ratio fold-SD / arch-SD = {ratio:.2f}")
print("Per-bridge fold-rep SDs:")
for b in [name for name, _ in BRIDGES]:
print(f" {b:<10s} SD = {per_bridge_sd[b]:.4f}")
# KDE x-axis: cover the union of all offset ranges
all_offsets = np.concatenate([df["arch_offset"].values, df["fold_offset"].values])
x_max = float(np.abs(all_offsets).max()) * 1.10
xs = np.linspace(-x_max, x_max, 600)
fig, ax = plt.subplots(figsize=(9.4, 5.6))
# Per-bridge fold-rep offset curves (4 curves, 50 values each)
bridge_order = [name for name, _ in BRIDGES]
for b in bridge_order:
offs = df.loc[df["bridge"] == b, "fold_offset"].values
kde = gaussian_kde(offs)
y = kde(xs)
sd = per_bridge_sd[b]
ax.plot(xs, y, color=BRIDGE_COLORS[b], linewidth=1.6, alpha=0.92,
zorder=3,
label=f"{b} fold-rep SD = {sd:.4f}")
# Architectural offset curve (200 values pooled across cells)
arch_offsets = df["arch_offset"].values
kde_arch = gaussian_kde(arch_offsets)
y_arch = kde_arch(xs)
ax.fill_between(xs, y_arch, color=C_ARCH, alpha=0.18, zorder=2)
ax.plot(xs, y_arch, color=C_ARCH, linewidth=2.2, linestyle="--", zorder=4,
label=f"Architectural (pooled) SD = {arch_sd_pooled:.4f}")
# Mean line at 0 (every distribution is centered there)
ax.axvline(0, color="#666", linewidth=0.8, linestyle=":", alpha=0.6, zorder=0)
ax.set_xlabel("AUC offset from grouping mean", fontsize=11)
ax.set_ylabel("Density", fontsize=11)
ax.set_xlim(-x_max, x_max)
ax.grid(axis="y", alpha=0.25, linestyle="--")
ax.legend(loc="upper left", fontsize=9.5, framealpha=0.92)
# Top-right annotation with the variance ratio
txt = f"variance ratio fold-SD / arch-SD = {ratio:.2f}"
ax.text(
0.985, 0.975, txt, transform=ax.transAxes,
ha="right", va="top", fontsize=10.5, family="monospace",
bbox=dict(boxstyle="round,pad=0.45", facecolor="white",
edgecolor="#888", alpha=0.92),
)
fig.suptitle(
"Architectural vs fold-rep variance: centered-offset distributions",
fontsize=12.5, fontweight="bold", y=0.995,
)
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1,49 @@
class,outcome,scope,quadrant,n_eyes,mean,sd
Glaucoma,correct,disc,ST,46,0.14268406172610618,0.08672064804334276
Glaucoma,correct,disc,SN,46,0.1891374642326569,0.15141795670756453
Glaucoma,correct,disc,IT,46,0.28698060971321787,0.1321960653720743
Glaucoma,correct,disc,IN,46,0.3811978502755397,0.17407907371247724
Glaucoma,correct,peri,ST,46,0.0801709241030986,0.08494386999472202
Glaucoma,correct,peri,SN,46,0.15918948407117592,0.20257575474186357
Glaucoma,correct,peri,IT,46,0.34826832191551693,0.2606073381849083
Glaucoma,correct,peri,IN,46,0.41237125804664543,0.249789089870656
Glaucoma,correct,full,ST,46,0.09742898919008372,0.08228642449987539
Glaucoma,correct,full,SN,46,0.18659980165443252,0.20125297725836833
Glaucoma,correct,full,IT,46,0.36286098988076154,0.26458782651352963
Glaucoma,correct,full,IN,46,0.35311022712131146,0.2246580892242915
Glaucoma,incorrect,disc,ST,34,0.252112440145696,0.15034014496616682
Glaucoma,incorrect,disc,SN,34,0.1746002632983389,0.09578946023984732
Glaucoma,incorrect,disc,IT,34,0.3293620129186265,0.17212955919145329
Glaucoma,incorrect,disc,IN,34,0.24392528597147015,0.17660301429323946
Glaucoma,incorrect,peri,ST,34,0.20385530932089996,0.17952535419921464
Glaucoma,incorrect,peri,SN,34,0.15875330500366588,0.15449712019023593
Glaucoma,incorrect,peri,IT,34,0.3862569142356727,0.2418765080584544
Glaucoma,incorrect,peri,IN,34,0.25113447428742586,0.24028533916258976
Glaucoma,incorrect,full,ST,34,0.21217464524321497,0.15540407279111246
Glaucoma,incorrect,full,SN,34,0.16330973974559065,0.12739118311502012
Glaucoma,incorrect,full,IT,34,0.37856896329311157,0.20473194944523931
Glaucoma,incorrect,full,IN,34,0.24594665148198902,0.20319230952963316
Normal,correct,disc,ST,313,0.21529517366946307,0.0928060891129116
Normal,correct,disc,SN,313,0.1571976673739274,0.08983072836980605
Normal,correct,disc,IT,313,0.3536251021009069,0.14392434591451472
Normal,correct,disc,IN,313,0.27388205685844386,0.13935549411695708
Normal,correct,peri,ST,313,0.16039412908580125,0.10491506604844639
Normal,correct,peri,SN,313,0.11198831344232964,0.1038424179426872
Normal,correct,peri,IT,313,0.4372451776941388,0.22439216417926178
Normal,correct,peri,IN,313,0.29037237912573877,0.21697235549864913
Normal,correct,full,ST,313,0.17585699926973924,0.09265567144523121
Normal,correct,full,SN,313,0.1305376994147368,0.09449518811759064
Normal,correct,full,IT,313,0.4112080738659084,0.19447938630719017
Normal,correct,full,IN,313,0.28239722323230754,0.18195870133945977
Normal,incorrect,disc,ST,27,0.12299851888544425,0.11261550366741999
Normal,incorrect,disc,SN,27,0.2121276641099002,0.1995218721251311
Normal,incorrect,disc,IT,27,0.26471045076221783,0.17809359884921286
Normal,incorrect,disc,IN,27,0.40016336326290614,0.22355291760060642
Normal,incorrect,peri,ST,27,0.07760201435962506,0.10155210979149742
Normal,incorrect,peri,SN,27,0.1965738090304749,0.2495116802723263
Normal,incorrect,peri,IT,27,0.2814199416369157,0.2308903646345705
Normal,incorrect,peri,IN,27,0.4444042475568166,0.27092685369261976
Normal,incorrect,full,ST,27,0.09632595685606479,0.13797078057713352
Normal,incorrect,full,SN,27,0.20212570434383328,0.2336043948759492
Normal,incorrect,full,IT,27,0.3081102746977408,0.2523555953549531
Normal,incorrect,full,IN,27,0.3934380562908973,0.24633953116106946
1 class outcome scope quadrant n_eyes mean sd
2 Glaucoma correct disc ST 46 0.14268406172610618 0.08672064804334276
3 Glaucoma correct disc SN 46 0.1891374642326569 0.15141795670756453
4 Glaucoma correct disc IT 46 0.28698060971321787 0.1321960653720743
5 Glaucoma correct disc IN 46 0.3811978502755397 0.17407907371247724
6 Glaucoma correct peri ST 46 0.0801709241030986 0.08494386999472202
7 Glaucoma correct peri SN 46 0.15918948407117592 0.20257575474186357
8 Glaucoma correct peri IT 46 0.34826832191551693 0.2606073381849083
9 Glaucoma correct peri IN 46 0.41237125804664543 0.249789089870656
10 Glaucoma correct full ST 46 0.09742898919008372 0.08228642449987539
11 Glaucoma correct full SN 46 0.18659980165443252 0.20125297725836833
12 Glaucoma correct full IT 46 0.36286098988076154 0.26458782651352963
13 Glaucoma correct full IN 46 0.35311022712131146 0.2246580892242915
14 Glaucoma incorrect disc ST 34 0.252112440145696 0.15034014496616682
15 Glaucoma incorrect disc SN 34 0.1746002632983389 0.09578946023984732
16 Glaucoma incorrect disc IT 34 0.3293620129186265 0.17212955919145329
17 Glaucoma incorrect disc IN 34 0.24392528597147015 0.17660301429323946
18 Glaucoma incorrect peri ST 34 0.20385530932089996 0.17952535419921464
19 Glaucoma incorrect peri SN 34 0.15875330500366588 0.15449712019023593
20 Glaucoma incorrect peri IT 34 0.3862569142356727 0.2418765080584544
21 Glaucoma incorrect peri IN 34 0.25113447428742586 0.24028533916258976
22 Glaucoma incorrect full ST 34 0.21217464524321497 0.15540407279111246
23 Glaucoma incorrect full SN 34 0.16330973974559065 0.12739118311502012
24 Glaucoma incorrect full IT 34 0.37856896329311157 0.20473194944523931
25 Glaucoma incorrect full IN 34 0.24594665148198902 0.20319230952963316
26 Normal correct disc ST 313 0.21529517366946307 0.0928060891129116
27 Normal correct disc SN 313 0.1571976673739274 0.08983072836980605
28 Normal correct disc IT 313 0.3536251021009069 0.14392434591451472
29 Normal correct disc IN 313 0.27388205685844386 0.13935549411695708
30 Normal correct peri ST 313 0.16039412908580125 0.10491506604844639
31 Normal correct peri SN 313 0.11198831344232964 0.1038424179426872
32 Normal correct peri IT 313 0.4372451776941388 0.22439216417926178
33 Normal correct peri IN 313 0.29037237912573877 0.21697235549864913
34 Normal correct full ST 313 0.17585699926973924 0.09265567144523121
35 Normal correct full SN 313 0.1305376994147368 0.09449518811759064
36 Normal correct full IT 313 0.4112080738659084 0.19447938630719017
37 Normal correct full IN 313 0.28239722323230754 0.18195870133945977
38 Normal incorrect disc ST 27 0.12299851888544425 0.11261550366741999
39 Normal incorrect disc SN 27 0.2121276641099002 0.1995218721251311
40 Normal incorrect disc IT 27 0.26471045076221783 0.17809359884921286
41 Normal incorrect disc IN 27 0.40016336326290614 0.22355291760060642
42 Normal incorrect peri ST 27 0.07760201435962506 0.10155210979149742
43 Normal incorrect peri SN 27 0.1965738090304749 0.2495116802723263
44 Normal incorrect peri IT 27 0.2814199416369157 0.2308903646345705
45 Normal incorrect peri IN 27 0.4444042475568166 0.27092685369261976
46 Normal incorrect full ST 27 0.09632595685606479 0.13797078057713352
47 Normal incorrect full SN 27 0.20212570434383328 0.2336043948759492
48 Normal incorrect full IT 27 0.3081102746977408 0.2523555953549531
49 Normal incorrect full IN 27 0.3934380562908973 0.24633953116106946
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 220 KiB

@@ -0,0 +1,10 @@
feature,mean_drop,std_drop,baseline_auc_mean
Age,0.1487745098039216,0.07246596166317897,0.7117647058823529
IOP_corr,0.0645588235294118,0.053595964337907635,0.7117647058823529
Phakic/Pseudophakic,0.031004901960784353,0.05039528009700277,0.7117647058823529
Pachymetry,0.011421568627451003,0.016547911692771797,0.7117647058823529
Gender,0.006102941176470622,0.022596178307964714,0.7117647058823529
eyeID,0.0,0.0,0.7117647058823529
dioptre_2,-0.0010294117647058861,0.003622645200240852,0.7117647058823529
astigmatism,-0.002156862745098008,0.008336072214271729,0.7117647058823529
dioptre_1,-0.006593137254901939,0.011003619078899692,0.7117647058823529
1 feature mean_drop std_drop baseline_auc_mean
2 Age 0.1487745098039216 0.07246596166317897 0.7117647058823529
3 IOP_corr 0.0645588235294118 0.053595964337907635 0.7117647058823529
4 Phakic/Pseudophakic 0.031004901960784353 0.05039528009700277 0.7117647058823529
5 Pachymetry 0.011421568627451003 0.016547911692771797 0.7117647058823529
6 Gender 0.006102941176470622 0.022596178307964714 0.7117647058823529
7 eyeID 0.0 0.0 0.7117647058823529
8 dioptre_2 -0.0010294117647058861 0.003622645200240852 0.7117647058823529
9 astigmatism -0.002156862745098008 0.008336072214271729 0.7117647058823529
10 dioptre_1 -0.006593137254901939 0.011003619078899692 0.7117647058823529
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB