update 3-19
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re-evaluate holdout accuracy using two threshold strategies:
|
||||
|
||||
1. acc — current behaviour: maximise raw accuracy on (imbalanced) val set
|
||||
2. youden — Youden's J = sensitivity + specificity − 1 on val set
|
||||
|
||||
For each fold the val probs (already saved) supply the threshold, then the
|
||||
model is re-run on the holdout set to get the actual holdout accuracy under
|
||||
each strategy.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/output_analysis/reeval_holdout_threshold.py \
|
||||
--run-dir analysis_data/pipeline_10x5 \
|
||||
--eval-mode binary
|
||||
|
||||
# or a single nocrop run:
|
||||
python scripts/output_analysis/reeval_holdout_threshold.py \
|
||||
--run-dir analysis_data/pipeline_nocrop \
|
||||
--eval-mode binary \
|
||||
--fold-seed 42
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from sklearn.metrics import balanced_accuracy_score, roc_auc_score, roc_curve
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from classes.v2.metrics import tune_multiclass_bias
|
||||
from classes.v2.papila_builders import build_papila_data
|
||||
from classes.v2.loader_factory import filter_bilateral_samples, make_loader
|
||||
from classes.v2.models import SingleEyeHT, collect_probs_single_components
|
||||
from classes.v2.profiles import build_papila_profile
|
||||
from classes.v2.split_manager import PatientFirstSplitManager
|
||||
from classes.v2.transforms import build_eval_transform
|
||||
from classes.v2.utils import choose_device
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threshold helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _acc_threshold(y: np.ndarray, p1: np.ndarray) -> float:
|
||||
grid = np.linspace(0.0, 1.0, 1001)
|
||||
best_t, best_acc = 0.5, -1.0
|
||||
for t in grid:
|
||||
acc = float(((p1 >= t).astype(int) == y).mean())
|
||||
if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)):
|
||||
best_acc, best_t = acc, float(t)
|
||||
return best_t
|
||||
|
||||
|
||||
def _youden_threshold(y: np.ndarray, p1: np.ndarray) -> float:
|
||||
if len(np.unique(y)) < 2:
|
||||
return 0.5
|
||||
fpr, tpr, thresholds = roc_curve(y, p1)
|
||||
j = tpr + (1.0 - fpr) - 1.0
|
||||
return float(thresholds[np.argmax(j)])
|
||||
|
||||
|
||||
def _apply_threshold(probs: np.ndarray, threshold: float, num_classes: int) -> np.ndarray:
|
||||
if num_classes == 2:
|
||||
return (probs[:, 1] >= threshold).astype(int)
|
||||
# multiclass: not applicable for a single scalar threshold
|
||||
return probs.argmax(axis=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-fold evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eval_fold(
|
||||
fold_dir: Path,
|
||||
fold_idx: int,
|
||||
fold_seed: int,
|
||||
eval_mode: str,
|
||||
args,
|
||||
device: torch.device,
|
||||
) -> dict | None:
|
||||
|
||||
checkpoint = fold_dir / "best_single.pt"
|
||||
if not checkpoint.exists():
|
||||
print(f" [skip] {fold_dir}: no best_single.pt")
|
||||
return None
|
||||
|
||||
val_y_path = fold_dir / "y_true.npy"
|
||||
val_p_path = fold_dir / "probs_fused.npy"
|
||||
if not val_y_path.exists() or not val_p_path.exists():
|
||||
print(f" [skip] {fold_dir}: no val probs")
|
||||
return None
|
||||
|
||||
val_y = np.load(val_y_path)
|
||||
val_p = np.load(val_p_path)
|
||||
num_classes = val_p.shape[1]
|
||||
|
||||
# ---- decision boundaries from val ----
|
||||
if num_classes == 2:
|
||||
t_acc = _acc_threshold(val_y, val_p[:, 1])
|
||||
t_youden = _youden_threshold(val_y, val_p[:, 1])
|
||||
else:
|
||||
# multiclass: compare raw-acc-optimised bias vs balanced-acc-optimised bias
|
||||
# raw-acc bias: temporarily swap objective back to raw accuracy
|
||||
from sklearn.metrics import accuracy_score
|
||||
import copy
|
||||
|
||||
def _tune_bias_raw(y, p):
|
||||
c = p.shape[1]
|
||||
bias = np.zeros(c)
|
||||
grid = np.linspace(-1.0, 1.0, 41)
|
||||
for _ in range(2):
|
||||
for k in range(c):
|
||||
best_v, best_acc = bias[k], -1.0
|
||||
old = bias[k]
|
||||
for v in grid:
|
||||
bias[k] = float(v)
|
||||
logits = np.log(np.clip(p, 1e-8, 1.0)) + bias.reshape(1, -1)
|
||||
acc = float((logits.argmax(1) == y).mean())
|
||||
if acc > best_acc or (acc == best_acc and abs(v) < abs(best_v)):
|
||||
best_acc, best_v = acc, float(v)
|
||||
bias[k] = best_v
|
||||
return bias
|
||||
|
||||
bias_raw = _tune_bias_raw(val_y, val_p)
|
||||
bias_bal = tune_multiclass_bias(val_y, val_p) # balanced acc objective
|
||||
|
||||
# ---- reconstruct holdout split ----
|
||||
data = build_papila_data(
|
||||
image_dir=args.image_dir,
|
||||
clinical_dir=args.clinical_dir,
|
||||
label_col=args.label_col,
|
||||
cat_cols=args.cat_cols,
|
||||
n_splits=args.n_splits,
|
||||
random_seed=fold_seed,
|
||||
iop_corr_method=getattr(args, "iop_corr_method", "ratio"),
|
||||
)
|
||||
df_mode = data.df.copy()
|
||||
if eval_mode == "binary":
|
||||
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
|
||||
|
||||
splitter = PatientFirstSplitManager(
|
||||
patient_col="Patient ID", label_col=args.label_col
|
||||
)
|
||||
split_args = SimpleNamespace(
|
||||
eval_mode=eval_mode,
|
||||
holdout_per_class=args.holdout_per_class,
|
||||
holdout_seed=args.holdout_seed,
|
||||
n_splits=args.n_splits,
|
||||
fold_seed=fold_seed,
|
||||
)
|
||||
plans = splitter.build_plans(
|
||||
clinical=SimpleNamespace(df=df_mode, label_col=args.label_col),
|
||||
args=split_args,
|
||||
profile=None,
|
||||
)
|
||||
split = plans[fold_idx]
|
||||
|
||||
if split.holdout is None or split.holdout.empty:
|
||||
print(f" [skip] {fold_dir}: no holdout")
|
||||
return None
|
||||
|
||||
# ---- build holdout loader ----
|
||||
profile_patient = build_papila_profile(
|
||||
patient_col="Patient ID", label_col=args.label_col, sample_mode="patient"
|
||||
)
|
||||
holdout_samples = filter_bilateral_samples(
|
||||
profile_patient.build_samples(df=split.holdout, clinical=data)
|
||||
)
|
||||
if not holdout_samples:
|
||||
print(f" [skip] {fold_dir}: no bilateral holdout samples")
|
||||
return None
|
||||
|
||||
eval_transform = build_eval_transform(args.backbone)
|
||||
holdout_loader = make_loader(
|
||||
holdout_samples,
|
||||
profile_patient.slot_descriptors(),
|
||||
image_transform=eval_transform,
|
||||
image_preprocessor=None,
|
||||
batch_size=args.batch_size,
|
||||
shuffle=False,
|
||||
num_workers=args.num_workers,
|
||||
)
|
||||
|
||||
# ---- load model ----
|
||||
model = SingleEyeHT(
|
||||
backbone=args.backbone,
|
||||
freeze_ratio=0.0,
|
||||
augment=False,
|
||||
clinical_data=data,
|
||||
num_classes=num_classes,
|
||||
md_hidden_dim=getattr(args, "md_hidden_dim", 64),
|
||||
fusion_dim=getattr(args, "fusion_dim", 128),
|
||||
bridge_mode=getattr(args, "bridge_mode", "fused"),
|
||||
).to(device)
|
||||
model.load_state_dict(
|
||||
torch.load(checkpoint, map_location=device, weights_only=False)
|
||||
)
|
||||
model.eval()
|
||||
|
||||
# ---- run inference ----
|
||||
hld_y, hld_p, _, _ = collect_probs_single_components(
|
||||
model, holdout_loader, device, aggregate_patient=True
|
||||
)
|
||||
|
||||
if len(hld_y) == 0:
|
||||
return None
|
||||
|
||||
hld_auc = float(roc_auc_score(
|
||||
hld_y, hld_p[:, 1] if num_classes == 2 else hld_p,
|
||||
multi_class="ovr" if num_classes > 2 else "raise",
|
||||
))
|
||||
|
||||
if num_classes == 2:
|
||||
acc_old = float(((hld_p[:, 1] >= t_acc).astype(int) == hld_y).mean())
|
||||
acc_new = float(((hld_p[:, 1] >= t_youden).astype(int) == hld_y).mean())
|
||||
bacc_old = balanced_accuracy_score(hld_y, (hld_p[:, 1] >= t_acc).astype(int))
|
||||
bacc_new = balanced_accuracy_score(hld_y, (hld_p[:, 1] >= t_youden).astype(int))
|
||||
row_extra = {"t_old": t_acc, "t_new": t_youden}
|
||||
else:
|
||||
logits_raw = np.log(np.clip(hld_p, 1e-8, 1.0)) + bias_raw.reshape(1, -1)
|
||||
logits_bal = np.log(np.clip(hld_p, 1e-8, 1.0)) + bias_bal.reshape(1, -1)
|
||||
preds_raw = logits_raw.argmax(1)
|
||||
preds_bal = logits_bal.argmax(1)
|
||||
acc_old = float((preds_raw == hld_y).mean())
|
||||
acc_new = float((preds_bal == hld_y).mean())
|
||||
bacc_old = balanced_accuracy_score(hld_y, preds_raw)
|
||||
bacc_new = balanced_accuracy_score(hld_y, preds_bal)
|
||||
row_extra = {"bias_raw": bias_raw.tolist(), "bias_bal": bias_bal.tolist()}
|
||||
|
||||
return {
|
||||
"fold_dir": str(fold_dir),
|
||||
"fold": fold_idx,
|
||||
"fold_seed": fold_seed,
|
||||
"hld_auc": hld_auc,
|
||||
"hld_acc_old": acc_old,
|
||||
"hld_acc_new": acc_new,
|
||||
"hld_bacc_old": bacc_old,
|
||||
"hld_bacc_new": bacc_new,
|
||||
"n_holdout": len(hld_y),
|
||||
**row_extra,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run-dir", required=True,
|
||||
help="e.g. analysis_data/pipeline_10x5 or analysis_data/pipeline_nocrop")
|
||||
ap.add_argument("--eval-mode", default="binary", choices=["binary", "multiclass"])
|
||||
ap.add_argument("--fold-seed", type=int, default=None,
|
||||
help="Override fold seed (for single-rep runs). "
|
||||
"For 10x5, seeds are inferred from rep dir name.")
|
||||
ap.add_argument("--backbone", default="refugelike")
|
||||
ap.add_argument("--n-splits", type=int, default=5)
|
||||
ap.add_argument("--holdout-per-class", type=int, default=5)
|
||||
ap.add_argument("--holdout-seed", type=int, default=123)
|
||||
ap.add_argument("--label-col", default="Diagnosis")
|
||||
ap.add_argument("--cat-cols", nargs="*", default=["Gender"])
|
||||
ap.add_argument("--image-dir", default="Papila/FundusImages")
|
||||
ap.add_argument("--clinical-dir",default="Papila/ClinicalData")
|
||||
ap.add_argument("--iop-corr-method", default="ratio")
|
||||
ap.add_argument("--batch-size", type=int, default=8)
|
||||
ap.add_argument("--num-workers", type=int, default=4)
|
||||
ap.add_argument("--md-hidden-dim", type=int, default=128)
|
||||
ap.add_argument("--fusion-dim", type=int, default=256)
|
||||
ap.add_argument("--bridge-mode", default="fused")
|
||||
ap.add_argument("--device", default="auto")
|
||||
ap.add_argument("--out", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
device = choose_device(args.device)
|
||||
run_dir = Path(args.run_dir)
|
||||
out_path = Path(args.out) if args.out else \
|
||||
run_dir / f"reeval_threshold_{args.eval_mode}.csv"
|
||||
|
||||
# Discover fold dirs — supports both flat (fold0..fold4) and
|
||||
# rep-based (rep00/binary/ensemble/fold0) layouts
|
||||
_BASE_SEED = 100
|
||||
_SEED_STRIDE = 100
|
||||
|
||||
fold_jobs: list[tuple[Path, int, int]] = [] # (fold_dir, fold_idx, fold_seed)
|
||||
|
||||
rep_dirs = sorted(run_dir.glob("rep[0-9]*"))
|
||||
if rep_dirs:
|
||||
for rep_dir in rep_dirs:
|
||||
rep_n = int(rep_dir.name.replace("rep", ""))
|
||||
fold_seed = _BASE_SEED + rep_n * _SEED_STRIDE
|
||||
mode_dir = rep_dir / args.eval_mode / "ensemble"
|
||||
if not mode_dir.exists():
|
||||
continue
|
||||
for fd in sorted(mode_dir.glob("fold[0-9]*"), key=lambda p: int(p.name[4:])):
|
||||
fold_jobs.append((fd, int(fd.name[4:]), fold_seed))
|
||||
else:
|
||||
# flat layout
|
||||
mode_dir = run_dir / args.eval_mode / "ensemble"
|
||||
fold_seed = args.fold_seed if args.fold_seed is not None else 42
|
||||
for fd in sorted(mode_dir.glob("fold[0-9]*"), key=lambda p: int(p.name[4:])):
|
||||
fold_jobs.append((fd, int(fd.name[4:]), fold_seed))
|
||||
|
||||
if not fold_jobs:
|
||||
sys.exit(f"No fold directories found under {run_dir}")
|
||||
|
||||
print(f"Found {len(fold_jobs)} folds to re-evaluate")
|
||||
|
||||
rows = []
|
||||
for i, (fold_dir, fold_idx, fold_seed) in enumerate(fold_jobs):
|
||||
print(f"\n[{i+1}/{len(fold_jobs)}] {fold_dir} fold_seed={fold_seed}")
|
||||
row = eval_fold(fold_dir, fold_idx, fold_seed, args.eval_mode, args, device)
|
||||
if row:
|
||||
rows.append(row)
|
||||
print(f" hld_acc(old)={row['hld_acc_old']:.3f} "
|
||||
f"hld_acc(new)={row['hld_acc_new']:.3f} "
|
||||
f"hld_bacc(old)={row['hld_bacc_old']:.3f} "
|
||||
f"hld_bacc(new)={row['hld_bacc_new']:.3f} "
|
||||
f"hld_auc={row['hld_auc']:.3f}")
|
||||
|
||||
if not rows:
|
||||
print("No results.")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df.to_csv(out_path, index=False)
|
||||
print(f"\nSaved → {out_path}")
|
||||
is_binary = args.eval_mode == "binary"
|
||||
old_label = "acc-threshold" if is_binary else "raw-acc bias"
|
||||
new_label = "Youden-J" if is_binary else "balanced-acc bias"
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Summary ({args.eval_mode}, n={len(df)} folds)")
|
||||
print(f"{'='*60}")
|
||||
print(f" Holdout AUC: {df.hld_auc.mean():.4f} ± {df.hld_auc.std():.4f}")
|
||||
print(f" Holdout acc ({old_label:<18}): {df.hld_acc_old.mean():.4f} ± {df.hld_acc_old.std():.4f}")
|
||||
print(f" Holdout acc ({new_label:<18}): {df.hld_acc_new.mean():.4f} ± {df.hld_acc_new.std():.4f}")
|
||||
print(f" Holdout bacc ({old_label:<18}): {df.hld_bacc_old.mean():.4f} ± {df.hld_bacc_old.std():.4f}")
|
||||
print(f" Holdout bacc ({new_label:<18}): {df.hld_bacc_new.mean():.4f} ± {df.hld_bacc_new.std():.4f}")
|
||||
print(f" Delta acc (new − old): {df.hld_acc_new.mean() - df.hld_acc_old.mean():+.4f}")
|
||||
print(f" Delta bacc (new − old): {df.hld_bacc_new.mean() - df.hld_bacc_old.mean():+.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user