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.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 134 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
|
||||
|
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
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
|
||||
|
|
After Width: | Height: | Size: 57 KiB |
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||