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.
This commit is contained in:
rpotter6298
2026-06-11 15:08:20 +02:00
parent 32a801a572
commit 280060db82
343 changed files with 8558 additions and 57747 deletions
+189
View File
@@ -0,0 +1,189 @@
"""F1 — System architecture diagram.
Bilateral multimodal fusion architecture. Modelled on v3's architecture_fused_head
but adapted for v4 + manuscript terminology:
* "OD HyperTower" / "OS HyperTower" -> "OD Fusion" / "OS Fusion"
* Bridge boxes show their math explicitly (image projection, clinical
projection, fusion operation), no longer abbreviated "Bridge"
* Title drops the HyperTower brand
Re-run anytime:
python -m v4.figures.F1_architecture
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
OUT = Path(__file__).parent / "output" / "F1_architecture.png"
# ── Palette (matches v3 plot_architecture.py for visual consistency) ────────
C_IMG = "#4e8d3a" # green — image / CNN
C_MD = "#4c72b0" # blue — clinical network
C_BRIDGE = "#c44e52" # red — fusion bridge
C_HEAD = "#d4a017" # gold — patient-level head
C_OUT = "#8c6bb1" # purple — output classes
C_INPUT = "#a0a0a0" # grey — raw inputs
C_BG = "#e8e8e8"
C_ARROW = "#444444"
FONT = "DejaVu Sans"
# ── Primitives ───────────────────────────────────────────────────────────────
def _box(ax, cx, cy, w, h, color, text="", fontsize=9, text_color="white",
bold=False, alpha=0.92, radius=0.12, lw=1.5):
patch = FancyBboxPatch(
(cx - w / 2, cy - h / 2), w, h,
boxstyle=f"round,pad=0,rounding_size={radius}",
facecolor=color, edgecolor="white", linewidth=lw, alpha=alpha, zorder=3,
transform=ax.transData,
)
ax.add_patch(patch)
if text:
ax.text(cx, cy, text, ha="center", va="center",
fontsize=fontsize, color=text_color,
fontweight="bold" if bold else "normal",
fontfamily=FONT, zorder=4)
return patch
def _arrow(ax, x0, y0, x1, y1, lw=1.4, color=C_ARROW, style="-|>"):
ax.annotate("", xy=(x1, y1), xytext=(x0, y0),
arrowprops=dict(arrowstyle=style, color=color, lw=lw),
zorder=2)
def _text(ax, x, y, s, fontsize=9, color="#333", ha="center", va="center", bold=False):
ax.text(x, y, s, ha=ha, va=va, fontsize=fontsize, color=color,
fontfamily=FONT, fontweight="bold" if bold else "normal", zorder=5)
def _bracket(ax, x, y0, y1, text="", pad=0.20, fontsize=9, badge_color="#555"):
mid = (y0 + y1) / 2
ax.plot([x, x + pad, x + pad, x], [y1, y1, y0, y0],
color=badge_color, lw=1.4, solid_capstyle="round", zorder=2)
if text:
ax.text(x + pad * 1.4, mid, text, ha="left", va="center",
fontsize=fontsize, color="white", fontfamily=FONT, fontweight="bold",
zorder=6,
bbox=dict(facecolor=badge_color, edgecolor="none", pad=3.5,
boxstyle="round,pad=0.3"))
def _draw_output(ax, x, y, classes=("Glaucoma", "Normal")):
bw, bh, gap = 1.10, 0.38, 0.08
n = len(classes)
total = n * bh + (n - 1) * gap
y_top = y + total / 2 - bh / 2
for i, cls in enumerate(classes):
cy = y_top - i * (bh + gap)
_box(ax, x + bw / 2, cy, bw, bh, C_OUT, cls, fontsize=8.5, radius=0.08)
_arrow(ax, x, y, x, cy, lw=1.1, style="-|>")
_text(ax, x + bw / 2, y - total / 2 - 0.20, "Softmax",
fontsize=7.5, color=C_OUT)
def _draw_eye_fusion(ax, x_left, y_img, y_md, eye_label):
"""One eye's row: Image Network box + Clinical Network box -> Fusion bridge box.
Returns (x_right_of_bridge, y_bridge_center).
"""
bw_img, bh_img = 1.45, 0.72
bw_md, bh_md = 1.48, 0.66
bw_br, bh_br = 1.75, 1.30
# Image network box
_box(ax, x_left + bw_img / 2, y_img, bw_img, bh_img, C_IMG,
f"{eye_label}\nImage Network", fontsize=8.5, radius=0.08)
# Clinical network box
_box(ax, x_left + bw_md / 2, y_md, bw_md, bh_md, C_MD,
f"{eye_label}\nClinical Network", fontsize=8.5, radius=0.08)
# Fusion bridge — with math detail (replaces compact "Bridge" label)
br_x = x_left + max(bw_img, bw_md) + 1.40
cy_br = (y_img + y_md) / 2
_arrow(ax, x_left + bw_img, y_img, br_x - bw_br / 2, cy_br, lw=1.2, style="-|>")
_arrow(ax, x_left + bw_md, y_md, br_x - bw_br / 2, cy_br, lw=1.2, style="-|>")
_box(ax, br_x, cy_br, bw_br, bh_br, C_BRIDGE,
"Fusion Bridge\nFC(img → 256)\nFC(md → 256)\nHadamard product",
fontsize=8, radius=0.10)
return br_x + bw_br / 2, cy_br
# ── Main figure ──────────────────────────────────────────────────────────────
def main() -> None:
W, H = 13.0, 7.8
fig, ax = plt.subplots(figsize=(W, H))
ax.set_xlim(0, W); ax.set_ylim(0, H); ax.axis("off")
ax.set_facecolor(C_BG); fig.patch.set_facecolor(C_BG)
ax.set_title("Bilateral Multimodal Fusion Architecture",
fontsize=13, fontweight="bold", fontfamily=FONT, pad=10, color="#222")
x_left = 3.2
inp_cx = 1.85
inp_w = 0.95
inp_h = 0.55
# OD (top)
od_y_img, od_y_md = 6.10, 4.80
br_od_x, cy_od = _draw_eye_fusion(ax, x_left, od_y_img, od_y_md, "OD")
_text(ax, 0.50, (od_y_img + od_y_md) / 2, "OD\n(Right Eye)",
fontsize=9.5, color="#444", bold=True)
_box(ax, inp_cx, od_y_img, inp_w, inp_h, C_INPUT, "Fundus\nImage",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_box(ax, inp_cx, od_y_md, inp_w, inp_h, C_INPUT, "Clinical\nData",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_arrow(ax, inp_cx + inp_w / 2, od_y_img, x_left, od_y_img, lw=1.2, style="-|>")
_arrow(ax, inp_cx + inp_w / 2, od_y_md, x_left, od_y_md, lw=1.2, style="-|>")
# OS (bottom)
os_y_img, os_y_md = 2.95, 1.65
br_os_x, cy_os = _draw_eye_fusion(ax, x_left, os_y_img, os_y_md, "OS")
_text(ax, 0.50, (os_y_img + os_y_md) / 2, "OS\n(Left Eye)",
fontsize=9.5, color="#444", bold=True)
_box(ax, inp_cx, os_y_img, inp_w, inp_h, C_INPUT, "Fundus\nImage",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_box(ax, inp_cx, os_y_md, inp_w, inp_h, C_INPUT, "Clinical\nData",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_arrow(ax, inp_cx + inp_w / 2, os_y_img, x_left, os_y_img, lw=1.2, style="-|>")
_arrow(ax, inp_cx + inp_w / 2, os_y_md, x_left, os_y_md, lw=1.2, style="-|>")
# Side brackets — re-labelled "OD Fusion" / "OS Fusion"
_bracket(ax, x=br_od_x + 0.05,
y0=od_y_md - 0.50, y1=od_y_img + 0.50,
text="OD Fusion", pad=0.22, fontsize=9, badge_color="#555")
_bracket(ax, x=br_os_x + 0.05,
y0=os_y_md - 0.50, y1=os_y_img + 0.50,
text="OS Fusion", pad=0.22, fontsize=9, badge_color="#555")
# Patient-level head (Fused Head)
head_y = (cy_od + cy_os) / 2
head_x = max(br_od_x, br_os_x) + 2.55
head_w, head_h = 1.95, 1.30
_arrow(ax, br_od_x + 0.05, cy_od, head_x - head_w / 2, head_y, lw=1.4, style="-|>")
_arrow(ax, br_os_x + 0.05, cy_os, head_x - head_w / 2, head_y, lw=1.4, style="-|>")
_box(ax, head_x, head_y, head_w, head_h, C_HEAD,
"Patient Head\ncat(z_OD, z_OS)\n→ FC(256) → FC(2)",
fontsize=8.5, radius=0.10)
# Output nodes
out_x = head_x + head_w / 2 + 0.55
_arrow(ax, head_x + head_w / 2, head_y, out_x, head_y, lw=1.5, style="-|>")
_draw_output(ax, out_x, head_y)
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__":
main()
@@ -0,0 +1,221 @@
"""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):
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.
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 3 (orange) — Baseline reference:
"Baseline (fine-tuned ResNet50)" — what we previously called refugelike.
Sourced from v3 phase 2 imageonly_refugelike_proper.
Each non-baseline box is labelled with a Wilcoxon two-sided p-value comparing
its fold AUCs to the baseline.
Re-run anytime:
python -m v4.figures.F2_papila_replication_and_single_mode
"""
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
from scipy.stats import wilcoxon
from v4.figures.util.loaders import REPO_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
ALPHA = 0.82
V3_PHASE1_DIR = REPO_ROOT / "v3" / "results" / "phase1"
V3_PHASE2_DIR = REPO_ROOT / "v3" / "results" / "phase2"
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
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
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())
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Each entry: (label, loader_fn, *args)
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"),
]
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"),
]
BASELINE_LABEL = "baseline\n(fine-tuned ResNet50)"
BASELINE_DATA = (_load_phase2_classic_aucs, "imageonly_refugelike_proper")
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)
print("Block 1 — Basic backbones:")
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:")
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")
# Lay out positions
gap = 0.7
pos: list[float] = []
p = 0.0
for _ in block1:
pos.append(p); p += 1.0
section1_right = p - 1.0
p += gap
section2_left = p
for _ in block2:
pos.append(p); p += 1.0
section2_right = p - 1.0
p += gap
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")
box_w = 0.55
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
all_aucs: list[np.ndarray] = []
all_labels: list[str] = []
all_colors: list[str] = []
for lbl, a in block1 + block2:
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(
aucs, positions=[x], widths=box_w, patch_artist=True, manage_ticks=False,
boxprops=dict(facecolor=color, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops,
whiskerprops=whiskerprops,
capprops=capprops,
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"),
]
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):
tick_labels.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_labels.append(f"{lbl}\n{ps}")
ax.set_xticks(pos)
ax.set_xticklabels(tick_labels, fontsize=9.5)
ax.set_xlim(-0.6, section3_right + 0.7)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
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()
+298
View File
@@ -0,0 +1,298 @@
"""F3 — Single-mode comparison via confidence strips.
Four panels showing per-eye predicted P(Glaucoma) coloured by VF-MD severity
(when known), in the style of v3/figures/explainability/confidence_strips_comparison.png.
Panels (left → right):
Clinical only (cd_solo_single)
Image only (img_solo_single_refugelike)
Hadamard fusion (ensemble_single_refugelike — baseline)
Concat fusion (phase3_v4/single_bcd_concat)
Each panel shows test predictions pooled across all reps × folds. Points are
jittered around their true-class column and coloured by VF-MD severity tier
(early / moderate / severe / unknown for glaucoma rows; grey for healthy).
Re-run anytime:
python -m v4.figures.F3_hyperfeature_ablation
"""
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 matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F3_single_mode_strips.png"
# ── Palette (matches v3 confidence_strips) ───────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"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}
# ── Per-panel definitions: (label, results dir, eval_stage) ──────────────────
# Top row: single-modality reference runs
TOP_ROW = [
("Clinical only", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
("Image only", RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike", "img_fuse"),
]
# Bottom row: L1 fusion bridge variants (eye-level img+cd ensembles)
BOTTOM_ROW = [
("Concat fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_concat", "nt"),
("Pairwise fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_pairwise", "nt"),
("Gated fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_gated", "nt"),
("Hadamard fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike", "nt"),
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── VFI loader (patient-level worst-eye severity, matches v3) ────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_= pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
# PAPILA: 0=Normal, 1=Glaucoma, 2=Suspect — keep only binary subjects
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = both.groupby("Patient ID")["Diagnosis"].agg(lambda x: x.mode().iloc[0]).reset_index()
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0: return "normal"
v = row["vf_md"]
if pd.isna(v): return "unknown"
if v > -6: return "early"
if v > -12: return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
# ── Prediction pooler ────────────────────────────────────────────────────────
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
"""Pool test rows across reps × folds. Returns DataFrame with
patient_id, y_true, prob_glaucoma, rep, fold.
Applies softmax to the 2-class logits."""
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
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"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, 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, :] # (n_test, 2)
# softmax
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append({
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
})
return pd.DataFrame(rows)
# ── Panel render ─────────────────────────────────────────────────────────────
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(data_by_class, positions=[0, 1], widths=0.7,
showmedians=False, showextrema=False)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color); body.set_alpha(0.30)
body.set_edgecolor("none"); body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any(): continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(x, sub["prob_glaucoma"].values,
c=SEV_COLORS[sev], s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev], linewidths=0, zorder=3)
# Median lines + TN/TP rate labels per class
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma"); continue
med = float(np.median(vals))
ax.plot([xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med], color="#222", lw=2.0, zorder=5)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1]); ax.set_xticklabels(xtick_labels, fontsize=10)
ax.set_ylim(-0.04, 1.04); ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=11, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
# AUC across reps×folds (per-fold AUC averaged)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2: continue
try: fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception: pass
if fold_aucs:
ax.text(0.66, 0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes, ha="right", va="bottom",
fontsize=9, color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2))
def render() -> None:
vfi = load_vfi()
top_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in TOP_ROW]
bottom_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in BOTTOM_ROW]
for lbl, df in top_dfs + bottom_dfs:
if len(df):
print(f" {lbl:<18s} n_rows={len(df):>5d} (pid={df['patient_id'].nunique()}, reps={df['rep'].nunique()})")
else:
print(f" {lbl:<18s} no data")
n_cols = len(BOTTOM_ROW)
fig = plt.figure(figsize=(4.4 * n_cols, 12))
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("L1 Fusion Comparison", fontsize=14, fontweight="bold")
gs = fig.add_gridspec(2, n_cols, hspace=0.30, wspace=0.15)
# Top row: 2 reference panels at same width as bottom panels, centered.
# In a 4-column bottom grid, that's columns 1 and 2.
n_top = len(top_dfs)
top_offset = (n_cols - n_top) // 2 # leading empty columns
top_axes = []
for i, (lbl, df) in enumerate(top_dfs):
ax = fig.add_subplot(gs[0, top_offset + i])
top_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
# Bottom row: 4 fusion variants
bottom_axes = []
sharey = None
for i, (lbl, df) in enumerate(bottom_dfs):
ax = fig.add_subplot(gs[1, i], sharey=sharey)
sharey = sharey or ax
bottom_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
top_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
bottom_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
for ax in bottom_axes[1:]:
ax.set_yticklabels([])
legend_patches = [mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]]
fig.legend(handles=legend_patches, fontsize=9,
loc="lower center", ncol=len(legend_patches),
framealpha=0.75, bbox_to_anchor=(0.5, -0.01))
fig.tight_layout(rect=[0, 0.06, 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()
+345
View File
@@ -0,0 +1,345 @@
"""F4 — Bilateral lift via confidence strips.
Six-panel grid in the same v3-style as F3. Rows are aggregation mode,
columns are tower configuration:
Clinical Image Fusion (img+cd)
Single | cd_solo_single | img_solo_single | ensemble_single (Hadamard L1)
Bilateral| cd_solo_bilat | img_solo_bilat | baseline_ensemble (L2 concat default)
All refugelike. Single-eye panels eval at the appropriate eye-level fusion
stage; bilateral panels eval at hb. Points are coloured by VF-MD severity.
Each panel shows per-patient or per-eye P(Glaucoma) with TN / TP rates and
AUC printed in.
Re-run anytime:
python -m v4.figures.F4_bilateral
"""
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 matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F4_bilateral.png"
# Palette (matches F3) ───────────────────────────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
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}
# Panel grid: [row][col] = (label, run_dir, eval_stage)
GRID = [
[
("Single · Clinical", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
(
"Single · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike",
"img_fuse",
),
(
"Single · Fusion",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike",
"nt",
),
],
[
(
"Bilateral · Clinical",
RESULTS_ROOT / "phase4_v4" / "cd_solo_bilateral",
"hb",
),
(
"Bilateral · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_bilateral_refugelike",
"hb",
),
("Bilateral · Fusion", RESULTS_ROOT / "tri_v1" / "baseline_ensemble", "hb"),
],
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── Same helpers as F3 ───────────────────────────────────────────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_ = pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = (
df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = (
both.groupby("Patient ID")["Diagnosis"]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0:
return "normal"
v = row["vf_md"]
if pd.isna(v):
return "unknown"
if v > -6:
return "early"
if v > -12:
return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
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"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, 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)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append(
{
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
}
)
return pd.DataFrame(rows)
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [
df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]
]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(
data_by_class,
positions=[0, 1],
widths=0.7,
showmedians=False,
showextrema=False,
)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color)
body.set_alpha(0.30)
body.set_edgecolor("none")
body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any():
continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(
x,
sub["prob_glaucoma"].values,
c=SEV_COLORS[sev],
s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev],
linewidths=0,
zorder=3,
)
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma")
continue
med = float(np.median(vals))
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1])
ax.set_xticklabels(xtick_labels, fontsize=9)
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=10.5, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2:
continue
try:
fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception:
pass
if fold_aucs:
ax.text(
0.66,
0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=8.5,
color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2),
)
def render() -> None:
vfi = load_vfi()
fig, axes = plt.subplots(2, 3, figsize=(13, 11), sharey=True)
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("Single → Bilateral Aggregation Lift", fontsize=13, fontweight="bold")
for ri, row in enumerate(GRID):
for ci, (lbl, path, stage) in enumerate(row):
ax = axes[ri, ci]
ax.set_facecolor("#e8e8e8")
df = collect_predictions(path, stage)
if len(df):
_draw_panel(ax, df, vfi, lbl)
print(f" [{ri},{ci}] {lbl:<22s} n={len(df):>5d}")
else:
ax.text(
0.5,
0.5,
"(pending)",
ha="center",
va="center",
fontsize=12,
color="#888",
transform=ax.transAxes,
)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(lbl, fontsize=10.5, fontweight="bold")
print(f" [{ri},{ci}] {lbl:<22s} no data yet")
for ri in range(2):
axes[ri, 0].set_ylabel("Predicted P(Glaucoma)", fontsize=10.5)
legend_patches = [
mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]
]
fig.legend(
handles=legend_patches,
fontsize=9,
loc="lower center",
ncol=len(legend_patches),
framealpha=0.75,
bbox_to_anchor=(0.5, -0.005),
)
fig.tight_layout(rect=[0, 0.04, 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()
+311
View File
@@ -0,0 +1,311 @@
"""F6 — Regression VF_MD with severity grouping.
Four sub-panels:
(a) Predicted vs Actual MD scatter (pooled across all test eyes)
(b) Three one-vs-rest ROC curves — severe, moderate, low — using the
regression head's continuous output as the score
(c) 3-tier confusion matrix at tuned thresholds (HAP truth boundaries,
val-tuned prediction thresholds)
(d) Per-class TP / FN / FP / TN and sens / spec / PPV / NPV
Re-run anytime predictions.h5 changes:
python -m v4.figures.F6_regression
"""
from __future__ import annotations
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_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"
# Prediction-side bin boundaries
NP_THRESH = -1.097 # mean of measured-healthy MD
SEV_PRED = -9.14 # midpoint of HAP -12 and mean predicted MD for severe truth
# Actual (HAP / Mills) clinical boundaries
HAP_SEV = -12.0
HAP_MOD = -6.0
LABELS = ["severe", "moderate", "low"]
# Severity colors (consistent across figures)
C_SEVERE = "#E53935"
C_MODERATE = "#FFB300"
C_LOW = "#3B6FB5"
def _decode(arr):
return np.array(
[s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr]
)
def _collect():
actuals, preds = [], []
for fp in sorted(RUN_DIR.rglob("predictions.h5")):
with h5py.File(fp, "r") as f:
if "hb" not in f:
continue
grp = f["hb"]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, _ = logits.shape
ep, head, out = n_epochs - 1, n_heads - 1, 0
for fold in range(n_folds):
labels = _decode(split[fold])
m = (
(labels == "test")
& np.isfinite(y_true)
& np.isfinite(logits[fold, ep, :, head, out])
)
actuals.append(y_true[m])
preds.append(logits[fold, ep, m, head, out].astype(float))
if not actuals:
return None, None
return np.concatenate(actuals), np.concatenate(preds)
def _bin(values, sev, np_th):
bins = np.full(values.shape, 2, dtype=int)
bins[values <= np_th] = 1
bins[values <= sev] = 0
return bins
def render() -> None:
if not RUN_DIR.exists() or not any(RUN_DIR.rglob("predictions.h5")):
print(f"[F6] no predictions.h5 yet under {RUN_DIR}. Run after data lands.")
return
a, p = _collect()
if a is None:
print("[F6] no usable predictions")
return
print(f"[F6] pooled n={a.size}")
fig = plt.figure(figsize=(14, 10.5))
gs = fig.add_gridspec(
2, 2, hspace=0.40, wspace=0.30, left=0.07, right=0.96, top=0.92, bottom=0.07
)
ax_sc = fig.add_subplot(gs[0, 0])
ax_rc = fig.add_subplot(gs[0, 1])
ax_cm = fig.add_subplot(gs[1, 0])
ax_tb = fig.add_subplot(gs[1, 1])
ax_tb.axis("off")
# ── (a) scatter ──────────────────────────────────────────────────────────
ax_sc.scatter(a, p, s=10, alpha=0.4, color="#2563eb", edgecolor="none")
lo, hi = -30, 6
ax_sc.plot([lo, hi], [lo, hi], ls="--", color="#9ca3af", lw=1, label="ideal y=x")
ax_sc.axvline(HAP_SEV, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.axvline(HAP_MOD, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.set_xlim(lo, hi)
ax_sc.set_ylim(lo, hi)
ax_sc.set_xlabel("Actual VF_MD (dB)")
ax_sc.set_ylabel("Predicted VF_MD (dB)")
ax_sc.set_title(f"(a) Predicted vs Actual MD (n={a.size})", fontsize=11)
r = np.corrcoef(a, p)[0, 1]
mae = float(np.mean(np.abs(p - a)))
ax_sc.text(
0.04,
0.95,
f"r = {r:.3f}\nMAE = {mae:.2f} dB",
transform=ax_sc.transAxes,
ha="left",
va="top",
fontsize=10,
bbox=dict(facecolor="white", alpha=0.85, edgecolor="#d1d5db"),
)
# ── (b) three one-vs-rest ROCs ───────────────────────────────────────────
# Severe vs rest: score = -p (more negative pred → more severe)
# Low vs rest: score = +p (more positive pred → more "low" / no-problem)
# Moderate vs rest: score = -|p - midpoint of moderate range|
# (closer to midpoint → more moderate-like)
mod_midpoint = 0.5 * (HAP_SEV + HAP_MOD) # -9 dB
truth_severe = (a <= HAP_SEV).astype(int)
truth_low = (a > HAP_MOD).astype(int)
truth_moderate = ((a > HAP_SEV) & (a <= HAP_MOD)).astype(int)
series = [
("Severe (≤ 12 dB) vs rest", truth_severe, -p, C_SEVERE),
(
"Moderate (12..6) vs rest",
truth_moderate,
-np.abs(p - mod_midpoint),
C_MODERATE,
),
("Low (> 6 dB) vs rest", truth_low, p, C_LOW),
]
for label, ybin, score, color in series:
if len(np.unique(ybin)) < 2:
continue
fpr, tpr, _ = roc_curve(ybin, score)
auc = roc_auc_score(ybin, score)
ax_rc.plot(fpr, tpr, color=color, lw=1.8, label=f"{label} (AUC = {auc:.3f})")
ax_rc.plot([0, 1], [0, 1], ls="--", color="#9ca3af", lw=0.8)
ax_rc.set_xlim(0, 1)
ax_rc.set_ylim(0, 1.02)
ax_rc.set_xlabel("False positive rate")
ax_rc.set_ylabel("True positive rate")
ax_rc.set_title("(b) One-vs-rest ROC per severity tier", fontsize=11)
ax_rc.legend(loc="lower right", fontsize=9, framealpha=0.95)
ax_rc.grid(alpha=0.25, linestyle="--")
# ── (c) confusion matrix ─────────────────────────────────────────────────
t_act = _bin(a, HAP_SEV, HAP_MOD)
t_pred = _bin(p, SEV_PRED, NP_THRESH)
cm = np.zeros((3, 3), dtype=int)
for x, y in zip(t_act, t_pred):
cm[x, y] += 1
cm_pct = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
ax_cm.imshow(cm_pct, cmap="Blues", vmin=0, vmax=1, aspect="equal")
for i in range(3):
for j in range(3):
text_color = "white" if cm_pct[i, j] > 0.55 else "black"
ax_cm.text(
j,
i,
f"{cm[i,j]}\n({cm_pct[i,j]*100:.0f}%)",
ha="center",
va="center",
fontsize=10,
color=text_color,
)
ax_cm.set_xticks(range(3))
ax_cm.set_xticklabels(LABELS, fontsize=10)
ax_cm.set_yticks(range(3))
ax_cm.set_yticklabels(LABELS, fontsize=10)
ax_cm.set_xlabel("Predicted", fontsize=10)
ax_cm.set_ylabel("Actual", fontsize=10)
ax_cm.set_title("(c) 3-tier confusion", fontsize=11)
# ── (d) per-class stats — sens / spec / PPV / NPV only ─────────────────
# We deliberately drop TP/FN/FP/TN here because in a 3-tier setting a
# "false negative" for severe could land in moderate (clinically
# different from landing in low). The confusion matrix in panel (c)
# already shows that distinction; sens/spec/PPV/NPV summarise the
# one-vs-rest performance without the blanket-count obfuscation.
ax_tb.set_title("(d) Per-class statistics", fontsize=11)
ax_tb.set_xlim(0, 10)
ax_tb.set_ylim(0, 5)
headers = ["class", "n", "sens", "spec", "PPV", "NPV"]
# Make the class column wider than the numeric columns to avoid clipping.
col_widths = np.array([2.4, 1.1, 1.4, 1.4, 1.4, 1.4])
col_widths *= 10.0 / col_widths.sum() # normalise to total width 10
col_edges = np.concatenate([[0], np.cumsum(col_widths)])
col_x = (col_edges[:-1] + col_edges[1:]) / 2 # column centers
row_y = [3.5, 2.5, 1.5, 0.5] # 1 header + 3 data rows
rows = []
for c in range(3):
ac = t_act == c
pc = t_pred == c
tp = int(np.sum(ac & pc))
fn = int(np.sum(ac & ~pc))
fp = int(np.sum(~ac & pc))
tn = int(np.sum(~ac & ~pc))
sens = tp / max(tp + fn, 1)
spec = tn / max(tn + fp, 1)
ppv = tp / max(tp + fp, 1)
npv = tn / max(tn + fn, 1)
rows.append(
[
LABELS[c],
int(ac.sum()),
f"{sens:.3f}",
f"{spec:.3f}",
f"{ppv:.3f}",
f"{npv:.3f}",
]
)
# Header band
ax_tb.add_patch(
plt.Rectangle(
(0, 3.05), 10, 0.9, facecolor="#dbeafe", edgecolor="none", zorder=1
)
)
for x, h in zip(col_x, headers):
ax_tb.text(
x,
row_y[0],
h,
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color="#1e3a8a",
zorder=2,
)
# Data rows with zebra shading
row_colors = ["#f8fafc", "#eef2f6", "#f8fafc"]
severity_color = {"severe": C_SEVERE, "moderate": C_MODERATE, "low": C_LOW}
for ri, row in enumerate(rows):
ax_tb.add_patch(
plt.Rectangle(
(0, row_y[ri + 1] - 0.45),
10,
0.9,
facecolor=row_colors[ri],
edgecolor="none",
zorder=1,
)
)
for ci, val in enumerate(row):
txt_color = "#222"
weight = "normal"
if ci == 0:
txt_color = severity_color.get(val, "#222")
weight = "bold"
ax_tb.text(
col_x[ci],
row_y[ri + 1],
str(val),
ha="center",
va="center",
fontsize=11,
fontweight=weight,
color=txt_color,
zorder=2,
)
# Subtle horizontal grid lines
for y in [
row_y[0] - 0.45,
row_y[0] + 0.45,
row_y[1] - 0.45,
row_y[2] - 0.45,
row_y[3] - 0.45,
]:
ax_tb.plot([0, 10], [y, y], color="#cbd5e1", lw=0.6, zorder=1.5)
fig.suptitle(
"Regression predicting VF_MD with severity grouping",
fontsize=13,
fontweight="bold",
y=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()
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
"""F5 — Adding geometry as a third information source.
Box plot in the F2 style (black-bordered boxes, red median lines, baseline
median reference, Wilcoxon p-values). Two sections separated by a divider:
Section A — Vector injection (compact 5-dim structured features)
baseline (no geom) | image+clinical ensemble, no geometry stream
unet vector | + 5-dim geometry features from UNet seg (auto)
gt vector | + 5-dim geometry features from GT contours (human)
Section B — geometry network (a parallel CNN on segmentation maps)
solo | geometry network alone, no img/cd
unet fusion | tritower img+cd+geom, UNet seg (auto)
gt fusion | tritower img+cd+geom, GT contours (human)
Baseline reference for both sections = "no geometry" ensemble. The figure
shows that:
* geometry features carry signal alone (solo > chance)
* a compact vector of GT-derived features modestly helps (+0.014)
* UNet-derived features (auto) don't help meaningfully
* a full geometry network doesn't help beyond what the img backbone has
Re-run after data lands:
python -m v4.figures.S1_geometry
"""
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
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "S1_geometry.png"
# ── Style (mirrors F2) ───────────────────────────────────────────────────────
C_VAR = "#4c72b0" # blue — variant boxes
C_BASE = "#dd8452" # orange — baseline reference box
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
"""Aggregate test AUC across all rep × fold, picking the run's eval_stage."""
import json
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Baseline (used in both sections as reference)
BASELINE_LABEL = "baseline\n(no geometry)"
BASELINE_RUN = RESULTS_ROOT / "ensemble_fused" / "no_geom"
# Section A — Vector injection variants (image+clinical ensemble, +EPC geom)
VECTOR_VARIANTS = [
("U-Net vector", RESULTS_ROOT / "tri_v1" / "geom_vec_unet"), # currently 3 reps; 10-rep bump queued
("GT vector", RESULTS_ROOT / "ensemble_fused" / "geom_gt"),
]
# Section B — network variants (CNN over segmentation maps)
NETWORK_VARIANTS = [
("solo (geom network alone)", RESULTS_ROOT / "tri_v1" / "baseline_solo"),
("U-Net fusion", RESULTS_ROOT / "tri_v1" / "baseline_tri"),
("GT fusion", RESULTS_ROOT / "phase6_v4" / "tritower_geom_gt"),
]
def render() -> None:
base_aucs = load_fold_aucs(BASELINE_RUN)
vec_data = [(lbl, load_fold_aucs(p)) for lbl, p in VECTOR_VARIANTS]
network_data = [(lbl, load_fold_aucs(p)) for lbl, p in NETWORK_VARIANTS]
print(f"Baseline (no geometry): n={len(base_aucs):>3d} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}"
if len(base_aucs) else "Baseline: no data")
print("Vector injection variants:")
for lbl, a in vec_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
print("Network variants:")
for lbl, a in network_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
# Layout positions
box_w = 0.55
inner_gap = 0.50
section_gap = 0.95
# Section A: baseline | unet vector | gt vector
section_a_labels = [BASELINE_LABEL] + [l for l, _ in vec_data]
section_a_data = [base_aucs] + [a for _, a in vec_data]
section_a_colors = [C_BASE] + [C_VAR] * len(vec_data)
# Section B: solo | unet fusion | gt fusion
section_b_labels = [l for l, _ in network_data]
section_b_data = [a for _, a in network_data]
section_b_colors = [C_VAR] * len(network_data)
positions: list[float] = []
p = 0.0
for _ in section_a_labels:
positions.append(p); p += box_w + inner_gap
section_a_right = positions[-1] + box_w / 2
p = positions[-1] + box_w + section_gap
section_b_left = p
for _ in section_b_labels:
positions.append(p); p += box_w + inner_gap
all_labels = section_a_labels + section_b_labels
all_data = section_a_data + section_b_data
all_colors = section_a_colors + section_b_colors
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Geometry Integration", fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for x, aucs, color in zip(positions, all_data, all_colors):
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,
whiskerprops=whiskerprops,
capprops=capprops,
flierprops=flierprops,
)
# Baseline median reference line across the whole plot
if len(base_aucs):
ax.axhline(np.median(base_aucs), color=C_BASE,
linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median (no geometry)")
# Section dividers
div_x = (section_a_right + section_b_left - box_w / 2) / 2
ax.axvline(div_x, color="#aaa", linewidth=0.7, alpha=0.6, linestyle="-")
# Section headers
sec_a_cx = (positions[0] + positions[len(section_a_labels) - 1]) / 2
sec_b_cx = (positions[len(section_a_labels)] + positions[-1]) / 2
ax.text(sec_a_cx, 1.02, "Vector injection (5-dim structured features)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
ax.text(sec_b_cx, 1.02, "Geometry network (CNN over segmentation map)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
# X-tick labels with Wilcoxon p-values vs baseline for non-baseline boxes
tick_lbls = []
for lbl, aucs in zip(all_labels, all_data):
if lbl == BASELINE_LABEL or not len(aucs) or not len(base_aucs):
tick_lbls.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lbls.append(f"{lbl}\n{ps}")
ax.set_xticks(positions)
ax.set_xticklabels(tick_lbls, fontsize=9.5)
ax.set_xlim(positions[0] - box_w, positions[-1] + box_w + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
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()
+297
View File
@@ -0,0 +1,297 @@
"""V2M_F3 — Single-mode comparison via confidence strips (refuge V2-M backbone).
V2-M counterpart to F3. Panels use the refuge_efficientnet_v2_m image-tower
backbone wherever the image stream is present. cd_solo_single is shared
(no image backbone), so the clinical floor is the same as in F3.
Panels (left → right):
Clinical only (cd_solo_single — backbone-independent)
Image only (refuge_v2m_baseline/img_solo_single_refuge_v2m)
Concat fusion (v2m_variants/single_bcd_concat_v2m)
Pairwise fusion (v2m_variants/single_bcd_pairwise_v2m)
Gated fusion (v2m_variants/single_bcd_gated_v2m)
Hadamard fusion (refuge_v2m_baseline/ensemble_single_refuge_v2m — baseline)
Re-run anytime:
python -m v4.figures.V2M_F3_hyperfeature_ablation
"""
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 matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F3_single_mode_strips.png"
# ── Palette (matches v3 confidence_strips) ───────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"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}
# ── Per-panel definitions: (label, results dir, eval_stage) ──────────────────
# Top row: single-modality reference runs (cd_solo is backbone-independent)
TOP_ROW = [
("Clinical only", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
("Image only", RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m", "img_fuse"),
]
# Bottom row: L1 fusion bridge variants at refuge V2-M (eye-level img+cd ensembles)
BOTTOM_ROW = [
("Concat fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_concat_v2m", "nt"),
("Pairwise fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_pairwise_v2m", "nt"),
("Gated fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_gated_v2m", "nt"),
("Hadamard fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m", "nt"),
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── VFI loader (patient-level worst-eye severity, matches v3) ────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_= pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
# PAPILA: 0=Normal, 1=Glaucoma, 2=Suspect — keep only binary subjects
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = both.groupby("Patient ID")["Diagnosis"].agg(lambda x: x.mode().iloc[0]).reset_index()
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0: return "normal"
v = row["vf_md"]
if pd.isna(v): return "unknown"
if v > -6: return "early"
if v > -12: return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
# ── Prediction pooler ────────────────────────────────────────────────────────
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
"""Pool test rows across reps × folds. Returns DataFrame with
patient_id, y_true, prob_glaucoma, rep, fold.
Applies softmax to the 2-class logits."""
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
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"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, 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, :] # (n_test, 2)
# softmax
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append({
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
})
return pd.DataFrame(rows)
# ── Panel render ─────────────────────────────────────────────────────────────
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(data_by_class, positions=[0, 1], widths=0.7,
showmedians=False, showextrema=False)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color); body.set_alpha(0.30)
body.set_edgecolor("none"); body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any(): continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(x, sub["prob_glaucoma"].values,
c=SEV_COLORS[sev], s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev], linewidths=0, zorder=3)
# Median lines + TN/TP rate labels per class
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma"); continue
med = float(np.median(vals))
ax.plot([xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med], color="#222", lw=2.0, zorder=5)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1]); ax.set_xticklabels(xtick_labels, fontsize=10)
ax.set_ylim(-0.04, 1.04); ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=11, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
# AUC across reps×folds (per-fold AUC averaged)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2: continue
try: fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception: pass
if fold_aucs:
ax.text(0.66, 0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes, ha="right", va="bottom",
fontsize=9, color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2))
def render() -> None:
vfi = load_vfi()
top_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in TOP_ROW]
bottom_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in BOTTOM_ROW]
for lbl, df in top_dfs + bottom_dfs:
if len(df):
print(f" {lbl:<18s} n_rows={len(df):>5d} (pid={df['patient_id'].nunique()}, reps={df['rep'].nunique()})")
else:
print(f" {lbl:<18s} no data")
n_cols = len(BOTTOM_ROW)
fig = plt.figure(figsize=(4.4 * n_cols, 12))
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("L1 Fusion Comparison — refuge V2-M backbone", fontsize=14, fontweight="bold")
gs = fig.add_gridspec(2, n_cols, hspace=0.30, wspace=0.15)
# Top row: 2 reference panels at same width as bottom panels, centered.
# In a 4-column bottom grid, that's columns 1 and 2.
n_top = len(top_dfs)
top_offset = (n_cols - n_top) // 2 # leading empty columns
top_axes = []
for i, (lbl, df) in enumerate(top_dfs):
ax = fig.add_subplot(gs[0, top_offset + i])
top_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
# Bottom row: 4 fusion variants
bottom_axes = []
sharey = None
for i, (lbl, df) in enumerate(bottom_dfs):
ax = fig.add_subplot(gs[1, i], sharey=sharey)
sharey = sharey or ax
bottom_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
top_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
bottom_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
for ax in bottom_axes[1:]:
ax.set_yticklabels([])
legend_patches = [mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]]
fig.legend(handles=legend_patches, fontsize=9,
loc="lower center", ncol=len(legend_patches),
framealpha=0.75, bbox_to_anchor=(0.5, -0.01))
fig.tight_layout(rect=[0, 0.06, 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()
+343
View File
@@ -0,0 +1,343 @@
"""V2M_F4 — Bilateral lift via confidence strips (refuge V2-M backbone).
V2-M counterpart to F4. Same 2x3 grid; image and fusion cells use refuge
V2-M runs. Clinical-only cells are backbone-independent so use the existing
phase2_v4/cd_solo_single and phase4_v4/cd_solo_bilateral runs.
Re-run anytime:
python -m v4.figures.V2M_F4_bilateral
"""
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 matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F4_bilateral.png"
# Palette (matches F3) ───────────────────────────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
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}
# Panel grid: [row][col] = (label, run_dir, eval_stage)
# All image / fusion cells use refuge V2-M backbone.
GRID = [
[
("Single · Clinical", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
(
"Single · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m",
"img_fuse",
),
(
"Single · Fusion",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m",
"nt",
),
],
[
(
"Bilateral · Clinical",
RESULTS_ROOT / "phase4_v4" / "cd_solo_bilateral",
"hb",
),
(
"Bilateral · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo",
"hb",
),
(
"Bilateral · Fusion",
RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m",
"hb",
),
],
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── Same helpers as F3 ───────────────────────────────────────────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_ = pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = (
df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = (
both.groupby("Patient ID")["Diagnosis"]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0:
return "normal"
v = row["vf_md"]
if pd.isna(v):
return "unknown"
if v > -6:
return "early"
if v > -12:
return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
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"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, 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)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append(
{
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
}
)
return pd.DataFrame(rows)
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [
df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]
]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(
data_by_class,
positions=[0, 1],
widths=0.7,
showmedians=False,
showextrema=False,
)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color)
body.set_alpha(0.30)
body.set_edgecolor("none")
body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any():
continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(
x,
sub["prob_glaucoma"].values,
c=SEV_COLORS[sev],
s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev],
linewidths=0,
zorder=3,
)
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma")
continue
med = float(np.median(vals))
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1])
ax.set_xticklabels(xtick_labels, fontsize=9)
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=10.5, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2:
continue
try:
fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception:
pass
if fold_aucs:
ax.text(
0.66,
0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=8.5,
color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2),
)
def render() -> None:
vfi = load_vfi()
fig, axes = plt.subplots(2, 3, figsize=(13, 11), sharey=True)
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("Single → Bilateral Aggregation Lift — refuge V2-M backbone",
fontsize=13, fontweight="bold")
for ri, row in enumerate(GRID):
for ci, (lbl, path, stage) in enumerate(row):
ax = axes[ri, ci]
ax.set_facecolor("#e8e8e8")
df = collect_predictions(path, stage)
if len(df):
_draw_panel(ax, df, vfi, lbl)
print(f" [{ri},{ci}] {lbl:<22s} n={len(df):>5d}")
else:
ax.text(
0.5,
0.5,
"(pending)",
ha="center",
va="center",
fontsize=12,
color="#888",
transform=ax.transAxes,
)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(lbl, fontsize=10.5, fontweight="bold")
print(f" [{ri},{ci}] {lbl:<22s} no data yet")
for ri in range(2):
axes[ri, 0].set_ylabel("Predicted P(Glaucoma)", fontsize=10.5)
legend_patches = [
mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]
]
fig.legend(
handles=legend_patches,
fontsize=9,
loc="lower center",
ncol=len(legend_patches),
framealpha=0.75,
bbox_to_anchor=(0.5, -0.005),
)
fig.tight_layout(rect=[0, 0.04, 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()
+307
View File
@@ -0,0 +1,307 @@
"""V2M_F6 — Regression VF_MD with severity grouping (refuge V2-M backbone).
V2-M counterpart to F6. Reads predictions from the refuge V2-M variant of
baseline_reg_nt50; otherwise identical layout to F6 so panels can be
compared side-by-side.
Re-run anytime predictions.h5 changes:
python -m v4.figures.V2M_F6_regression
"""
from __future__ import annotations
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_curve, roc_auc_score
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F6_regression.png"
RUN_DIR = RESULTS_ROOT / "v2m_variants" / "baseline_reg_nt50_v2m"
# Prediction-side bin boundaries
NP_THRESH = -1.097 # mean of measured-healthy MD
SEV_PRED = -9.14 # midpoint of HAP -12 and mean predicted MD for severe truth
# Actual (HAP / Mills) clinical boundaries
HAP_SEV = -12.0
HAP_MOD = -6.0
LABELS = ["severe", "moderate", "low"]
# Severity colors (consistent across figures)
C_SEVERE = "#E53935"
C_MODERATE = "#FFB300"
C_LOW = "#3B6FB5"
def _decode(arr):
return np.array(
[s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr]
)
def _collect():
actuals, preds = [], []
for fp in sorted(RUN_DIR.rglob("predictions.h5")):
with h5py.File(fp, "r") as f:
if "hb" not in f:
continue
grp = f["hb"]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, _ = logits.shape
ep, head, out = n_epochs - 1, n_heads - 1, 0
for fold in range(n_folds):
labels = _decode(split[fold])
m = (
(labels == "test")
& np.isfinite(y_true)
& np.isfinite(logits[fold, ep, :, head, out])
)
actuals.append(y_true[m])
preds.append(logits[fold, ep, m, head, out].astype(float))
if not actuals:
return None, None
return np.concatenate(actuals), np.concatenate(preds)
def _bin(values, sev, np_th):
bins = np.full(values.shape, 2, dtype=int)
bins[values <= np_th] = 1
bins[values <= sev] = 0
return bins
def render() -> None:
if not RUN_DIR.exists() or not any(RUN_DIR.rglob("predictions.h5")):
print(f"[F6] no predictions.h5 yet under {RUN_DIR}. Run after data lands.")
return
a, p = _collect()
if a is None:
print("[F6] no usable predictions")
return
print(f"[F6] pooled n={a.size}")
fig = plt.figure(figsize=(14, 10.5))
gs = fig.add_gridspec(
2, 2, hspace=0.40, wspace=0.30, left=0.07, right=0.96, top=0.92, bottom=0.07
)
ax_sc = fig.add_subplot(gs[0, 0])
ax_rc = fig.add_subplot(gs[0, 1])
ax_cm = fig.add_subplot(gs[1, 0])
ax_tb = fig.add_subplot(gs[1, 1])
ax_tb.axis("off")
# ── (a) scatter ──────────────────────────────────────────────────────────
ax_sc.scatter(a, p, s=10, alpha=0.4, color="#2563eb", edgecolor="none")
lo, hi = -30, 6
ax_sc.plot([lo, hi], [lo, hi], ls="--", color="#9ca3af", lw=1, label="ideal y=x")
ax_sc.axvline(HAP_SEV, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.axvline(HAP_MOD, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.set_xlim(lo, hi)
ax_sc.set_ylim(lo, hi)
ax_sc.set_xlabel("Actual VF_MD (dB)")
ax_sc.set_ylabel("Predicted VF_MD (dB)")
ax_sc.set_title(f"(a) Predicted vs Actual MD (n={a.size})", fontsize=11)
r = np.corrcoef(a, p)[0, 1]
mae = float(np.mean(np.abs(p - a)))
ax_sc.text(
0.04,
0.95,
f"r = {r:.3f}\nMAE = {mae:.2f} dB",
transform=ax_sc.transAxes,
ha="left",
va="top",
fontsize=10,
bbox=dict(facecolor="white", alpha=0.85, edgecolor="#d1d5db"),
)
# ── (b) three one-vs-rest ROCs ───────────────────────────────────────────
# Severe vs rest: score = -p (more negative pred → more severe)
# Low vs rest: score = +p (more positive pred → more "low" / no-problem)
# Moderate vs rest: score = -|p - midpoint of moderate range|
# (closer to midpoint → more moderate-like)
mod_midpoint = 0.5 * (HAP_SEV + HAP_MOD) # -9 dB
truth_severe = (a <= HAP_SEV).astype(int)
truth_low = (a > HAP_MOD).astype(int)
truth_moderate = ((a > HAP_SEV) & (a <= HAP_MOD)).astype(int)
series = [
("Severe (≤ 12 dB) vs rest", truth_severe, -p, C_SEVERE),
(
"Moderate (12..6) vs rest",
truth_moderate,
-np.abs(p - mod_midpoint),
C_MODERATE,
),
("Low (> 6 dB) vs rest", truth_low, p, C_LOW),
]
for label, ybin, score, color in series:
if len(np.unique(ybin)) < 2:
continue
fpr, tpr, _ = roc_curve(ybin, score)
auc = roc_auc_score(ybin, score)
ax_rc.plot(fpr, tpr, color=color, lw=1.8, label=f"{label} (AUC = {auc:.3f})")
ax_rc.plot([0, 1], [0, 1], ls="--", color="#9ca3af", lw=0.8)
ax_rc.set_xlim(0, 1)
ax_rc.set_ylim(0, 1.02)
ax_rc.set_xlabel("False positive rate")
ax_rc.set_ylabel("True positive rate")
ax_rc.set_title("(b) One-vs-rest ROC per severity tier", fontsize=11)
ax_rc.legend(loc="lower right", fontsize=9, framealpha=0.95)
ax_rc.grid(alpha=0.25, linestyle="--")
# ── (c) confusion matrix ─────────────────────────────────────────────────
t_act = _bin(a, HAP_SEV, HAP_MOD)
t_pred = _bin(p, SEV_PRED, NP_THRESH)
cm = np.zeros((3, 3), dtype=int)
for x, y in zip(t_act, t_pred):
cm[x, y] += 1
cm_pct = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
ax_cm.imshow(cm_pct, cmap="Blues", vmin=0, vmax=1, aspect="equal")
for i in range(3):
for j in range(3):
text_color = "white" if cm_pct[i, j] > 0.55 else "black"
ax_cm.text(
j,
i,
f"{cm[i,j]}\n({cm_pct[i,j]*100:.0f}%)",
ha="center",
va="center",
fontsize=10,
color=text_color,
)
ax_cm.set_xticks(range(3))
ax_cm.set_xticklabels(LABELS, fontsize=10)
ax_cm.set_yticks(range(3))
ax_cm.set_yticklabels(LABELS, fontsize=10)
ax_cm.set_xlabel("Predicted", fontsize=10)
ax_cm.set_ylabel("Actual", fontsize=10)
ax_cm.set_title("(c) 3-tier confusion", fontsize=11)
# ── (d) per-class stats — sens / spec / PPV / NPV only ─────────────────
# We deliberately drop TP/FN/FP/TN here because in a 3-tier setting a
# "false negative" for severe could land in moderate (clinically
# different from landing in low). The confusion matrix in panel (c)
# already shows that distinction; sens/spec/PPV/NPV summarise the
# one-vs-rest performance without the blanket-count obfuscation.
ax_tb.set_title("(d) Per-class statistics", fontsize=11)
ax_tb.set_xlim(0, 10)
ax_tb.set_ylim(0, 5)
headers = ["class", "n", "sens", "spec", "PPV", "NPV"]
# Make the class column wider than the numeric columns to avoid clipping.
col_widths = np.array([2.4, 1.1, 1.4, 1.4, 1.4, 1.4])
col_widths *= 10.0 / col_widths.sum() # normalise to total width 10
col_edges = np.concatenate([[0], np.cumsum(col_widths)])
col_x = (col_edges[:-1] + col_edges[1:]) / 2 # column centers
row_y = [3.5, 2.5, 1.5, 0.5] # 1 header + 3 data rows
rows = []
for c in range(3):
ac = t_act == c
pc = t_pred == c
tp = int(np.sum(ac & pc))
fn = int(np.sum(ac & ~pc))
fp = int(np.sum(~ac & pc))
tn = int(np.sum(~ac & ~pc))
sens = tp / max(tp + fn, 1)
spec = tn / max(tn + fp, 1)
ppv = tp / max(tp + fp, 1)
npv = tn / max(tn + fn, 1)
rows.append(
[
LABELS[c],
int(ac.sum()),
f"{sens:.3f}",
f"{spec:.3f}",
f"{ppv:.3f}",
f"{npv:.3f}",
]
)
# Header band
ax_tb.add_patch(
plt.Rectangle(
(0, 3.05), 10, 0.9, facecolor="#dbeafe", edgecolor="none", zorder=1
)
)
for x, h in zip(col_x, headers):
ax_tb.text(
x,
row_y[0],
h,
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color="#1e3a8a",
zorder=2,
)
# Data rows with zebra shading
row_colors = ["#f8fafc", "#eef2f6", "#f8fafc"]
severity_color = {"severe": C_SEVERE, "moderate": C_MODERATE, "low": C_LOW}
for ri, row in enumerate(rows):
ax_tb.add_patch(
plt.Rectangle(
(0, row_y[ri + 1] - 0.45),
10,
0.9,
facecolor=row_colors[ri],
edgecolor="none",
zorder=1,
)
)
for ci, val in enumerate(row):
txt_color = "#222"
weight = "normal"
if ci == 0:
txt_color = severity_color.get(val, "#222")
weight = "bold"
ax_tb.text(
col_x[ci],
row_y[ri + 1],
str(val),
ha="center",
va="center",
fontsize=11,
fontweight=weight,
color=txt_color,
zorder=2,
)
# Subtle horizontal grid lines
for y in [
row_y[0] - 0.45,
row_y[0] + 0.45,
row_y[1] - 0.45,
row_y[2] - 0.45,
row_y[3] - 0.45,
]:
ax_tb.plot([0, 10], [y, y], color="#cbd5e1", lw=0.6, zorder=1.5)
fig.suptitle(
"Regression predicting VF_MD with severity grouping — refuge V2-M backbone",
fontsize=13,
fontweight="bold",
y=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()
+198
View File
@@ -0,0 +1,198 @@
"""V2M_S1 — Adding geometry as a third information source (refuge V2-M backbone).
V2-M counterpart to S1. Section structure unchanged; runs swapped for V2-M
variants where the image stream is present. "solo" still uses the existing
geometry-only run since that path doesn't use the image backbone.
Re-run after data lands:
python -m v4.figures.V2M_S1_geometry
"""
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
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_S1_geometry.png"
# ── Style (mirrors F2) ───────────────────────────────────────────────────────
C_VAR = "#4c72b0" # blue — variant boxes
C_BASE = "#dd8452" # orange — baseline reference box
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
"""Aggregate test AUC across all rep × fold, picking the run's eval_stage."""
import json
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Baseline (used in both sections as reference) — refuge V2-M ensemble, no geometry
BASELINE_LABEL = "baseline\n(no geometry)"
BASELINE_RUN = RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m"
# Section A — Vector injection variants at refuge V2-M
VECTOR_VARIANTS = [
("U-Net vector", RESULTS_ROOT / "v2m_variants" / "ensemble_geom_vec_unet_v2m"),
("GT vector", RESULTS_ROOT / "v2m_variants" / "ensemble_geom_vec_gt_v2m"),
]
# Section B — network variants (CNN over segmentation maps) at refuge V2-M
NETWORK_VARIANTS = [
# Geometry-only network does not use the image backbone, so refugelike data
# is the same as V2-M would be.
("solo (geom network alone)", RESULTS_ROOT / "tri_v1" / "baseline_solo"),
("U-Net fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "tritower"),
("GT fusion", RESULTS_ROOT / "v2m_variants" / "tritower_geom_gt_v2m"),
]
def render() -> None:
base_aucs = load_fold_aucs(BASELINE_RUN)
vec_data = [(lbl, load_fold_aucs(p)) for lbl, p in VECTOR_VARIANTS]
network_data = [(lbl, load_fold_aucs(p)) for lbl, p in NETWORK_VARIANTS]
print(f"Baseline (no geometry): n={len(base_aucs):>3d} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}"
if len(base_aucs) else "Baseline: no data")
print("Vector injection variants:")
for lbl, a in vec_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
print("Network variants:")
for lbl, a in network_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
# Layout positions
box_w = 0.55
inner_gap = 0.50
section_gap = 0.95
# Section A: baseline | unet vector | gt vector
section_a_labels = [BASELINE_LABEL] + [l for l, _ in vec_data]
section_a_data = [base_aucs] + [a for _, a in vec_data]
section_a_colors = [C_BASE] + [C_VAR] * len(vec_data)
# Section B: solo | unet fusion | gt fusion
section_b_labels = [l for l, _ in network_data]
section_b_data = [a for _, a in network_data]
section_b_colors = [C_VAR] * len(network_data)
positions: list[float] = []
p = 0.0
for _ in section_a_labels:
positions.append(p); p += box_w + inner_gap
section_a_right = positions[-1] + box_w / 2
p = positions[-1] + box_w + section_gap
section_b_left = p
for _ in section_b_labels:
positions.append(p); p += box_w + inner_gap
all_labels = section_a_labels + section_b_labels
all_data = section_a_data + section_b_data
all_colors = section_a_colors + section_b_colors
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Geometry Integration — refuge V2-M backbone",
fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for x, aucs, color in zip(positions, all_data, all_colors):
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,
whiskerprops=whiskerprops,
capprops=capprops,
flierprops=flierprops,
)
# Baseline median reference line across the whole plot
if len(base_aucs):
ax.axhline(np.median(base_aucs), color=C_BASE,
linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median (no geometry)")
# Section dividers
div_x = (section_a_right + section_b_left - box_w / 2) / 2
ax.axvline(div_x, color="#aaa", linewidth=0.7, alpha=0.6, linestyle="-")
# Section headers
sec_a_cx = (positions[0] + positions[len(section_a_labels) - 1]) / 2
sec_b_cx = (positions[len(section_a_labels)] + positions[-1]) / 2
ax.text(sec_a_cx, 1.02, "Vector injection (5-dim structured features)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
ax.text(sec_b_cx, 1.02, "Geometry network (CNN over segmentation map)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
# X-tick labels with Wilcoxon p-values vs baseline for non-baseline boxes
tick_lbls = []
for lbl, aucs in zip(all_labels, all_data):
if lbl == BASELINE_LABEL or not len(aucs) or not len(base_aucs):
tick_lbls.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lbls.append(f"{lbl}\n{ps}")
ax.set_xticks(positions)
ax.set_xticklabels(tick_lbls, fontsize=9.5)
ax.set_xlim(positions[0] - box_w, positions[-1] + box_w + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
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()
+177
View File
@@ -0,0 +1,177 @@
"""F7 — Backbone upgrade: refugelike → refuge V2-M.
Four architecture configurations ordered from least to most complex, each
shown as a paired box plot (refugelike vs refuge_efficientnet_v2_m). Same
F2-style: black-bordered boxes, red median lines, baseline median dashed
reference. Within each group a Wilcoxon p-value compares V2-M to refugelike.
Configs (left → right, increasing architectural complexity):
1. Single-eye img+cd ensemble (no bilateral aggregation)
2. Bilateral img only (bilateral hb, single tower)
3. Bilateral img+cd ensemble (production architecture)
4. Bilateral tritower (img+cd+geom)
Re-run anytime:
python -m v4.figures.X1_v2m_punch
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "X1_v2m_backbone.png"
# ── Style ───────────────────────────────────────────────────────────────────
C_REFUGELIKE = "#dd8452" # orange — the older fundus-pretrained baseline
C_REFUGE_V2M = "#4c72b0" # blue — the upgraded fundus-pretrained backbone
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
# (config_label, refugelike_run_path, refuge_v2m_run_path)
CONFIGS = [
(
"Single\nimg only",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m",
),
(
"Single ensemble\n(img+cd)",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m",
),
(
"Bilateral ensemble\n(img+cd)",
RESULTS_ROOT / "tri_v1" / "baseline_ensemble",
RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m",
),
(
"3-way fusion\n(img+cd+geom)",
RESULTS_ROOT / "tri_v1" / "baseline_tri",
RESULTS_ROOT / "refuge_v2m_baseline" / "tritower",
),
]
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
def render() -> None:
data = []
for label, refg_path, v2m_path in CONFIGS:
refg = load_fold_aucs(refg_path)
v2m = load_fold_aucs(v2m_path)
data.append((label, refg, v2m))
print(f" {label.replace(chr(10), ' '):<32s} refg n={len(refg):>3d} {refg.mean():.3f}±{refg.std():.3f} "
f"v2m n={len(v2m):>3d} {v2m.mean():.3f}±{v2m.std():.3f}"
if (len(refg) and len(v2m)) else f" {label} pending")
# Layout: 4 groups of 2 boxes
box_w = 0.46
pair_gap = 0.10
group_gap = 0.85
group_width = 2 * box_w + pair_gap
positions: list[tuple[float, float]] = []
p = 0.0
for _ in CONFIGS:
positions.append((p, p + box_w + pair_gap))
p += group_width + group_gap
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Backbone Upgrade — refugelike → refuge V2-M",
fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for (label, refg, v2m), (xr, xv) in zip(data, positions):
if len(refg):
ax.boxplot(refg, positions=[xr], widths=box_w, patch_artist=True,
manage_ticks=False,
boxprops=dict(facecolor=C_REFUGELIKE, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops, whiskerprops=whiskerprops,
capprops=capprops, flierprops=flierprops)
if len(v2m):
ax.boxplot(v2m, positions=[xv], widths=box_w, patch_artist=True,
manage_ticks=False,
boxprops=dict(facecolor=C_REFUGE_V2M, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops, whiskerprops=whiskerprops,
capprops=capprops, flierprops=flierprops)
# Group tick labels (config name + Wilcoxon p between paired boxes)
tick_x = [(xr + xv) / 2 for xr, xv in positions]
tick_lb = []
for (label, refg, v2m), _ in zip(data, positions):
if len(refg) and len(v2m):
n = min(len(refg), len(v2m))
p_val = _wilcoxon_p(v2m[:n], refg[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lb.append(f"{label}\n{ps}")
else:
tick_lb.append(label)
ax.set_xticks(tick_x)
ax.set_xticklabels(tick_lb, fontsize=9.5)
# Legend
legend_handles = [
mpatches.Patch(facecolor=C_REFUGELIKE, edgecolor="black",
alpha=ALPHA, label="refugelike (ResNet50 + REFUGE)"),
mpatches.Patch(facecolor=C_REFUGE_V2M, edgecolor="black",
alpha=ALPHA, label="refuge V2-M (EfficientNetV2-M + REFUGE)"),
]
ax.legend(handles=legend_handles, loc="lower right", fontsize=9, framealpha=0.92)
# Limits and grid
xmin = positions[0][0] - box_w
xmax = positions[-1][1] + box_w
ax.set_xlim(xmin - 0.3, xmax + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC (10 reps × 5 folds)", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
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()
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+10
View File
@@ -0,0 +1,10 @@
feature,mean_importance,std_importance
Age,0.03184101084778838,0.017362350323790826
IOP_corr,0.0209298093602393,0.01619384191432414
Gender,0.010245143460808846,0.020271910566046904
Phakic/Pseudophakic,0.005718439848904106,0.019878985551523384
eyeID,0.002907359675067821,0.0068517202428395665
Pachymetry,0.0027483776870354105,0.008864856763204554
dioptre_2,8.49432781653429e-05,0.0010216320594945336
astigmatism,-0.0012830897957007016,0.0044473592790778
dioptre_1,-0.002925119731183013,0.006220349074293031
1 feature mean_importance std_importance
2 Age 0.03184101084778838 0.017362350323790826
3 IOP_corr 0.0209298093602393 0.01619384191432414
4 Gender 0.010245143460808846 0.020271910566046904
5 Phakic/Pseudophakic 0.005718439848904106 0.019878985551523384
6 eyeID 0.002907359675067821 0.0068517202428395665
7 Pachymetry 0.0027483776870354105 0.008864856763204554
8 dioptre_2 8.49432781653429e-05 0.0010216320594945336
9 astigmatism -0.0012830897957007016 0.0044473592790778
10 dioptre_1 -0.002925119731183013 0.006220349074293031
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -0,0 +1,10 @@
feature,mean_importance,std_importance
Age,0.03184101084778838,0.017362350323790826
IOP_corr,0.0209298093602393,0.01619384191432414
Gender,0.010245143460808846,0.020271910566046904
Phakic/Pseudophakic,0.005718439848904106,0.019878985551523384
eyeID,0.002907359675067821,0.0068517202428395665
Pachymetry,0.0027483776870354105,0.008864856763204554
dioptre_2,8.49432781653429e-05,0.0010216320594945336
astigmatism,-0.0012830897957007016,0.0044473592790778
dioptre_1,-0.002925119731183013,0.006220349074293031
1 feature mean_importance std_importance
2 Age 0.03184101084778838 0.017362350323790826
3 IOP_corr 0.0209298093602393 0.01619384191432414
4 Gender 0.010245143460808846 0.020271910566046904
5 Phakic/Pseudophakic 0.005718439848904106 0.019878985551523384
6 eyeID 0.002907359675067821 0.0068517202428395665
7 Pachymetry 0.0027483776870354105 0.008864856763204554
8 dioptre_2 8.49432781653429e-05 0.0010216320594945336
9 astigmatism -0.0012830897957007016 0.0044473592790778
10 dioptre_1 -0.002925119731183013 0.006220349074293031
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
PYTHON_BIN="${PYTHON_BIN:-/home/rpotter/miniconda3/envs/fundus_imaging/bin/python}"
export MPLCONFIGDIR="${MPLCONFIGDIR:-/tmp/mplconfig}"
FUSION_SPLIT="${FUSION_SPLIT:-test}"
GRADCAM_GRID="${GRADCAM_GRID:-16}"
GRADCAM_ALPHA="${GRADCAM_ALPHA:-0.45}"
FUSION_SOURCE="${FUSION_SOURCE:-v3}"
GRADCAM_SOURCE="${GRADCAM_SOURCE:-v3}"
echo "Running F8a fusion event panel from ${FUSION_SOURCE} ${FUSION_SPLIT} predictions..."
"${PYTHON_BIN}" -m v4.figures.F8_explainability \
--only-fusion \
--fusion-source "${FUSION_SOURCE}" \
--fusion-split "${FUSION_SPLIT}"
echo "Running oriented GradCAM outputs from ${GRADCAM_SOURCE}..."
"${PYTHON_BIN}" -m v4.figures.F8_explainability \
--only-gradcam \
--gradcam-source "${GRADCAM_SOURCE}" \
--n-grid "${GRADCAM_GRID}" \
--alpha "${GRADCAM_ALPHA}"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
export MPLCONFIGDIR="${MPLCONFIGDIR:-/tmp/mplconfig}"
/home/rpotter/miniconda3/envs/fundus_imaging/bin/python -m v4.figures.F8_explainability \
--only-gradcam \
--n-grid 16 \
--alpha 0.45
View File
+55
View File
@@ -0,0 +1,55 @@
"""Shared loaders/aggregators for v4 figure scripts."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[3]
RESULTS_ROOT = REPO_ROOT / "v4" / "results" / "experiments"
def summarise_run(run_path: Path, primary_hint: Optional[str] = None) -> Optional[dict]:
"""Aggregate val/test primary-metric mean & std across reps for one run folder.
Returns dict with n, val_mean, val_std, test_mean, test_std, metric_name, or None if no reps."""
val, test = [], []
metric_name = primary_hint
for rep in sorted(run_path.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if not s:
continue
d = json.loads(s.read_text())
pm = d.get("primary_metric") or metric_name or "auc"
metric_name = metric_name or pm
v = d.get(f"mean_val_{pm}")
t = d.get(f"mean_test_{pm}")
if v is None or t is None or not np.isfinite(v) or not np.isfinite(t):
continue
val.append(float(v)); test.append(float(t))
if not val:
return None
return {
"n": len(val),
"metric": metric_name or "auc",
"val_mean": float(np.mean(val)),
"val_std": float(np.std(val)),
"test_mean": float(np.mean(test)),
"test_std": float(np.std(test)),
"val_arr": np.array(val),
"test_arr": np.array(test),
}
def summarise_many(name_to_path: dict[str, Path], primary_hint: Optional[str] = None) -> dict[str, Optional[dict]]:
"""Apply summarise_run to a dict of labelled run folders."""
return {label: summarise_run(p, primary_hint) for label, p in name_to_path.items()}
def fmt_status(s: Optional[dict]) -> str:
if s is None:
return "pending"
return f"n={s['n']:>2d} test={s['test_mean']:.4f}±{s['test_std']:.4f}"