Files
hypertower/v3/scripts/output_analysis/explainability/confidence_strips.py
T
2026-04-14 19:42:16 +02:00

616 lines
20 KiB
Python
Raw 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.
"""
Phase 5 explainability — confidence strips and head comparison.
Works from saved prediction CSVs. If patient_id column is present (requires
a re-run after the v3_hypertower.py update), points are colored by VFI
severity group. Otherwise falls back to a single color per true class.
VFI severity groups (VF_MD from clinical data):
Early VF_MD > -6
Moderate VF_MD -6 to -12
Severe VF_MD < -12
Produces (all in figures/explainability/):
confidence_strips.png — vertical strip: P(glaucoma) by true class, VFI colored
head_comparison.png — fused vs img vs md distributions side by side
Usage:
python -m v3.scripts.output_analysis.explainability.confidence_strips
python -m v3.scripts.output_analysis.explainability.confidence_strips \
--run phase5/logit_mlp_head --clinical-dir Papila/ClinicalData
"""
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
REPO_ROOT = Path(__file__).resolve().parents[4]
RESULTS_ROOT = REPO_ROOT / "v3" / "results"
FIGURES_ROOT = REPO_ROOT / "v3" / "figures"
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
FONT = "DejaVu Sans"
# Colour palette
C_NORMAL = "#78909C" # blue-grey — healthy controls (no VFI staging)
C_EARLY = "#29B6F6" # sky-blue — glaucoma, early VFI loss
C_MODERATE = "#FFB300" # amber — glaucoma, moderate VFI loss
C_SEVERE = "#E53935" # vivid red — glaucoma, severe VFI loss
C_UNKNOWN = "#BDBDBD" # light grey — glaucoma, VFI not recorded
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,
}
HEAD_COLORS = {"fused": "#d4a017", "img": "#4e8d3a", "md": "#4c72b0"}
HEAD_LABELS = {
"fused": "Fused head",
"img": "Image-only head",
"md": "Clinical-only head",
}
# ── VFI data ──────────────────────────────────────────────────────────────────
def load_vfi(clinical_dir: Path) -> pd.DataFrame:
"""Return DataFrame with columns [patient_id (int), vf_md (float), severity (str)].
Only includes patients in the binary study (Diagnosis 0=Normal, 1=Glaucoma).
"""
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, eye):
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 patients
df = df[df["Diagnosis"].isin([0, 1])].copy()
df["eye"] = eye
return df[["Patient ID", "Diagnosis", "VF_MD", "eye"]]
combined = pd.concat([_clean(od, "OD"), _clean(os_, "OS")], ignore_index=True)
# Per patient: modal diagnosis, worst (most negative) VF_MD across eyes
diag = (
combined.groupby("Patient ID")["Diagnosis"]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
vf = combined.groupby("Patient ID")["VF_MD"].min().reset_index()
worst = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "VF_MD": "vf_md", "Diagnosis": "diagnosis"}
)
def _severity(row):
if int(row["diagnosis"]) == 0:
return "normal" # healthy control — no VFI staging
v = row["vf_md"]
if pd.isna(v):
return "unknown" # glaucoma, no VFI recorded
if v > -6:
return "early"
if v > -12:
return "moderate"
return "severe"
worst["severity"] = worst.apply(_severity, axis=1)
return worst
# ── Prediction loading ────────────────────────────────────────────────────────
def load_all_predictions(
run_dir: Path, tower_path: str = "binary/ensemble"
) -> pd.DataFrame:
"""Pool predictions_test.csv across all reps and folds."""
rows = []
for rep_dir in sorted(run_dir.glob("rep*")):
tm_dir = rep_dir / tower_path
if not tm_dir.exists():
continue
for fold_dir in sorted(tm_dir.glob("fold[0-9]")):
csv_path = fold_dir / "predictions_test.csv"
if not csv_path.exists():
continue
df = pd.read_csv(csv_path)
df["rep"] = rep_dir.name
df["fold"] = fold_dir.name
rows.append(df)
if not rows:
raise FileNotFoundError(f"No predictions_test.csv found under {run_dir}")
return pd.concat(rows, ignore_index=True)
# ── Figure 1: Confidence strips (vertical) ───────────────────────────────────
def make_confidence_strips(
df: pd.DataFrame, vfi: pd.DataFrame | None, out_path: Path
) -> None:
"""
Vertical strip plot: x = true class, y = P(glaucoma).
Points colored by VFI severity if patient_id column available, else uniform.
"""
rng = np.random.default_rng(42)
has_vfi = (
vfi is not None
and "patient_id" in df.columns
and df["patient_id"].notna().any()
)
if has_vfi:
df = df.copy()
df["patient_id"] = pd.to_numeric(df["patient_id"], errors="coerce").astype(
"Int64"
)
vfi_merge = vfi.copy()
vfi_merge["patient_id"] = vfi_merge["patient_id"].astype("Int64")
df = df.merge(
vfi_merge[["patient_id", "severity"]], on="patient_id", how="left"
)
df["severity"] = df["severity"].fillna("unknown")
else:
df["severity"] = "unknown"
fig, ax = plt.subplots(figsize=(6, 7))
fig.patch.set_facecolor("#e8e8e8")
ax.set_facecolor("#e8e8e8")
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Draw in severity order so severe is on top
sev_order = ["normal", "unknown", "early", "moderate", "severe"]
sev_alpha = {
"normal": 0.40,
"early": 0.55,
"moderate": 0.70,
"severe": 0.85,
"unknown": 0.35,
}
sev_size = {"normal": 6, "early": 8, "moderate": 10, "severe": 12, "unknown": 6}
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_fused_c1"].values,
c=SEV_COLORS[sev],
s=sev_size[sev],
alpha=sev_alpha[sev],
linewidths=0,
zorder=3,
label=SEV_LABELS[sev],
)
# Median lines per class
for cls, xc in x_pos.items():
med = np.median(df.loc[df["y_true"] == cls, "prob_fused_c1"])
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
)
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1])
ax.set_xticklabels(["Normal", "Glaucoma"], fontsize=11)
ax.set_ylabel("Predicted P(Glaucoma)", fontsize=11)
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.set_title(
"Confidence Strips — Fused Head\n(Phase 5, all folds)",
fontsize=12,
fontweight="bold",
)
ax.grid(axis="y", alpha=0.3, zorder=1)
# Legend — only show groups that appear
handles, labels = ax.get_legend_handles_labels()
if handles:
ax.legend(
handles=handles,
labels=labels,
fontsize=8.5,
loc="upper center",
framealpha=0.75,
ncol=2,
)
if not has_vfi:
ax.text(
0.98,
0.02,
"Re-run with updated v3_hypertower.py\nto enable VFI severity coloring",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=7.5,
color="#888",
style="italic",
)
fig.tight_layout()
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {out_path}")
# ── Figure 2: Head comparison ─────────────────────────────────────────────────
def make_head_comparison(df: pd.DataFrame, out_path: Path) -> None:
"""Side-by-side violin + strip of P(glaucoma) by true class for each head."""
heads = ["fused", "img", "md"]
prob_cols = {"fused": "prob_fused_c1", "img": "prob_img_c1", "md": "prob_md_c1"}
rng = np.random.default_rng(42)
fig, axes = plt.subplots(1, 3, figsize=(11, 4.5), sharey=True)
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle(
"Head Comparison — P(Glaucoma) by True Class (Phase 5, all folds)",
fontsize=12,
fontweight="bold",
)
c_normal = "#4c72b0"
c_glaucoma = "#c44e52"
for ax, head in zip(axes, heads):
ax.set_facecolor("#e8e8e8")
col = prob_cols[head]
data_by_class = [df.loc[df["y_true"] == cls, col].values for cls in [0, 1]]
vp = ax.violinplot(
data_by_class,
positions=[0, 1],
widths=0.6,
showmedians=True,
showextrema=False,
)
for body, color in zip(vp["bodies"], [c_normal, c_glaucoma]):
body.set_facecolor(color)
body.set_alpha(0.35)
vp["cmedians"].set_color("#222")
vp["cmedians"].set_linewidth(2)
for cls, color in zip([0, 1], [c_normal, c_glaucoma]):
vals = data_by_class[cls]
jitter = rng.uniform(-0.12, 0.12, len(vals))
ax.scatter(
cls + jitter, vals, color=color, s=4, alpha=0.30, linewidths=0, zorder=3
)
ax.axhline(0.5, color="#888", lw=1.0, ls="--", alpha=0.6)
ax.set_xticks([0, 1])
ax.set_xticklabels(["Normal", "Glaucoma"], fontsize=9)
ax.set_title(
HEAD_LABELS[head], fontsize=10, fontweight="bold", color=HEAD_COLORS[head]
)
ax.set_ylim(-0.05, 1.05)
ax.grid(axis="y", alpha=0.3)
if head == "fused":
ax.set_ylabel("Predicted P(Glaucoma)", fontsize=10)
from sklearn.metrics import roc_auc_score
try:
auc = roc_auc_score(df["y_true"], df[col])
ax.text(
0.97,
0.04,
f"AUC = {auc:.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=9,
color="#333",
bbox=dict(facecolor="white", alpha=0.65, edgecolor="none", pad=2),
)
except Exception:
pass
fig.tight_layout()
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {out_path}")
# ── Multi-model comparison strip ──────────────────────────────────────────────
def make_comparison_strips(
run_configs: list[dict], vfi: pd.DataFrame, out_path: Path
) -> None:
"""
Side-by-side confidence strips for multiple runs.
Each config: {"label": str, "df": DataFrame}.
"""
from sklearn.metrics import roc_auc_score
rng = np.random.default_rng(42)
n = len(run_configs)
fig, axes = plt.subplots(1, n, figsize=(4.5 * n, 7), sharey=True)
if n == 1:
axes = [axes]
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle(
"Confidence Strips by Model (Phase 5, all folds)",
fontsize=13,
fontweight="bold",
)
sev_order = ["normal", "unknown", "early", "moderate", "severe"]
sev_alpha = {
"normal": 0.40,
"early": 0.55,
"moderate": 0.70,
"severe": 0.85,
"unknown": 0.35,
}
sev_size = {"normal": 6, "early": 8, "moderate": 10, "severe": 12, "unknown": 6}
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
for ax, cfg in zip(axes, run_configs):
ax.set_facecolor("#e8e8e8")
df = cfg["df"]
# Attach VFI severity
df = df.copy()
df["patient_id"] = pd.to_numeric(df["patient_id"], errors="coerce").astype(
"Int64"
)
vfi_m = vfi.copy()
vfi_m["patient_id"] = vfi_m["patient_id"].astype("Int64")
df = df.merge(vfi_m[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
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_fused_c1"].values,
c=SEV_COLORS[sev],
s=sev_size[sev],
alpha=sev_alpha[sev],
linewidths=0,
zorder=3,
)
# IQR box + median line per class
iqr_w = 0.06
xticklabels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_fused_c1"]
med = np.median(vals)
q25 = np.percentile(vals, 25)
q75 = np.percentile(vals, 75)
# Subtle translucent IQR box
ax.add_patch(
plt.Rectangle(
(xc - iqr_w, q25),
2 * iqr_w,
q75 - q25,
facecolor="#555",
alpha=0.18,
linewidth=0,
zorder=4,
)
)
# Median line
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
label="Median" if cls == 0 else None,
)
# TP / TN rate below x-label
if cls == 1:
rate = (vals > 0.5).mean() * 100
xticklabels.append(f"Glaucoma\nTP {rate:.0f}%")
else:
rate = (vals <= 0.5).mean() * 100
xticklabels.append(f"Normal\nTN {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(xticklabels, fontsize=10)
ax.set_title(cfg["label"], fontsize=11, fontweight="bold")
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.grid(axis="y", alpha=0.3, zorder=1)
try:
fold_aucs = [
roc_auc_score(g["y_true"], g["prob_fused_c1"])
for _, g in df.groupby(["rep", "fold"])
if g["y_true"].nunique() > 1
]
mean_auc = np.mean(fold_aucs)
std_auc = np.std(fold_aucs)
ax.text(
0.65,
0.0,
f"AUC = {mean_auc:.3f} ± {std_auc:.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=9,
color="#333",
bbox=dict(facecolor="white", alpha=0.65, edgecolor="none", pad=2),
)
except Exception:
pass
axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
# Shared legend
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.02),
)
fig.tight_layout(rect=[0, 0.06, 1, 1])
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {out_path}")
# ── Main ──────────────────────────────────────────────────────────────────────
# Default runs shown in the comparison
DEFAULT_RUNS = [
{
"run": "phase4/single",
"tower_path": "binary/single",
"label": "Single HyperTower",
},
{
"run": "phase5/ensemble_fused",
"tower_path": "binary/ensemble",
"label": "Bilateral Ensemble",
},
{
"run": "phase5/logit_mlp_head",
"tower_path": "binary/ensemble",
"label": "Fused Head",
},
]
def _aggregate_eye_to_patient(df: pd.DataFrame) -> pd.DataFrame:
"""
Single-mode predictions are eye-level (2 rows per patient per fold).
In the test loader, OD rows come first (sorted patient ID order) then OS.
Average the two eyes to get one patient-level row per fold.
"""
rows = []
prob_cols = [c for c in df.columns if c.startswith("prob_")]
pred_cols = [c for c in df.columns if c.startswith("pred_")]
for (rep, fold), grp in df.groupby(["rep", "fold"]):
n = len(grp)
half = n // 2
od = grp.iloc[:half].reset_index(drop=True)
os_ = grp.iloc[half:].reset_index(drop=True)
pat = od.copy()
for col in prob_cols:
pat[col] = (od[col].values + os_[col].values) / 2
for col in pred_cols:
pat[col] = (pat[col.replace("pred_", "prob_") + "_c1"] >= 0.5).astype(int)
rows.append(pat)
return pd.concat(rows, ignore_index=True)
def _load_run(run: str, tower_path: str, clinical_dir: Path) -> pd.DataFrame:
run_dir = RESULTS_ROOT / run
print(f" Loading {run} ...")
df = load_all_predictions(run_dir, tower_path=tower_path)
if tower_path.endswith("/single"):
from v3.scripts.output_analysis.explainability.fold_patient_ids import (
attach_patient_ids_single,
)
df = attach_patient_ids_single(df, clinical_dir=clinical_dir, batch_size=8)
else:
from v3.scripts.output_analysis.explainability.fold_patient_ids import (
attach_patient_ids,
)
df = attach_patient_ids(df, clinical_dir=clinical_dir)
return df
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--run", default="phase5/logit_mlp_head", help="Single run for standalone plots"
)
ap.add_argument("--tower-path", default="binary/ensemble")
ap.add_argument("--clinical-dir", type=Path, default=CLINICAL_DIR)
ap.add_argument("--out", type=Path, default=FIGURES_ROOT / "explainability")
args = ap.parse_args()
print("Loading VFI data ...")
vfi = load_vfi(args.clinical_dir)
# ── Single-run plots (strips + head comparison) ──────────────────────────
df = _load_run(args.run, args.tower_path, args.clinical_dir)
make_confidence_strips(df, vfi, args.out / "confidence_strips.png")
make_head_comparison(df, args.out / "head_comparison.png")
# ── Multi-model comparison ───────────────────────────────────────────────
print("Building comparison strips ...")
run_configs = []
for cfg in DEFAULT_RUNS:
try:
df_r = _load_run(cfg["run"], cfg["tower_path"], args.clinical_dir)
run_configs.append({"label": cfg["label"], "df": df_r})
except FileNotFoundError as e:
print(f" Skipping {cfg['run']}: {e}")
if run_configs:
make_comparison_strips(
run_configs, vfi, args.out / "confidence_strips_comparison.png"
)
if __name__ == "__main__":
main()