Files
hypertower/classes/v2/v2_hypertower.py
T
2026-02-26 06:47:58 +01:00

3784 lines
159 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.
from __future__ import annotations
import os
import math
import argparse
import json
import time
import copy
import csv
from pathlib import Path
import shutil
import random as pyrandom
from dataclasses import dataclass
import pandas as pd
from PIL import Image, ImageDraw
import torch
from torch import nn
from torch.utils.data import DataLoader
from torch.utils.data.sampler import WeightedRandomSampler
from classes.v2.data_bundle import DataBundle
from classes.v2.dataset import ClinicalDataset
from classes.early_stop import EarlyStopper
from classes.unet_segmenter import UNetSegmenter
from classes.geometry_features import (
FEATURE_DIM,
compute_geometry_features,
disc_cup_from_mask_image,
)
from classes.refuge_classification import _geometry_from_mask
from classes.v2.bridges import Bridge, VoteBridge
from classes.v2.hypertower_logger import HypertowerLogger
from classes.v2.towers import ImageTower, MDTower
from torchvision import transforms
# from clinical_data import ClinicalData
# from dataset import ClinicalDataset
# from image_tower import ImageTower
# from md_tower import MDTower
# from bridge import Bridge, VoteBridge
from random import random
from sklearn.metrics import roc_auc_score, cohen_kappa_score, f1_score, matthews_corrcoef, recall_score
import torch.nn.functional as F
import numpy as np
from sklearn.metrics import roc_curve, auc
from sklearn.preprocessing import label_binarize
from typing import Optional, Tuple
from types import SimpleNamespace
from classes.backbones import BACKBONES
from classes.v2.papila_builders import build_papila_data
from classes.v2.split_manager import PatientFirstSplitManager
LOG_FIELDS = [
"epoch",
"eval_loss",
"acc_fused", "acc_img", "acc_md",
"auc_fused", "auc_img", "auc_md",
"top2_fused", "top2_img", "top2_md",
"margin_fused", "margin_img", "margin_md",
"agree_fused_img", "agree_fused_md",
"pct_fused", "pct_img", "pct_md",
"phase",
"holdout_loss",
"holdout_acc_fused",
"holdout_acc_img",
"holdout_acc_md",
"holdout_auc_fused",
"holdout_auc_img",
"holdout_auc_md",
]
def focal_loss(
logits: torch.Tensor,
targets: torch.Tensor,
gamma: float = 0.0,
weight: Optional[torch.Tensor] = None,
reduction: str = "mean",
) -> torch.Tensor:
"""
Standard focal loss wrapper. When gamma=0 it reduces to cross entropy.
weight should be per-class weights (same semantics as CrossEntropyLoss).
"""
if gamma <= 0:
return F.cross_entropy(logits, targets, weight=weight, reduction=reduction)
log_probs = F.log_softmax(logits, dim=1)
probs = log_probs.exp()
targets = targets.long().view(-1, 1)
logpt = log_probs.gather(1, targets)
pt = probs.gather(1, targets)
focal_factor = (1.0 - pt).clamp_min(0.0) ** gamma
loss = -focal_factor * logpt
if weight is not None:
class_weight = weight.gather(0, targets.view(-1))
loss = loss * class_weight.view(-1, 1)
loss = loss.view(-1)
if reduction == "sum":
return loss.sum()
if reduction == "mean":
return loss.mean()
return loss
class UNetImageCropper:
def __init__(
self,
manifest_path: Path,
weights_path: Path,
normalize: str = "per_image",
threshold: float = 0.5,
tta: bool = False,
scale: float = 2.5,
target_size: int = 224,
cache_dir: Optional[Path] = None,
) -> None:
self.segmenter = UNetSegmenter(
manifest_path=manifest_path,
normalize=normalize,
)
state = torch.load(weights_path, map_location=self.segmenter.device)
state_dict = state.get("model", state)
self.segmenter.model.load_state_dict(state_dict)
self.segmenter.model.to(self.segmenter.device)
self.segmenter.model.eval()
self.threshold = threshold
self.tta = tta
self.scale = scale
self.target_size = target_size
self.cache_dir = Path(cache_dir) if cache_dir is not None else None
if self.cache_dir is not None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.to_tensor = transforms.ToTensor()
def _cache_path(self, image_path: Path) -> Optional[Path]:
if self.cache_dir is None:
return None
stem = image_path.stem
return self.cache_dir / f"{stem}_s{int(self.scale * 100)}.npz"
def clear_cache(self) -> None:
if self.cache_dir is None or not self.cache_dir.exists():
return
removed = sum(1 for f in self.cache_dir.glob("*.npz") if f.unlink() or True)
print(f"[UNetImageCropper] Cleared {removed} cached crop files from {self.cache_dir}")
def _infer_masks(self, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]:
resized = self.segmenter.preprocess_image(image)
tensor = self.to_tensor(resized).unsqueeze(0).to(self.segmenter.device)
with torch.no_grad():
logits = self.segmenter.model(tensor)
if self.tta:
t_h = torch.flip(tensor, dims=[3])
log_h = self.segmenter.model(t_h)
log_h = torch.flip(log_h, dims=[3])
t_v = torch.flip(tensor, dims=[2])
log_v = self.segmenter.model(t_v)
log_v = torch.flip(log_v, dims=[2])
logits = (logits + log_h + log_v) / 3.0
probs = torch.sigmoid(logits)[0].cpu().numpy()
disc_pred = (probs[0] > self.threshold).astype(np.uint8) * 255
cup_pred = (probs[1] > self.threshold).astype(np.uint8) * 255
disc_img = Image.fromarray(disc_pred, mode="L").resize(image.size, Image.NEAREST)
disc_mask = np.array(disc_img, dtype=np.uint8)
cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST)
cup_mask = (np.array(cup_img, dtype=np.uint8) > 0).astype(np.uint8)
cup_mask = (cup_mask > 0) & (disc_mask > 0)
cup_mask = cup_mask.astype(np.uint8)
disc_mask = (disc_mask > 0).astype(np.uint8)
return disc_mask, cup_mask
def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]:
image_path = Path(image_path).resolve()
cache_path = self._cache_path(image_path)
cached_bounds = None
if cache_path is not None and cache_path.exists():
data = np.load(cache_path, allow_pickle=False)
try:
cached_bounds = {
"left": float(data["left"]),
"upper": float(data["upper"]),
"right": float(data["right"]),
"lower": float(data["lower"]),
}
if "features" in data.files:
cached_bounds["features"] = data["features"].astype(np.float32)
return cached_bounds
except KeyError:
cached_bounds = None
masks = self._infer_masks(image)
if masks is None:
return cached_bounds
disc_mask, cup_mask = masks
try:
geom = _geometry_from_mask(disc_mask, self.scale)
except Exception:
return cached_bounds
cx = geom["centre_x"]
cy = geom["centre_y"]
r = geom["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(float(image.width), cx + r)
lower = min(float(image.height), cy + r)
features = compute_geometry_features(disc_mask, cup_mask)
info = {
"left": left,
"upper": upper,
"right": right,
"lower": lower,
"features": features,
}
if cache_path is not None:
np.savez(
cache_path,
left=left,
upper=upper,
right=right,
lower=lower,
width=float(image.width),
height=float(image.height),
scale=self.scale,
target_size=self.target_size,
features=features,
)
return info
def __call__(self, image: Image.Image, image_path: Path) -> Image.Image:
info = self._compute_crop_info(image, image_path)
if info is None:
return image
left = info["left"]
upper = info["upper"]
right = info["right"]
lower = info["lower"]
if right <= left or lower <= upper:
return image
crop = image.crop((left, upper, right, lower))
return crop.resize((self.target_size, self.target_size), Image.BILINEAR)
def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]:
info = self._compute_crop_info(image, image_path)
if info is None:
return None
features = info.get("features")
if features is None:
return None
return np.asarray(features, dtype=np.float32)
class ManifestImageCropper:
def __init__(
self,
manifest_path: Path,
scale: float = 2.5,
target_size: int = 224,
cache_dir: Optional[Path] = None,
) -> None:
self.scale = scale
self.target_size = target_size
self.cache_dir = Path(cache_dir) if cache_dir is not None else None
if self.cache_dir is not None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
df = pd.read_csv(manifest_path)
self.entries: Dict[str, dict] = {}
for _, row in df.iterrows():
img_path = Path(row["image_path"]).resolve()
self.entries[str(img_path)] = {
"annotation_disc": row.get("annotation_disc"),
"annotation_cup": row.get("annotation_cup"),
"annotation_type_disc": row.get("annotation_type_disc"),
"annotation_type_cup": row.get("annotation_type_cup"),
}
def _cache_path(self, image_path: Path) -> Optional[Path]:
if self.cache_dir is None:
return None
return self.cache_dir / f"{image_path.stem}_s{int(self.scale * 100)}.npz"
def clear_cache(self) -> None:
if self.cache_dir is None or not self.cache_dir.exists():
return
removed = sum(1 for f in self.cache_dir.glob("*.npz") if f.unlink() or True)
print(f"[ManifestImageCropper] Cleared {removed} cached crop files from {self.cache_dir}")
@staticmethod
def _load_contour(path: Path) -> np.ndarray:
coords = np.loadtxt(path)
if coords.ndim == 1:
coords = coords.reshape(-1, 2)
return coords
@staticmethod
def _contour_to_mask(coords: np.ndarray, size: tuple[int, int]) -> np.ndarray:
if coords is None or coords.size == 0:
return np.zeros((size[1], size[0]), dtype=np.uint8)
img = Image.new("L", size, 0)
draw = ImageDraw.Draw(img)
points = [tuple(map(float, pt)) for pt in coords]
draw.polygon(points, outline=1, fill=1)
return np.array(img, dtype=np.uint8)
def _load_masks(self, entry: dict, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]:
disc_path = entry.get("annotation_disc")
cup_path = entry.get("annotation_cup")
disc_type = (entry.get("annotation_type_disc") or "").lower()
cup_type = (entry.get("annotation_type_cup") or "").lower()
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
if disc_path and not pd.isna(disc_path):
disc_path = Path(disc_path)
try:
if disc_type == "mask":
mask_img = Image.open(disc_path)
mask_img = mask_img.resize(image.size, Image.NEAREST)
disc_mask, cup_from_mask = disc_cup_from_mask_image(mask_img)
if cup_from_mask.sum() > 0:
cup_mask = cup_from_mask
elif disc_type == "contour":
coords = self._load_contour(disc_path)
disc_mask = self._contour_to_mask(coords, image.size)
except Exception:
disc_mask = None
if cup_mask is None and cup_path and not pd.isna(cup_path):
cup_path = Path(cup_path)
try:
if cup_type == "mask":
mask_img = Image.open(cup_path)
mask_img = mask_img.resize(image.size, Image.NEAREST)
_, cup_mask = disc_cup_from_mask_image(mask_img)
elif cup_type == "contour":
coords = self._load_contour(cup_path)
cup_mask = self._contour_to_mask(coords, image.size)
except Exception:
cup_mask = None
if disc_mask is None:
return None
disc_mask = (disc_mask > 0).astype(np.uint8)
if cup_mask is None:
cup_mask = np.zeros_like(disc_mask, dtype=np.uint8)
cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8)
return disc_mask, cup_mask
def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]:
image_path = Path(image_path).resolve()
entry = self.entries.get(str(image_path))
if entry is None:
return None
cache_path = self._cache_path(image_path)
cached_bounds = None
if cache_path is not None and cache_path.exists():
data = np.load(cache_path, allow_pickle=False)
try:
cached_bounds = {
"left": float(data["left"]),
"upper": float(data["upper"]),
"right": float(data["right"]),
"lower": float(data["lower"]),
}
if "features" in data.files:
cached_bounds["features"] = data["features"].astype(np.float32)
return cached_bounds
except KeyError:
cached_bounds = None
masks = self._load_masks(entry, image)
if masks is None:
return cached_bounds
disc_mask, cup_mask = masks
try:
geom = _geometry_from_mask(disc_mask, self.scale)
except Exception:
return cached_bounds
cx = geom["centre_x"]
cy = geom["centre_y"]
r = geom["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(float(image.width), cx + r)
lower = min(float(image.height), cy + r)
features = compute_geometry_features(disc_mask, cup_mask)
info = {
"left": left,
"upper": upper,
"right": right,
"lower": lower,
"features": features,
}
if cache_path is not None:
np.savez(
cache_path,
left=left,
upper=upper,
right=right,
lower=lower,
width=float(image.width),
height=float(image.height),
scale=self.scale,
target_size=self.target_size,
features=features,
)
return info
def __call__(self, image: Image.Image, image_path: Path) -> Image.Image:
info = self._compute_crop_info(image, image_path)
if info is None:
return image
left = info["left"]
upper = info["upper"]
right = info["right"]
lower = info["lower"]
if right <= left or lower <= upper:
return image
crop = image.crop((left, upper, right, lower))
return crop.resize((self.target_size, self.target_size), Image.BILINEAR)
def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]:
info = self._compute_crop_info(image, image_path)
if info is None:
return None
features = info.get("features")
if features is None:
return None
return np.asarray(features, dtype=np.float32)
def _num(x):
"""float(x) or None if NaN/None/invalid."""
try:
v = float(x)
except Exception:
return None
return None if math.isnan(v) else v
class _ClinicalView:
"""Minimal shim so ClinicalDataset can iterate an epoch-specific DataFrame
while still delegating encoding/paths/labels to the DataBundle object."""
def __init__(self, base: DataBundle, df):
self.base = base
self.df = df
@property
def image_dir(self):
return self.base.image_dir
@property
def clinical_dir(self):
return self.base.clinical_dir
@property
def id_cols(self):
return ("Patient ID", "eyeID")
@property
def label_col(self):
return self.base.label_col
@property
def filename_template(self):
# prefer whatever the dataset defined; otherwise fall back to RET{pid}{eye}.jpg style
return getattr(self.base, "filename_template", "RET{pid:03d}{eye}.jpg")
@property
def dim(self):
return self.base.feature_dim
def encode_metadata(self, row):
# DataBundle returns numpy; convert to torch here to keep towers torch-only
import torch as _torch
vec = self.base.vectorize_row(row)
return _torch.as_tensor(vec, dtype=_torch.float32)
def get_image_path(self, row):
return self.base.get_image_path(row)
def get_label(self, row):
return int(row[self.base.label_col])
class HyperTower:
def __init__(self, clinical: DataBundle, args):
# Expects a fully built DataBundle (add_df handled upstream)
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {self.device}")
self.clinical = clinical
sample_mode = str(getattr(args, "sample_mode", "eye") or "eye").lower()
tower_warmup = getattr(args, "warmup_tower_epochs", None)
fused_warmup = getattr(args, "warmup_fused_epochs", None)
if tower_warmup is None:
tower_warmup = 8 if sample_mode == "patient" else 2
if fused_warmup is None:
fused_warmup = 5 if sample_mode == "patient" else 3
self.warmup_tower_epochs = int(tower_warmup)
self.warmup_fused_epochs = int(fused_warmup)
self.args = args # cache for checkpointing
self._early = None
self.aux_img = getattr(args, "aux_img", 0.05)
self.aux_md = getattr(args, "aux_md", 0.05)
self.aux_detach = getattr(args, "aux_detach", True)
# SE placement configuration
se_where = getattr(args, "se_where", "bridge") # 'bridge'|'tower'|'both'|'none'
use_se_tower = se_where in ("tower", "both")
# Optional disc-centric cropping
self.image_preprocessor = None
crop_manifest = getattr(args, "img_crop_manifest", None)
crop_weights = getattr(args, "img_crop_weights", None)
use_gt = getattr(args, "img_crop_gt", False)
if crop_manifest:
crop_cache = getattr(args, "img_crop_cache", Path("cache_data/hypertower_crops"))
crop_cache = Path(crop_cache)
persist_cache = bool(getattr(args, "persist_img_crop_cache", False))
if use_gt:
self.image_preprocessor = ManifestImageCropper(
manifest_path=Path(crop_manifest),
scale=getattr(args, "img_crop_scale", 2.5),
target_size=getattr(args, "img_crop_size", 224),
cache_dir=crop_cache,
)
if not persist_cache:
self.image_preprocessor.clear_cache()
print(f"[HyperTower] GT disc cropper enabled → cache at {crop_cache}")
elif crop_weights:
self.image_preprocessor = UNetImageCropper(
manifest_path=Path(crop_manifest),
weights_path=Path(crop_weights),
normalize=getattr(args, "img_crop_normalize", "per_image"),
threshold=getattr(args, "img_crop_threshold", 0.5),
tta=getattr(args, "img_crop_tta", False),
scale=getattr(args, "img_crop_scale", 2.5),
target_size=getattr(args, "img_crop_size", 224),
cache_dir=crop_cache,
)
if not persist_cache:
self.image_preprocessor.clear_cache()
print(f"[HyperTower] UNet disc cropper enabled → cache at {crop_cache}")
else:
print("[HyperTower] img_crop_manifest provided but no weights/gt flag; skipping cropping")
self.use_geometry_features = bool(getattr(args, "img_geometry_features", False))
if self.use_geometry_features and self.image_preprocessor is None:
raise ValueError("img_geometry_features requires --img-crop-manifest with either --img-crop-weights or --img-crop-gt.")
self.geometry_dim = FEATURE_DIM if self.use_geometry_features else 0
# Towers derive their dimensions from DataBundle
self.img_tower = ImageTower(
backbone=getattr(args, "backbone", "efficientnet_b0"),
freeze_ratio=getattr(args, "freeze_ratio", 0.0),
use_se=use_se_tower,
se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)),
se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)),
augment=getattr(args, "img_augment", True),
geometry_dim=self.geometry_dim,
).to(self.device)
self.md_tower = MDTower(
self.clinical,
use_se=use_se_tower,
se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)),
se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)),
).to(self.device)
self.mode = args.fusion_mode # cache
# Training hyperparams / objects
self.batch_size = args.batch_size
# Main epochs exclude warmup; total training epochs adds warmup phases.
self.epochs = int(args.epochs)
self.total_epochs = int(self.warmup_tower_epochs + self.warmup_fused_epochs + self.epochs)
print(
f"[HyperTower] Warmup schedule: tower={self.warmup_tower_epochs}, "
f"fused={self.warmup_fused_epochs}, main={self.epochs}, total={self.total_epochs}"
)
self.lr = args.lr
self.fold = args.fold
self.criterion = nn.CrossEntropyLoss()
self._ce_weight = self.criterion.weight.detach().clone() if self.criterion.weight is not None else None
if self._ce_weight is not None:
self._ce_weight = self._ce_weight.to(self.device)
self._ce_reduction = self.criterion.reduction
self.focal_gamma = float(getattr(args, "focal_gamma", 0.0) or 0.0)
# Run and model directories come from the script
self.run_dir = Path(getattr(args, "run_dir", ".")).resolve()
self.run_dir.mkdir(parents=True, exist_ok=True)
# Per-run log locations to avoid collisions across concurrent workers
self.epoch_log_path = self.run_dir / "epoch_log.csv"
self.train_log_path = self.run_dir / "train.log"
self.models_dir = Path(getattr(args, "models_dir", "models")).resolve()
self.models_dir.mkdir(parents=True, exist_ok=True)
self.holdout_df = getattr(args, "holdout_df", None)
self.holdout_loader = None
if isinstance(self.holdout_df, pd.DataFrame) and not self.holdout_df.empty:
if getattr(args, "eval_mode", "multiclass") == "binary":
label_col = getattr(self.clinical, "label_col", None)
if label_col and label_col in self.holdout_df.columns:
self.holdout_df = self.holdout_df[self.holdout_df[label_col].isin([0, 1])].reset_index(drop=True)
self.holdout_loader = self._make_loader_for_df(self.holdout_df, is_train=False)
print(f"[HyperTower] Holdout loader prepared with {len(self.holdout_df)} samples")
if self.mode == "vote":
# Per-tower classification heads (logits) + vote combiner
self.head_img = nn.Linear(self.img_tower.out_dim, args.num_classes).to(self.device)
self.head_md = nn.Linear(self.md_tower.out_dim, args.num_classes).to(self.device)
self.vote = VoteBridge(num_classes=args.num_classes).to(self.device)
# Optimizer: towers + heads + vote
self.optimizer = torch.optim.Adam(
list(self.img_tower.parameters())
+ list(self.md_tower.parameters())
+ list(self.head_img.parameters())
+ list(self.head_md.parameters())
+ list(self.vote.parameters()),
lr=self.lr,
)
else:
# Existing feature-fusion bridge
self.bridge = Bridge(
img_dim=self.img_tower.out_dim,
meta_dim=self.md_tower.out_dim,
num_classes=args.num_classes,
fusion_dim=256,
mode=args.fusion_mode,
use_se=(se_where in ("bridge","both")) and getattr(args, "use_se", True),
se_reduction=(getattr(args, "se_reduction", 16) if getattr(args, "use_se", True) else 0),
se_pre_norm=getattr(args, "se_pre_norm", True)
).to(self.device)
self.optimizer = torch.optim.Adam(
list(self.img_tower.parameters())
+ list(self.md_tower.parameters())
+ list(self.bridge.parameters()),
lr=self.lr,
)
# EMA-forgiveness + BCD knobs; logging stays simple for now
self.ema_alpha = args.ema_alpha
self.bcd_prob = args.bcd_prob
self.ema_fused_loss = None
# Shared V2 logger for train.log + epoch_log.csv
self.ht_logger = HypertowerLogger(
run_dir=self.run_dir,
train_log_path=self.train_log_path,
epoch_log_path=self.epoch_log_path,
)
self.logger = self.ht_logger.logger
# Dynamic BCD configuration (epoch baselines + batch nudges)
self.bcd_cfg = {
"metric": getattr(args, "bcd_metric", "auc"),
"p0": getattr(args, "bcd_p0", 0.20),
"k": getattr(args, "bcd_k", 0.4),
"pmin": getattr(args, "bcd_min", 0.05),
"pmax": getattr(args, "bcd_max", 0.30),
"alpha_batch": getattr(args, "bcd_alpha_batch", 0.2),
"alpha_tower": getattr(args, "bcd_alpha_tower", 0.3),
"explore_floor": getattr(args, "bcd_explore_floor", 0.15),
"entropy_ema": getattr(args, "entropy_ema", 0.7),
}
# Track last epoch's eval metrics for baseline deficits; initialize safely
self.last_eval = {
"acc_fused": 0.0, "acc_img": 0.0, "acc_md": 0.0,
"auc_fused": 0.0, "auc_img": 0.0, "auc_md": 0.0,
}
# Running EMA of per-head uncertainty (entropy in [0,1])
self.entropy_ema = {"fused": 0.5, "img": 0.5, "md": 0.5}
# DataLoaders are (re)built each epoch from DataBundle's splits
self.train_loader = None
self.test_loader = None
self._last_step_mix = None
if not getattr(self.clinical, "folds", None):
raise RuntimeError("DataBundle has no built folds. Did you call add_df(...) upstream?")
@staticmethod
def _confusion(preds, labels):
# Quick TP/TN/FP/FN for binary debug (kept as-is)
tp = ((preds == 1) & (labels == 1)).sum().item()
tn = ((preds == 0) & (labels == 0)).sum().item()
fp = ((preds == 1) & (labels == 0)).sum().item()
fn = ((preds == 0) & (labels == 1)).sum().item()
return {"tp": tp, "tn": tn, "fp": fp, "fn": fn}
def _unpack_batch(self, batch):
if len(batch) == 4:
imgs, metas, geometry, labels = batch
else:
imgs, metas, labels = batch
if self.geometry_dim > 0:
geometry = torch.zeros(imgs.size(0), self.geometry_dim, dtype=torch.float32)
else:
geometry = None
return imgs, metas, geometry, labels
def _classification_loss(self, logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
"""
Shared classification loss (CrossEntropy or focal depending on gamma).
"""
weight = self._ce_weight
if weight is not None and weight.device != logits.device:
weight = weight.to(logits.device)
return focal_loss(
logits,
labels,
gamma=self.focal_gamma,
weight=weight,
reduction=self._ce_reduction,
)
def _step_image_only(self, imgs, metas, geometry, labels):
if self.mode == "vote":
img_feats = self.img_tower(imgs, geometry)
out_img = self.head_img(img_feats)
loss_i = self._classification_loss(out_img, labels)
preds_i = out_img.argmax(dim=1)
acc_i = (preds_i == labels).float().mean().item()
cm = self._confusion(preds_i, labels)
self.optimizer.zero_grad()
loss_i.backward()
self.optimizer.step()
return None, loss_i.item(), None, None, acc_i, None, {"img": cm}
else:# Optimize the image head alone (used when BCD chooses image-only)
img_feats = self.img_tower(imgs, geometry)
out_img = self.bridge.classifier_img(img_feats)
loss_i = self._classification_loss(out_img, labels)
preds_i = out_img.argmax(dim=1)
acc_i = (preds_i == labels).float().mean().item()
cm = self._confusion(preds_i, labels)
self.optimizer.zero_grad()
loss_i.backward()
self.optimizer.step()
return None, loss_i.item(), None, None, acc_i, None, {"img": cm}
def _step_meta_only(self, imgs, metas, geometry, labels):
# Optimize the metadata head alone (used when BCD chooses meta-only)
if self.mode == "vote":
meta_feats = self.md_tower(metas)
out_md = self.head_md(meta_feats)
loss_m = self._classification_loss(out_md, labels)
preds_m = out_md.argmax(dim=1)
acc_m = (preds_m == labels).float().mean().item()
cm = self._confusion(preds_m, labels)
self.optimizer.zero_grad()
loss_m.backward()
self.optimizer.step()
return None, None, loss_m.item(), None, None, acc_m, {"meta": cm}
else:
meta_feats = self.md_tower(metas)
out_md = self.bridge.classifier_md(meta_feats)
loss_m = self._classification_loss(out_md, labels)
preds_m = out_md.argmax(dim=1)
acc_m = (preds_m == labels).float().mean().item()
cm = self._confusion(preds_m, labels)
self.optimizer.zero_grad()
loss_m.backward()
self.optimizer.step()
return None, None, loss_m.item(), None, None, acc_m, {"meta": cm}
def _step_fused(self, imgs, metas, geometry, labels):
if self.mode == "vote":
img_feats = self.img_tower(imgs, geometry)
md_feats = self.md_tower(metas)
out_img = self.head_img(img_feats)
out_md = self.head_md(md_feats)
out_fused = self.vote(out_img, out_md)
preds_f = out_fused.argmax(dim=1)
acc_f = (preds_f == labels).float().mean().item()
loss_f = self._classification_loss(out_fused, labels)
loss_f_val = float(loss_f.detach().item())
# Optional: keep your EMA + small aux tower losses just like before
if self.ema_fused_loss is None:
self.ema_fused_loss = loss_f_val
L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss)
aux_terms = 0.0
if self.aux_img > 0:
aux_terms = aux_terms + self.aux_img * self._classification_loss(
out_img.detach() if self.aux_detach else out_img,
labels,
)
if self.aux_md > 0:
aux_terms = aux_terms + self.aux_md * self._classification_loss(
out_md.detach() if self.aux_detach else out_md,
labels,
)
L_total = L_total + aux_terms
self.optimizer.zero_grad()
L_total.backward()
self.optimizer.step()
self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val
# Confusion tables for logging
cm = {
"fused": self._confusion(preds_f, labels),
"img": self._confusion(out_img.argmax(dim=1), labels),
"meta": self._confusion(out_md.argmax(dim=1), labels),
}
acc_i = (out_img.argmax(dim=1) == labels).float().mean().item()
acc_m = (out_md.argmax(dim=1) == labels).float().mean().item()
with torch.no_grad():
loss_img_val = self._classification_loss(out_img, labels).item()
loss_md_val = self._classification_loss(out_md, labels).item()
return loss_f_val, loss_img_val, loss_md_val, acc_f, acc_i, acc_m, cm
else:
img_feats = self.img_tower(imgs, geometry)
meta_feats = self.md_tower(metas)
out_fused, out_img, out_md = self.bridge(img_feats, meta_feats)
preds_f = out_fused.argmax(dim=1)
acc_f = (preds_f == labels).float().mean().item()
loss_f = self._classification_loss(out_fused, labels)
loss_f_val = float(loss_f.detach().item())
cm = {"fused": self._confusion(preds_f, labels)}
loss_i = acc_i = loss_m = acc_m = None
if out_img is not None:
with torch.no_grad():
preds_i = out_img.argmax(dim=1)
acc_i = (preds_i == labels).float().mean().item()
loss_i = self._classification_loss(out_img, labels).item()
cm["img"] = self._confusion(preds_i, labels)
if out_md is not None:
with torch.no_grad():
preds_m = out_md.argmax(dim=1)
acc_m = (preds_m == labels).float().mean().item()
loss_m = self._classification_loss(out_md, labels).item()
cm["meta"] = self._confusion(preds_m, labels)
# EMA-blended fused loss
if self.ema_fused_loss is None:
self.ema_fused_loss = loss_f_val
L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss)
# Auxiliary tower losses (small weights). Use detached features to calibrate heads only.
aux_terms = 0.0
if self.aux_img > 0 and out_img is not None:
logits_img_for_aux = self.bridge.classifier_img(img_feats.detach()) if self.aux_detach else out_img
aux_terms = aux_terms + self.aux_img * self._classification_loss(logits_img_for_aux, labels)
if self.aux_md > 0 and out_md is not None:
logits_md_for_aux = self.bridge.classifier_md(meta_feats.detach()) if self.aux_detach else out_md
aux_terms = aux_terms + self.aux_md * self._classification_loss(logits_md_for_aux, labels)
L_total = L_total + aux_terms
self.optimizer.zero_grad()
L_total.backward()
self.optimizer.step()
# update EMA after the step
self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val
return loss_f_val, loss_i, loss_m, acc_f, acc_i, acc_m, cm
def _make_loader_for_df(self, df, is_train: bool = False):
# Rebuild a DataLoader for the current epoch's split
view = _ClinicalView(self.clinical, df)
geometry_provider = self.image_preprocessor if self.geometry_dim > 0 else None
ds = ClinicalDataset(
view,
self.img_tower.transform,
image_preprocessor=self.image_preprocessor,
geometry_provider=geometry_provider,
geometry_dim=self.geometry_dim,
)
# Optional: class-balanced bootstrapped sampling for TRAIN only
if is_train and getattr(self.args, "balanced_sampler", False):
import numpy as _np
y = _np.asarray(df[self.clinical.label_col].values)
# inverse-frequency weights per class
uniq, counts = _np.unique(y, return_counts=True)
inv = {c: (1.0 / cnt if cnt > 0 else 0.0) for c, cnt in zip(uniq, counts)}
w = _np.array([inv[c] for c in y], dtype=_np.float32)
sampler = WeightedRandomSampler(weights=w, num_samples=len(y), replacement=True)
return DataLoader(ds, batch_size=self.batch_size, sampler=sampler, shuffle=False)
return DataLoader(ds, batch_size=self.batch_size, shuffle=not is_train)
# ---- Dynamic BCD helpers ----
def _metric_value(self, name: str) -> float:
# Pull either AUC or ACC from last_eval, falling back if NaN/zero
if self.bcd_cfg["metric"] == "auc":
v = self.last_eval.get(f"auc_{name}", 0.0)
if v == v: # not NaN
return float(v)
# fallback to accuracy
return float(self.last_eval.get(f"acc_{name}", 0.0))
return float(self.last_eval.get(f"acc_{name}", 0.0))
def _epoch_bcd_baseline(self):
# Compute epoch-level baselines: p_bcd_epoch and tower weights w_i, w_m
p0 = self.bcd_cfg["p0"]; k = self.bcd_cfg["k"]
pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"]
eps = 1e-6
Af = self._metric_value("fused"); Ai = self._metric_value("img"); Am = self._metric_value("md")
di = max(0.0, Af - Ai); dm = max(0.0, Af - Am)
p_bcd_epoch = max(pmin, min(pmax, p0 + k * (di + dm) / 2.0))
wi = (di + eps) / (di + dm + 2 * eps)
wm = 1.0 - wi
return p_bcd_epoch, wi, wm, {"di": di, "dm": dm, "Af": Af, "Ai": Ai, "Am": Am}
@staticmethod
def _entropy_from_logits(logits: torch.Tensor, num_classes: int) -> float:
# Returns entropy normalized to [0,1] using log(K) denominator
with torch.no_grad():
probs = F.softmax(logits, dim=1)
ent = -(probs * (probs.clamp_min(1e-12)).log()).sum(dim=1)
ent = ent / np.log(num_classes)
return float(ent.mean().item())
def _batch_uncertainty(self, imgs: torch.Tensor, metas: torch.Tensor, geometry: Optional[torch.Tensor] = None) -> dict:
self.img_tower.eval(); self.md_tower.eval()
if self.mode == "vote":
self.head_img.eval(); self.head_md.eval(); self.vote.eval()
with torch.no_grad():
if geometry is None and self.geometry_dim > 0:
geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype)
img_feats = self.img_tower(imgs, geometry)
md_feats = self.md_tower(metas)
out_img = self.head_img(img_feats)
out_md = self.head_md(md_feats)
out_fused = self.vote(out_img, out_md)
K = out_fused.shape[1]
e_f = self._entropy_from_logits(out_fused, K)
e_i = self._entropy_from_logits(out_img, K)
e_m = self._entropy_from_logits(out_md, K)
# restore train()
self.img_tower.train(); self.md_tower.train()
self.head_img.train(); self.head_md.train(); self.vote.train()
else:
self.bridge.eval()
with torch.no_grad():
if geometry is None and self.geometry_dim > 0:
geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype)
img_feats = self.img_tower(imgs, geometry)
meta_feats = self.md_tower(metas)
out_fused, out_img, out_md = self.bridge(img_feats, meta_feats)
K = out_fused.shape[1]
e_f = self._entropy_from_logits(out_fused, K)
e_i = self._entropy_from_logits(out_img, K) if out_img is not None else 0.5
e_m = self._entropy_from_logits(out_md, K) if out_md is not None else 0.5
self.img_tower.train(); self.md_tower.train(); self.bridge.train()
a = self.bcd_cfg["entropy_ema"]
self.entropy_ema["fused"] = a * self.entropy_ema["fused"] + (1 - a) * e_f
self.entropy_ema["img"] = a * self.entropy_ema["img"] + (1 - a) * e_i
self.entropy_ema["md"] = a * self.entropy_ema["md"] + (1 - a) * e_m
return {"fused": self.entropy_ema["fused"], "img": self.entropy_ema["img"], "md": self.entropy_ema["md"]}
def _write_epoch_log(self, row: dict, path: str | Path | None = None):
self.ht_logger.write_epoch_row(row=row, path=path)
def _snapshot(self):
state = {
"img_tower": self.img_tower.state_dict(),
"md_tower": self.md_tower.state_dict(),
"optimizer": self.optimizer.state_dict(),
"args": vars(self.args),
}
if hasattr(self, "bridge"): state["bridge"] = self.bridge.state_dict()
if hasattr(self, "head_img"): state["head_img"] = self.head_img.state_dict()
if hasattr(self, "head_md"): state["head_md"] = self.head_md.state_dict()
return state
def train(self):
best_path = None
if getattr(self.args, "checkpoint_best", False):
best_path = str((self.models_dir / "best.pth").resolve())
# Always track the best model even if we don't stop early
monitor = getattr(self.args, "early_metric", None)
if not monitor:
# Prefer AUC-based monitoring by default; fall back to loss only if specified
if self.mode == "image_only":
monitor = "auc_img"
elif self.mode == "metadata_only":
monitor = "auc_md"
else: # fused or vote
monitor = "auc_fused"
mode = getattr(self.args, "early_mode", "auto")
# Optional switch: monitor holdout metrics instead of validation
use_holdout_monitor = bool(getattr(self.args, "early_monitor_holdout", False))
if use_holdout_monitor and self.holdout_loader is None:
print("[early] Requested holdout monitoring but no holdout set is configured; falling back to validation metrics.")
use_holdout_monitor = False
if use_holdout_monitor:
# If the monitor isn't already a holdout metric, prepend it
if not monitor.startswith("holdout_"):
monitor = f"holdout_{monitor}"
# Force sensible default if no monitor was provided
if monitor == "holdout_auc_fused" and mode == "auto":
mode = "max"
if mode == "auto":
mode = ("min" if ("loss" in monitor.lower()) else "max")
self._best_info = {
"monitor": monitor,
"mode": mode,
"min_delta": float(getattr(self.args, "early_min_delta", 0.0)),
"value": (-math.inf if mode == "max" else math.inf),
"epoch": -1,
"state": None,
"save_path": best_path,
}
# keep args in sync so EarlyStopper uses the same monitor/mode
self.args.early_metric = monitor
self.args.early_mode = mode
# Separate tracker for best holdout performance (if a holdout set is provided)
self._holdout_best = None
if self.holdout_loader is not None:
self._holdout_best = {
"monitor": "holdout_auc_fused",
"mode": "max",
"min_delta": 0.0,
"value": -math.inf,
"epoch": -1,
"state": None,
"save_path": str((self.models_dir / "model_holdout_best.pt").resolve()),
}
self._early = None
if getattr(self.args, "early_stop", False):
self._early = EarlyStopper(
monitor=monitor,
mode=mode,
patience=self.args.early_patience,
min_delta=self.args.early_min_delta,
save_path=best_path,
restore_best=True,
)
# Gradual thaw config
gradual = bool(getattr(self.args, "gradual_thaw", False))
thaw_ratio = float(getattr(self.args, "thaw_ratio", 0.33))
thaw_phase = int(getattr(self.args, "thaw_phase_duration", 5))
thaw_target = str(getattr(self.args, "thaw_target", "image"))
thaw_start_epoch = int(getattr(self.args, "thaw_start_epoch", -1))
thaw_initial_freeze = bool(getattr(self.args, "thaw_initial_freeze", False))
if thaw_start_epoch < 0:
thaw_start_epoch = int(getattr(self, "warmup_tower_epochs", 0))
for epoch in range(self.total_epochs):
# Apply thaw schedule at epoch start: start fully frozen and unfreeze by ratios
if gradual and thaw_phase > 0:
try:
if epoch < thaw_start_epoch:
if thaw_initial_freeze:
freeze_r = 1.0
if thaw_target in ("image", "both") and hasattr(self, "img_tower"):
self.img_tower.set_freeze_ratio(freeze_r)
if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"):
self.md_tower.set_freeze_ratio(freeze_r)
# else: leave current requires_grad as constructed (no forced freeze)
else:
phase_idx = (epoch - thaw_start_epoch) // thaw_phase
freeze_r = max(0.0, 1.0 - phase_idx * thaw_ratio)
if thaw_target in ("image", "both") and hasattr(self, "img_tower"):
self.img_tower.set_freeze_ratio(freeze_r)
if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"):
self.md_tower.set_freeze_ratio(freeze_r)
except Exception:
pass
# build splits/loaders per-epoch
train_df, test_df = self.clinical.get_split_dfs(self.fold)
if hasattr(self.bridge, "reset_se_stats"):
self.bridge.reset_se_stats()
if getattr(self, "args", None) is None:
self.args = argparse.Namespace() # if not stashed already
# Expect user to pass --eval_mode and --num-classes==2 for binary
if getattr(self.args, "eval_mode", "multiclass") == "binary":
# Drop Suspect (assumed label==2 in PAPILA spreadsheets)
train_df = train_df[train_df[self.clinical.label_col].isin([0,1])].copy()
test_df = test_df [test_df [self.clinical.label_col].isin([0,1])].copy()
self.train_loader = self._make_loader_for_df(train_df, is_train=True)
self.test_loader = self._make_loader_for_df(test_df, is_train=False)
self.img_tower.train(); self.md_tower.train(); self.bridge.train()
total_losses = {"fused":0.0,"image_only":0.0,"metadata_only":0.0}
total_accs = {"fused":0.0,"image_only":0.0,"metadata_only":0.0}
counts = {"fused":0, "image_only":0, "metadata_only":0}
step_counts = {"fused":0, "image_only":0, "metadata_only":0}
# phase selection
in_tower_warmup = epoch < self.warmup_tower_epochs
in_fused_warmup = (self.warmup_tower_epochs <= epoch < (self.warmup_tower_epochs + self.warmup_fused_epochs))
# epoch baselines (needed for splits and logging)
p_bcd_epoch, wi_epoch, wm_epoch, diag = self._epoch_bcd_baseline()
if in_tower_warmup:
# tower-only warmup: alternate or use epoch split; default to 0.5 if empty
wi = wi_epoch if (diag["Ai"] or diag["Am"]) else 0.5
for batch_idx, batch in enumerate(self.train_loader):
imgs, metas, geometry, labels = self._unpack_batch(batch)
imgs = imgs.to(self.device)
metas = metas.to(self.device)
labels = labels.to(self.device)
geometry = geometry.to(self.device) if geometry is not None else None
if random() < wi:
step_type = "image"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels)
step_counts["image_only"] += 1
else:
step_type = "meta"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels)
step_counts["metadata_only"] += 1
self.logger.info(f"epoch={epoch} batch={batch_idx} phase=tower_warmup step={step_type} "
f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}")
if loss_i is not None:
total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1
if loss_m is not None:
total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1
elif in_fused_warmup:
# fused-only warmup
for batch_idx, batch in enumerate(self.train_loader):
imgs, metas, geometry, labels = self._unpack_batch(batch)
imgs = imgs.to(self.device)
metas = metas.to(self.device)
labels = labels.to(self.device)
geometry = geometry.to(self.device) if geometry is not None else None
step_type = "fused"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels)
step_counts["fused"] += 1
self.logger.info(f"epoch={epoch} batch={batch_idx} phase=fused_warmup step={step_type} "
f"loss_f={loss_f} acc_f={acc_f} cm={cm}")
total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1
else:
# dynamic BCD (fusion-centric)
self.logger.info(
f"epoch={epoch} bcd_epoch={p_bcd_epoch:.3f} wi_epoch={wi_epoch:.3f} wm_epoch={wm_epoch:.3f} "
f"deficits={{img:{diag['di']:.3f}, md:{diag['dm']:.3f}}} metrics={{Af:{diag['Af']:.3f}, Ai:{diag['Ai']:.3f}, Am:{diag['Am']:.3f}}}"
)
for batch_idx, batch in enumerate(self.train_loader):
imgs, metas, geometry, labels = self._unpack_batch(batch)
imgs = imgs.to(self.device)
metas = metas.to(self.device)
labels = labels.to(self.device)
geometry = geometry.to(self.device) if geometry is not None else None
ents = self._batch_uncertainty(imgs, metas, geometry)
# flip rule: higher fused entropy => fewer tower-only batches => more fused training
alpha_b = self.bcd_cfg["alpha_batch"]; pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"]
p_bcd_batch = (1 - alpha_b) * p_bcd_epoch + alpha_b * (1.0 - ents["fused"])
p_bcd_batch = max(pmin, min(pmax, p_bcd_batch))
# image vs meta split
alpha_t = self.bcd_cfg["alpha_tower"]; floor = self.bcd_cfg["explore_floor"]; eps = 1e-6
s_img = ents["img"] / (ents["img"] + ents["md"] + eps)
p_img_batch = (1 - alpha_t) * wi_epoch + alpha_t * s_img
p_img_batch = max(floor, min(1.0 - floor, p_img_batch))
u = random(); v = random()
if u < p_bcd_batch:
if v < p_img_batch:
step_type = "image"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels)
step_counts["image_only"] += 1
else:
step_type = "meta"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels)
step_counts["metadata_only"] += 1
else:
step_type = "fused"
loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels)
step_counts["fused"] += 1
self.logger.info(
f"epoch={epoch} batch={batch_idx} phase=dynamic step={step_type} "
f"p_bcd_batch={p_bcd_batch:.3f} p_img_batch={p_img_batch:.3f} "
f"ents={{f:{ents['fused']:.3f}, i:{ents['img']:.3f}, m:{ents['md']:.3f}}} "
f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}"
)
if loss_f is not None:
total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1
if loss_i is not None:
total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1
if loss_m is not None:
total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1
# epoch prints
avg_loss = total_losses["fused"] / counts["fused"] if counts["fused"] else 0.0
avg_fused = total_accs["fused"] / counts["fused"] if counts["fused"] else 0.0
avg_img = total_accs["image_only"] / counts["image_only"] if counts["image_only"] else 0.0
avg_md = total_accs["metadata_only"] / counts["metadata_only"] if counts["metadata_only"] else 0.0
# realized mix for logging
tot_steps = sum(step_counts.values()) or 1
self._last_step_mix = {
"pct_fused": step_counts["fused"] / tot_steps,
"pct_img": step_counts["image_only"] / tot_steps,
"pct_md": step_counts["metadata_only"] / tot_steps,
"phase": "tower_warmup" if in_tower_warmup else ("fused_warmup" if in_fused_warmup else "dynamic")
}
print(
f"[Epoch {epoch+1}/{self.total_epochs}] Train Loss: {avg_loss:.4f} | "
f"Fused Acc: {100*avg_fused:.2f}% | Img Acc: {100*avg_img:.2f}% | Md Acc: {100*avg_md:.2f}%"
)
row, stop_now = self.evaluate(epoch)
if stop_now:
print(f"[early] stopping on '{self.args.early_metric}' "
f"with best={self._early.best:.5f} at epoch {epoch+1 - self._early.bad_epochs}")
break
torch.save(self._snapshot(), str(self.models_dir / "model_last.pt"))
# Ensure holdout-best checkpoint is written
if getattr(self, "_holdout_best", None):
hb = self._holdout_best
if hb.get("state") is not None and hb.get("save_path"):
try:
torch.save(hb["state"], hb["save_path"])
except Exception:
pass
def _copy_best_roc(epoch_idx: int, tag: str):
if epoch_idx is None or epoch_idx < 0:
return
src_dir = self.run_dir / "roc_curves"
if not src_dir.exists():
return
prefix = f"epoch{epoch_idx + 1}_"
dest_dir = self.run_dir / f"roc_curves_{tag}"
dest_dir.mkdir(parents=True, exist_ok=True)
for path in src_dir.glob(f"{prefix}*.json"):
try:
shutil.copy2(path, dest_dir / path.name)
except Exception:
pass
if getattr(self, "_early", None):
print(f"[early] restoring best model at epoch {self._early.best_epoch+1}: {self._early.best:.5f}")
self._early.restore(self)
elif getattr(self, "_best_info", None) and self._best_info.get("state") is not None:
be = self._best_info
print(f"[best] restoring best model at epoch {be['epoch']+1}: {be['value']:.5f} (monitor={be['monitor']})")
self._restore_from_state(be["state"])
torch.save(self._snapshot(), str(self.models_dir / "model_best.pt"))
# Snapshot ROC curves for both main-best and holdout-best epochs
if getattr(self, "_early", None) and getattr(self._early, "best_epoch", None) is not None:
_copy_best_roc(self._early.best_epoch, "best")
elif getattr(self, "_best_info", None):
_copy_best_roc(self._best_info.get("epoch", -1), "best")
if getattr(self, "_holdout_best", None):
_copy_best_roc(self._holdout_best.get("epoch", -1), "holdout_best")
self.ht_logger.close()
def _evaluate_split(self, loader, epoch: int, split: str):
"""
Shared evaluation helper that computes core metrics (loss/accuracy/AUC/etc.)
for a given dataloader. Returns a dict with scalar metrics or None if the
loader is empty.
"""
if loader is None:
return None
total_loss = 0.0
total_counts = {"fused": 0, "image_only": 0, "metadata_only": 0}
total_accs = {"fused": 0.0, "image_only": 0.0, "metadata_only": 0.0}
all_y = []
probs_f_list, probs_i_list, probs_m_list = [], [], []
agree_f_img = 0
agree_f_md = 0
n_img_comp = 0
n_md_comp = 0
with torch.no_grad():
for batch in loader:
imgs, metas, geometry, labels = self._unpack_batch(batch)
imgs = imgs.to(self.device)
metas = metas.to(self.device)
labels = labels.to(self.device)
geometry = geometry.to(self.device) if geometry is not None else None
if self.mode == "vote":
img_feats = self.img_tower(imgs, geometry)
md_feats = self.md_tower(metas)
out_img = self.head_img(img_feats)
out_md = self.head_md(md_feats)
out_fused = self.vote(out_img, out_md)
else:
img_feats = self.img_tower(imgs, geometry)
md_feats = self.md_tower(metas)
outputs = self.bridge(img_feats, md_feats)
if isinstance(outputs, tuple):
out_fused, out_img, out_md = outputs
else:
out_fused, out_img, out_md = outputs, None, None
all_y.append(labels.cpu().numpy())
probs_f_list.append(F.softmax(out_fused, dim=1).cpu().numpy())
loss = self._classification_loss(out_fused, labels)
total_loss += loss.item()
preds_f = out_fused.argmax(dim=1)
total_accs["fused"] += (preds_f == labels).float().mean().item()
total_counts["fused"] += 1
if out_img is not None:
preds_i = out_img.argmax(dim=1)
total_accs["image_only"] += (preds_i == labels).float().mean().item()
total_counts["image_only"] += 1
probs_i_list.append(F.softmax(out_img, dim=1).cpu().numpy())
agree_f_img += (preds_f == preds_i).sum().item()
n_img_comp += preds_f.numel()
if out_md is not None:
preds_m = out_md.argmax(dim=1)
total_accs["metadata_only"] += (preds_m == labels).float().mean().item()
total_counts["metadata_only"] += 1
probs_m_list.append(F.softmax(out_md, dim=1).cpu().numpy())
agree_f_md += (preds_f == preds_m).sum().item()
n_md_comp += preds_f.numel()
if total_counts["fused"] == 0:
return None
avg_loss = total_loss / total_counts["fused"]
avg_fused = total_accs["fused"] / total_counts["fused"]
avg_img = (total_accs["image_only"] / total_counts["image_only"]) if total_counts["image_only"] else 0.0
avg_md = (total_accs["metadata_only"] / total_counts["metadata_only"]) if total_counts["metadata_only"] else 0.0
y_true = np.concatenate(all_y, axis=0) if all_y else np.array([])
y_prob_f = np.concatenate(probs_f_list, axis=0) if probs_f_list else None
y_prob_i = np.concatenate(probs_i_list, axis=0) if probs_i_list else None
y_prob_m = np.concatenate(probs_m_list, axis=0) if probs_m_list else None
def compute_multiclass_roc(y_true_np, prob_np):
if prob_np is None or prob_np.size == 0:
return None
C = int(prob_np.shape[1])
classes = list(range(C))
y_bin = label_binarize(y_true_np, classes=classes)
per_class = {}
aucs = []
for c in range(C):
try:
fpr, tpr, _ = roc_curve(y_bin[:, c], prob_np[:, c])
auc_val = float(auc(fpr, tpr)) if len(fpr) > 1 else float("nan")
per_class[c] = {"fpr": fpr.tolist(), "tpr": tpr.tolist(), "auc": auc_val}
aucs.append(auc_val)
except Exception:
per_class[c] = {"fpr": [0.0, 1.0], "tpr": [0.0, 1.0], "auc": float("nan")}
try:
fpr_micro, tpr_micro, _ = roc_curve(y_bin.ravel(), prob_np[:, :C].ravel())
auc_micro = float(auc(fpr_micro, tpr_micro))
micro = {"fpr": fpr_micro.tolist(), "tpr": tpr_micro.tolist(), "auc": auc_micro}
except Exception:
micro = None
auc_vals = [v["auc"] for v in per_class.values() if v["auc"] == v["auc"]]
macro_auc = float(np.mean(auc_vals)) if auc_vals else float("nan")
return {"per_class": per_class, "micro": micro, "macro_auc": macro_auc}
def macro_ovr_auc(y, p):
try:
y = np.asarray(y)
if p is None:
return float("nan")
p = np.asarray(p)
if p.ndim == 2 and p.shape[1] == 2:
return roc_auc_score(y, p[:, 1])
if p.ndim == 1 or p.shape[1] == 1:
return roc_auc_score(y, p.ravel())
return roc_auc_score(y, p, multi_class="ovr", average="macro")
except Exception:
return float("nan")
def top2_acc(y, p):
if p is None:
return float("nan")
if p.ndim != 2:
return float("nan")
k = 2 if p.shape[1] >= 2 else 1
topk = np.argpartition(-p, kth=k - 1, axis=1)[:, :k]
return np.mean((topk == y[:, None]).any(axis=1).astype(np.float32))
def avg_margin(p):
if p is None or p.ndim != 2:
return float("nan")
s = np.sort(p, axis=1)[:, ::-1]
if s.shape[1] == 1:
return float("nan")
return float(np.mean(s[:, 0] - s[:, 1]))
roc_f = compute_multiclass_roc(y_true, y_prob_f)
roc_i = compute_multiclass_roc(y_true, y_prob_i)
roc_m = compute_multiclass_roc(y_true, y_prob_m)
roc_dir = self.run_dir / "roc_curves"
roc_dir.mkdir(parents=True, exist_ok=True)
def dump_roc(blob, head: str):
if blob is None:
return
suffix = head if split == "val" else f"{split}_{head}"
path = roc_dir / f"epoch{epoch + 1}_{suffix}.json"
with open(path, "w") as f:
json.dump(blob, f)
dump_roc(roc_f, "fused")
dump_roc(roc_i, "image")
dump_roc(roc_m, "metadata")
auc_f = macro_ovr_auc(y_true, y_prob_f) if y_prob_f is not None else float("nan")
auc_i = macro_ovr_auc(y_true, y_prob_i) if y_prob_i is not None else float("nan")
auc_m = macro_ovr_auc(y_true, y_prob_m) if y_prob_m is not None else float("nan")
top2_f = top2_acc(y_true, y_prob_f)
top2_i = top2_acc(y_true, y_prob_i)
top2_m = top2_acc(y_true, y_prob_m)
mar_f = avg_margin(y_prob_f)
mar_i = avg_margin(y_prob_i)
mar_m = avg_margin(y_prob_m)
agree_rate_f_img = (agree_f_img / n_img_comp) if n_img_comp else float("nan")
agree_rate_f_md = (agree_f_md / n_md_comp) if n_md_comp else float("nan")
return {
"loss": float(avg_loss),
"acc_fused": float(avg_fused),
"acc_img": float(avg_img),
"acc_md": float(avg_md),
"auc_fused": float(auc_f) if auc_f == auc_f else float("nan"),
"auc_img": float(auc_i) if auc_i == auc_i else float("nan"),
"auc_md": float(auc_m) if auc_m == auc_m else float("nan"),
"top2_fused": float(top2_f) if top2_f == top2_f else float("nan"),
"top2_img": float(top2_i) if top2_i == top2_i else float("nan"),
"top2_md": float(top2_m) if top2_m == top2_m else float("nan"),
"margin_fused": float(mar_f) if mar_f == mar_f else float("nan"),
"margin_img": float(mar_i) if mar_i == mar_i else float("nan"),
"margin_md": float(mar_m) if mar_m == mar_m else float("nan"),
"agree_fused_img": float(agree_rate_f_img) if agree_rate_f_img == agree_rate_f_img else float("nan"),
"agree_fused_md": float(agree_rate_f_md) if agree_rate_f_md == agree_rate_f_md else float("nan"),
}
def evaluate(self, epoch: int):
# Evaluation additionally collects per-head probabilities to report macro AUC
self.img_tower.eval()
self.md_tower.eval()
if self.mode == "vote":
self.head_img.eval(); self.head_md.eval(); self.vote.eval()
else:
self.bridge.eval()
metrics_val = self._evaluate_split(self.test_loader, epoch, "val")
metrics_holdout = self._evaluate_split(self.holdout_loader, epoch, "holdout") if self.holdout_loader else None
if metrics_val is None:
raise RuntimeError("Validation loader produced no batches; cannot compute metrics.")
def ffmt(x):
return "NA" if (x != x) else f"{x:.3f}"
print(
f"[Epoch {epoch+1}/{self.total_epochs}] Eval Loss: {metrics_val['loss']:.4f} | "
f"Fused Acc: {100*metrics_val['acc_fused']:.2f}% | "
f"Img Acc: {100*metrics_val['acc_img']:.2f}% | "
f"Md Acc: {100*metrics_val['acc_md']:.2f}% | "
f"Fused AUC: {ffmt(metrics_val['auc_fused'])} | "
f"Img AUC: {ffmt(metrics_val['auc_img'])} | "
f"Md AUC: {ffmt(metrics_val['auc_md'])}"
)
if metrics_holdout:
print(
f"[Epoch {epoch+1}/{self.total_epochs}] Holdout Loss: {metrics_holdout['loss']:.4f} | "
f"Fused Acc: {100*metrics_holdout['acc_fused']:.2f}% | "
f"Img Acc: {100*metrics_holdout['acc_img']:.2f}% | "
f"Md Acc: {100*metrics_holdout['acc_md']:.2f}% | "
f"Fused AUC: {ffmt(metrics_holdout['auc_fused'])} | "
f"Img AUC: {ffmt(metrics_holdout['auc_img'])} | "
f"Md AUC: {ffmt(metrics_holdout['auc_md'])}"
)
self.last_eval = {
"acc_fused": float(metrics_val["acc_fused"]),
"acc_img": float(metrics_val["acc_img"]),
"acc_md": float(metrics_val["acc_md"]),
"auc_fused": float(metrics_val["auc_fused"]) if metrics_val["auc_fused"] == metrics_val["auc_fused"] else 0.0,
"auc_img": float(metrics_val["auc_img"]) if metrics_val["auc_img"] == metrics_val["auc_img"] else 0.0,
"auc_md": float(metrics_val["auc_md"]) if metrics_val["auc_md"] == metrics_val["auc_md"] else 0.0,
}
row = {
"epoch": int(epoch + 1),
"eval_loss": _num(metrics_val["loss"]),
"acc_fused": _num(metrics_val["acc_fused"]),
"acc_img": _num(metrics_val["acc_img"]),
"acc_md": _num(metrics_val["acc_md"]),
"auc_fused": _num(metrics_val["auc_fused"]),
"auc_img": _num(metrics_val["auc_img"]),
"auc_md": _num(metrics_val["auc_md"]),
"top2_fused": _num(metrics_val["top2_fused"]),
"top2_img": _num(metrics_val["top2_img"]),
"top2_md": _num(metrics_val["top2_md"]),
"margin_fused": _num(metrics_val["margin_fused"]),
"margin_img": _num(metrics_val["margin_img"]),
"margin_md": _num(metrics_val["margin_md"]),
"agree_fused_img": _num(metrics_val["agree_fused_img"]),
"agree_fused_md": _num(metrics_val["agree_fused_md"]),
}
if metrics_holdout:
row.update({
"holdout_loss": _num(metrics_holdout["loss"]),
"holdout_acc_fused": _num(metrics_holdout["acc_fused"]),
"holdout_acc_img": _num(metrics_holdout["acc_img"]),
"holdout_acc_md": _num(metrics_holdout["acc_md"]),
"holdout_auc_fused": _num(metrics_holdout["auc_fused"]),
"holdout_auc_img": _num(metrics_holdout["auc_img"]),
"holdout_auc_md": _num(metrics_holdout["auc_md"]),
})
else:
row.setdefault("holdout_loss", None)
row.setdefault("holdout_acc_fused", None)
row.setdefault("holdout_acc_img", None)
row.setdefault("holdout_acc_md", None)
row.setdefault("holdout_auc_fused", None)
row.setdefault("holdout_auc_img", None)
row.setdefault("holdout_auc_md", None)
# realized step mix from training (if present)
mix = getattr(self, "_last_step_mix", None)
if mix:
row.update({
"pct_fused": _num(mix.get("pct_fused")),
"pct_img": _num(mix.get("pct_img")),
"pct_md": _num(mix.get("pct_md")),
"phase": mix.get("phase"),
})
# SE gate stats (if the bridge exposes them)
# fetch without clearing
bridge_module = getattr(self, "bridge", None)
get_se_stats = getattr(bridge_module, "get_se_stats", None) if bridge_module is not None else None
if callable(get_se_stats):
se_stats = get_se_stats(reset=False)
if se_stats:
row.update({
"se_mean": _num(se_stats.get("mean")),
"se_std": _num(se_stats.get("std")),
"se_pct_lt_0.2": _num(se_stats.get("pct_lt_0.2")),
"se_pct_gt_0.8": _num(se_stats.get("pct_gt_0.8")),
})
# debug print BEFORE reset so you can see the true count
try:
print(f"SE gate samples seen: {getattr(getattr(bridge_module, 'se_log', None), '_n', 0)}")
except Exception:
pass
# now clear for the next epoch
try:
get_se_stats(reset=True)
except Exception:
pass
row.setdefault(self.args.early_metric, None)
# early-stop decision (no break here)
should_stop = False
if getattr(self, "_early", None):
should_stop = self._early.step(row, trainer=self, epoch=epoch)
row.update({
"early_best_so_far" : float(self._early.best),
"early_bad_epochs" : int(self._early.bad_epochs),
"early_improved" : int(self._early.last_improved),
"early_monitor" : self.args.early_metric,
})
# concise console status each epoch
mon = self.args.early_metric
cur = row.get(mon, None)
if cur is not None and cur == cur: # not NaN
msg = (
f"[early] epoch {epoch+1}: {mon}={cur:.5f} | best={self._early.best:.5f} | "
f"bad_epochs={self._early.bad_epochs}/{self._early.patience} | improved={'yes' if self._early.last_improved else 'no'}"
)
print(msg)
try:
self.logger.info(msg)
except Exception:
pass
# Holdout best-tracker (if a holdout set is present)
if getattr(self, "_holdout_best", None):
mon_h = self._holdout_best["monitor"]
mode_h = self._holdout_best["mode"]
min_delta_h = self._holdout_best["min_delta"]
val_h = row.get(mon_h, None)
improved_h = False
if val_h is not None and val_h == val_h:
best_val_h = self._holdout_best["value"]
if mode_h == "max":
improved_h = (val_h > best_val_h + min_delta_h)
else:
improved_h = (val_h < best_val_h - min_delta_h)
if improved_h:
self._holdout_best["value"] = float(val_h)
self._holdout_best["epoch"] = int(epoch)
st_h = self._snapshot()
self._holdout_best["state"] = st_h
if self._holdout_best.get("save_path"):
try:
torch.save(st_h, self._holdout_best["save_path"])
except Exception:
pass
print(f"[holdout_best] ↑ new best {mon_h}={val_h:.5f} at epoch {epoch+1}")
row.update({
"holdout_best_monitor": mon_h,
"holdout_best_so_far": (None if self._holdout_best["value"] in (math.inf, -math.inf) else float(self._holdout_best["value"])),
"holdout_best_epoch": (None if self._holdout_best["epoch"] < 0 else int(self._holdout_best["epoch"] + 1)),
})
# Best-tracker (always-on): save best model snapshot and optional checkpoint
if getattr(self, "_best_info", None):
# If early stopper is active, mirror its best info and avoid duplicate prints
if getattr(self, "_early", None):
row.update({
"best_monitor": self._best_info["monitor"],
"best_so_far": float(self._early.best) if self._early.best == self._early.best else None,
"best_epoch": (int(self._early.best_epoch) + 1) if (self._early.best_epoch is not None) else None,
})
# skip independent tracking/prints to avoid duplication
self._write_epoch_log(row)
return row, should_stop
mon = self._best_info["monitor"]
mode = self._best_info["mode"]
min_delta = self._best_info["min_delta"]
val = row.get(mon, None)
improved = False
if val is not None and val == val: # not NaN
best_val = self._best_info["value"]
if mode == "max":
improved = (val > best_val + min_delta)
else:
improved = (val < best_val - min_delta)
if improved:
self._best_info["value"] = float(val)
self._best_info["epoch"] = int(epoch)
st = self._snapshot()
self._best_info["state"] = st
if self._best_info.get("save_path"):
try:
torch.save(st, self._best_info["save_path"])
except Exception:
pass
print(f"[best] ↑ new best {mon}={val:.5f} at epoch {epoch+1}")
# Add best-so-far to row
row.update({
"best_monitor": mon,
"best_so_far": (None if self._best_info["value"] in (math.inf, -math.inf) else float(self._best_info["value"])),
"best_epoch": (None if self._best_info["epoch"] < 0 else int(self._best_info["epoch"] + 1)),
})
# finally: write once, after all updates
self._write_epoch_log(row)
return row, should_stop
def _restore_from_state(self, st: dict):
if not st:
return
self.img_tower.load_state_dict(st["img_tower"])
self.md_tower.load_state_dict(st["md_tower"])
if "bridge" in st and hasattr(self, "bridge"):
self.bridge.load_state_dict(st["bridge"])
if "head_img" in st and hasattr(self, "head_img"):
self.head_img.load_state_dict(st["head_img"])
if "head_md" in st and hasattr(self, "head_md"):
self.head_md.load_state_dict(st["head_md"])
if "optimizer" in st:
self.optimizer.load_state_dict(st["optimizer"])
# --- V2 loader integration (no v1 hypertower import) ---
from classes.v2 import build_papila_profile
from classes.v2.slot_dataset import SlotDataset, slot_collate
__all__ = [
"HyperTower",
"V2HyperTower",
"V2ModeComparisonOps",
"V2ModeComparator",
]
def _tuple_collate(batch: list[dict[str, object]]):
"""
Convert a slot-collated batch dict into (imgs, metas, labels) tuples so
the legacy HyperTower training loop can consume it.
"""
data = slot_collate(batch)
imgs = data.get("image_1")
metas = data.get("matrix_1")
labels = data.get("label_1")
if labels is not None and not torch.is_tensor(labels):
labels = torch.as_tensor(labels, dtype=torch.long)
return imgs, metas, labels
class V2HyperTower(HyperTower):
"""
HyperTower that swaps the loader pipeline for V2 slot datasets.
This preserves the original training loop, logging, and metrics.
"""
def __init__(self, clinical, args):
sample_mode = getattr(args, "sample_mode", "eye")
self._v2_profile = build_papila_profile(
patient_col="Patient ID",
label_col=getattr(clinical, "label_col", "Diagnosis"),
sample_mode=sample_mode,
)
super().__init__(clinical, args)
def _make_loader_for_df(self, df, is_train: bool = False):
samples = self._v2_profile.build_samples(df=df, clinical=self.clinical)
dataset = SlotDataset(
samples,
self._v2_profile.slot_descriptors(),
image_transform=self.img_tower.transform,
image_preprocessor=self.image_preprocessor,
)
# Optional: class-balanced bootstrapped sampling for TRAIN only
if is_train and getattr(self.args, "balanced_sampler", False):
y = np.asarray(df[self.clinical.label_col].values)
uniq, counts = np.unique(y, return_counts=True)
inv = {c: (1.0 / cnt if cnt > 0 else 0.0) for c, cnt in zip(uniq, counts)}
w = np.array([inv[c] for c in y], dtype=np.float32)
sampler = WeightedRandomSampler(weights=w, num_samples=len(y), replacement=True)
return DataLoader(
dataset,
batch_size=self.batch_size,
sampler=sampler,
shuffle=False,
collate_fn=_tuple_collate,
)
return DataLoader(
dataset,
batch_size=self.batch_size,
shuffle=not is_train,
collate_fn=_tuple_collate,
)
class V2ModeComparisonOps:
"""
Shared mode-comparison training/eval ops hosted in V2 code so scripts
remain orchestration-only.
"""
@staticmethod
def _to_label_tensor(labels, device: torch.device) -> torch.Tensor:
if torch.is_tensor(labels):
return labels.to(device=device, dtype=torch.long)
return torch.as_tensor(labels, dtype=torch.long, device=device)
@staticmethod
def _set_requires_grad(module: nn.Module, enabled: bool) -> None:
for p in module.parameters():
p.requires_grad = enabled
@staticmethod
def _set_single_phase(model: nn.Module, phase: str) -> None:
if phase == "tower_warmup":
V2ModeComparisonOps._set_requires_grad(model.img_tower, True)
V2ModeComparisonOps._set_requires_grad(model.md_tower, True)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_img, True)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_md, True)
V2ModeComparisonOps._set_requires_grad(model.bridge.W_img, False)
V2ModeComparisonOps._set_requires_grad(model.bridge.W_md, False)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_fused, False)
return
if phase == "fused_warmup":
V2ModeComparisonOps._set_requires_grad(model.img_tower, False)
V2ModeComparisonOps._set_requires_grad(model.md_tower, False)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_img, False)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_md, False)
V2ModeComparisonOps._set_requires_grad(model.bridge.W_img, True)
V2ModeComparisonOps._set_requires_grad(model.bridge.W_md, True)
V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_fused, True)
return
V2ModeComparisonOps._set_requires_grad(model, True)
@staticmethod
def _set_bilateral_phase(model: nn.Module, phase: str) -> None:
if phase == "tower_warmup":
V2ModeComparisonOps._set_requires_grad(model.eye_img_tower, True)
V2ModeComparisonOps._set_requires_grad(model.eye_md_tower, True)
V2ModeComparisonOps._set_requires_grad(model.joint_img, True)
V2ModeComparisonOps._set_requires_grad(model.joint_md, True)
V2ModeComparisonOps._set_requires_grad(model.aux_img, True)
V2ModeComparisonOps._set_requires_grad(model.aux_md, True)
V2ModeComparisonOps._set_requires_grad(model.bridge, False)
return
if phase == "fused_warmup":
V2ModeComparisonOps._set_requires_grad(model.eye_img_tower, False)
V2ModeComparisonOps._set_requires_grad(model.eye_md_tower, False)
V2ModeComparisonOps._set_requires_grad(model.joint_img, False)
V2ModeComparisonOps._set_requires_grad(model.joint_md, False)
V2ModeComparisonOps._set_requires_grad(model.aux_img, False)
V2ModeComparisonOps._set_requires_grad(model.aux_md, False)
V2ModeComparisonOps._set_requires_grad(model.bridge, True)
return
V2ModeComparisonOps._set_requires_grad(model, True)
@staticmethod
def train_single_epoch(model: nn.Module, loader: DataLoader, opt, device: torch.device, *, phase: str, bcd_prob: float = 0.5):
model.train()
V2ModeComparisonOps._set_single_phase(model, phase)
total_loss = total_correct = total_n = 0
for batch in loader:
x = batch.get("image_1")
m = batch.get("matrix_1")
y = batch.get("label_1")
if not torch.is_tensor(x) or not torch.is_tensor(m):
continue
x = x.to(device)
m = m.to(device)
y = V2ModeComparisonOps._to_label_tensor(y, device)
img_feats = model.img_tower(x)
md_feats = model.md_tower(m)
if phase == "tower_warmup":
logits_i = model.bridge.classifier_img(img_feats)
logits_m = model.bridge.classifier_md(md_feats)
loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y))
logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1))
elif phase == "fused_warmup":
logits, _, _ = model.bridge(img_feats, md_feats)
loss = F.cross_entropy(logits, y)
else:
if random() < bcd_prob:
if random() < 0.5:
logits = model.bridge.classifier_img(img_feats)
else:
logits = model.bridge.classifier_md(md_feats)
loss = F.cross_entropy(logits, y)
else:
logits, _, _ = model.bridge(img_feats, md_feats)
loss = F.cross_entropy(logits, y)
opt.zero_grad()
loss.backward()
opt.step()
bs = y.shape[0]
total_loss += float(loss.item()) * bs
total_correct += int((logits.argmax(1) == y).sum())
total_n += bs
return (
total_loss / total_n if total_n else float("nan"),
total_correct / total_n if total_n else float("nan"),
)
@staticmethod
def train_bilateral_epoch(model: nn.Module, loader: DataLoader, opt, device: torch.device, *, phase: str, bcd_prob: float = 0.5):
model.train()
V2ModeComparisonOps._set_bilateral_phase(model, phase)
total_loss = total_correct = total_n = 0
for batch in loader:
x1 = batch.get("image_1")
m1 = batch.get("matrix_1")
x2 = batch.get("image_2")
m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
x1 = x1.to(device); m1 = m1.to(device)
x2 = x2.to(device); m2 = m2.to(device)
y = V2ModeComparisonOps._to_label_tensor(y, device)
joint_img, joint_md = model.encode_joint(x1, m1, x2, m2)
if phase == "tower_warmup":
logits_i = model.aux_img(joint_img)
logits_m = model.aux_md(joint_md)
loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y))
logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1))
elif phase == "fused_warmup":
logits, _, _ = model.bridge(joint_img, joint_md)
loss = F.cross_entropy(logits, y)
else:
if random() < bcd_prob:
if random() < 0.5:
logits = model.aux_img(joint_img)
else:
logits = model.aux_md(joint_md)
loss = F.cross_entropy(logits, y)
else:
logits, _, _ = model.bridge(joint_img, joint_md)
loss = F.cross_entropy(logits, y)
opt.zero_grad()
loss.backward()
opt.step()
bs = y.shape[0]
total_loss += float(loss.item()) * bs
total_correct += int((logits.argmax(1) == y).sum())
total_n += bs
return (
total_loss / total_n if total_n else float("nan"),
total_correct / total_n if total_n else float("nan"),
)
@staticmethod
def collect_probs_classic(model: nn.Module, loader: DataLoader, device: torch.device):
model.eval()
y_chunks, p_chunks = [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
y_t = V2ModeComparisonOps._to_label_tensor(y, device)
p_od = F.softmax(model(x1.to(device), m1.to(device)), dim=1)
p_os = F.softmax(model(x2.to(device), m2.to(device)), dim=1)
y_np = y_t.cpu().numpy()
y_chunks += [y_np, y_np]
p_chunks += [p_od.cpu().numpy(), p_os.cpu().numpy()]
if not y_chunks:
return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32)
return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0)
@staticmethod
def collect_probs_ensemble(model: nn.Module, loader: DataLoader, device: torch.device):
model.eval()
y_chunks, p_chunks = [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
y_t = V2ModeComparisonOps._to_label_tensor(y, device)
p_od = F.softmax(model(x1.to(device), m1.to(device)), dim=1)
p_os = F.softmax(model(x2.to(device), m2.to(device)), dim=1)
p = 0.5 * (p_od + p_os)
y_chunks.append(y_t.cpu().numpy())
p_chunks.append(p.cpu().numpy())
if not y_chunks:
return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32)
return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0)
@staticmethod
def collect_probs_bilateral(model: nn.Module, loader: DataLoader, device: torch.device):
model.eval()
y_chunks, p_chunks = [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
y_t = V2ModeComparisonOps._to_label_tensor(y, device)
p = F.softmax(model(x1.to(device), m1.to(device), x2.to(device), m2.to(device)), dim=1)
y_chunks.append(y_t.cpu().numpy())
p_chunks.append(p.cpu().numpy())
if not y_chunks:
return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32)
return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0)
class V2ModeComparator:
"""
V2 entrypoint for the three-mode comparison workflow:
classic, ensemble, bilateral.
"""
@staticmethod
def build_parser():
return build_parser()
@staticmethod
def run(cli_args=None):
parser = V2ModeComparator.build_parser()
args = parser.parse_args(cli_args)
return run_mode(args)
# --- Mode comparison orchestration (migrated from mode_compare_engine) ---
def seed_everything(seed: int) -> None:
pyrandom.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class SingleEyeHT(nn.Module):
"""
ImageTower + MDTower + Bridge, trained on eye-level samples.
Supports both Classic (eye-level) and Ensemble (patient-level averaging) eval.
"""
def __init__(
self,
*,
backbone: str,
freeze_ratio: float,
augment: bool,
clinical_data,
num_classes: int,
md_hidden_dim: int = 128,
fusion_dim: int = 256,
):
super().__init__()
self.img_tower = ImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
)
self.md_tower = MDTower(
clinical_data=clinical_data,
hidden_dim=md_hidden_dim,
use_se=False,
)
self.bridge = Bridge(
img_dim=self.img_tower.out_dim,
meta_dim=self.md_tower.out_dim,
num_classes=num_classes,
fusion_dim=fusion_dim,
mode="fused",
use_se=False,
)
@property
def transform(self):
return self.img_tower.transform
def forward(self, x: torch.Tensor, meta: torch.Tensor) -> torch.Tensor:
img_feats = self.img_tower(x)
md_feats = self.md_tower(meta)
out_f, _, _ = self.bridge(img_feats, md_feats)
return out_f
class BilateralHT(nn.Module):
"""
Bilateral mode with joint towers:
- shared eye-level towers encode OD/OS independently
- joint image and metadata towers combine OD/OS embeddings
- standard Bridge fuses joint image + joint metadata embeddings
"""
def __init__(
self,
*,
backbone: str,
freeze_ratio: float,
augment: bool,
clinical_data,
num_classes: int,
md_hidden_dim: int = 128,
fusion_dim: int = 256,
):
super().__init__()
self.eye_img_tower = ImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
)
self.eye_md_tower = MDTower(
clinical_data=clinical_data,
hidden_dim=md_hidden_dim,
use_se=False,
)
img_dim = self.eye_img_tower.out_dim
md_dim = self.eye_md_tower.out_dim
self.joint_img = nn.Sequential(
nn.Linear(2 * img_dim, fusion_dim),
nn.LayerNorm(fusion_dim),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(fusion_dim, img_dim),
)
self.joint_md = nn.Sequential(
nn.Linear(2 * md_dim, fusion_dim),
nn.LayerNorm(fusion_dim),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(fusion_dim, md_dim),
)
self.bridge = Bridge(
img_dim=img_dim,
meta_dim=md_dim,
num_classes=num_classes,
fusion_dim=fusion_dim,
mode="fused",
use_se=False,
)
# Auxiliary heads for tower warmup / BCD tower steps.
self.aux_img = nn.Linear(img_dim, num_classes)
self.aux_md = nn.Linear(md_dim, num_classes)
@property
def transform(self):
return self.eye_img_tower.transform
def encode_joint(
self,
x_od: torch.Tensor,
meta_od: torch.Tensor,
x_os: torch.Tensor,
meta_os: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
img_od = self.eye_img_tower(x_od)
md_od = self.eye_md_tower(meta_od)
img_os = self.eye_img_tower(x_os)
md_os = self.eye_md_tower(meta_os)
joint_img = self.joint_img(torch.cat([img_od, img_os], dim=1))
joint_md = self.joint_md(torch.cat([md_od, md_os], dim=1))
return joint_img, joint_md
def forward(
self,
x_od: torch.Tensor,
meta_od: torch.Tensor,
x_os: torch.Tensor,
meta_os: torch.Tensor,
) -> torch.Tensor:
joint_img, joint_md = self.encode_joint(x_od, meta_od, x_os, meta_os)
out_f, _, _ = self.bridge(joint_img, joint_md)
return out_f
def _set_requires_grad(module: nn.Module, enabled: bool) -> None:
V2ModeComparisonOps._set_requires_grad(module, enabled)
def _set_single_phase(model: SingleEyeHT, phase: str) -> None:
V2ModeComparisonOps._set_single_phase(model, phase)
def _set_bilateral_phase(model: BilateralHT, phase: str) -> None:
V2ModeComparisonOps._set_bilateral_phase(model, phase)
# ---------------------------------------------------------------------------
# Data helpers
# ---------------------------------------------------------------------------
def filter_eye_samples(samples: list[dict]) -> list[dict]:
"""Keep any single-eye sample with a valid image, matrix, and label."""
return [
s for s in samples
if s.get("image_1") is not None
and s.get("matrix_1") is not None
and s.get("label_1") is not None
]
def filter_bilateral_samples(samples: list[dict]) -> list[dict]:
"""Keep only patient-level samples where both eyes are fully present."""
return [
s for s in samples
if s.get("image_1") is not None
and s.get("matrix_1") is not None
and s.get("image_2") is not None
and s.get("matrix_2") is not None
and s.get("label_1") is not None
]
def make_loader(
samples: list[dict],
slots: dict,
*,
image_transform,
image_preprocessor=None,
batch_size: int,
shuffle: bool,
num_workers: int,
) -> DataLoader:
ds = SlotDataset(
samples,
slots,
image_transform=image_transform,
image_preprocessor=image_preprocessor,
)
return DataLoader(
ds,
batch_size=batch_size,
shuffle=shuffle,
num_workers=num_workers,
collate_fn=slot_collate,
)
def to_label_tensor(labels, device: torch.device) -> torch.Tensor:
if torch.is_tensor(labels):
return labels.to(device=device, dtype=torch.long)
return torch.as_tensor(labels, dtype=torch.long, device=device)
def _drop_mixed_label_patients(df, *, patient_col: str, label_col: str):
per_patient = (
df.groupby(patient_col)[label_col]
.agg(lambda s: set(pd.to_numeric(s, errors="coerce").dropna().astype(int).tolist()))
)
mixed = [pid for pid, labels in per_patient.items() if len(labels) > 1]
if not mixed:
return df, []
return df[~df[patient_col].isin(mixed)].reset_index(drop=True), mixed
def _relabel_mixed_patients_to_max(df, *, patient_col: str, label_col: str):
"""Set all rows for each patient to that patient's max observed label."""
out = df.copy()
labels = pd.to_numeric(out[label_col], errors="coerce")
patient_max = labels.groupby(out[patient_col]).transform("max")
changed_rows = int((labels != patient_max).fillna(False).sum())
out[label_col] = patient_max.astype(int)
per_patient_unique = out.groupby(patient_col)[label_col].nunique(dropna=True)
still_mixed = per_patient_unique[per_patient_unique > 1].index.tolist()
return out.reset_index(drop=True), changed_rows, still_mixed
def build_image_preprocessor_from_args(args):
crop_manifest = getattr(args, "img_crop_manifest", None)
crop_weights = getattr(args, "img_crop_weights", None)
use_gt = bool(getattr(args, "img_crop_gt", False))
if not crop_manifest:
return None
crop_cache = Path(getattr(args, "img_crop_cache", Path("cache_data/hypertower_crops")))
persist_cache = bool(getattr(args, "persist_img_crop_cache", False))
if use_gt:
pre = ManifestImageCropper(
manifest_path=Path(crop_manifest),
scale=getattr(args, "img_crop_scale", 2.5),
target_size=getattr(args, "img_crop_size", 224),
cache_dir=crop_cache,
)
if not persist_cache:
pre.clear_cache()
print(f"[V2 modes] GT disc cropper enabled -> cache at {crop_cache}", flush=True)
return pre
if crop_weights:
pre = UNetImageCropper(
manifest_path=Path(crop_manifest),
weights_path=Path(crop_weights),
normalize=getattr(args, "img_crop_normalize", "per_image"),
threshold=getattr(args, "img_crop_threshold", 0.5),
tta=getattr(args, "img_crop_tta", False),
scale=getattr(args, "img_crop_scale", 2.5),
target_size=getattr(args, "img_crop_size", 224),
cache_dir=crop_cache,
)
if not persist_cache:
pre.clear_cache()
print(f"[V2 modes] UNet disc cropper enabled -> cache at {crop_cache}", flush=True)
return pre
print(
"[V2 modes] img_crop_manifest provided but no --img-crop-gt or --img-crop-weights; cropping disabled.",
flush=True,
)
return None
# ---------------------------------------------------------------------------
# Metrics helpers
# ---------------------------------------------------------------------------
def compute_ece(y_true: np.ndarray, probs: np.ndarray, n_bins: int = 10) -> float:
"""Expected Calibration Error: weighted mean of |confidence - accuracy| per bin."""
if y_true.size == 0:
return float("nan")
confidences = probs.max(axis=1)
predictions = probs.argmax(axis=1)
bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
ece = 0.0
n = len(y_true)
for i, (lo, hi) in enumerate(zip(bin_edges[:-1], bin_edges[1:])):
mask = (confidences >= lo) & (
confidences <= hi if i == n_bins - 1 else confidences < hi
)
if not mask.any():
continue
bin_acc = float((predictions[mask] == y_true[mask]).mean())
bin_conf = float(confidences[mask].mean())
ece += float(mask.sum()) / n * abs(bin_conf - bin_acc)
return float(ece)
def compute_extended_metrics(
y_true: np.ndarray,
probs: np.ndarray,
num_classes: int,
n_bins: int = 10,
preds_override: Optional[np.ndarray] = None,
) -> dict:
nan = float("nan")
if y_true.size == 0:
return dict(
kappa=nan, mcc=nan, macro_f1=nan,
per_class_recall=np.full(num_classes, nan), ece=nan,
)
preds = preds_override if preds_override is not None else probs.argmax(axis=1)
try:
kappa = float(cohen_kappa_score(y_true, preds))
except Exception:
kappa = nan
try:
mcc = float(matthews_corrcoef(y_true, preds))
except Exception:
mcc = nan
try:
macro_f1 = float(f1_score(y_true, preds, average="macro", zero_division=0))
except Exception:
macro_f1 = nan
try:
pcr = recall_score(
y_true, preds, average=None,
labels=list(range(num_classes)), zero_division=0,
).astype(float)
except Exception:
pcr = np.full(num_classes, nan)
ece = compute_ece(y_true, probs, n_bins=n_bins)
return dict(kappa=kappa, mcc=mcc, macro_f1=macro_f1, per_class_recall=pcr, ece=ece)
def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float:
if y_true.size == 0:
return 0.5
grid = np.linspace(0.0, 1.0, 1001)
best_t, best_acc = 0.5, -1.0
for t in grid:
pred = (p1 >= t).astype(int)
acc = float((pred == y_true).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 multiclass_acc_with_bias(y_true: np.ndarray, probs: np.ndarray, bias: np.ndarray) -> float:
if y_true.size == 0:
return float("nan")
logits = np.log(np.clip(probs, 1e-8, 1.0)) + bias.reshape(1, -1)
return float((np.argmax(logits, axis=1) == y_true).mean())
def tune_multiclass_bias(y_true: np.ndarray, probs: np.ndarray, *, iters: int = 2) -> np.ndarray:
if y_true.size == 0 or probs.size == 0:
return np.zeros((0,), dtype=float)
c = probs.shape[1]
bias = np.zeros((c,), dtype=float)
grid = np.linspace(-1.0, 1.0, 41)
for _ in range(iters):
for k in range(c):
best_v = bias[k]
best_acc = multiclass_acc_with_bias(y_true, probs, bias)
old = bias[k]
for v in grid:
bias[k] = float(v)
acc = multiclass_acc_with_bias(y_true, probs, bias)
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
if np.isnan(best_acc):
bias[k] = old
return bias
def _svf(vec) -> Optional[str]:
if vec is None:
return None
arr = np.asarray(vec, dtype=float)
if arr.size == 0:
return None
return "|".join(f"{float(v):.4f}" for v in arr.tolist())
def _score_arrays(y_true: np.ndarray, probs: np.ndarray, num_classes: int):
"""Returns (acc, auc, n)."""
if y_true.size == 0:
return float("nan"), float("nan"), 0
acc = float((probs.argmax(1) == y_true).mean())
try:
auc = (
float(roc_auc_score(y_true, probs[:, 1]))
if num_classes == 2
else float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro"))
)
except Exception:
auc = float("nan")
return acc, auc, int(len(y_true))
def build_eval_transform(backbone: str):
"""Deterministic eval transform matching backbone normalization."""
key = (backbone or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unsupported backbone '{backbone}'")
spec = BACKBONES[key]
mean = getattr(spec.weights_default, "meta", {}).get("mean", (0.485, 0.456, 0.406))
std = getattr(spec.weights_default, "meta", {}).get("std", (0.229, 0.224, 0.225))
crop = 299 if key == "inception_v3" else 224
return transforms.Compose(
[
transforms.Resize(256),
transforms.CenterCrop(crop),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
]
)
# ---------------------------------------------------------------------------
# Train / collect
# ---------------------------------------------------------------------------
def train_single_epoch(
model: SingleEyeHT,
loader: DataLoader,
opt,
device,
*,
phase: str,
bcd_prob: float = 0.5,
):
return V2ModeComparisonOps.train_single_epoch(
model, loader, opt, device, phase=phase, bcd_prob=bcd_prob
)
def train_bilateral_epoch(
model: BilateralHT,
loader: DataLoader,
opt,
device,
*,
phase: str,
bcd_prob: float = 0.5,
):
return V2ModeComparisonOps.train_bilateral_epoch(
model, loader, opt, device, phase=phase, bcd_prob=bcd_prob
)
def collect_probs_classic(
model: SingleEyeHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray]:
"""
Classic eye-level eval using the bilateral val loader.
OD and OS are treated as independent samples (both contribute to the
arrays with the same patient label). Returns (y_true [2N], probs [2N, C]).
"""
return V2ModeComparisonOps.collect_probs_classic(model, loader, device)
def collect_probs_ensemble(
model: SingleEyeHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray]:
"""
Patient-level ensemble eval: average OD and OS softmax probabilities.
Returns (y_true [N], probs [N, C]).
"""
return V2ModeComparisonOps.collect_probs_ensemble(model, loader, device)
def collect_probs_single_components(
model: SingleEyeHT,
loader: DataLoader,
device: torch.device,
*,
aggregate_patient: bool,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Collect fused/img/md probabilities for SingleEyeHT.
- aggregate_patient=False: eye-level (OD/OS as independent samples)
- aggregate_patient=True : patient-level (average OD/OS per head)
"""
model.eval()
y_chunks = []
pf_chunks, pi_chunks, pm_chunks = [], [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
y_t = V2ModeComparisonOps._to_label_tensor(y, device)
def _per_eye_probs(x, m):
img_feats = model.img_tower(x.to(device))
md_feats = model.md_tower(m.to(device))
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
return (
F.softmax(out_f, dim=1),
F.softmax(out_i, dim=1),
F.softmax(out_m, dim=1),
)
pf_od, pi_od, pm_od = _per_eye_probs(x1, m1)
pf_os, pi_os, pm_os = _per_eye_probs(x2, m2)
if aggregate_patient:
y_chunks.append(y_t.cpu().numpy())
pf_chunks.append((0.5 * (pf_od + pf_os)).cpu().numpy())
pi_chunks.append((0.5 * (pi_od + pi_os)).cpu().numpy())
pm_chunks.append((0.5 * (pm_od + pm_os)).cpu().numpy())
else:
y_np = y_t.cpu().numpy()
y_chunks += [y_np, y_np]
pf_chunks += [pf_od.cpu().numpy(), pf_os.cpu().numpy()]
pi_chunks += [pi_od.cpu().numpy(), pi_os.cpu().numpy()]
pm_chunks += [pm_od.cpu().numpy(), pm_os.cpu().numpy()]
if not y_chunks:
z = np.zeros((0, 0), dtype=np.float32)
return np.array([], dtype=np.int64), z, z, z
return (
np.concatenate(y_chunks),
np.concatenate(pf_chunks, axis=0),
np.concatenate(pi_chunks, axis=0),
np.concatenate(pm_chunks, axis=0),
)
def collect_probs_bilateral(
model: BilateralHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray]:
"""Patient-level bilateral eval. Returns (y_true [N], probs [N, C])."""
return V2ModeComparisonOps.collect_probs_bilateral(model, loader, device)
def collect_probs_bilateral_components(
model: BilateralHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Collect fused/img/md probabilities for bilateral joint-tower model."""
model.eval()
y_chunks = []
pf_chunks, pi_chunks, pm_chunks = [], [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)):
continue
y_t = V2ModeComparisonOps._to_label_tensor(y, device)
joint_img, joint_md = model.encode_joint(
x1.to(device), m1.to(device), x2.to(device), m2.to(device)
)
out_f, _, _ = model.bridge(joint_img, joint_md)
out_i = model.aux_img(joint_img)
out_m = model.aux_md(joint_md)
y_chunks.append(y_t.cpu().numpy())
pf_chunks.append(F.softmax(out_f, dim=1).cpu().numpy())
pi_chunks.append(F.softmax(out_i, dim=1).cpu().numpy())
pm_chunks.append(F.softmax(out_m, dim=1).cpu().numpy())
if not y_chunks:
z = np.zeros((0, 0), dtype=np.float32)
return np.array([], dtype=np.int64), z, z, z
return (
np.concatenate(y_chunks),
np.concatenate(pf_chunks, axis=0),
np.concatenate(pi_chunks, axis=0),
np.concatenate(pm_chunks, axis=0),
)
# ---------------------------------------------------------------------------
# Tuning helpers
# ---------------------------------------------------------------------------
def _tune_and_snap(
y: np.ndarray,
p: np.ndarray,
acc: float,
num_classes: int,
args,
n_bins: int,
) -> tuple[dict, float, Optional[np.ndarray], Optional[np.ndarray]]:
"""
Apply threshold/bias tuning and compute extended metrics.
Returns (snap_dict, tuned_auc, threshold, bias).
"""
thr = 0.5 if num_classes == 2 else float("nan")
bias = None
ext_preds = None
if args.tune_binary_threshold and num_classes == 2 and y.size > 0:
thr = tune_binary_threshold(y, p[:, 1])
ext_preds = (p[:, 1] >= thr).astype(int)
acc = float((ext_preds == y).mean())
elif args.tune_multiclass_bias and num_classes > 2 and y.size > 0:
bias = tune_multiclass_bias(y, p)
logits = np.log(np.clip(p, 1e-8, 1.0)) + bias.reshape(1, -1)
ext_preds = np.argmax(logits, axis=1)
acc = float((ext_preds == y).mean())
ext = compute_extended_metrics(y, p, num_classes, n_bins=n_bins, preds_override=ext_preds)
_, auc, n = _score_arrays(y, p, num_classes)
snap = dict(
auc=auc, acc=acc, n=n,
kappa=ext["kappa"], mcc=ext["mcc"], macro_f1=ext["macro_f1"],
per_class_recall=ext["per_class_recall"], ece=ext["ece"],
threshold=thr, bias=bias,
)
return snap, auc, thr, bias
# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------
def _nan() -> float:
return float("nan")
@dataclass
class FoldResult:
mode: str
fold: int
# Epoch where each model hit its peak val AUC
best_epoch_single: int # SingleEyeHT — selected by ensemble val AUC
best_epoch_bilat: int # BilateralHT — selected by bilateral val AUC
# Classic (eye-level eval of SingleEyeHT; n = 2 * ensemble_val_n)
classic_val_auc: float
classic_val_acc: float
classic_val_kappa: float
classic_val_mcc: float
classic_val_f1: float
classic_val_recall: Optional[str]
classic_val_ece: float
classic_val_threshold: float
classic_val_bias: Optional[str]
classic_val_n: int
# Ensemble (patient-level eval of same SingleEyeHT)
ensemble_val_auc: float
ensemble_val_acc: float
ensemble_val_kappa: float
ensemble_val_mcc: float
ensemble_val_f1: float
ensemble_val_recall: Optional[str]
ensemble_val_ece: float
ensemble_val_threshold: float
ensemble_val_bias: Optional[str]
ensemble_val_n: int
# Bilateral (BilateralHT patient-level)
bilat_val_auc: float
bilat_val_acc: float
bilat_val_kappa: float
bilat_val_mcc: float
bilat_val_f1: float
bilat_val_recall: Optional[str]
bilat_val_ece: float
bilat_val_threshold: float
bilat_val_bias: Optional[str]
bilat_val_n: int
# Holdout metrics (evaluated at the same epoch as best val; nan if no holdout)
classic_holdout_auc: float
classic_holdout_acc: float
ensemble_holdout_auc: float
ensemble_holdout_acc: float
bilat_holdout_auc: float
bilat_holdout_acc: float
holdout_n: int # number of holdout bilateral samples
# Training sample counts
single_train_n: int
bilat_train_n: int
@dataclass
class FoldArtifacts:
y_true_classic: Optional[np.ndarray]
probs_classic: Optional[np.ndarray]
y_true_ensemble: Optional[np.ndarray]
probs_ensemble: Optional[np.ndarray]
y_true_bilat: Optional[np.ndarray]
probs_bilat: Optional[np.ndarray]
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def _f(v) -> Optional[float]:
if v is None or (isinstance(v, float) and np.isnan(v)):
return None
return round(float(v), 6)
def _sv(vec) -> Optional[str]:
if vec is None:
return None
return "|".join(f"{float(v):.4f}" for v in vec)
# ---------------------------------------------------------------------------
# Main fold runner
# ---------------------------------------------------------------------------
def run_fold(
fold: int,
split,
mode: str,
args,
device: torch.device,
data,
num_classes: int,
profile_eye,
profile_patient,
image_preprocessor,
fold_dir: Path,
tower_mode: str,
) -> tuple[FoldResult, FoldArtifacts]:
nan = _nan()
tower_mode = "single" if tower_mode == "classic" else tower_mode
run_single = tower_mode in ("single", "ensemble")
run_bilat = tower_mode == "bilateral"
global_warmup_tower = getattr(args, "warmup_tower_epochs", None)
global_warmup_fused = getattr(args, "warmup_fused_epochs", None)
single_warmup_tower = (
int(args.single_warmup_tower_epochs)
if getattr(args, "single_warmup_tower_epochs", None) is not None
else int(global_warmup_tower) if global_warmup_tower is not None else 2
)
single_warmup_fused = (
int(args.single_warmup_fused_epochs)
if getattr(args, "single_warmup_fused_epochs", None) is not None
else int(global_warmup_fused) if global_warmup_fused is not None else 2
)
bilat_warmup_tower = (
int(args.bilat_warmup_tower_epochs)
if getattr(args, "bilat_warmup_tower_epochs", None) is not None
else int(global_warmup_tower) if global_warmup_tower is not None else 4
)
bilat_warmup_fused = (
int(args.bilat_warmup_fused_epochs)
if getattr(args, "bilat_warmup_fused_epochs", None) is not None
else int(global_warmup_fused) if global_warmup_fused is not None else 3
)
if not run_single:
single_warmup_tower = 0
single_warmup_fused = 0
if not run_bilat:
bilat_warmup_tower = 0
bilat_warmup_fused = 0
main_epochs = int(args.epochs)
total_single_epochs = (single_warmup_tower + single_warmup_fused + main_epochs) if run_single else 0
total_bilat_epochs = (bilat_warmup_tower + bilat_warmup_fused + main_epochs) if run_bilat else 0
total_epochs = max(total_single_epochs, total_bilat_epochs)
# ---- samples -------------------------------------------------------------
eye_train = filter_eye_samples(
profile_eye.build_samples(df=split.train, clinical=data)
)
bilat_train = filter_bilateral_samples(
profile_patient.build_samples(df=split.train, clinical=data)
)
bilat_val = filter_bilateral_samples(
profile_patient.build_samples(df=split.val, clinical=data)
)
if len(bilat_val) == 0:
print(f" [fold {fold+1}] WARNING: no bilateral val samples; skipping fold.", flush=True)
empty = FoldResult(
mode=mode, fold=fold,
best_epoch_single=0, best_epoch_bilat=0,
classic_val_auc=nan, classic_val_acc=nan, classic_val_kappa=nan,
classic_val_mcc=nan, classic_val_f1=nan, classic_val_recall=None,
classic_val_ece=nan, classic_val_threshold=nan, classic_val_bias=None,
classic_val_n=0,
ensemble_val_auc=nan, ensemble_val_acc=nan, ensemble_val_kappa=nan,
ensemble_val_mcc=nan, ensemble_val_f1=nan, ensemble_val_recall=None,
ensemble_val_ece=nan, ensemble_val_threshold=nan, ensemble_val_bias=None,
ensemble_val_n=0,
bilat_val_auc=nan, bilat_val_acc=nan, bilat_val_kappa=nan,
bilat_val_mcc=nan, bilat_val_f1=nan, bilat_val_recall=None,
bilat_val_ece=nan, bilat_val_threshold=nan, bilat_val_bias=None,
bilat_val_n=0,
classic_holdout_auc=nan, classic_holdout_acc=nan,
ensemble_holdout_auc=nan, ensemble_holdout_acc=nan,
bilat_holdout_auc=nan, bilat_holdout_acc=nan,
holdout_n=0,
single_train_n=len(eye_train), bilat_train_n=len(bilat_train),
)
return empty, FoldArtifacts(
y_true_classic=None,
probs_classic=None,
y_true_ensemble=None,
probs_ensemble=None,
y_true_bilat=None,
probs_bilat=None,
)
# ---- models --------------------------------------------------------------
single = None
bilateral = None
if run_single:
single = SingleEyeHT(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
augment=args.augment, clinical_data=data,
num_classes=num_classes,
md_hidden_dim=args.md_hidden_dim, fusion_dim=args.fusion_dim,
).to(device)
if run_bilat:
bilateral = BilateralHT(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
augment=args.augment, clinical_data=data,
num_classes=num_classes,
md_hidden_dim=args.md_hidden_dim, fusion_dim=args.fusion_dim,
).to(device)
slots_eye = profile_eye.slot_descriptors()
slots_patient = profile_patient.slot_descriptors()
loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers)
# ---- loaders -------------------------------------------------------------
train_single_loader = None
train_bilat_loader = None
if run_single:
train_single_loader = make_loader(
eye_train, slots_eye,
image_transform=single.transform,
image_preprocessor=image_preprocessor,
shuffle=True,
**loader_kw,
)
if run_bilat:
train_bilat_loader = make_loader(
bilat_train, slots_patient,
image_transform=bilateral.transform,
image_preprocessor=image_preprocessor,
shuffle=True,
**loader_kw,
)
eval_transform = build_eval_transform(args.backbone)
val_loader = make_loader(
bilat_val, slots_patient,
image_transform=eval_transform,
image_preprocessor=image_preprocessor,
shuffle=False,
**loader_kw,
)
# ---- holdout loader (if holdout patients are available) ------------------
holdout_bilat: list = []
holdout_loader = None
if split.holdout is not None and not split.holdout.empty:
holdout_bilat = filter_bilateral_samples(
profile_patient.build_samples(df=split.holdout, clinical=data)
)
if holdout_bilat:
holdout_loader = make_loader(
holdout_bilat, slots_patient,
image_transform=eval_transform,
image_preprocessor=image_preprocessor,
shuffle=False,
**loader_kw,
)
print(f" [fold {fold+1}] holdout_n={len(holdout_bilat)} (bilateral patients)", flush=True)
opt_single = torch.optim.Adam(single.parameters(), lr=args.lr) if run_single else None
opt_bilateral = torch.optim.Adam(bilateral.parameters(), lr=args.lr) if run_bilat else None
# ---- epoch log -----------------------------------------------------------
epoch_fields = [
"fold", "epoch",
"phase_single", "phase_bilat",
"main_epoch_single", "main_epoch_bilat",
"single_active", "bilat_active",
"single_train_loss", "single_train_acc",
"classic_val_auc", "classic_val_acc", "classic_val_n",
"ensemble_val_auc", "ensemble_val_acc", "ensemble_val_n",
"bilat_train_loss", "bilat_train_acc",
"bilat_val_auc", "bilat_val_acc", "bilat_val_n",
"classic_holdout_auc", "classic_holdout_acc",
"ensemble_holdout_auc", "ensemble_holdout_acc",
"bilat_holdout_auc", "bilat_holdout_acc",
"is_best_single", "is_best_bilat",
"is_best_holdout_single", "is_best_holdout_bilat",
]
fold_logger = HypertowerLogger(run_dir=fold_dir)
# ---- best-epoch trackers -------------------------------------------------
best_single_auc = -1.0 # tracked by ensemble AUC
best_bilat_auc = -1.0
best_epoch_single = 0
best_epoch_bilat = 0
best_single_state: Optional[dict] = None
best_bilat_state: Optional[dict] = None
snap_classic: dict = {}
snap_ensemble: dict = {}
snap_bilat: dict = {}
# holdout snaps (metrics captured at the same epoch as best val)
snap_holdout_single: dict = {}
snap_holdout_bilat: dict = {}
# separate best-holdout trackers (for checkpointing)
best_holdout_single_auc = -1.0
best_holdout_bilat_auc = -1.0
best_epoch_holdout_single = 0
best_epoch_holdout_bilat = 0
best_holdout_single_state: Optional[dict] = None
best_holdout_bilat_state: Optional[dict] = None
if run_single:
print(
f" [fold {fold+1}] single_train_n={len(eye_train)} (eye-level) "
f"val_n={len(bilat_val)} "
f"single_warmup={single_warmup_tower}+{single_warmup_fused} total={total_single_epochs}",
flush=True,
)
else:
print(
f" [fold {fold+1}] bilat_train_n={len(bilat_train)} (bilateral) "
f"val_n={len(bilat_val)} "
f"bilat_warmup={bilat_warmup_tower}+{bilat_warmup_fused} total={total_bilat_epochs}",
flush=True,
)
# ---- epoch loop ----------------------------------------------------------
for epoch in range(total_epochs):
if not run_single:
phase_single, main_epoch_single, single_active = "inactive", 0, False
elif epoch < single_warmup_tower:
phase_single, main_epoch_single, single_active = "tower_warmup", 0, True
elif epoch < (single_warmup_tower + single_warmup_fused):
phase_single, main_epoch_single, single_active = "fused_warmup", 0, True
elif epoch < total_single_epochs:
phase_single, main_epoch_single, single_active = (
"main",
epoch - single_warmup_tower - single_warmup_fused + 1,
True,
)
else:
phase_single, main_epoch_single, single_active = "done", main_epochs, False
if not run_bilat:
phase_bilat, main_epoch_bilat, bilat_active = "inactive", 0, False
elif epoch < bilat_warmup_tower:
phase_bilat, main_epoch_bilat, bilat_active = "tower_warmup", 0, True
elif epoch < (bilat_warmup_tower + bilat_warmup_fused):
phase_bilat, main_epoch_bilat, bilat_active = "fused_warmup", 0, True
elif epoch < total_bilat_epochs:
phase_bilat, main_epoch_bilat, bilat_active = (
"main",
epoch - bilat_warmup_tower - bilat_warmup_fused + 1,
True,
)
else:
phase_bilat, main_epoch_bilat, bilat_active = "done", main_epochs, False
if run_single and single_active:
sl_loss, sl_acc = train_single_epoch(
single,
train_single_loader,
opt_single,
device,
phase=phase_single,
bcd_prob=float(args.bcd_prob),
)
else:
sl_loss, sl_acc = nan, nan
if run_bilat and bilat_active:
bl_loss, bl_acc = train_bilateral_epoch(
bilateral,
train_bilat_loader,
opt_bilateral,
device,
phase=phase_bilat,
bcd_prob=float(args.bcd_prob),
)
else:
bl_loss, bl_acc = nan, nan
if run_single and tower_mode == "single":
y_cl, p_cl, p_cl_img, p_cl_md = collect_probs_single_components(
single, val_loader, device, aggregate_patient=False
)
cl_acc, cl_auc, cl_n = _score_arrays(y_cl, p_cl, num_classes)
cl_acc_img = float((p_cl_img.argmax(1) == y_cl).mean()) if y_cl.size else nan
cl_acc_md = float((p_cl_md.argmax(1) == y_cl).mean()) if y_cl.size else nan
_, cl_auc_img, _ = _score_arrays(y_cl, p_cl_img, num_classes)
_, cl_auc_md, _ = _score_arrays(y_cl, p_cl_md, num_classes)
y_en = np.array([], dtype=np.int64)
p_en = np.zeros((0, 0), dtype=np.float32)
en_acc = en_auc = nan
en_n = 0
en_acc_img = en_acc_md = en_auc_img = en_auc_md = nan
elif run_single and tower_mode == "ensemble":
y_en, p_en, p_en_img, p_en_md = collect_probs_single_components(
single, val_loader, device, aggregate_patient=True
)
en_acc, en_auc, en_n = _score_arrays(y_en, p_en, num_classes)
en_acc_img = float((p_en_img.argmax(1) == y_en).mean()) if y_en.size else nan
en_acc_md = float((p_en_md.argmax(1) == y_en).mean()) if y_en.size else nan
_, en_auc_img, _ = _score_arrays(y_en, p_en_img, num_classes)
_, en_auc_md, _ = _score_arrays(y_en, p_en_md, num_classes)
y_cl = np.array([], dtype=np.int64)
p_cl = np.zeros((0, 0), dtype=np.float32)
cl_acc = cl_auc = nan
cl_n = 0
cl_acc_img = cl_acc_md = cl_auc_img = cl_auc_md = nan
else:
y_cl = y_en = np.array([], dtype=np.int64)
p_cl = p_en = np.zeros((0, 0), dtype=np.float32)
cl_acc = cl_auc = en_acc = en_auc = nan
cl_n = en_n = 0
cl_acc_img = cl_acc_md = en_acc_img = en_acc_md = nan
cl_auc_img = cl_auc_md = en_auc_img = en_auc_md = nan
if run_bilat:
y_bi, p_bi, p_bi_img, p_bi_md = collect_probs_bilateral_components(bilateral, val_loader, device)
bi_acc, bi_auc, bi_n = _score_arrays(y_bi, p_bi, num_classes)
bi_acc_img = float((p_bi_img.argmax(1) == y_bi).mean()) if y_bi.size else nan
bi_acc_md = float((p_bi_md.argmax(1) == y_bi).mean()) if y_bi.size else nan
_, bi_auc_img, _ = _score_arrays(y_bi, p_bi_img, num_classes)
_, bi_auc_md, _ = _score_arrays(y_bi, p_bi_md, num_classes)
else:
y_bi = np.array([], dtype=np.int64)
p_bi = np.zeros((0, 0), dtype=np.float32)
bi_acc = bi_auc = nan
bi_n = 0
bi_acc_img = bi_acc_md = bi_auc_img = bi_auc_md = nan
# --- holdout evaluation -----------------------------------------------
if holdout_loader is not None:
if run_single and tower_mode == "single":
y_cl_h, p_cl_h, _, _ = collect_probs_single_components(
single, holdout_loader, device, aggregate_patient=False
)
_, cl_auc_h, _ = _score_arrays(y_cl_h, p_cl_h, num_classes)
cl_acc_h = float((p_cl_h.argmax(1) == y_cl_h).mean()) if y_cl_h.size else nan
en_auc_h = en_acc_h = nan
elif run_single and tower_mode == "ensemble":
y_en_h, p_en_h, _, _ = collect_probs_single_components(
single, holdout_loader, device, aggregate_patient=True
)
_, en_auc_h, _ = _score_arrays(y_en_h, p_en_h, num_classes)
en_acc_h = float((p_en_h.argmax(1) == y_en_h).mean()) if y_en_h.size else nan
cl_auc_h = cl_acc_h = nan
else:
cl_auc_h = cl_acc_h = en_auc_h = en_acc_h = nan
if run_bilat:
y_bi_h, p_bi_h, _, _ = collect_probs_bilateral_components(bilateral, holdout_loader, device)
_, bi_auc_h, _ = _score_arrays(y_bi_h, p_bi_h, num_classes)
bi_acc_h = float((p_bi_h.argmax(1) == y_bi_h).mean()) if y_bi_h.size else nan
else:
bi_auc_h = bi_acc_h = nan
else:
cl_auc_h = cl_acc_h = en_auc_h = en_acc_h = bi_auc_h = bi_acc_h = nan
# Best-epoch checks: checkpointing is restricted to the main phase only.
target_single_auc = cl_auc if tower_mode == "single" else en_auc
target_holdout_single_auc = cl_auc_h if tower_mode == "single" else en_auc_h
single_ckpt_eligible = run_single and (phase_single == "main")
is_best_single = (
single_ckpt_eligible
and (not np.isnan(target_single_auc))
and (target_single_auc > best_single_auc)
)
if is_best_single:
best_single_auc = target_single_auc
best_epoch_single = epoch + 1
best_single_state = copy.deepcopy(single.state_dict())
if tower_mode == "single":
snap_cl, _, _, _ = _tune_and_snap(y_cl, p_cl, cl_acc, num_classes, args, args.ece_bins)
snap_classic = snap_cl
else:
snap_en, _, _, _ = _tune_and_snap(y_en, p_en, en_acc, num_classes, args, args.ece_bins)
snap_ensemble = snap_en
# capture holdout metrics at this val-best epoch
snap_holdout_single = {"auc": float(target_holdout_single_auc), "acc": float(cl_acc_h if tower_mode == "single" else en_acc_h)}
is_best_holdout_single = (
holdout_loader is not None
and single_ckpt_eligible
and (not np.isnan(target_holdout_single_auc))
and (target_holdout_single_auc > best_holdout_single_auc)
)
if is_best_holdout_single:
best_holdout_single_auc = target_holdout_single_auc
best_epoch_holdout_single = epoch + 1
best_holdout_single_state = copy.deepcopy(single.state_dict())
bilat_ckpt_eligible = run_bilat and (phase_bilat == "main")
is_best_bilat = (
bilat_ckpt_eligible
and (not np.isnan(bi_auc))
and (bi_auc > best_bilat_auc)
)
if is_best_bilat:
best_bilat_auc = bi_auc
best_epoch_bilat = epoch + 1
best_bilat_state = copy.deepcopy(bilateral.state_dict())
snap_bi, _, _, _ = _tune_and_snap(y_bi, p_bi, bi_acc, num_classes, args, args.ece_bins)
snap_bilat = snap_bi
# capture holdout metrics at this val-best epoch
snap_holdout_bilat = {"auc": float(bi_auc_h), "acc": float(bi_acc_h)}
is_best_holdout_bilat = (
holdout_loader is not None
and bilat_ckpt_eligible
and (not np.isnan(bi_auc_h))
and (bi_auc_h > best_holdout_bilat_auc)
)
if is_best_holdout_bilat:
best_holdout_bilat_auc = bi_auc_h
best_epoch_holdout_bilat = epoch + 1
best_holdout_bilat_state = copy.deepcopy(bilateral.state_dict())
fold_logger.write_epoch_row({
"fold": fold, "epoch": epoch + 1,
"phase_single": phase_single,
"phase_bilat": phase_bilat,
"main_epoch_single": main_epoch_single,
"main_epoch_bilat": main_epoch_bilat,
"single_active": int(single_active),
"bilat_active": int(bilat_active),
"single_train_loss": _f(sl_loss), "single_train_acc": _f(sl_acc),
"classic_val_auc": _f(cl_auc), "classic_val_acc": _f(cl_acc), "classic_val_n": cl_n,
"ensemble_val_auc": _f(en_auc), "ensemble_val_acc": _f(en_acc), "ensemble_val_n": en_n,
"bilat_train_loss": _f(bl_loss), "bilat_train_acc": _f(bl_acc),
"bilat_val_auc": _f(bi_auc), "bilat_val_acc": _f(bi_acc), "bilat_val_n": bi_n,
"classic_holdout_auc": _f(cl_auc_h), "classic_holdout_acc": _f(cl_acc_h),
"ensemble_holdout_auc": _f(en_auc_h), "ensemble_holdout_acc": _f(en_acc_h),
"bilat_holdout_auc": _f(bi_auc_h), "bilat_holdout_acc": _f(bi_acc_h),
"is_best_single": int(is_best_single),
"is_best_bilat": int(is_best_bilat),
"is_best_holdout_single": int(is_best_holdout_single),
"is_best_holdout_bilat": int(is_best_holdout_bilat),
}, optional_cols=epoch_fields)
if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
hld_auc = target_holdout_single_auc if run_single else bi_auc_h
hld_suffix = f" hld_auc={hld_auc:.4f}" if holdout_loader is not None else ""
if run_single:
if tower_mode == "single":
msg = (
f" ep {epoch+1:>3}/{total_epochs} "
f"[single:{phase_single} {main_epoch_single}/{main_epochs}] "
f"fused(acc={cl_acc:.4f},auc={cl_auc:.4f}) "
f"img(acc={cl_acc_img:.4f},auc={cl_auc_img:.4f}) "
f"md(acc={cl_acc_md:.4f},auc={cl_auc_md:.4f}) "
f"(best_fused={best_single_auc:.4f} @ep{best_epoch_single})"
f"{hld_suffix}"
)
else:
msg = (
f" ep {epoch+1:>3}/{total_epochs} "
f"[single:{phase_single} {main_epoch_single}/{main_epochs}] "
f"fused(acc={en_acc:.4f},auc={en_auc:.4f}) "
f"img(acc={en_acc_img:.4f},auc={en_auc_img:.4f}) "
f"md(acc={en_acc_md:.4f},auc={en_auc_md:.4f}) "
f"(best_fused={best_single_auc:.4f} @ep{best_epoch_single})"
f"{hld_suffix}"
)
else:
msg = (
f" ep {epoch+1:>3}/{total_epochs} "
f"[bilat:{phase_bilat} {main_epoch_bilat}/{main_epochs}] "
f"fused(acc={bi_acc:.4f},auc={bi_auc:.4f}) "
f"img(acc={bi_acc_img:.4f},auc={bi_auc_img:.4f}) "
f"md(acc={bi_acc_md:.4f},auc={bi_auc_md:.4f}) "
f"(best_bilat={best_bilat_auc:.4f} @ep{best_epoch_bilat})"
f"{hld_suffix}"
)
print(msg, flush=True)
fold_logger.info(msg)
fold_logger.close()
if args.save_checkpoints:
if best_single_state is not None:
torch.save(best_single_state, fold_dir / "best_single.pt")
if best_bilat_state is not None:
torch.save(best_bilat_state, fold_dir / "best_bilateral.pt")
if best_holdout_single_state is not None:
torch.save(best_holdout_single_state, fold_dir / "best_holdout_single.pt")
if best_holdout_bilat_state is not None:
torch.save(best_holdout_bilat_state, fold_dir / "best_holdout_bilateral.pt")
if run_single:
if tower_mode == "single":
print(
f" [fold {fold+1}] BEST "
f"fused(acc={snap_classic.get('acc', nan):.4f},auc={snap_classic.get('auc', nan):.4f}) "
f"kappa={snap_classic.get('kappa', nan):.4f} "
f"F1={snap_classic.get('macro_f1', nan):.4f} "
f"ECE={snap_classic.get('ece', nan):.4f} @ep{best_epoch_single}",
flush=True,
)
else:
print(
f" [fold {fold+1}] BEST "
f"ensemble(acc={snap_ensemble.get('acc', nan):.4f},auc={snap_ensemble.get('auc', nan):.4f}) "
f"kappa={snap_ensemble.get('kappa', nan):.4f} "
f"F1={snap_ensemble.get('macro_f1', nan):.4f} "
f"ECE={snap_ensemble.get('ece', nan):.4f} @ep{best_epoch_single}",
flush=True,
)
else:
print(
f" [fold {fold+1}] BEST "
f"fused(acc={snap_bilat.get('acc', nan):.4f},auc={snap_bilat.get('auc', nan):.4f}) "
f"kappa={snap_bilat.get('kappa', nan):.4f} "
f"F1={snap_bilat.get('macro_f1', nan):.4f} "
f"ECE={snap_bilat.get('ece', nan):.4f} @ep{best_epoch_bilat}",
flush=True,
)
# Export best-epoch prediction artifacts for easier side-by-side analysis.
if run_single and best_single_state is not None:
single.load_state_dict(best_single_state)
if run_bilat and best_bilat_state is not None:
bilateral.load_state_dict(best_bilat_state)
if run_single and tower_mode == "single":
y_cl_best, p_cl_best = collect_probs_classic(single, val_loader, device)
y_en_best = p_en_best = None
elif run_single and tower_mode == "ensemble":
y_en_best, p_en_best = collect_probs_ensemble(single, val_loader, device)
y_cl_best = p_cl_best = None
else:
y_cl_best = y_en_best = None
p_cl_best = p_en_best = None
if run_bilat:
y_bi_best, p_bi_best = collect_probs_bilateral(bilateral, val_loader, device)
else:
y_bi_best = p_bi_best = None
return FoldResult(
mode=mode, fold=fold,
best_epoch_single=best_epoch_single, best_epoch_bilat=best_epoch_bilat,
classic_val_auc=snap_classic.get("auc", nan),
classic_val_acc=snap_classic.get("acc", nan),
classic_val_kappa=snap_classic.get("kappa", nan),
classic_val_mcc=snap_classic.get("mcc", nan),
classic_val_f1=snap_classic.get("macro_f1", nan),
classic_val_recall=_sv(snap_classic.get("per_class_recall")),
classic_val_ece=snap_classic.get("ece", nan),
classic_val_threshold=snap_classic.get("threshold", nan),
classic_val_bias=_svf(snap_classic.get("bias")),
classic_val_n=snap_classic.get("n", 0),
ensemble_val_auc=snap_ensemble.get("auc", nan),
ensemble_val_acc=snap_ensemble.get("acc", nan),
ensemble_val_kappa=snap_ensemble.get("kappa", nan),
ensemble_val_mcc=snap_ensemble.get("mcc", nan),
ensemble_val_f1=snap_ensemble.get("macro_f1", nan),
ensemble_val_recall=_sv(snap_ensemble.get("per_class_recall")),
ensemble_val_ece=snap_ensemble.get("ece", nan),
ensemble_val_threshold=snap_ensemble.get("threshold", nan),
ensemble_val_bias=_svf(snap_ensemble.get("bias")),
ensemble_val_n=snap_ensemble.get("n", 0),
bilat_val_auc=snap_bilat.get("auc", nan),
bilat_val_acc=snap_bilat.get("acc", nan),
bilat_val_kappa=snap_bilat.get("kappa", nan),
bilat_val_mcc=snap_bilat.get("mcc", nan),
bilat_val_f1=snap_bilat.get("macro_f1", nan),
bilat_val_recall=_sv(snap_bilat.get("per_class_recall")),
bilat_val_ece=snap_bilat.get("ece", nan),
bilat_val_threshold=snap_bilat.get("threshold", nan),
bilat_val_bias=_svf(snap_bilat.get("bias")),
bilat_val_n=snap_bilat.get("n", 0),
classic_holdout_auc=snap_holdout_single.get("auc", nan) if tower_mode == "single" else nan,
classic_holdout_acc=snap_holdout_single.get("acc", nan) if tower_mode == "single" else nan,
ensemble_holdout_auc=snap_holdout_single.get("auc", nan) if tower_mode == "ensemble" else nan,
ensemble_holdout_acc=snap_holdout_single.get("acc", nan) if tower_mode == "ensemble" else nan,
bilat_holdout_auc=snap_holdout_bilat.get("auc", nan),
bilat_holdout_acc=snap_holdout_bilat.get("acc", nan),
holdout_n=len(holdout_bilat),
single_train_n=len(eye_train),
bilat_train_n=len(bilat_train),
), FoldArtifacts(
y_true_classic=y_cl_best,
probs_classic=p_cl_best,
y_true_ensemble=y_en_best,
probs_ensemble=p_en_best,
y_true_bilat=y_bi_best,
probs_bilat=p_bi_best,
)
# ---------------------------------------------------------------------------
# Summary helpers
# ---------------------------------------------------------------------------
def _summary(results: list[FoldResult]) -> dict:
def _ms(vals):
v = np.array([x for x in vals if x is not None and not np.isnan(float(x))], dtype=float)
return (float(np.mean(v)) if v.size else None, float(np.std(v)) if v.size else None)
out = {}
for label, prefix in [
("classic_best_val", "classic_val"),
("ensemble_best_val", "ensemble_val"),
("bilat_best_val", "bilat_val"),
]:
sub = {}
for m in ["auc", "acc", "kappa", "mcc", "f1", "ece", "threshold"]:
vals = [getattr(r, f"{prefix}_{m}") for r in results]
mean, std = _ms(vals)
sub[f"{m}_mean"] = mean
if m in ("auc", "f1", "kappa"):
sub[f"{m}_std"] = std
out[label] = sub
for label, prefix in [
("classic_holdout", "classic_holdout"),
("ensemble_holdout", "ensemble_holdout"),
("bilat_holdout", "bilat_holdout"),
]:
sub = {}
for m in ["auc", "acc"]:
vals = [getattr(r, f"{prefix}_{m}") for r in results]
mean, std = _ms(vals)
sub[f"{m}_mean"] = mean
if m == "auc":
sub[f"{m}_std"] = std
out[label] = sub
# Deltas: ensemble classic (eval strategy effect, same model)
# bilateral ensemble (bilateral training effect)
for delta_label, prefix_a, prefix_b in [
("delta_ensemble_vs_classic", "classic_val", "ensemble_val"),
("delta_bilat_vs_ensemble", "ensemble_val", "bilat_val"),
]:
delta = {}
for m in ["auc", "f1", "kappa"]:
pairs = [
getattr(r, f"{prefix_b}_{m}") - getattr(r, f"{prefix_a}_{m}")
for r in results
if not np.isnan(float(getattr(r, f"{prefix_a}_{m}")))
and not np.isnan(float(getattr(r, f"{prefix_b}_{m}")))
]
delta[f"{m}_mean"] = float(np.mean(pairs)) if pairs else None
delta[f"{m}_std"] = float(np.std(pairs)) if pairs else None
out[delta_label] = delta
out["n_folds_completed"] = len(results)
out["single_train_mode"] = "eye-level (all OD+OS samples)"
out["bilat_train_mode"] = "patient-level (bilateral only)"
out["eval_note"] = (
"classic=eye-level SingleEyeHT; "
"ensemble=patient-level SingleEyeHT (OD+OS averaged); "
"bilateral=patient-level BilateralHT"
)
return out
def _print_summary(mode: str, s: dict, tower_mode: str | None = None) -> None:
def f(v):
return "nan" if v is None else f"{v:.4f}"
cv = s["classic_best_val"]
ev = s["ensemble_best_val"]
bv = s["bilat_best_val"]
d1 = s["delta_ensemble_vs_classic"]
d2 = s["delta_bilat_vs_ensemble"]
print(f"\n=== Summary [{mode}] — best-epoch val ===")
print(f" {'':26s} {'AUC':>8} {'ACC':>8} {'Kappa':>8} {'F1-mac':>8} {'ECE':>8}")
if tower_mode == "single":
rows = [("single (eye-lvl eval)", cv)]
elif tower_mode == "ensemble":
rows = [("ensemble (pat-lvl eval)", ev)]
elif tower_mode == "bilateral":
rows = [("bilateral (bilat eval)", bv)]
else:
rows = [
("classic (eye-lvl eval)", cv),
("ensemble (pat-lvl eval)", ev),
("bilateral (bilat eval)", bv),
]
for label, d in rows:
print(
f" {label:26s} "
f"{f(d['auc_mean']):>8} {f(d['acc_mean']):>8} "
f"{f(d['kappa_mean']):>8} {f(d['f1_mean']):>8} {f(d['ece_mean']):>8}"
)
if tower_mode is None:
print(
f" {'Δ ensembleclassic':26s} "
f"{f(d1['auc_mean']):>8} {'':>8} "
f"{f(d1['kappa_mean']):>8} {f(d1['f1_mean']):>8}"
)
print(
f" {'Δ bilateralensemble':26s} "
f"{f(d2['auc_mean']):>8} {'':>8} "
f"{f(d2['kappa_mean']):>8} {f(d2['f1_mean']):>8}"
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
description=(
"Three HyperTower modes: Classic (eye-level), Ensemble (patient-level avg), "
"Bilateral (BilateralBridge with shared towers). Pure k-fold CV."
)
)
ap.add_argument("--image-dir", default="Papila/FundusImages")
ap.add_argument("--clinical-dir", default="Papila/ClinicalData")
ap.add_argument("--label-col", default="Diagnosis")
ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"])
ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="multiclass")
ap.add_argument(
"--tower-mode", choices=["single", "ensemble", "bilateral", "classic"],
default="single",
help="Train/evaluate a single tower mode.",
)
ap.add_argument("--n-splits", type=int, default=5)
ap.add_argument("--fold-seed", type=int, default=42)
ap.add_argument("--holdout-per-class", type=int, default=5,
help="Patients per class reserved for holdout before train/test split (0 disables)")
ap.add_argument("--holdout-seed", type=int, default=123,
help="Random seed for holdout sampling")
ap.add_argument(
"--folds",
type=int,
default=None,
help="Optional cap on how many folds to run (default: all --n-splits).",
)
ap.add_argument("--epochs", type=int, default=40)
ap.add_argument(
"--warmup-tower-epochs", type=int, default=None,
help="Extra tower warmup epochs (added before main epochs). Default: auto by mode.",
)
ap.add_argument(
"--warmup-fused-epochs", type=int, default=None,
help="Extra fused warmup epochs (added before main epochs). Default: auto by mode.",
)
ap.add_argument("--single-warmup-tower-epochs", type=int, default=None,
help="Single-eye model tower warmup (overrides --warmup-tower-epochs).")
ap.add_argument("--single-warmup-fused-epochs", type=int, default=None,
help="Single-eye model fused warmup (overrides --warmup-fused-epochs).")
ap.add_argument("--bilat-warmup-tower-epochs", type=int, default=None,
help="Bilateral model tower warmup (overrides --warmup-tower-epochs).")
ap.add_argument("--bilat-warmup-fused-epochs", type=int, default=None,
help="Bilateral model fused warmup (overrides --warmup-fused-epochs).")
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--bcd-prob", type=float, default=0.5,
help="Tower-only step probability during main phase (per model).")
ap.add_argument("--backbone", default="refugelike")
ap.add_argument("--freeze-ratio", type=float, default=0.0)
ap.add_argument("--augment", action="store_true")
ap.add_argument("--num-workers", type=int, default=0)
ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
ap.add_argument("--seed", type=int, default=1234)
ap.add_argument("--run-name", default=None)
ap.add_argument("--output-root", default="analysis_data")
# Optional ROI cropping (GT manifest or UNet-generated mask crop)
ap.add_argument("--img-crop-manifest", type=str, default=None,
help="Path to crop manifest CSV for ROI cropping.")
ap.add_argument("--img-crop-gt", action="store_true",
help="Use ground-truth masks/contours from manifest for ROI crop.")
ap.add_argument("--img-crop-weights", type=str, default=None,
help="UNet weights path for ROI cropping from predicted masks.")
ap.add_argument("--img-crop-normalize", type=str, default="per_image",
choices=["per_image", "imagenet"],
help="UNet input normalization mode.")
ap.add_argument("--img-crop-threshold", type=float, default=0.5,
help="UNet mask threshold for ROI extraction.")
ap.add_argument("--img-crop-tta", action="store_true",
help="Enable flip-TTA during UNet mask inference.")
ap.add_argument("--img-crop-scale", type=float, default=2.5,
help="Disc-radius multiplier for square crop.")
ap.add_argument("--img-crop-size", type=int, default=224,
help="Output ROI size before tower transforms.")
ap.add_argument("--img-crop-cache", type=str, default="cache_data/hypertower_crops",
help="Cache directory for cropped images and geometry sidecars.")
ap.add_argument("--persist-img-crop-cache", action="store_true",
help="Keep existing cached crop .npz files instead of clearing at run start.")
# Architecture
ap.add_argument("--md-hidden-dim", type=int, default=128,
help="MDTower hidden dimension.")
ap.add_argument("--fusion-dim", type=int, default=256,
help="Bridge/BilateralBridge fusion dimension.")
# Mixed patients
ap.add_argument(
"--exclude-mixed-patients",
dest="exclude_mixed_patients", action="store_true",
help="Drop patients whose two eyes have different labels before splitting.",
)
ap.add_argument(
"--include-mixed-patients",
dest="exclude_mixed_patients", action="store_false",
)
ap.add_argument(
"--relabel-mixed-patients-to-max",
dest="relabel_mixed_patients_to_max",
action="store_true",
help="When mixed patients are included, relabel both eyes to patient max severity.",
)
# Backward-compatible alias; default behavior is now to keep raw labels.
ap.add_argument(
"--keep-mixed-raw-labels",
dest="relabel_mixed_patients_to_max",
action="store_false",
help=argparse.SUPPRESS,
)
ap.set_defaults(exclude_mixed_patients=False, relabel_mixed_patients_to_max=False)
# Tuning
ap.add_argument(
"--tune-binary-threshold", action="store_true",
help="Tune per-model binary threshold on validation each epoch.",
)
ap.add_argument(
"--tune-multiclass-bias", action="store_true",
help="Tune per-model multiclass log-prob bias on validation each epoch.",
)
ap.add_argument("--ece-bins", type=int, default=10)
ap.add_argument("--log-every", type=int, default=1)
ap.add_argument("--save-checkpoints", action=argparse.BooleanOptionalAction, default=True,
help="Save best_single.pt / best_holdout_single.pt per fold (use --no-save-checkpoints to disable)")
return ap
def parse_args():
return build_parser().parse_args()
def choose_device(name: str) -> torch.device:
if name == "cuda":
if not torch.cuda.is_available():
raise RuntimeError("--device cuda requested but CUDA is not available.")
return torch.device("cuda")
if name == "cpu":
return torch.device("cpu")
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def run_mode(args) -> Path:
# Defensive local import for CLI/entrypoint execution paths.
import csv
device = choose_device(args.device)
seed_everything(args.seed)
print(f"Device: {device}", flush=True)
print("Loading PAPILA data...", flush=True)
data = build_papila_data(
image_dir=args.image_dir,
clinical_dir=args.clinical_dir,
label_col=args.label_col,
cat_cols=list(args.cat_cols),
n_splits=args.n_splits,
random_seed=args.fold_seed,
)
print(f"Loaded: {len(data.df)} rows feature_dim={data.feature_dim}", flush=True)
image_preprocessor = build_image_preprocessor_from_args(args)
ts = time.strftime("%Y%m%d_%H%M%S")
run_name = args.run_name or f"hypertower_modes_{ts}"
out_dir = Path(args.output_root) / run_name
out_dir.mkdir(parents=True, exist_ok=True)
mode = args.eval_mode
tower_mode = "single" if args.tower_mode == "classic" else args.tower_mode
df_mode = data.df.copy()
if args.exclude_mixed_patients:
before = df_mode["Patient ID"].nunique()
df_mode, mixed = _drop_mixed_label_patients(
df_mode, patient_col="Patient ID", label_col=args.label_col
)
print(
f"[{mode}] dropped {len(mixed)} mixed-label patients "
f"({before}{df_mode['Patient ID'].nunique()})",
flush=True,
)
else:
if args.relabel_mixed_patients_to_max:
before_rows = len(df_mode)
df_mode, changed_rows, still_mixed = _relabel_mixed_patients_to_max(
df_mode, patient_col="Patient ID", label_col=args.label_col
)
print(
f"[{mode}] relabeled mixed patients to max severity "
f"(changed={changed_rows}, rows={before_rows}{len(df_mode)}, "
f"remaining_mixed={len(still_mixed)}).",
flush=True,
)
else:
print(f"[{mode}] keeping mixed-label patients with raw per-eye labels.", flush=True)
if mode == "binary":
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
num_classes = 2 if mode == "binary" else int(df_mode[args.label_col].nunique())
print(
f"\n[{mode}] num_classes={num_classes} rows={len(df_mode)} "
f"patients={df_mode['Patient ID'].nunique()}",
flush=True,
)
split_manager = PatientFirstSplitManager(
patient_col="Patient ID", label_col=args.label_col
)
split_args = SimpleNamespace(
eval_mode=mode,
holdout_per_class=args.holdout_per_class,
holdout_seed=args.holdout_seed,
n_splits=args.n_splits,
fold_seed=args.fold_seed,
)
clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col)
plans = split_manager.build_plans(clinical=clinical_ns, args=split_args, profile=None)
requested_folds = args.n_splits if args.folds is None else int(args.folds)
n_folds = min(requested_folds, len(plans))
profile_eye = build_papila_profile(
patient_col="Patient ID", label_col=args.label_col, sample_mode="eye"
)
profile_patient = build_papila_profile(
patient_col="Patient ID", label_col=args.label_col, sample_mode="patient"
)
tm_dir = out_dir / mode / tower_mode
tm_dir.mkdir(parents=True, exist_ok=True)
fold_results: list[FoldResult] = []
for fold in range(n_folds):
seed_everything(args.seed + fold * 100)
fold_dir = tm_dir / f"fold{fold}"
fold_dir.mkdir(exist_ok=True)
print(f"\n[{mode}:{tower_mode}] fold {fold+1}/{n_folds}", flush=True)
result, artifacts = run_fold(
fold=fold,
split=plans[fold],
mode=mode,
args=args,
device=device,
data=data,
num_classes=num_classes,
profile_eye=profile_eye,
profile_patient=profile_patient,
image_preprocessor=image_preprocessor,
fold_dir=fold_dir,
tower_mode=tower_mode,
)
fold_results.append(result)
if artifacts.y_true_ensemble is not None:
np.save(fold_dir / "y_true.npy", artifacts.y_true_ensemble)
if artifacts.probs_ensemble is not None:
np.save(fold_dir / "probs_fused.npy", artifacts.probs_ensemble)
if artifacts.probs_classic is not None:
np.save(fold_dir / "probs_classic.npy", artifacts.probs_classic)
if artifacts.probs_bilat is not None:
np.save(fold_dir / "probs_bilat.npy", artifacts.probs_bilat)
fold_csv = tm_dir / "fold_results.csv"
csv_fields = list(FoldResult.__dataclass_fields__.keys())
with fold_csv.open("w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=csv_fields)
w.writeheader()
for r in fold_results:
w.writerow({k: getattr(r, k) for k in csv_fields})
summary = _summary(fold_results)
_print_summary(f"{mode}:{tower_mode}", summary, tower_mode=tower_mode)
metric_key = {
"single": "classic_val_auc",
"ensemble": "ensemble_val_auc",
"bilateral": "bilat_val_auc",
}[tower_mode]
fold_metrics = []
best_vals = []
for r in fold_results:
best_val = getattr(r, metric_key)
fold_metrics.append(
{
"fold": r.fold,
"best_metric_value": _f(best_val),
"best_epoch": (r.best_epoch_bilat if tower_mode == "bilateral" else r.best_epoch_single),
"monitor": metric_key,
}
)
if not np.isnan(float(best_val)):
best_vals.append(float(best_val))
mode_summary = {
"run_id": run_name,
"backbone": args.backbone,
"epochs": args.epochs,
"warmup_tower_epochs": args.warmup_tower_epochs,
"warmup_fused_epochs": args.warmup_fused_epochs,
"single_warmup_tower_epochs": args.single_warmup_tower_epochs,
"single_warmup_fused_epochs": args.single_warmup_fused_epochs,
"bilat_warmup_tower_epochs": args.bilat_warmup_tower_epochs,
"bilat_warmup_fused_epochs": args.bilat_warmup_fused_epochs,
"batch_size": args.batch_size,
"lr": args.lr,
"eval_mode": mode,
"tower_mode": tower_mode,
"n_splits": n_folds,
"best_metric": metric_key,
"best_metric_mode": "max",
"best_metric_mean": (float(np.mean(best_vals)) if best_vals else None),
"best_metric_std": (float(np.std(best_vals)) if best_vals else None),
"fold_metrics": fold_metrics,
"mode_summary": summary,
}
(tm_dir / "summary.json").write_text(json.dumps(mode_summary, indent=2), encoding="utf-8")
# Merge run-level summary across sequential invocations.
root_summary_path = out_dir / "summary.json"
if root_summary_path.exists():
try:
payload = json.loads(root_summary_path.read_text(encoding="utf-8"))
except Exception:
payload = {}
else:
payload = {}
payload.setdefault("run_name", run_name)
payload.setdefault("timestamp", ts)
payload["config"] = vars(args)
payload.setdefault("summaries", {})
payload["summaries"][f"{mode}:{tower_mode}"] = summary
root_summary_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"\nOutputs written to: {out_dir}")
return out_dir