pre-refactor 041426

This commit is contained in:
rpotter6298
2026-04-14 19:42:16 +02:00
parent eb9eafe715
commit 13290575d5
75 changed files with 8900 additions and 77 deletions
+19 -16
View File
@@ -14,6 +14,7 @@ class Bridge(nn.Module):
num_classes,
fusion_dim=256,
mode="fused",
dropout: float = 0.5,
use_se: bool = True,
se_reduction: int = 16,
se_pre_norm: bool = True,
@@ -37,7 +38,7 @@ class Bridge(nn.Module):
# heads
self.classifier_fused = nn.Sequential(
nn.ReLU(),
nn.Dropout(0.5),
nn.Dropout(dropout),
nn.Linear(fusion_dim, num_classes),
)
self.classifier_img = nn.Linear(img_dim, num_classes)
@@ -54,26 +55,28 @@ class Bridge(nn.Module):
return self.se_log.get(reset=reset)
return None
def _compute_fused(self, img_feats, md_feats):
"""Return z_fused embedding (before classifier_fused). Used by encode() and forward()."""
hi = self.ln_img(self.W_img(img_feats))
hm = self.ln_md(self.W_md(md_feats))
fused = hi * hm
if self.se is not None:
fused, gates = self.se(fused)
if self.se_log.enabled:
self.se_log.accumulate(gates)
return fused
def encode(self, img_feats, md_feats) -> torch.Tensor:
"""Return z_fused embedding without applying the classifier head."""
assert self.mode == "fused", "encode() only valid in fused mode"
return self._compute_fused(img_feats, md_feats)
def forward(self, img_feats, md_feats):
out_img = None if self.mode == "clinical_only" else self.classifier_img(img_feats)
out_md = None if self.mode == "image_only" else self.classifier_cd(md_feats)
if self.mode == "fused":
hi = self.ln_img(self.W_img(img_feats)) # image features
hm = self.ln_md(self.W_md(md_feats)) # clinical data features
fused = hi * hm # elementwise product
# apply SE gates
if self.se is not None:
fused, gates = self.se(fused)
if self.se_log.enabled:
self.se_log.accumulate(gates)
if self.se is not None and self.training and self.se_log.enabled:
if not hasattr(self, "_dbg_seen"):
self._dbg_seen = 0
if self._dbg_seen < 3: # print only a few times
print("[SE] gate mean this batch:", gates.mean().item())
self._dbg_seen += 1
fused = self._compute_fused(img_feats, md_feats)
out_f = self.classifier_fused(fused)
return out_f, out_img, out_md
# if ablation modes:
+54
View File
@@ -189,6 +189,31 @@ class UNetImageCropper:
return None
return np.asarray(features, dtype=np.float32)
def precompute_geometry(self, image_paths) -> None:
"""Pre-compute geometry features for all image_paths into an in-memory cache.
Safe to call in the main process; geometry_for_image() can then be called
from DataLoader workers without touching CUDA.
"""
self._geometry_cache: Dict[str, Optional[np.ndarray]] = {}
paths = list(image_paths)
print(f"[UNetImageCropper] pre-computing geometry for {len(paths)} images...", flush=True)
for img_path in paths:
key = str(Path(img_path).resolve())
try:
img = Image.open(img_path).convert("RGB")
self._geometry_cache[key] = self.geometry_features(img, img_path)
except Exception:
self._geometry_cache[key] = None
n_ok = sum(1 for v in self._geometry_cache.values() if v is not None)
print(f"[UNetImageCropper] {n_ok}/{len(paths)} geometry vectors computed", flush=True)
def geometry_for_image(self, image_path) -> Optional[np.ndarray]:
"""Return pre-computed geometry vector for image_path (call precompute_geometry first)."""
cache = getattr(self, "_geometry_cache", None)
if cache is None:
raise RuntimeError("Call precompute_geometry() before geometry_for_image()")
return cache.get(str(Path(image_path).resolve()))
class ManifestImageCropper:
def __init__(
@@ -371,6 +396,35 @@ class ManifestImageCropper:
return None
return np.asarray(features, dtype=np.float32)
def precompute_geometry(self, image_paths) -> None:
"""Pre-compute geometry features for all image_paths into an in-memory cache.
Safe to call in the main process; geometry_for_image() can then be called
without re-opening images or re-loading annotations.
"""
self._geometry_cache: Dict[str, Optional[np.ndarray]] = {}
paths = list(image_paths)
print(f"[ManifestImageCropper] pre-computing geometry for {len(paths)} images...", flush=True)
for img_path in paths:
key = str(Path(img_path).resolve())
entry = self.entries.get(key)
if entry is None:
self._geometry_cache[key] = None
continue
try:
img = Image.open(img_path).convert("RGB")
self._geometry_cache[key] = self.geometry_features(img, img_path)
except Exception:
self._geometry_cache[key] = None
n_ok = sum(1 for v in self._geometry_cache.values() if v is not None)
print(f"[ManifestImageCropper] {n_ok}/{len(paths)} geometry vectors computed", flush=True)
def geometry_for_image(self, image_path) -> Optional[np.ndarray]:
"""Return pre-computed geometry vector for image_path (call precompute_geometry first)."""
cache = getattr(self, "_geometry_cache", None)
if cache is None:
raise RuntimeError("Call precompute_geometry() before geometry_for_image()")
return cache.get(str(Path(image_path).resolve()))
# ---------------------------------------------------------------------------
# Factory
+2
View File
@@ -211,6 +211,7 @@ def make_loader(
shuffle: bool,
num_workers: int,
sampler: Optional[WeightedRandomSampler] = None,
persistent_workers: bool = False,
) -> DataLoader:
ds = SlotDataset(
samples,
@@ -226,6 +227,7 @@ def make_loader(
sampler=sampler,
num_workers=num_workers,
collate_fn=slot_collate,
persistent_workers=(persistent_workers and num_workers > 0),
)
+222 -6
View File
@@ -11,7 +11,7 @@ from torch import nn
from torch.utils.data import DataLoader
from v3.classes.bridges import Bridge
from v3.classes.towers import ImageTower, ClinicalTower
from v3.classes.towers import ImageTower, ClinicalTower, SiameseImageTower
# ---------------------------------------------------------------------------
@@ -35,18 +35,24 @@ class SingleEyeHT(nn.Module):
cd_hidden_dim: int = 128,
fusion_dim: int = 256,
bridge_mode: str = "fused",
bridge_dropout: float = 0.5,
cd_dropout: float = 0.1,
se_img_tower: bool = False,
se_cd_tower: bool = False,
se_bridge: bool = False,
):
super().__init__()
self.img_tower = ImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
use_se=se_img_tower,
)
self.cd_tower = ClinicalTower(
clinical_data=clinical_data,
hidden_dim=cd_hidden_dim,
use_se=False,
dropout=cd_dropout,
use_se=se_cd_tower,
)
self.bridge = Bridge(
img_dim=self.img_tower.out_dim,
@@ -54,13 +60,20 @@ class SingleEyeHT(nn.Module):
num_classes=num_classes,
fusion_dim=fusion_dim,
mode=bridge_mode,
use_se=False,
dropout=bridge_dropout,
use_se=se_bridge,
)
@property
def transform(self):
return self.img_tower.transform
def encode(self, x: torch.Tensor, meta: torch.Tensor) -> torch.Tensor:
"""Return z_fused embedding (fusion_dim) without applying the classifier head."""
img_feats = self.img_tower(x)
md_feats = self.cd_tower(meta)
return self.bridge.encode(img_feats, md_feats)
def forward(self, x: torch.Tensor, meta: torch.Tensor) -> torch.Tensor:
img_feats = None if self.bridge.mode == "clinical_only" else self.img_tower(x)
md_feats = None if self.bridge.mode == "image_only" else self.cd_tower(meta)
@@ -158,6 +171,59 @@ class BilateralHT(nn.Module):
return out_f
class SiameseHT(nn.Module):
"""
Bilateral model using a shared-weight SiameseImageTower (mean+delta).
Both eyes pass through the same backbone; features are combined as
cat([mean(f_od, f_os), f_od - f_os]) giving the model both a shared
bilateral representation and an asymmetry signal.
Uses the same bilateral loader and training loop as BilateralHT.
The bridge operates in image_only mode (no clinical data in phase 4).
"""
def __init__(
self,
*,
backbone: str,
freeze_ratio: float,
augment: bool,
num_classes: int,
fusion_dim: int = 256,
bridge_mode: str = "image_only",
):
super().__init__()
self.img_tower = SiameseImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
)
img_dim = self.img_tower.out_dim # 2 * backbone_out_dim
self.bridge = Bridge(
img_dim=img_dim,
meta_dim=1, # dummy — not used in image_only mode
num_classes=num_classes,
fusion_dim=fusion_dim,
mode="image_only",
use_se=False,
)
self.aux_img = nn.Linear(img_dim, num_classes)
@property
def transform(self):
return self.img_tower.transform
def encode(self, x_od: torch.Tensor, x_os: torch.Tensor) -> torch.Tensor:
return self.img_tower(x_od, x_os)
def forward(self, x_od: torch.Tensor, x_os: torch.Tensor) -> torch.Tensor:
feats = self.encode(x_od, x_os)
out_f, _, _ = self.bridge(feats, None)
return out_f
class FusedEnsembleHT(nn.Module):
"""
SingleEyeHT base with a per-eye attention scorer for bilateral fusion.
@@ -190,6 +256,10 @@ class FusedEnsembleHT(nn.Module):
# Learns the GC-direction in logit space from bilateral labels.
self.eye_scorer = nn.Linear(num_classes, 1, bias=True)
@property
def head(self) -> nn.Module:
return self.eye_scorer
def forward(
self,
x_od: torch.Tensor,
@@ -205,6 +275,78 @@ class FusedEnsembleHT(nn.Module):
return alpha[:, 0:1] * logit_od + alpha[:, 1:2] * logit_os # [B, C]
class LogitMLPEnsembleHT(nn.Module):
"""
MLP head trained on concatenated per-eye logits.
Both eyes pass through the frozen base independently, producing per-eye
logit vectors. These are concatenated and fed through a small MLP:
cat([logit_od, logit_os]) [B, 2C]
→ Linear(2C, hidden) → ReLU → Dropout → Linear(hidden, C)
Permutation-variant by design: the model can learn left/right asymmetries
directly from the concatenated pair, at the cost of needing a consistent
OD-first input ordering.
"""
def __init__(self, base: SingleEyeHT, num_classes: int, hidden: int = 64):
super().__init__()
self.base = base
self.head = nn.Sequential(
nn.Linear(2 * num_classes, hidden),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden, num_classes),
)
def forward(
self,
x_od: torch.Tensor, meta_od: torch.Tensor,
x_os: torch.Tensor, meta_os: torch.Tensor,
) -> torch.Tensor:
logit_od = self.base(x_od, meta_od)
logit_os = self.base(x_os, meta_os)
return self.head(torch.cat([logit_od, logit_os], dim=1))
class EmbeddingMLPEnsembleHT(nn.Module):
"""
MLP head trained on concatenated per-eye z_fused embeddings.
Both eyes pass through the frozen base independently, and their bridge
embeddings (pre-classifier, shape [B, fusion_dim]) are concatenated and
fed through an MLP:
cat([z_od, z_os]) [B, 2 * fusion_dim]
→ Linear(2*fusion_dim, hidden) → ReLU → Dropout → Linear(hidden, C)
Richer than logit-level: the head sees pre-softmax feature vectors rather
than the compressed C-dimensional output, giving it more signal to work
with when fusion_dim >> C.
"""
def __init__(self, base: SingleEyeHT, num_classes: int, hidden: int = 256):
super().__init__()
self.base = base
fusion_dim = base.bridge.W_img.out_features
self.head = nn.Sequential(
nn.Linear(2 * fusion_dim, hidden),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden, num_classes),
)
def forward(
self,
x_od: torch.Tensor, meta_od: torch.Tensor,
x_os: torch.Tensor, meta_os: torch.Tensor,
) -> torch.Tensor:
z_od = self.base.encode(x_od, meta_od)
z_os = self.base.encode(x_os, meta_os)
return self.head(torch.cat([z_od, z_os], dim=1))
# ---------------------------------------------------------------------------
# Phase control
# ---------------------------------------------------------------------------
@@ -429,15 +571,89 @@ def train_bilateral_epoch(
)
def train_siamese_epoch(
model: SiameseHT,
loader: DataLoader,
opt,
device: torch.device,
*,
bcd_prob: float = 0.5,
tower_loss_mode: str = "bcd",
) -> tuple[float, float]:
"""Train one epoch of SiameseHT on bilateral (patient-level) samples."""
model.train()
total_loss = total_correct = total_n = 0
for batch in loader:
x1 = batch.get("image_1")
x2 = batch.get("image_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(x2)):
continue
x1 = x1.to(device); x2 = x2.to(device)
y = _to_label_tensor(y, device)
feats = model.encode(x1, x2)
if tower_loss_mode == "all":
logits_i = model.aux_img(feats)
out_f, _, _ = model.bridge(feats, None)
loss = F.cross_entropy(out_f, y) + F.cross_entropy(logits_i, y)
logits = out_f
elif random() < bcd_prob:
logits = model.aux_img(feats)
loss = F.cross_entropy(logits, y)
else:
logits, _, _ = model.bridge(feats, None)
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"),
)
def collect_probs_siamese(
model: SiameseHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray]:
"""Patient-level probs from a bilateral loader using SiameseHT."""
model.eval()
y_c, p_c = [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1")
x2 = batch.get("image_2")
y = batch.get("label_1")
if not (torch.is_tensor(x1) and torch.is_tensor(x2)):
continue
x1 = x1.to(device); x2 = x2.to(device)
feats = model.encode(x1, x2)
logits, _, _ = model.bridge(feats, None)
probs = torch.softmax(logits, dim=1)
y_c.append(np.array(y) if not torch.is_tensor(y) else y.cpu().numpy())
p_c.append(probs.cpu().numpy())
return (
np.concatenate(y_c, axis=0),
np.concatenate(p_c, axis=0),
)
def train_fusion_epoch(
model: FusedEnsembleHT,
model, # FusedEnsembleHT | LogitMLPEnsembleHT | EmbeddingMLPEnsembleHT
loader: DataLoader,
opt,
device: torch.device,
) -> tuple[float, float]:
"""Train only the fusion head; the base SingleEyeHT is frozen in eval mode."""
model.base.eval()
model.eye_scorer.train()
model.head.train()
total_loss = total_correct = total_n = 0
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
+2
View File
@@ -31,6 +31,8 @@ def head_names_for_mode(tower_mode: str, *, fused_head: bool = False) -> list[st
return names + ["bilat_fused"] if fused_head else names
if tower_mode == "bilateral":
return ["fused", "img_joint", "md_joint"]
if tower_mode == "siamese":
return ["fused"]
raise ValueError(f"Unknown tower_mode: {tower_mode!r}")
+521
View File
@@ -0,0 +1,521 @@
"""Segmentation-map CNN for glaucoma grading.
Trains a CNN on combined disc/cup segmentation maps — pixel values
0 = background, 1 = disc (rim only), 2 = cup
— instead of raw RGB fundus images, forcing the model to learn purely
from optic nerve head geometry (CDR, rim width, cup location, etc.).
Two segmentation sources are supported:
gt rasterise expert contour/mask annotations directly (pure NumPy/PIL,
no CUDA — safe in DataLoader worker processes)
unet run a trained UNetSegmenter on the raw fundus image
Usage (import from training script):
from v3.classes.seg_cnn import SegMapRecord, SegMapDataset, SegCNN, seg_map_to_tensor
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import torch
import torch.nn as nn
from PIL import Image, ImageDraw
from PIL.Image import Resampling
from torch.utils.data import Dataset
from torchvision import models, transforms
from tqdm import tqdm
# ---------------------------------------------------------------------------
# Data record
# ---------------------------------------------------------------------------
@dataclass
class SegMapRecord:
"""One labelled eye sample for the seg-map CNN."""
sample_id: str
image_path: Path # original fundus image (used by unet mode)
annotation_disc: Path # contour (.txt) or mask (.bmp/.png)
annotation_cup: Path
annotation_type_disc: str # "contour" or "mask"
annotation_type_cup: str
patient_id: int # for group-CV: keep both eyes of a patient together
eye: str # "OD" or "OS"
label: int # 0 = Normal, 1 = Glaucoma
# ---------------------------------------------------------------------------
# Seg-map utilities
# ---------------------------------------------------------------------------
def _combine_masks(disc_mask: np.ndarray, cup_mask: np.ndarray) -> np.ndarray:
"""
Combine binary disc and cup masks into a 3-class label map.
Returns a uint8 array with values:
0 — background
1 — optic disc rim (disc but not cup)
2 — optic cup
"""
disc = (disc_mask > 0).astype(np.uint8)
cup = (cup_mask > 0).astype(np.uint8)
cup = (cup & disc) # structural prior: cup must be inside disc
seg = disc + cup # 0, 1 (rim), or 2 (cup)
return seg.astype(np.uint8)
def crop_to_disc(seg_map: np.ndarray) -> np.ndarray:
"""
Crop a seg map tightly to the disc bounding box.
The disc is anywhere seg_map > 0 (i.e. rim or cup).
Returns the original array unchanged if no disc is found.
"""
rows = np.any(seg_map > 0, axis=1)
cols = np.any(seg_map > 0, axis=0)
if not rows.any():
return seg_map
r0, r1 = int(np.argmax(rows)), int(len(rows) - 1 - np.argmax(rows[::-1]))
c0, c1 = int(np.argmax(cols)), int(len(cols) - 1 - np.argmax(cols[::-1]))
return seg_map[r0:r1 + 1, c0:c1 + 1]
def seg_map_to_tensor(
seg_map: np.ndarray,
channels: int,
target_size: int,
) -> torch.Tensor:
"""
Convert an (H, W) seg map with values {0, 1, 2} to a float tensor.
channels=1 → (1, H, W) float in [0, 1] (values 0/0.5/1.0)
channels=3 → (3, H, W) one-hot binary channels [bg, disc_rim, cup]
"""
pil = Image.fromarray(seg_map.astype(np.uint8), mode="L")
pil = pil.resize((target_size, target_size), Image.NEAREST)
seg = np.array(pil, dtype=np.uint8)
if channels == 1:
arr = seg.astype(np.float32) / 2.0 # {0, 0.5, 1.0}
return torch.from_numpy(arr).unsqueeze(0)
if channels == 3:
bg = (seg == 0).astype(np.float32)
disc_rim = (seg == 1).astype(np.float32)
cup = (seg == 2).astype(np.float32)
return torch.from_numpy(np.stack([bg, disc_rim, cup], axis=0))
raise ValueError(f"channels must be 1 or 3, got {channels}")
# ---------------------------------------------------------------------------
# GT mask loading (pure NumPy / PIL — no CUDA, safe in DataLoader workers)
# ---------------------------------------------------------------------------
def _load_contour(path: Path) -> np.ndarray:
"""Load x,y contour pairs from a whitespace- or comma-delimited text file."""
for delimiter in (",", None):
try:
arr = np.loadtxt(str(path), delimiter=delimiter, comments="#", dtype=np.float32)
if arr.size > 0:
break
except Exception:
arr = np.zeros((0, 2), dtype=np.float32)
if arr.size == 0 or arr.ndim == 1:
return np.zeros((0, 2), dtype=np.float32)
if arr.shape[1] < 2:
return np.zeros((0, 2), dtype=np.float32)
return arr[:, :2]
def _contour_to_mask(
coords: np.ndarray, image_size: Tuple[int, int], target_size: int
) -> np.ndarray:
"""
Rasterise a polygon defined by (x, y) coords into a binary mask.
image_size is the (width, height) of the original fundus image — the
coordinate space the contour was annotated in. The mask is drawn at
that resolution then resized to target_size, matching UNetSegmenter's
behaviour and avoiding off-canvas clipping.
"""
if coords is None or len(coords) < 3:
return np.zeros((target_size, target_size), dtype=np.uint8)
points = [tuple(map(float, pt)) for pt in coords]
img = Image.new("L", image_size, 0)
ImageDraw.Draw(img).polygon(points, outline=1, fill=1)
img = img.resize((target_size, target_size), Resampling.NEAREST)
return (np.array(img, dtype=np.uint8) > 0).astype(np.uint8)
def _extract_masks_from_image(
mask_path: Path, target_size: int
) -> Tuple[np.ndarray, np.ndarray]:
"""
Extract disc and cup binary masks from a segmentation image file.
Handles both grayscale label images (e.g. REFUGE .bmp) and
RGB colour-coded masks. Returns (disc_mask, cup_mask) both at
target_size × target_size.
"""
raw = Image.open(mask_path)
arr = np.array(raw)
if arr.ndim == 2:
# Grayscale: identify background from edge statistics
edges = np.concatenate([arr[0], arr[-1], arr[:, 0], arr[:, -1]])
bg_val = int(np.argmax(np.bincount(edges.astype(np.int64).clip(0, 255), minlength=256)))
disc_arr = (arr != bg_val).astype(np.uint8)
vals = np.unique(arr)
non_bg = vals[vals != bg_val]
cup_arr: np.ndarray
if non_bg.size > 1:
cup_val = int(non_bg.min())
cup_arr = (arr == cup_val).astype(np.uint8)
else:
cup_arr = np.zeros_like(disc_arr, dtype=np.uint8)
else:
img_rgb = raw.convert("RGB")
arr = np.array(img_rgb)
h, w, c = arr.shape
edges_rgb = np.concatenate(
[arr[0], arr[-1], arr[:, 0], arr[:, -1]], axis=0
)
edge_colors, edge_counts = np.unique(edges_rgb.reshape(-1, c), axis=0, return_counts=True)
bg_color = edge_colors[int(np.argmax(edge_counts))]
colors, counts = np.unique(arr.reshape(-1, c), axis=0, return_counts=True)
not_bg = np.any(colors != bg_color.reshape(1, -1), axis=1)
colors, counts = colors[not_bg], counts[not_bg]
disc_arr = np.zeros((h, w), dtype=np.uint8)
cup_arr = np.zeros((h, w), dtype=np.uint8)
if colors.shape[0] >= 1:
order = np.argsort(-counts)
disc_color = colors[order[0]]
disc_arr[np.all(arr == disc_color, axis=-1)] = 1
if colors.shape[0] >= 2:
cup_color = colors[order[1]]
cup_arr[np.all(arr == cup_color, axis=-1)] = 1
# Resize to target_size with nearest-neighbour to preserve binary values
def _resize(m: np.ndarray) -> np.ndarray:
pil = Image.fromarray((m > 0).astype(np.uint8) * 255)
pil = pil.resize((target_size, target_size), Resampling.NEAREST)
return (np.array(pil) > 0).astype(np.uint8)
return _resize(disc_arr), _resize(cup_arr)
def load_gt_masks(rec: "SegMapRecord", target_size: int) -> Tuple[np.ndarray, np.ndarray]:
"""
Load GT disc + cup masks for one record.
Handles annotation_type "contour" (x,y text file) and "mask" (image file).
Returns (disc_mask, cup_mask) as uint8 arrays of shape (target_size, target_size).
"""
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
# Get original image size so contour coordinates are drawn in the right space
with Image.open(rec.image_path) as _img:
image_size = _img.size # (width, height)
# ---- Disc ----
if rec.annotation_type_disc == "mask":
disc_mask, cup_from_disc = _extract_masks_from_image(rec.annotation_disc, target_size)
if cup_from_disc.any():
cup_mask = cup_from_disc
else: # contour
coords = _load_contour(rec.annotation_disc)
disc_mask = _contour_to_mask(coords, image_size, target_size)
# ---- Cup ----
if cup_mask is None:
if rec.annotation_type_cup == "mask":
_, cup_from_cup = _extract_masks_from_image(rec.annotation_cup, target_size)
cup_mask = cup_from_cup
else: # contour
coords = _load_contour(rec.annotation_cup)
cup_mask = _contour_to_mask(coords, image_size, target_size)
if disc_mask is None:
disc_mask = np.zeros((target_size, target_size), dtype=np.uint8)
if cup_mask is None:
cup_mask = np.zeros((target_size, target_size), dtype=np.uint8)
# Structural prior: cup must lie within disc
cup_mask = (cup_mask > 0) & (disc_mask > 0)
return disc_mask.astype(np.uint8), cup_mask.astype(np.uint8)
# ---------------------------------------------------------------------------
# U-Net fine-tuning dataset
# ---------------------------------------------------------------------------
class UNetFineTuneDataset(Dataset):
"""
Loads (image_tensor, mask_tensor) pairs for fine-tuning the U-Net on
PAPILA GT annotations. Uses the same preprocessing as UNetSegmenter
so the fine-tuned weights are compatible with inference.
"""
def __init__(
self,
records: List[SegMapRecord],
target_size: int = 512,
normalize: str = "per_image",
) -> None:
self.records = records
self.target_size = target_size
self.normalize = normalize
self.to_tensor = transforms.ToTensor()
def __len__(self) -> int:
return len(self.records)
def _normalize(self, tensor: torch.Tensor) -> torch.Tensor:
if self.normalize == "per_image":
mean = tensor.mean(dim=(1, 2), keepdim=True)
std = tensor.std(dim=(1, 2), keepdim=True).clamp(min=1e-6)
return (tensor - mean) / std
if self.normalize == "imagenet":
mean = torch.tensor([0.485, 0.456, 0.406]).view(-1, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225]).view(-1, 1, 1)
return (tensor - mean) / std
return tensor
def __getitem__(self, idx: int):
rec = self.records[idx]
image = Image.open(rec.image_path).convert("RGB")
image = image.resize((self.target_size, self.target_size), Resampling.BILINEAR)
img_tensor = self._normalize(self.to_tensor(image))
disc_mask, cup_mask = load_gt_masks(rec, self.target_size)
mask_tensor = torch.from_numpy(
np.stack([disc_mask, cup_mask], axis=0).astype(np.float32)
)
return img_tensor, mask_tensor
# ---------------------------------------------------------------------------
# U-Net precomputation (run once per full record list, not per fold)
# ---------------------------------------------------------------------------
def precompute_unet_seg_maps(
records: List["SegMapRecord"],
segmenter,
threshold: float = 0.5,
) -> List[np.ndarray]:
"""
Run the U-Net on every record and return a list of combined seg maps.
Call this once before the CV loop and pass the results to each fold's
SegMapDataset via precomputed_seg_maps, so the U-Net isn't re-run per fold.
"""
to_tensor = transforms.ToTensor()
seg_maps = []
for rec in tqdm(records, desc="U-Net inference", unit="img", leave=False):
image = Image.open(rec.image_path).convert("RGB")
resized = segmenter.preprocess_image(image)
tensor = segmenter._normalize_tensor(
to_tensor(resized).to(segmenter.device)
).unsqueeze(0)
with torch.no_grad():
logits = segmenter.model(tensor)
probs = torch.sigmoid(logits)[0].cpu().numpy()
disc = (probs[0] > threshold).astype(np.uint8)
cup = (probs[1] > threshold).astype(np.uint8)
cup = (cup & disc)
seg_maps.append(_combine_masks(disc, cup.astype(np.uint8)))
return seg_maps
# ---------------------------------------------------------------------------
# Dataset
# ---------------------------------------------------------------------------
class SegMapDataset(Dataset):
"""
PyTorch Dataset that yields (seg_tensor, label) pairs.
Parameters
----------
records : list of SegMapRecord
target_size : CNN input spatial size (images are resized to this)
channels : 1 = single-channel label map; 3 = one-hot three channels
augment : apply random flips + rotation (for training set)
unet_segmenter : if provided, use U-Net predictions instead of GT masks;
must be a loaded UNetSegmenter with model weights set
unet_threshold : threshold for U-Net logit → binary mask
seg_target_size: resolution at which GT masks are rasterised (or U-Net
output size). Default 512 matches UNetSegmenter default.
crop_to_disc : crop the seg map tightly to the disc bounding box before
resizing to target_size (default True — eliminates the
background zeros that make up most of the full image)
"""
def __init__(
self,
records: List[SegMapRecord],
target_size: int = 224,
channels: int = 3,
augment: bool = False,
unet_segmenter=None,
unet_threshold: float = 0.5,
seg_target_size: int = 512,
crop_to_disc: bool = True,
precomputed_seg_maps: Optional[List[np.ndarray]] = None,
) -> None:
self.records = records
self.target_size = target_size
self.channels = channels
self.augment = augment
self.seg_target_size = seg_target_size
self.crop_to_disc = crop_to_disc
if precomputed_seg_maps is not None:
self._seg_maps = precomputed_seg_maps
elif unet_segmenter is not None:
self._seg_maps = precompute_unet_seg_maps(
records, unet_segmenter, unet_threshold
)
else:
self._seg_maps = None
# ------------------------------------------------------------------
def __len__(self) -> int:
return len(self.records)
# ------------------------------------------------------------------
def _augment(self, seg_map: np.ndarray) -> np.ndarray:
"""Random flips + 90° rotations (label-safe since NEAREST resize)."""
if np.random.rand() < 0.5:
seg_map = np.fliplr(seg_map)
if np.random.rand() < 0.5:
seg_map = np.flipud(seg_map)
k = np.random.randint(0, 4)
if k:
seg_map = np.rot90(seg_map, k=k)
return np.ascontiguousarray(seg_map)
# ------------------------------------------------------------------
def __getitem__(self, idx: int):
rec = self.records[idx]
if self._seg_maps is not None:
seg_map = self._seg_maps[idx]
else:
disc_mask, cup_mask = load_gt_masks(rec, self.seg_target_size)
seg_map = _combine_masks(disc_mask, cup_mask)
if self.crop_to_disc:
seg_map = crop_to_disc(seg_map)
if self.augment:
seg_map = self._augment(seg_map)
tensor = seg_map_to_tensor(seg_map, self.channels, self.target_size)
return tensor, rec.label
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class SegCNN(nn.Module):
"""
Pretrained CNN backbone adapted for segmentation-map input.
Parameters
----------
num_classes : output classes (2 for binary glaucoma grading)
backbone : "resnet18" | "resnet50" | "efficientnet_b0"
pretrained : initialise with ImageNet weights (recommended even for
non-RGB input — transfer generalises across domains)
in_channels : 1 (single label map) or 3 (one-hot channels)
dropout : dropout rate before the final classifier head
"""
def __init__(
self,
num_classes: int = 2,
backbone: str = "resnet18",
pretrained: bool = True,
in_channels: int = 3,
dropout: float = 0.3,
) -> None:
super().__init__()
weights_arg = "DEFAULT" if pretrained else None
if backbone == "resnet18":
base = models.resnet18(weights=weights_arg)
feat_dim = base.fc.in_features
base.fc = nn.Identity()
elif backbone == "resnet50":
base = models.resnet50(weights=weights_arg)
feat_dim = base.fc.in_features
base.fc = nn.Identity()
elif backbone == "efficientnet_b0":
base = models.efficientnet_b0(weights=weights_arg)
feat_dim = base.classifier[1].in_features
base.classifier = nn.Identity()
else:
raise ValueError(f"Unknown backbone: {backbone!r}")
# Adapt first conv layer if in_channels ≠ 3
if in_channels != 3:
first_conv = self._find_first_conv(base)
new_conv = nn.Conv2d(
in_channels,
first_conv.out_channels,
kernel_size=first_conv.kernel_size,
stride=first_conv.stride,
padding=first_conv.padding,
bias=first_conv.bias is not None,
)
if pretrained:
# Average pretrained RGB weights across channel dim
with torch.no_grad():
new_conv.weight.copy_(
first_conv.weight.mean(dim=1, keepdim=True).expand_as(new_conv.weight)
)
self._replace_first_conv(base, new_conv)
self.backbone = base
self.head = nn.Sequential(
nn.Dropout(p=dropout),
nn.Linear(feat_dim, num_classes),
)
# ------------------------------------------------------------------
@staticmethod
def _find_first_conv(module: nn.Module) -> nn.Conv2d:
for m in module.modules():
if isinstance(m, nn.Conv2d):
return m
raise RuntimeError("No Conv2d found in backbone")
@staticmethod
def _replace_first_conv(module: nn.Module, new_conv: nn.Conv2d) -> None:
"""Replace the first Conv2d in-place (handles resnet and efficientnet)."""
for name, child in module.named_children():
if isinstance(child, nn.Conv2d):
setattr(module, name, new_conv)
return
try:
SegCNN._replace_first_conv(child, new_conv)
return
except RuntimeError:
pass
raise RuntimeError("Could not replace first Conv2d")
# ------------------------------------------------------------------
def forward(self, x: torch.Tensor) -> torch.Tensor:
feats = self.backbone(x)
if feats.dim() > 2:
feats = feats.flatten(1)
return self.head(feats)
+59
View File
@@ -83,6 +83,65 @@ def build_patient_split_plans(
return plans
class EyeLevelSplitManager:
"""
Eye-level (leaky) splitter — splits on individual eye rows, ignoring
patient grouping. Same patient's eyes can appear in different folds.
Used to demonstrate the effect of data leakage.
"""
def __init__(
self,
*,
patient_col: str = "Patient ID",
label_col: Optional[str] = None,
) -> None:
self.patient_col = patient_col
self.label_col = label_col
def build_plans(
self,
*,
clinical: Any,
args: Any,
profile: Optional[Any] = None,
) -> list[PatientSplit]:
profile_label_col = getattr(profile, "label_col", None) if profile is not None else None
label_col = self.label_col or profile_label_col or getattr(clinical, "label_col", None)
if label_col is None:
raise ValueError("Could not resolve label column")
if not hasattr(clinical, "df"):
raise ValueError("Clinical object must expose a dataframe at .df")
df_full = clinical.df.copy().reset_index(drop=True)
eval_mode = str(getattr(args, "eval_mode", "multiclass")).lower()
if eval_mode == "binary":
df_full = df_full[df_full[label_col].isin([0, 1])].reset_index(drop=True)
n_splits = int(getattr(args, "n_splits", 5))
fold_seed = int(getattr(args, "fold_seed", 42))
labels = df_full[label_col].to_numpy()
eye_ids = df_full.index.to_numpy()
# Reuse build_patient_split_plans with eye-row IDs as the "patients"
plans = build_patient_split_plans(
patient_ids=eye_ids,
patient_labels=labels,
n_splits=n_splits,
seed=fold_seed,
)
out: list[PatientSplit] = []
for plan in plans:
train_df = df_full[df_full.index.isin(plan.train_patient_ids)].reset_index(drop=True)
val_df = df_full[df_full.index.isin(plan.val_patient_ids)].reset_index(drop=True)
test_df = df_full[df_full.index.isin(plan.test_patient_ids)].reset_index(drop=True)
out.append(PatientSplit(train=train_df, val=val_df, test=test_df))
return out
class PatientFirstSplitManager:
"""Patient-level splitter for V3. Outer/inner k-fold, no holdout."""
+223 -51
View File
@@ -23,7 +23,11 @@ from typing import Optional
import numpy as np
import torch
from v3.classes.croppers import build_image_preprocessor_from_args
from v3.classes.croppers import (
ManifestImageCropper,
UNetImageCropper,
build_image_preprocessor_from_args,
)
from v3.classes.image_loader import CachedImageLoader
from v3.classes.dataset import _ClinicalView # noqa: F401
from v3.classes.loader_factory import (
@@ -35,10 +39,14 @@ from v3.classes.loader_factory import (
from v3.classes.metrics import _score_arrays, _svf, _tune_and_snap
from v3.classes.models import (
BilateralHT,
EmbeddingMLPEnsembleHT,
FusedEnsembleHT,
LogitMLPEnsembleHT,
SiameseHT,
SingleEyeHT,
V2ModeComparisonOps,
collect_probs_bilateral,
collect_probs_siamese,
collect_probs_bilateral_components,
collect_probs_classic,
collect_probs_ensemble,
@@ -47,6 +55,7 @@ from v3.classes.models import (
collect_probs_fused,
collect_probs_single_components,
train_bilateral_epoch,
train_siamese_epoch,
train_fusion_epoch,
train_single_epoch,
)
@@ -54,7 +63,7 @@ from v3.classes.papila_builders import build_papila_data
from v3.classes.predictions import PredictionStore, head_names_for_mode
from v3.classes.profiles import build_papila_profile
from v3.classes.results import FoldArtifacts, FoldResult, _f, _nan, _sv
from v3.classes.split_manager import PatientFirstSplitManager
from v3.classes.split_manager import EyeLevelSplitManager, PatientFirstSplitManager
from v3.classes.transforms import build_eval_transform
from v3.classes.utils import (
_drop_mixed_label_patients,
@@ -145,11 +154,14 @@ class V3HyperTower:
ap.add_argument("--exclude-cols", nargs="*", default=[])
ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="binary")
ap.add_argument(
"--tower-mode", choices=["single", "ensemble", "bilateral", "classic"],
"--tower-mode", choices=["single", "ensemble", "bilateral", "siamese", "classic"],
default="ensemble",
)
ap.add_argument("--n-splits", type=int, default=5)
ap.add_argument("--fold-seed", type=int, default=42)
ap.add_argument("--leaky-cv", action="store_true",
help="Split at eye level (leaky: same patient can span folds). "
"Used to demonstrate data-leakage effect.")
ap.add_argument(
"--folds", type=int, default=None,
help="Optional cap on number of folds to run.",
@@ -157,9 +169,9 @@ class V3HyperTower:
ap.add_argument("--epochs", type=int, default=40)
ap.add_argument("--warmup-tower-epochs", type=int, default=None)
ap.add_argument("--warmup-fused-epochs", type=int, default=None)
ap.add_argument("--single-warmup-tower-epochs", type=int, default=None)
ap.add_argument("--single-warmup-fused-epochs", type=int, default=None)
ap.add_argument("--warmup-cd-epochs", type=int, default=0)
ap.add_argument("--single-warmup-tower-epochs", type=int, default=3)
ap.add_argument("--single-warmup-fused-epochs", type=int, default=3)
ap.add_argument("--warmup-cd-epochs", type=int, default=40)
ap.add_argument("--bilat-warmup-tower-epochs", type=int, default=None)
ap.add_argument("--bilat-warmup-fused-epochs", type=int, default=None)
ap.add_argument("--batch-size", type=int, default=8)
@@ -170,7 +182,7 @@ class V3HyperTower:
ap.add_argument("--freeze-ratio", type=float, default=0.0)
ap.add_argument("--augment", action="store_true")
ap.add_argument("--balanced-sampling", action="store_true")
ap.add_argument("--num-workers", type=int, default=4)
ap.add_argument("--num-workers", type=int, default=8)
ap.add_argument("--in-memory-cache", action="store_true", default=True)
ap.add_argument("--no-in-memory-cache", action="store_false", dest="in_memory_cache")
ap.add_argument("--cache-workers", type=int, default=4)
@@ -191,10 +203,20 @@ class V3HyperTower:
ap.add_argument("--img-crop-cache", type=str, default="cache_data/hypertower_crops")
ap.add_argument("--persist-img-crop-cache", action="store_true")
# Architecture
ap.add_argument("--cd-hidden-dim", type=int, default=128)
ap.add_argument("--fusion-dim", type=int, default=256)
ap.add_argument("--bridge-mode", default="fused",
ap.add_argument("--cd-hidden-dim", type=int, default=128)
ap.add_argument("--fusion-dim", type=int, default=256)
ap.add_argument("--bridge-mode", default="fused",
choices=["fused", "image_only", "clinical_only"])
ap.add_argument("--bridge-dropout", type=float, default=0.5,
help="Dropout in bridge classifier_fused (default: 0.5)")
ap.add_argument("--cd-dropout", type=float, default=0.1,
help="Dropout in clinical tower MLP (default: 0.1)")
ap.add_argument("--se-img-tower", action="store_true",
help="Enable SE gate on image tower output features")
ap.add_argument("--se-cd-tower", action="store_true",
help="Enable SE gate on clinical tower output features")
ap.add_argument("--se-bridge", action="store_true",
help="Enable SE gate on fused vector inside the bridge")
# Mixed patients
ap.add_argument("--exclude-mixed-patients", dest="exclude_mixed_patients",
action="store_true")
@@ -217,6 +239,19 @@ class V3HyperTower:
# Fused head
ap.add_argument("--fused-head", action="store_true")
ap.add_argument("--fusion-epochs", type=int, default=10)
ap.add_argument("--head-type",
choices=["attention", "logit_mlp", "embedding_mlp"],
default="attention",
help="Which bilateral head to train on top of frozen ensemble base")
ap.add_argument("--save-checkpoints", action="store_true",
help="Save best_single.pt per fold for explainability / GradCAM")
# Geometry features
ap.add_argument("--geometry-dim", type=int, default=0,
help="Append N geometry features to clinical metadata (0=disabled, 5=all). "
"Requires --img-crop-manifest.")
ap.add_argument("--geometry-source", default="gt", choices=["gt", "unet"],
help="Source for geometry features: gt (GT contour annotations) or "
"unet (U-Net segmentation). unet also requires --img-crop-weights.")
return ap
def __init__(self, args) -> None:
@@ -246,6 +281,49 @@ class V3HyperTower:
patient_col="Patient ID", label_col=args.label_col, sample_mode="patient"
)
# Build geometry provider if requested, extend feature_dim to include geometry.
# Both ManifestImageCropper and UNetImageCropper already have geometry_features()
# and precompute_geometry() — we just pick the right one and pre-compute upfront.
self.geometry_provider = None
geom_dim = int(getattr(args, "geometry_dim", 0))
if geom_dim > 0:
source = getattr(args, "geometry_source", "gt")
manifest = getattr(args, "img_crop_manifest", None)
if not manifest:
raise ValueError("--geometry-dim requires --img-crop-manifest")
all_paths = [
self.data.get_image_path(row)
for _, row in self.data.df.iterrows()
]
if source == "gt":
# Reuse image_preprocessor if it's already a ManifestImageCropper,
# otherwise build a lightweight one just for geometry (no crop cache).
if isinstance(self.image_preprocessor, ManifestImageCropper):
provider = self.image_preprocessor
else:
provider = ManifestImageCropper(manifest_path=Path(manifest))
print(f"[geometry] GT source — pre-computing geometry from {manifest}", flush=True)
elif source == "unet":
weights = getattr(args, "img_crop_weights", None)
if not weights:
raise ValueError("--geometry-source unet requires --img-crop-weights")
if isinstance(self.image_preprocessor, UNetImageCropper):
provider = self.image_preprocessor
else:
provider = UNetImageCropper(
manifest_path=Path(manifest),
weights_path=Path(weights),
normalize=getattr(args, "img_crop_normalize", "per_image"),
threshold=getattr(args, "img_crop_threshold", 0.5),
)
print(f"[geometry] UNet source — pre-computing geometry from {weights}", flush=True)
else:
raise ValueError(f"Unknown --geometry-source: {source!r}")
provider.precompute_geometry(all_paths)
self.geometry_provider = provider
self.data.feature_dim += geom_dim
print(f"[geometry] feature_dim extended to {self.data.feature_dim} (+{geom_dim} geometry)", flush=True)
def run(self) -> Path:
"""Execute the full fold loop."""
args = self.args
@@ -276,7 +354,11 @@ class V3HyperTower:
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)} patients={df_mode['Patient ID'].nunique()}", flush=True)
split_manager = PatientFirstSplitManager(patient_col="Patient ID", label_col=args.label_col)
if getattr(args, "leaky_cv", False):
split_manager = EyeLevelSplitManager(patient_col="Patient ID", label_col=args.label_col)
print("[CV] WARNING: leaky-cv mode — eye-level splits, same patient can span folds.", flush=True)
else:
split_manager = PatientFirstSplitManager(patient_col="Patient ID", label_col=args.label_col)
split_args = SimpleNamespace(
eval_mode=mode,
n_splits=args.n_splits,
@@ -390,7 +472,7 @@ class V3HyperTower:
_test_key = "classic_test"
elif tower_mode == "ensemble":
_test_key = "ensemble_test"
elif tower_mode == "bilateral":
elif tower_mode in ("bilateral", "siamese"):
_test_key = "bilat_test"
else:
_test_key = "classic_test"
@@ -400,6 +482,25 @@ class V3HyperTower:
return out_dir
def _augment_geometry(self, samples: list) -> list:
"""Append geometry features to matrix_1/matrix_2 in each sample dict."""
if self.geometry_provider is None:
return samples
geom_dim = int(getattr(self.args, "geometry_dim", 0))
for s in samples:
for img_slot, mat_slot in (("image_1", "matrix_1"), ("image_2", "matrix_2")):
img_path = s.get(img_slot)
mat = s.get(mat_slot)
if img_path is None or mat is None:
continue
vec = self.geometry_provider.geometry_for_image(img_path)
if vec is not None and len(vec) >= geom_dim:
geom = vec[:geom_dim].astype(np.float32)
else:
geom = np.zeros(geom_dim, dtype=np.float32)
s[mat_slot] = np.concatenate([np.asarray(mat, dtype=np.float32), geom])
return samples
def _run_fold(
self,
*,
@@ -420,9 +521,10 @@ class V3HyperTower:
image_preprocessor = self.image_preprocessor
nan = float("nan")
run_single = tower_mode in ("single", "ensemble")
run_bilat = tower_mode == "bilateral"
run_fused = tower_mode == "ensemble" and getattr(args, "fused_head", False)
run_single = tower_mode in ("single", "ensemble")
run_bilat = tower_mode == "bilateral"
run_siamese = tower_mode == "siamese"
run_fused = tower_mode == "ensemble" and getattr(args, "fused_head", False)
# ---- warmup schedule -------------------------------------------
global_warmup_tower = getattr(args, "warmup_tower_epochs", None)
@@ -442,7 +544,7 @@ class V3HyperTower:
single_warmup_cd = int(getattr(args, "warmup_cd_epochs", 0)) if run_single else 0
if not run_single:
single_warmup_tower = single_warmup_fused = 0
if not run_bilat:
if not run_bilat and not run_siamese:
bilat_warmup_tower = bilat_warmup_fused = 0
# Warmup is meaningless in single-pathway modes — skip it entirely
_bridge_mode = getattr(args, "bridge_mode", "fused")
@@ -451,7 +553,7 @@ class V3HyperTower:
bilat_warmup_tower = bilat_warmup_fused = 0
main_epochs = int(args.epochs)
total_single_epochs = (single_warmup_cd + 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_bilat_epochs = (bilat_warmup_tower + bilat_warmup_fused + main_epochs) if (run_bilat or run_siamese) else 0
total_epochs = max(total_single_epochs, total_bilat_epochs)
# ---- samples ---------------------------------------------------
@@ -460,6 +562,12 @@ class V3HyperTower:
bilat_val = filter_bilateral_samples(profile_patient.build_samples(df=split.val, clinical=data))
bilat_test = filter_bilateral_samples(profile_patient.build_samples(df=split.test, clinical=data)) if split.test is not None else []
if self.geometry_provider is not None:
eye_train = self._augment_geometry(eye_train)
bilat_train = self._augment_geometry(bilat_train)
bilat_val = self._augment_geometry(bilat_val)
bilat_test = self._augment_geometry(bilat_test)
if pred_store is not None:
if tower_mode in ("single", "classic"):
train_sids = [f"{s['id_1']}{s.get('eye_id_1','')}" for s in eye_train]
@@ -502,27 +610,41 @@ class V3HyperTower:
y_true_bilat=None, probs_bilat=None,
)
# ---- models ----------------------------------------------------
# ---- models (CPU for now — moved to device after workers spawn) ---
single = None
bilateral = None
siamese = None
if run_single:
single = SingleEyeHT(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
augment=args.augment, clinical_data=data, num_classes=num_classes,
cd_hidden_dim=args.cd_hidden_dim, fusion_dim=args.fusion_dim,
bridge_mode=getattr(args, "bridge_mode", "fused"),
).to(device)
bridge_dropout=getattr(args, "bridge_dropout", 0.5),
cd_dropout=getattr(args, "cd_dropout", 0.1),
se_img_tower=getattr(args, "se_img_tower", False),
se_cd_tower=getattr(args, "se_cd_tower", False),
se_bridge=getattr(args, "se_bridge", False),
)
if run_bilat:
bilateral = BilateralHT(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
augment=args.augment, clinical_data=data, num_classes=num_classes,
cd_hidden_dim=args.cd_hidden_dim, fusion_dim=args.fusion_dim,
).to(device)
)
if run_siamese:
siamese = SiameseHT(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
augment=args.augment, num_classes=num_classes,
fusion_dim=args.fusion_dim,
)
slots_eye = profile_eye.slot_descriptors()
slots_patient = profile_patient.slot_descriptors()
_persistent_workers = args.num_workers > 0
loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers,
image_cache=image_cache)
image_cache=image_cache,
persistent_workers=_persistent_workers)
# ---- loaders ---------------------------------------------------
use_balanced = bool(getattr(args, "balanced_sampling", False))
@@ -553,6 +675,13 @@ class V3HyperTower:
image_preprocessor=image_preprocessor, shuffle=True,
sampler=bilat_sampler, **loader_kw,
)
elif run_siamese:
siamese_sampler = build_balanced_sampler(bilat_train) if use_balanced else None
train_bilat_loader = make_loader(
bilat_train, slots_patient, image_transform=siamese.transform,
image_preprocessor=image_preprocessor, shuffle=True,
sampler=siamese_sampler, **loader_kw,
)
elif run_fused:
fused_sampler = build_balanced_sampler(bilat_train) if use_balanced else None
train_bilat_loader = make_loader(
@@ -581,8 +710,26 @@ class V3HyperTower:
if _ldr is not None:
_ldr.dataset.prebuild_image_cache()
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
# ---- spawn DataLoader workers BEFORE CUDA init -----------------
# Workers fork here (clean process state, no CUDA context yet).
# persistent_workers=True keeps them alive so the training loop
# reuses them rather than re-forking after .to(device).
if _persistent_workers:
for _ldr in [train_single_loader, train_bilat_loader, val_loader, test_loader]:
if _ldr is not None:
_ = iter(_ldr) # triggers fork now, before CUDA
# ---- move models to device (CUDA init happens here) ------------
if single is not None:
single = single.to(device)
if bilateral is not None:
bilateral = bilateral.to(device)
if siamese is not None:
siamese = siamese.to(device)
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
opt_siamese = torch.optim.Adam(siamese.parameters(), lr=args.lr) if run_siamese else None
# ---- epoch log -------------------------------------------------
epoch_fields = [
@@ -679,7 +826,7 @@ class V3HyperTower:
else:
phase_single, main_epoch_single, single_active = "done", main_epochs, False
if not run_bilat:
if not run_bilat and not run_siamese:
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
@@ -709,6 +856,12 @@ class V3HyperTower:
phase=phase_bilat, bcd_prob=float(args.bcd_prob),
tower_loss_mode=args.tower_loss_mode,
)
elif run_siamese and bilat_active:
bl_loss, bl_acc = train_siamese_epoch(
siamese, train_bilat_loader, opt_siamese, device,
bcd_prob=float(args.bcd_prob),
tower_loss_mode=args.tower_loss_mode,
)
else:
bl_loss, bl_acc = nan, nan
@@ -763,6 +916,10 @@ class V3HyperTower:
bi_acc_cd = float((p_bi_cd.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_cd, _ = _score_arrays(y_bi, p_bi_cd, num_classes)
elif run_siamese and not _skip_val_eval:
y_bi, p_bi = collect_probs_siamese(siamese, val_loader, device)
bi_acc, bi_auc, bi_n = _score_arrays(y_bi, p_bi, num_classes)
bi_acc_img = bi_acc_cd = bi_auc_img = bi_auc_cd = nan
else:
y_bi = np.array([], dtype=np.int64)
p_bi = np.zeros((0, 0), dtype=np.float32)
@@ -905,7 +1062,7 @@ class V3HyperTower:
_bar_w = 30
_filled = int(_bar_w * (epoch + 1) / single_warmup_cd)
_bar = "#" * _filled + "-" * (_bar_w - _filled)
msg = f" [fold {fold+1}] md_warmup [{_bar}] {epoch+1}/{single_warmup_cd} loss={sl_loss:.4f}"
msg = f" [fold {fold+1}] md_warmup [{_bar}] {epoch+1}/{single_warmup_cd} loss={sl_loss:.2f}"
print(f"\r{msg}", end="", flush=True)
fold_logger.info(msg)
_prev_phase_single = phase_single
@@ -917,13 +1074,13 @@ class V3HyperTower:
if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
_epoch_secs = time.time() - _epoch_t0
if tower_mode == "ensemble":
msg = f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) auc={en_auc:.4f} acc={en_acc:.4f}"
elif tower_mode == "bilateral":
msg = f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) auc={bi_auc:.4f} acc={bi_acc:.4f}"
_auc_v, _acc_v = en_auc, en_acc
elif tower_mode in ("bilateral", "siamese"):
_auc_v, _acc_v = bi_auc, bi_acc
else:
msg = f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) auc={cl_auc:.4f} acc={cl_acc:.4f}"
print(msg, flush=True)
fold_logger.info(msg)
_auc_v, _acc_v = cl_auc, cl_acc
print(f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) auc={_auc_v:.2f} acc={_acc_v:.2f}", flush=True)
fold_logger.info(f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) auc={_auc_v:.4f} acc={_acc_v:.4f}")
_prev_phase_single = phase_single
@@ -958,8 +1115,14 @@ class V3HyperTower:
if run_fused and single is not None:
for p in single.parameters():
p.requires_grad_(False)
fused = FusedEnsembleHT(single, num_classes).to(device)
opt_fused = torch.optim.Adam(fused.eye_scorer.parameters(), lr=args.lr)
head_type = getattr(args, "head_type", "attention")
if head_type == "logit_mlp":
fused = LogitMLPEnsembleHT(single, num_classes).to(device)
elif head_type == "embedding_mlp":
fused = EmbeddingMLPEnsembleHT(single, num_classes).to(device)
else:
fused = FusedEnsembleHT(single, num_classes).to(device)
opt_fused = torch.optim.Adam(fused.head.parameters(), lr=args.lr)
fusion_epochs = int(getattr(args, "fusion_epochs", 10))
print(
f" [fold {fold+1}] Phase 2: fusion head bilat_train_n={len(bilat_train)} epochs={fusion_epochs}",
@@ -977,8 +1140,8 @@ class V3HyperTower:
snap_fused, _, _, _ = _tune_and_snap(y_fu, p_fu, fu_acc_val, num_classes, args, args.ece_bins)
if (fep + 1) % max(1, args.log_every) == 0:
print(
f" [fold {fold+1}] fusion ep{fep+1:>3} loss={fu_loss:.4f} "
f"val_auc={fu_auc:.4f}",
f" [fold {fold+1}] fusion ep{fep+1:>3} loss={fu_loss:.2f} "
f"val_auc={fu_auc:.2f}",
flush=True,
)
# No checkpoint saving for fused head either.
@@ -1015,9 +1178,16 @@ class V3HyperTower:
p_cl_best_img = p_cl_best_md = None
if run_bilat:
y_bi_best, p_bi_best = collect_probs_bilateral(bilateral, val_loader, device)
elif run_siamese:
y_bi_best, p_bi_best = collect_probs_siamese(siamese, val_loader, device)
else:
y_bi_best = p_bi_best = None
# Optional checkpoint saving (final-epoch weights for explainability)
if getattr(args, "save_checkpoints", False) and run_single and single is not None:
import torch as _torch
_torch.save(single.state_dict(), fold_dir / "best_single.pt")
# Compute val snaps from final-epoch model state
if run_single and tower_mode == "single" and y_cl_best is not None:
snap_cl, _, _, _ = _tune_and_snap(y_cl_best, p_cl_best, float((p_cl_best.argmax(1) == y_cl_best).mean()), num_classes, args, args.ece_bins)
@@ -1025,7 +1195,7 @@ class V3HyperTower:
elif run_single and tower_mode == "ensemble" and y_en_best is not None:
snap_en, _, _, _ = _tune_and_snap(y_en_best, p_en_best, float((p_en_best.argmax(1) == y_en_best).mean()), num_classes, args, args.ece_bins)
snap_ensemble = snap_en
if run_bilat and y_bi_best is not None:
if (run_bilat or run_siamese) and y_bi_best is not None:
snap_bi, _, _, _ = _tune_and_snap(y_bi_best, p_bi_best, float((p_bi_best.argmax(1) == y_bi_best).mean()), num_classes, args, args.ece_bins)
snap_bilat = snap_bi
@@ -1050,6 +1220,8 @@ class V3HyperTower:
y_test_out, p_test_out, _, _ = collect_probs_bilateral_components(
bilateral, test_loader, device
)
elif run_siamese:
y_test_out, p_test_out = collect_probs_siamese(siamese, test_loader, device)
if y_test_out is not None and y_test_out.size:
test_acc_raw = float((p_test_out.argmax(1) == y_test_out).mean())
snap_test, _, _, _ = _tune_and_snap(
@@ -1057,11 +1229,11 @@ class V3HyperTower:
)
print(
f" [fold {fold+1}] TEST "
f"auc={snap_test.get('auc', nan):.4f} "
f"acc={snap_test.get('acc', nan):.4f} "
f"kappa={snap_test.get('kappa', nan):.4f} "
f"f1={snap_test.get('macro_f1', nan):.4f} "
f"ece={snap_test.get('ece', nan):.4f} "
f"auc={snap_test.get('auc', nan):.2f} "
f"acc={snap_test.get('acc', nan):.2f} "
f"kappa={snap_test.get('kappa', nan):.2f} "
f"f1={snap_test.get('macro_f1', nan):.2f} "
f"ece={snap_test.get('ece', nan):.2f} "
f"n={snap_test.get('n', 0)}",
flush=True,
)
@@ -1118,11 +1290,11 @@ class V3HyperTower:
classic_test_kappa=snap_test.get("kappa", nan) if tower_mode == "single" else nan,
classic_test_f1=snap_test.get("macro_f1", nan) if tower_mode == "single" else nan,
classic_test_ece=snap_test.get("ece", nan) if tower_mode == "single" else nan,
bilat_test_auc=snap_test.get("auc", nan) if tower_mode == "bilateral" else nan,
bilat_test_acc=snap_test.get("acc", nan) if tower_mode == "bilateral" else nan,
bilat_test_kappa=snap_test.get("kappa", nan) if tower_mode == "bilateral" else nan,
bilat_test_f1=snap_test.get("macro_f1", nan) if tower_mode == "bilateral" else nan,
bilat_test_ece=snap_test.get("ece", nan) if tower_mode == "bilateral" else nan,
bilat_test_auc=snap_test.get("auc", nan) if tower_mode in ("bilateral", "siamese") else nan,
bilat_test_acc=snap_test.get("acc", nan) if tower_mode in ("bilateral", "siamese") else nan,
bilat_test_kappa=snap_test.get("kappa", nan) if tower_mode in ("bilateral", "siamese") else nan,
bilat_test_f1=snap_test.get("macro_f1", nan) if tower_mode in ("bilateral", "siamese") else nan,
bilat_test_ece=snap_test.get("ece", nan) if tower_mode in ("bilateral", "siamese") else nan,
test_n=test_n,
single_train_n=len(eye_train),
bilat_train_n=len(bilat_train),
@@ -1222,18 +1394,18 @@ class V3HyperTower:
@staticmethod
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}"
return " nan " if v is None else f"{v:.2f}"
def fsd(mean, std):
if mean is None: return " nan "
if std is None: return f"{mean:.4f} "
return f"{mean:.4f}±{std:.4f}"
if mean is None: return " nan "
if std is None: return f"{mean:.2f} "
return f"{mean:.2f}±{std:.2f}"
# Resolve test key
if tower_mode in ("single", "classic"):
test_key = "classic_test"
elif tower_mode == "ensemble":
test_key = "ensemble_test"
elif tower_mode == "bilateral":
elif tower_mode in ("bilateral", "siamese"):
test_key = "bilat_test"
else:
test_key = "classic_test"