pre-refactor 041426

This commit is contained in:
rpotter6298
2026-04-14 19:42:16 +02:00
parent eb9eafe715
commit 13290575d5
75 changed files with 8900 additions and 77 deletions
@@ -0,0 +1,615 @@
"""
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()