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

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

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

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

298 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()