Compare commits

..

7 Commits

Author SHA1 Message Date
rpotter6298 3d7777f010 Add new experiments and analysis scripts for dropzero features
- Introduced `fused_importance_with_axial.py` to evaluate the importance of Axial_Length in the fused-head model.
- Created JSON configurations for various experiments excluding zero-importance clinical features:
  - `cd_solo_bilateral_dropzero.json`: Bilateral clinical-only evaluation.
  - `cd_solo_single_dropzero.json`: Single-eye clinical-only evaluation.
  - `ensemble_refugelike_ckpt_dropzero.json`: Ensemble model with dropped zero-importance features.
  - `ensemble_single_refugelike_dropzero.json`: Single-eye ensemble model with dropped features.
2026-08-24 12:26:16 +02:00
rpotter6298 708fbc70ce Add analysis scripts and experiment configurations for bridge attention and sensitivity studies
- Introduced `bridge_attention_ceiling_check.py` for variance decomposition analysis on bridge attention configurations.
- Added `bridge_attention_readout.py` to perform per-tower gate and contribution readouts, including AUC sanity checks.
- Created multiple JSON configuration files for backbone replication experiments, including anonymous CV variants and basic backbones.
- Implemented sensitivity experiments to evaluate the impact of axial length inclusion and EfficientNetV2-M performance at higher resolutions.
- Added a memory probe script to assess GPU memory usage during training with EfficientNetV2-M.
2026-07-03 08:51:44 +02:00
rpotter6298 3d954a4606 Implement GPU precheck and client fail streak management 2026-06-12 10:36:33 +02:00
rpotter6298 280060db82 Add new regression and ensemble experiment configurations for V2-M and OrthoBridge
- Introduced multiple regression experiment configurations targeting vf_md, including:
  - cd_solo_reg_set.json: CD tower only regression setup.
  - img_solo_reg_set.json: Image tower only regression setup.
  - reg_head_epoch_sweep.json: Baseline regression sweeps at different epochs (50, 75, 100).
  - reg_head_set.json: Various regression setups including baseline and OrthoBridge configurations.
  - single_eye_reg.json: Single-eye regression setup for worst-eye aggregation analysis.

- Added ensemble configurations for OrthoBridge with different inner bridges:
  - ortho_alts_ensemble.json: Ensemble tests with ConcatBridge, PairwiseAdditiveBridge, and GatedAdditiveBridge.
  - ortho_alts_tritower.json: Tritower tests with the same inner bridges.

- Created V2-M specific configurations:
  - baseline_reg_nt50.json: Regression baseline with V2-M backbone.
  - geom_vec_gt.json and geom_vec_unet.json: Geometry vector injection experiments with V2-M.
  - single_l1_bridges.json: Single-eye ensemble experiments with various bridge types.
  - tritower_geom_gt.json: Tritower setup with GT contour-rasterized masks.

- Promoted existing experiments to higher repetitions for robustness.
2026-06-11 15:08:20 +02:00
rpotter6298 32a801a572 update with orthobridge 2026-05-15 10:27:24 +02:00
rpotter6298 4dd2dbc734 update 2026-05-14 13:51:47 +02:00
rpotter6298 86ed453736 Add new analysis scripts and configuration files for model comparison and feature extraction 2026-05-14 13:51:41 +02:00
407 changed files with 12538 additions and 57863 deletions
+3 -1
View File
@@ -17,4 +17,6 @@ scripts/utility/backup_mirror_with_archive.sh
v3/distributed/logs/*
v4/configs/**/
v4/distributed/logs/*
v4/results/*
v4/results/*
manuscript/*
binocular_analog/*
-849
View File
@@ -1,849 +0,0 @@
"""REFUGE glaucoma classification with rotation-based TTT."""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
import random
import numpy as np
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import models, transforms
from torchvision.transforms import functional as TF
import torch.nn.functional as F
from sklearn.metrics import roc_auc_score
from skimage.transform import warp_polar
from tqdm import tqdm
from classes.geometry_features import (
FEATURE_DIM,
EPS,
compute_geometry_features,
disc_cup_from_mask_image,
)
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
from classes.refuge_segmentation import RefugeSegmentation
from classes.unet_segmenter import UNetSegmenter
# ---------------------------------------------------------------------------
# Dataset utilities
# ---------------------------------------------------------------------------
def _default_image_transform(size: int = 256) -> transforms.Compose:
return transforms.Compose(
[
transforms.Resize((size, size)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
def _augment_image_transform(size: int = 256) -> transforms.Compose:
return transforms.Compose(
[
transforms.Resize((size, size)),
transforms.RandomHorizontalFlip(),
transforms.RandomRotation(10),
transforms.ColorJitter(0.1, 0.1, 0.1, 0.05),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
def _crop_from_geometry(image: Image.Image, geometry: Dict[str, float], size: int = 256) -> Image.Image:
cx, cy = geometry["centre_x"], geometry["centre_y"]
r = geometry["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(image.width, cx + r)
lower = min(image.height, cy + r)
crop = image.crop((left, upper, right, lower))
return crop.resize((size, size), Image.BILINEAR)
def _geometry_from_mask(mask: np.ndarray, scale: float) -> Dict[str, float]:
mask = np.asarray(mask) > 0
coords = np.argwhere(mask)
if coords.size == 0:
raise RuntimeError("Empty mask; cannot derive geometry")
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * scale
return {
"centre_x": centre_x,
"centre_y": centre_y,
"radius": radius,
"crop_radius": crop_radius,
"crop_size": crop_radius * 2.0,
}
def _compute_feature_vector(disc_mask: np.ndarray, cup_mask: np.ndarray) -> np.ndarray:
return compute_geometry_features(disc_mask, cup_mask)
def _compute_polar_image(crop: Image.Image, size: int) -> Image.Image:
arr = np.asarray(crop).astype(np.float32) / 255.0
radius = min(arr.shape[0], arr.shape[1]) / 2.0
polar = warp_polar(
arr,
radius=radius,
scaling="linear",
channel_axis=-1,
)
polar = np.clip(polar, 0.0, 1.0)
polar_img = Image.fromarray((polar * 255).astype(np.uint8))
return polar_img.resize((size, size), Image.BILINEAR)
def _crop_mask_from_geometry(mask: np.ndarray, geometry: Dict[str, float], size: int) -> np.ndarray:
mask_img = Image.fromarray((mask > 0).astype(np.uint8) * 255)
cx, cy = geometry["centre_x"], geometry["centre_y"]
r = geometry["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(mask_img.width, cx + r)
lower = min(mask_img.height, cy + r)
crop = mask_img.crop((left, upper, right, lower)).resize((size, size), Image.NEAREST)
return (np.asarray(crop) > 0).astype(np.uint8)
@dataclass
class RefugeClassificationRecord:
sample: RefugeSample
geometry: Dict[str, float]
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
class RefugeClassificationDataset(Dataset):
def __init__(
self,
records: Sequence[RefugeClassificationRecord],
transform: transforms.Compose,
polar_transform: transforms.Compose,
size: int = 256,
) -> None:
self.records = list(records)
self.transform = transform
self.polar_transform = polar_transform
self.size = size
def __len__(self) -> int:
return len(self.records)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
rec = self.records[idx]
image = Image.open(rec.sample.image_path).convert("RGB")
crop = _crop_from_geometry(image, rec.geometry, size=self.size)
polar_image = _compute_polar_image(crop, size=self.size)
tensor = self.transform(crop)
polar_tensor = self.polar_transform(polar_image)
features = np.zeros((FEATURE_DIM,), dtype=np.float32)
if rec.disc_mask is not None and rec.cup_mask is not None:
disc_crop = _crop_mask_from_geometry(rec.disc_mask, rec.geometry, self.size)
cup_crop = _crop_mask_from_geometry(rec.cup_mask, rec.geometry, self.size)
features = _compute_feature_vector(disc_crop, cup_crop)
feature_tensor = torch.from_numpy(features).float()
label = rec.sample.label
if label is None:
raise ValueError(f"Sample {rec.sample.sample_id} is missing glaucoma label")
return {
"image": tensor,
"polar": polar_tensor,
"features": feature_tensor,
"label": torch.tensor(label, dtype=torch.long),
"sample_id": rec.sample.sample_id,
}
class RefugeTTTDataset(Dataset):
"""Dataset providing unlabeled crops for test-time training."""
def __init__(self, records: Sequence[RefugeClassificationRecord], transform: transforms.Compose, size: int = 256) -> None:
self.records = list(records)
self.transform = transform
self.size = size
def __len__(self) -> int:
return len(self.records)
def __getitem__(self, idx: int) -> torch.Tensor:
rec = self.records[idx]
image = Image.open(rec.sample.image_path).convert("RGB")
crop = _crop_from_geometry(image, rec.geometry, size=self.size)
return self.transform(crop)
class UNetGeometryProvider:
"""Callable wrapper that derives disc geometry using a trained UNetSegmenter."""
def __init__(
self,
segmenter: UNetSegmenter,
threshold: float = 0.5,
tta: bool = False,
) -> None:
self.segmenter = segmenter
self.threshold = threshold
self.tta = tta
self.segmenter.model.eval()
def __call__(self, sample: RefugeSample, scale: float) -> Tuple[Dict[str, float], np.ndarray, np.ndarray]:
image = Image.open(sample.image_path).convert("RGB")
resized = self.segmenter.preprocess_image(image)
tensor = transforms.ToTensor()(resized)
tensor = self.segmenter._normalize_tensor(tensor)
tensor = tensor.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)
cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST)
disc_mask = (np.array(disc_img, dtype=np.uint8) > 0).astype(np.uint8)
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)
geom = _geometry_from_mask(disc_mask, scale)
return geom, disc_mask, cup_mask
# ---------------------------------------------------------------------------
# Classification module
# ---------------------------------------------------------------------------
class ArcMarginProduct(nn.Module):
"""Additive angular margin (ArcFace) head."""
def __init__(
self,
in_features: int,
out_features: int,
s: float = 30.0,
m: float = 0.5,
easy_margin: bool = False,
) -> None:
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.s = float(s)
self.m = float(m)
self.easy_margin = easy_margin
self.weight = nn.Parameter(torch.empty(out_features, in_features))
nn.init.xavier_uniform_(self.weight)
self.cos_m = math.cos(m)
self.sin_m = math.sin(m)
self.th = math.cos(math.pi - m)
self.mm = math.sin(math.pi - m) * m
def forward(self, input: torch.Tensor, label: Optional[torch.Tensor] = None) -> torch.Tensor:
cosine = F.linear(F.normalize(input), F.normalize(self.weight))
if label is None:
return cosine * self.s
sine = torch.sqrt(torch.clamp(1.0 - cosine.pow(2), min=0.0))
phi = cosine * self.cos_m - sine * self.sin_m
if self.easy_margin:
phi = torch.where(cosine > 0, phi, cosine)
else:
phi = torch.where(cosine > self.th, phi, cosine - self.mm)
one_hot = torch.zeros_like(cosine)
one_hot.scatter_(1, label.view(-1, 1), 1.0)
logits = (one_hot * phi) + ((1.0 - one_hot) * cosine)
logits *= self.s
return logits
class RefugeClassification:
"""Train and evaluate REFUGE glaucoma classifiers with TTT support."""
def __init__(
self,
preprocessing: RefugePreprocessing,
segmentation: RefugeSegmentation,
backbone: Optional[nn.Module] = None,
geometry_fn: Optional[
Callable[
[RefugeSample, float],
Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]],
]
] = None,
cache_dir: Optional[Path] = None,
use_all_labeled: bool = False,
auto_val_ratio: float = 0.1,
use_margin: bool = False,
margin_s: float = 30.0,
margin_m: float = 0.5,
) -> None:
self.preprocessing = preprocessing
self.segmentation = segmentation
if backbone is not None:
self.backbone = backbone
in_features = getattr(self.backbone, "_feature_dim", None)
if in_features is None:
if hasattr(self.backbone, "fc") and hasattr(self.backbone.fc, "in_features"):
in_features = self.backbone.fc.in_features # type: ignore[attr-defined]
self.backbone.fc = nn.Identity() # type: ignore[attr-defined]
else:
raise ValueError(
"Provided backbone must have '_feature_dim' or expose fc.in_features"
)
else:
self.backbone = self._default_backbone()
in_features = getattr(self.backbone, "_feature_dim", None)
if in_features is None:
in_features = self.backbone.fc.in_features # type: ignore[attr-defined]
self.backbone.fc = nn.Identity() # type: ignore[attr-defined]
self.feature_dim = in_features
self.use_polar = True
self.extra_feature_dim = FEATURE_DIM
combined_dim = self.feature_dim * (1 + int(self.use_polar)) + self.extra_feature_dim
self.margin_s = float(margin_s)
self.margin_m = float(margin_m)
self.use_margin = bool(use_margin)
if self.use_margin:
self.classifier_head = ArcMarginProduct(
combined_dim, 2, s=self.margin_s, m=self.margin_m
)
else:
self.classifier_head = nn.Linear(combined_dim, 2)
self.rotation_head = nn.Linear(self.feature_dim, 4)
self.train_dataset: Optional[RefugeClassificationDataset] = None
self.val_dataset: Optional[RefugeClassificationDataset] = None
self.train_loader: Optional[DataLoader] = None
self.val_loader: Optional[DataLoader] = None
self.ttt_transform = _default_image_transform()
self.train_transform = _augment_image_transform()
self.eval_transform = _default_image_transform()
self.polar_transform = _default_image_transform()
self.crop_scale = 2.5
self.crop_size = 256
self.geometry_cache: Dict[
str, Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]]
] = {}
self.train_records: List[RefugeClassificationRecord] = []
self.val_records: List[RefugeClassificationRecord] = []
self._geometry_fn = geometry_fn
self.cache_dir = cache_dir
if self.cache_dir is not None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.use_all_labeled = use_all_labeled
self.auto_val_ratio = auto_val_ratio
# ------------------------------------------------------------------
@staticmethod
def _default_backbone() -> nn.Module:
weights = models.ResNet50_Weights.IMAGENET1K_V2
model = models.resnet50(weights=weights)
in_features = model.fc.in_features
model.fc = nn.Identity()
setattr(model, "_feature_dim", in_features)
return model
# ------------------------------------------------------------------
def build_datasets(
self,
crop_scale: float = 2.5,
crop_size: int = 256,
batch_size: int = 16,
num_workers: int = 4,
) -> None:
self.crop_scale = crop_scale
self.crop_size = crop_size
self.train_transform = _augment_image_transform(crop_size)
self.eval_transform = _default_image_transform(crop_size)
self.ttt_transform = _default_image_transform(crop_size)
self.polar_transform = _default_image_transform(crop_size)
manifest = list(self.preprocessing.build_manifest())
train_records: List[RefugeClassificationRecord] = []
val_records: List[RefugeClassificationRecord] = []
allowed_splits = {"train", "val"}
candidates = [
sample
for sample in manifest
if sample.label is not None and sample.split in allowed_splits
]
print(
f"[classifier] Building datasets from {len(candidates)} labelled samples (train/val)"
)
skipped: List[str] = []
for sample in tqdm(
candidates,
desc="Preparing records",
unit="sample",
leave=False,
):
try:
geom, disc_mask, cup_mask = self._resolve_geometry(sample, crop_scale)
except RuntimeError:
skipped.append(sample.sample_id)
continue
record = RefugeClassificationRecord(
sample=sample,
geometry=geom,
disc_mask=disc_mask,
cup_mask=cup_mask,
)
if sample.split == "train" or (
self.use_all_labeled and sample.split == "val"
):
train_records.append(record)
else:
val_records.append(record)
if skipped:
print(
f"[classifier] WARNING: {len(skipped)}/{len(candidates)} samples skipped "
f"due to empty segmentation mask: {skipped}"
)
if (not val_records or self.use_all_labeled) and train_records and self.auto_val_ratio > 0.0:
rng = random.Random(42)
label_groups: Dict[int, List[RefugeClassificationRecord]] = {}
for rec in train_records:
label = int(rec.sample.label or 0)
label_groups.setdefault(label, []).append(rec)
new_train: List[RefugeClassificationRecord] = []
new_val: List[RefugeClassificationRecord] = []
for recs in label_groups.values():
rng.shuffle(recs)
if len(recs) <= 1:
new_train.extend(recs)
continue
val_count = max(1, int(round(len(recs) * self.auto_val_ratio)))
if val_count >= len(recs):
val_count = len(recs) - 1
new_val.extend(recs[:val_count])
new_train.extend(recs[val_count:])
if not new_val:
# Fallback: ensure at least one validation sample if possible
if len(new_train) > 1:
new_val.append(new_train.pop())
if new_val:
val_records = new_val
train_records = new_train
self.train_records = train_records
self.val_records = val_records
print(
f"[classifier] Records ready → train: {len(train_records)}, val: {len(val_records)}"
)
self.train_dataset = RefugeClassificationDataset(
train_records,
transform=self.train_transform,
polar_transform=self.polar_transform,
size=crop_size,
)
self.val_dataset = RefugeClassificationDataset(
val_records,
transform=self.eval_transform,
polar_transform=self.polar_transform,
size=crop_size,
)
self.train_loader = DataLoader(
self.train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
self.val_loader = DataLoader(
self.val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
print(
"[classifier] DataLoaders prepared — training batches will start shortly"
)
# ------------------------------------------------------------------
def _resolve_geometry(
self, sample: RefugeSample, scale: float
) -> Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]]:
key = self._cache_key(sample.sample_id, scale)
cached = self.geometry_cache.get(key)
if cached is not None:
return cached
cache_path = self._cache_path(sample.sample_id, scale)
if cache_path is not None and cache_path.exists():
data = np.load(cache_path, allow_pickle=False)
geom = {
"centre_x": float(data["centre_x"]),
"centre_y": float(data["centre_y"]),
"radius": float(data["radius"]),
"crop_radius": float(data["crop_radius"]),
"crop_size": float(data["crop_size"]),
}
disc_mask = None
cup_mask = None
if int(data["has_disc"]):
disc_mask = data["disc_mask"].astype(np.uint8)
if int(data["has_cup"]):
cup_mask = data["cup_mask"].astype(np.uint8)
self.geometry_cache[key] = (geom, disc_mask, cup_mask)
return geom, disc_mask, cup_mask
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
if sample.mask_path and sample.mask_path.exists():
mask_img = Image.open(sample.mask_path).convert("RGB")
disc_mask, cup_mask = disc_cup_from_mask_image(mask_img)
geom = _geometry_from_mask(disc_mask, scale)
elif self._geometry_fn is not None:
geom, disc_mask, cup_mask = self._geometry_fn(sample, scale)
else:
geom = self.segmentation.infer_disc_geometry(sample, scale=scale)
try:
pred_mask = self.segmentation.predict_mask(sample).numpy()
disc_mask = pred_mask.astype(np.uint8)
except Exception:
disc_mask = None
cup_mask = None
if cache_path is not None:
try:
np.savez_compressed(
cache_path,
centre_x=geom["centre_x"],
centre_y=geom["centre_y"],
radius=geom["radius"],
crop_radius=geom["crop_radius"],
crop_size=geom.get("crop_size", geom["crop_radius"] * 2.0),
disc_mask=disc_mask if disc_mask is not None else np.array([], dtype=np.uint8),
cup_mask=cup_mask if cup_mask is not None else np.array([], dtype=np.uint8),
has_disc=int(disc_mask is not None),
has_cup=int(cup_mask is not None),
)
except Exception:
pass
self.geometry_cache[key] = (geom, disc_mask, cup_mask)
return geom, disc_mask, cup_mask
def set_geometry_fn(
self,
geometry_fn: Optional[
Callable[
[RefugeSample, float],
Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]],
]
],
) -> None:
self._geometry_fn = geometry_fn
self.geometry_cache.clear()
def build_records_for_samples(
self,
samples: Sequence[RefugeSample],
crop_scale: Optional[float] = None,
progress_prefix: Optional[str] = None,
) -> List[RefugeClassificationRecord]:
scale = crop_scale if crop_scale is not None else self.crop_scale
records: List[RefugeClassificationRecord] = []
skipped: List[str] = []
iterator: Iterable[RefugeSample]
if progress_prefix is not None:
iterator = tqdm(samples, desc=progress_prefix, unit="sample", leave=False)
else:
iterator = samples
labeled = [s for s in samples if s.label is not None]
for sample in iterator:
if sample.label is None:
continue
try:
geom, disc_mask, cup_mask = self._resolve_geometry(sample, scale)
except RuntimeError:
skipped.append(sample.sample_id)
continue
records.append(
RefugeClassificationRecord(
sample=sample,
geometry=geom,
disc_mask=disc_mask,
cup_mask=cup_mask,
)
)
prefix = f"[{progress_prefix}]" if progress_prefix else "[classifier]"
if skipped:
print(
f"{prefix} WARNING: {len(skipped)}/{len(labeled)} samples skipped "
f"due to empty segmentation mask: {skipped}"
)
else:
print(f"{prefix} All {len(labeled)} samples processed successfully.")
return records
def clear_disk_cache(self) -> None:
"""Delete all cached geometry/mask .npz files in cache_dir."""
if self.cache_dir is None or not self.cache_dir.exists():
return
removed = 0
for f in self.cache_dir.glob("*.npz"):
f.unlink()
removed += 1
self.geometry_cache.clear()
print(f"[classifier] Cleared {removed} cached geometry files from {self.cache_dir}")
def _cache_key(self, sample_id: str, scale: float) -> str:
scale_tag = int(round(scale * 100))
return f"{sample_id}_s{scale_tag}"
def _cache_path(self, sample_id: str, scale: float) -> Optional[Path]:
if self.cache_dir is None:
return None
return self.cache_dir / f"{self._cache_key(sample_id, scale)}.npz"
# ------------------------------------------------------------------
def train(
self,
epochs: int = 30,
lr: float = 1e-4,
weight_decay: float = 1e-4,
device: Optional[str] = None,
rotation_weight: float = 0.5,
checkpoint_dir: Optional[Path] = None,
) -> Dict[str, float]:
if self.train_loader is None or self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.classifier_head.to(device)
self.rotation_head.to(device)
params = list(self.backbone.parameters()) + list(self.classifier_head.parameters()) + list(self.rotation_head.parameters())
optimizer = torch.optim.Adam(params, lr=lr, weight_decay=weight_decay)
clf_loss = nn.CrossEntropyLoss()
rot_loss = nn.CrossEntropyLoss()
best_auc = 0.0
history: Dict[str, float] = {}
epoch_iter = tqdm(range(1, epochs + 1), desc="Epochs", unit="epoch")
print(
f"[classifier] Starting training for {epochs} epochs with batch size {self.train_loader.batch_size}"
)
for epoch in epoch_iter:
self.backbone.train()
self.classifier_head.train()
self.rotation_head.train()
running_loss = 0.0
batch_iter = tqdm(
self.train_loader, # type: ignore[arg-type]
desc=f"Train {epoch}/{epochs}",
leave=False,
unit="batch",
)
for batch in batch_iter:
images = batch["image"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
labels = batch["label"].to(device)
optimizer.zero_grad()
feats_img = self.backbone(images)
feats = feats_img
if self.use_polar:
feats_polar = self.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if self.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
if self.use_margin:
logits = self.classifier_head(feats, labels)
else:
logits = self.classifier_head(feats)
loss_cls = clf_loss(logits, labels)
rot_imgs, rot_labels = self._build_rotation_batch(images)
feats_rot = self.backbone(rot_imgs)
logits_rot = self.rotation_head(feats_rot)
loss_rot = rot_loss(logits_rot, rot_labels)
loss = loss_cls + rotation_weight * loss_rot
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
train_loss = running_loss / len(self.train_loader.dataset) # type: ignore[arg-type]
metrics = self.evaluate(device=device)
history[f"epoch_{epoch}_loss"] = train_loss
history[f"epoch_{epoch}_auc"] = metrics.get("auc", float("nan"))
auc_val = metrics.get("auc", 0.0)
epoch_iter.set_postfix(loss=f"{train_loss:.4f}", auc=f"{auc_val:.4f}")
if auc_val > best_auc:
best_auc = metrics["auc"]
if checkpoint_dir is not None:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
torch.save({
"backbone": self.backbone.state_dict(),
"classifier": self.classifier_head.state_dict(),
"rotation": self.rotation_head.state_dict(),
}, checkpoint_dir / "refuge_classifier_best.pt")
return {"best_auc": best_auc, **history}
# ------------------------------------------------------------------
def evaluate(
self,
split: str = "val",
apply_ttt: bool = False,
device: Optional[str] = None,
) -> Dict[str, float]:
if split != "val":
raise ValueError("Only validation split supported currently")
if self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.classifier_head.to(device)
self.rotation_head.to(device)
if apply_ttt:
ttt_ds = RefugeTTTDataset(self.val_records, transform=self.ttt_transform, size=self.crop_size)
ttt_loader = DataLoader(ttt_ds, batch_size=32, shuffle=False)
self.apply_ttt(ttt_loader, device=device)
self.backbone.eval()
self.classifier_head.eval()
preds: List[float] = []
targets: List[int] = []
with torch.no_grad():
val_iter = tqdm(self.val_loader, desc="Validate", leave=False, unit="batch")
for batch in val_iter: # type: ignore[arg-type]
images = batch["image"].to(device)
labels = batch["label"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
feats_img = self.backbone(images)
feats = feats_img
if self.use_polar:
feats_polar = self.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if self.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
if self.use_margin:
logits = self.classifier_head(feats)
else:
logits = self.classifier_head(feats)
probs = torch.softmax(logits, dim=1)[:, 1]
preds.extend(probs.cpu().numpy().tolist())
targets.extend(labels.cpu().numpy().tolist())
auc = 0.0
try:
if len(set(targets)) > 1:
auc = float(roc_auc_score(targets, preds))
except ValueError:
auc = 0.0
return {"auc": auc}
# ------------------------------------------------------------------
def apply_ttt(self, loader: DataLoader, device: Optional[str] = None, steps: int = 1, lr: float = 1e-5) -> None:
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.rotation_head.to(device)
self.backbone.train()
self.rotation_head.train()
optimizer = torch.optim.Adam(list(self.backbone.parameters()) + list(self.rotation_head.parameters()), lr=lr)
criterion = nn.CrossEntropyLoss()
for _ in range(steps):
for batch in tqdm(loader, desc="TTT adapt", leave=False, unit="batch"):
if isinstance(batch, dict):
images = batch["image"].to(device)
else:
images = batch.to(device)
optimizer.zero_grad()
rot_imgs, rot_labels = self._build_rotation_batch(images)
feats = self.backbone(rot_imgs)
logits = self.rotation_head(feats)
loss = criterion(logits, rot_labels)
loss.backward()
optimizer.step()
# ------------------------------------------------------------------
def extract_backbone(self) -> nn.Module:
return self.backbone
def save_checkpoint(self, output_dir: Path) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
torch.save({
"backbone": self.backbone.state_dict(),
"classifier": self.classifier_head.state_dict(),
"rotation": self.rotation_head.state_dict(),
}, output_dir / "refuge_classifier.pt")
def load_checkpoint(self, checkpoint_path: Path) -> None:
payload = torch.load(checkpoint_path, map_location="cpu")
self.backbone.load_state_dict(payload["backbone"])
self.classifier_head.load_state_dict(payload["classifier"])
self.rotation_head.load_state_dict(payload["rotation"])
# ------------------------------------------------------------------
def _build_rotation_batch(self, images: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
rotations = [0, 90, 180, 270]
rotated = []
labels = []
for idx, angle in enumerate(rotations):
rot = TF.rotate(images, angle)
rotated.append(rot)
labels.append(torch.full((images.size(0),), idx, dtype=torch.long, device=images.device))
batch = torch.cat(rotated, dim=0)
batch_labels = torch.cat(labels, dim=0)
return batch, batch_labels
-306
View File
@@ -1,306 +0,0 @@
"""Utilities for preparing REFUGE (REFUGE1/REFUGE2) datasets.
Builds a unified manifest across all provided splits (REFUGE1 train/val/test
and REFUGE2 validation/test), exposing image paths, glaucoma labels, disc/cup
masks, and fovea coordinates so downstream segmentation/classification modules
can operate without additional bookkeeping.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
import pandas as pd
@dataclass
class RefugeSample:
"""Lightweight container describing a REFUGE sample."""
sample_id: str
dataset: str
split: str
image_path: Path
label: Optional[int]
device: Optional[str]
mask_path: Optional[Path]
fovea_coord: Optional[Tuple[float, float]]
class RefugePreprocessing:
"""Builds manifests and provides shared helpers for REFUGE workflows.
Responsibilities:
* scan the REFUGE directory structure and build a consistent manifest
(train/val/test, device vendor, ground-truth labels)
* expose convenience loaders for raw RGB frames, OD/OC masks, and
optional fovea landmarks
* compute geometric metadata (disc centres, diameters) so downstream
stages can crop ROIs lazily instead of storing pre-rendered tiles
"""
def __init__(self, root_dir: Path | str) -> None:
self.root_dir = Path(root_dir)
self._manifest = None # populated by build_manifest()
# ------------------------------------------------------------------
# Manifest handling
# ------------------------------------------------------------------
def build_manifest(self, refresh: bool = False) -> Iterable[RefugeSample]:
"""Return an iterable of :class:`RefugeSample` records.
Parameters
----------
refresh:
when True, force a rescan of the filesystem instead of reusing the
cached manifest.
Returns
-------
Iterable[RefugeSample]
A sequence containing one entry per sample in the REFUGE datasets.
Notes
-----
The actual manifest-building logic will live here: parsing the
directory structure, reading any provided CSV/Excel metadata, and
aligning masks/labels. For now, this method raises ``NotImplementedError``
so callers are reminded to hook it up before use.
"""
if self._manifest is not None and not refresh:
return self._manifest
manifest: List[RefugeSample] = []
manifest.extend(self._collect_refuge1_train())
manifest.extend(self._collect_refuge1_val())
manifest.extend(self._collect_refuge1_test())
manifest.extend(self._collect_refuge2_val())
manifest.extend(self._collect_refuge2_test())
self._manifest = manifest
return self._manifest
# ------------------------------------------------------------------
# Accessors for downstream modules
# ------------------------------------------------------------------
def load_image(self, sample: RefugeSample):
"""Return the RGB fundus image for ``sample``.
Implementors should handle color-space consistency (e.g., ensure RGB vs
BGR) and any global normalisation desired across devices.
"""
raise NotImplementedError("Image loading to be implemented")
def load_mask(self, sample: RefugeSample):
"""Return the optic disc/cup mask for ``sample`` if available."""
raise NotImplementedError("Mask loading to be implemented")
def disc_geometry(self, sample: RefugeSample) -> Dict[str, float]:
"""Compute disc centre and diameter from the mask.
The segmentation module will rely on this to crop 2.53× disc-diameter
ROIs at training time.
"""
raise NotImplementedError("Disc geometry helper to be implemented")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _collect_refuge1_train(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-train"
if not base.exists():
return []
fovea_path = base / "Fovea_location.xlsx"
fovea_map = self._read_fovea_table(fovea_path, img_col="ImgName")
samples: List[RefugeSample] = []
image_root = base / "Training400"
mask_root = base / "Disc_Cup_Masks"
for label_name, label_val in ("Glaucoma", 1), ("Non-Glaucoma", 0):
img_dir = image_root / label_name
mask_dir = mask_root / label_name
if not img_dir.exists():
continue
for image_path in sorted(img_dir.glob("*.jpg")):
img_name = image_path.name
mask_path = (mask_dir / image_path.with_suffix(".bmp").name)
fovea = fovea_map.get(img_name)
sample_id = f"refuge1_train_{image_path.stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="train",
image_path=image_path,
label=label_val,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge1_val(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-val"
if not base.exists():
return []
fovea_path = base / "Fovea_locations.xlsx"
df = pd.read_excel(fovea_path)
samples: List[RefugeSample] = []
image_root = base / "REFUGE-Validation400"
mask_root = base / "Disc_Cup_Masks"
for _, row in df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".bmp").name
fovea = self._extract_fovea(row, x_key="Fovea_X", y_key="Fovea_Y")
label = int(row.get("Glaucoma Label", 0)) if not pd.isna(row.get("Glaucoma Label", 0)) else None
sample_id = f"refuge1_val_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="val",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge1_test(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-test"
if not base.exists():
return []
df = pd.read_excel(base / "Glaucoma_label_and_Fovea_location.xlsx")
image_root = base / "Test400"
mask_root = base / "Disc_Cup_Masks"
samples: List[RefugeSample] = []
for _, row in df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".bmp").name
fovea = self._extract_fovea(row, x_key="Fovea_X", y_key="Fovea_Y")
label = int(row.get("Label(Glaucoma=1)", 0)) if not pd.isna(row.get("Label(Glaucoma=1)", 0)) else None
sample_id = f"refuge1_test_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="test",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge2_val(self) -> List[RefugeSample]:
base = self.root_dir / "Validation"
if not base.exists():
return []
label_df = pd.read_csv(base / "glaucoma.csv")
fovea_df = pd.read_csv(base / "fovea.csv")
fovea_map = {
row["ImageName"]: (float(row["Fovea_X"]), float(row["Fovea_Y"]))
for _, row in fovea_df.iterrows()
}
samples: List[RefugeSample] = []
image_root = base / "Images"
mask_root = base / "Disc_Masks"
for _, row in label_df.iterrows():
img_name = row["FileName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".png").name
label = row.get("Glaucoma Risk")
label = int(label) if label == label else None
sample_id = f"refuge2_val_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge2",
split="val",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea_map.get(img_name),
)
)
return samples
def _collect_refuge2_test(self) -> List[RefugeSample]:
base = self.root_dir / "Test"
if not base.exists():
return []
label_df = pd.read_excel(base / "task1.xls", header=None, names=["ImgName", "Glaucoma"])
fovea_df = pd.read_excel(base / "fovea.xlsx")
fovea_map = {
row["ImageName"]: (float(row["Fovea_X"]), float(row["Fovea_Y"]))
for _, row in fovea_df.iterrows()
}
samples: List[RefugeSample] = []
image_root = base / "refuge2-test"
mask_root = base / "Disc_Mask"
for _, row in label_df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".png").name
label = row.get("Glaucoma")
label = int(label) if label == label else None
sample_id = f"refuge2_test_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge2",
split="test",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea_map.get(img_name),
)
)
return samples
@staticmethod
def _read_fovea_table(path: Path, img_col: str) -> Dict[str, Tuple[float, float]]:
if not path.exists():
return {}
df = pd.read_excel(path)
mapping: Dict[str, Tuple[float, float]] = {}
for _, row in df.iterrows():
mapping[row[img_col]] = (
float(row.get("Fovea_X", float("nan"))),
float(row.get("Fovea_Y", float("nan"))),
)
return mapping
@staticmethod
def _extract_fovea(row: pd.Series, x_key: str, y_key: str) -> Optional[Tuple[float, float]]:
x_val = row.get(x_key)
y_val = row.get(y_key)
if pd.isna(x_val) or pd.isna(y_val):
return None
return float(x_val), float(y_val)
-383
View File
@@ -1,383 +0,0 @@
"""REFUGE optic disc / cup segmentation utilities."""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import numpy as np
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
# ---------------------------------------------------------------------------
# Dataset helpers
# ---------------------------------------------------------------------------
def _load_rgb(path: Path) -> Image.Image:
img = Image.open(path)
if img.mode != "RGB":
img = img.convert("RGB")
return img
def _load_mask_array(path: Path) -> np.ndarray:
mask_img = Image.open(path).convert("L")
mask = np.array(mask_img, dtype=np.float32)
# REFUGE masks encode disc/cup with different intensities; treat any
# positive value as disc for coarse localisation.
mask = np.where(mask > 0, 1.0, 0.0)
return mask
@dataclass
class RefugeSegmentationSample:
sample: RefugeSample
image_path: Path
mask_path: Path
class RefugeSegmentationDataset(Dataset):
"""Simple segmentation dataset returning tensors."""
def __init__(
self,
samples: Sequence[RefugeSegmentationSample],
image_size: int = 512,
) -> None:
self.samples = list(samples)
self.image_size = image_size
self.to_tensor = transforms.ToTensor()
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
rec = self.samples[idx]
image = _load_rgb(rec.image_path)
mask_arr = _load_mask_array(rec.mask_path)
if self.image_size is not None:
image = image.resize((self.image_size, self.image_size), Image.BILINEAR)
mask_img = Image.fromarray(mask_arr).resize(
(self.image_size, self.image_size), Image.NEAREST
)
mask_arr = np.array(mask_img, dtype=np.float32)
image_tensor = self.to_tensor(image)
mask_tensor = torch.from_numpy(mask_arr).unsqueeze(0) # [1,H,W]
return {
"image": image_tensor,
"mask": mask_tensor,
"sample_id": rec.sample.sample_id,
}
# ---------------------------------------------------------------------------
# Model definition (lightweight U-Net)
# ---------------------------------------------------------------------------
class DoubleConv(nn.Module):
def __init__(self, in_channels: int, out_channels: int):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
class UNet(nn.Module):
def __init__(self, in_channels: int = 3, base_channels: int = 64):
super().__init__()
self.enc1 = DoubleConv(in_channels, base_channels)
self.enc2 = DoubleConv(base_channels, base_channels * 2)
self.enc3 = DoubleConv(base_channels * 2, base_channels * 4)
self.enc4 = DoubleConv(base_channels * 4, base_channels * 8)
self.pool = nn.MaxPool2d(2)
self.bottleneck = DoubleConv(base_channels * 8, base_channels * 16)
self.up4 = nn.ConvTranspose2d(base_channels * 16, base_channels * 8, 2, stride=2)
self.dec4 = DoubleConv(base_channels * 16, base_channels * 8)
self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2)
self.dec3 = DoubleConv(base_channels * 8, base_channels * 4)
self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2)
self.dec2 = DoubleConv(base_channels * 4, base_channels * 2)
self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2)
self.dec1 = DoubleConv(base_channels * 2, base_channels)
self.out = nn.Conv2d(base_channels, 1, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
b = self.bottleneck(self.pool(e4))
d4 = self.up4(b)
d4 = torch.cat([d4, e4], dim=1)
d4 = self.dec4(d4)
d3 = self.up3(d4)
d3 = torch.cat([d3, e3], dim=1)
d3 = self.dec3(d3)
d2 = self.up2(d3)
d2 = torch.cat([d2, e2], dim=1)
d2 = self.dec2(d2)
d1 = self.up1(d2)
d1 = torch.cat([d1, e1], dim=1)
d1 = self.dec1(d1)
return self.out(d1)
# ---------------------------------------------------------------------------
# Segmentation manager
# ---------------------------------------------------------------------------
class RefugeSegmentation:
"""Train and run coarse-to-fine OD/OC segmentation for REFUGE."""
def __init__(
self,
preprocessing: RefugePreprocessing,
model: Optional[nn.Module] = None,
) -> None:
self.preprocessing = preprocessing
self.model = model or UNet()
self.train_dataset: Optional[RefugeSegmentationDataset] = None
self.val_dataset: Optional[RefugeSegmentationDataset] = None
self.train_loader: Optional[DataLoader] = None
self.val_loader: Optional[DataLoader] = None
# ------------------------------------------------------------------
def build_datasets(
self,
image_size: int = 512,
batch_size: int = 8,
num_workers: int = 4,
) -> None:
manifest = self.preprocessing.build_manifest()
train_samples: List[RefugeSegmentationSample] = []
val_samples: List[RefugeSegmentationSample] = []
for sample in manifest:
if not sample.mask_path or not sample.mask_path.exists():
continue
rec = RefugeSegmentationSample(sample=sample, image_path=sample.image_path, mask_path=sample.mask_path)
if sample.split == "train":
train_samples.append(rec)
elif sample.split in {"val", "validation"}:
val_samples.append(rec)
if not val_samples:
# Fall back to using a subset of training data for validation
split = max(1, int(0.1 * len(train_samples)))
val_samples = train_samples[:split]
train_samples = train_samples[split:]
self.train_dataset = RefugeSegmentationDataset(train_samples, image_size=image_size)
self.val_dataset = RefugeSegmentationDataset(val_samples, image_size=image_size)
self.train_loader = DataLoader(
self.train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
self.val_loader = DataLoader(
self.val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
# ------------------------------------------------------------------
def train(
self,
epochs: int = 40,
lr: float = 1e-3,
weight_decay: float = 1e-5,
device: Optional[str] = None,
checkpoint_dir: Optional[Path] = None,
) -> Dict[str, float]:
if self.train_loader is None or self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr, weight_decay=weight_decay)
best_dice = 0.0
history: Dict[str, float] = {}
for epoch in range(1, epochs + 1):
print(f"[Seg] Processing epoch {epoch}/{epochs}")
self.model.train()
running_loss = 0.0
for batch in self.train_loader: # type: ignore[arg-type]
images = batch["image"].to(device)
masks = batch["mask"].to(device)
optimizer.zero_grad()
logits = self.model(images)
loss = criterion(logits, masks)
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
train_loss = running_loss / len(self.train_loader.dataset) # type: ignore[arg-type]
val_metrics = self.evaluate(device=device)
history[f"epoch_{epoch}_loss"] = train_loss
history[f"epoch_{epoch}_dice"] = val_metrics.get("dice", float("nan"))
if val_metrics.get("dice", 0.0) > best_dice:
best_dice = val_metrics["dice"]
if checkpoint_dir is not None:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
torch.save(self.model.state_dict(), checkpoint_dir / "refuge_segmentation_best.pt")
return {"best_dice": best_dice, **history}
# ------------------------------------------------------------------
def evaluate(self, split: str = "val", device: Optional[str] = None) -> Dict[str, float]:
if split != "val":
raise ValueError("Only validation split supported currently")
if self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
self.model.eval()
dices: List[float] = []
criterion = nn.BCEWithLogitsLoss()
losses: List[float] = []
with torch.no_grad():
for batch in self.val_loader: # type: ignore[arg-type]
images = batch["image"].to(device)
masks = batch["mask"].to(device)
logits = self.model(images)
loss = criterion(logits, masks)
losses.append(loss.item() * images.size(0))
probs = torch.sigmoid(logits)
preds = (probs > 0.5).float()
dice = self._dice_coefficient(preds, masks)
dices.extend(dice)
mean_dice = float(np.mean(dices)) if dices else 0.0
mean_loss = float(np.sum(losses) / len(self.val_loader.dataset)) # type: ignore[arg-type]
return {"dice": mean_dice, "loss": mean_loss}
# ------------------------------------------------------------------
def predict_mask(self, sample: RefugeSample, device: Optional[str] = None) -> torch.Tensor:
if self.train_dataset is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
self.model.eval()
image = _load_rgb(sample.image_path)
original_size = image.size # (width, height)
image_resized = image.resize((self.train_dataset.image_size, self.train_dataset.image_size), Image.BILINEAR) # type: ignore[union-attr]
tensor = transforms.ToTensor()(image_resized).unsqueeze(0).to(device)
with torch.no_grad():
logits = self.model(tensor)
mask_resized = torch.sigmoid(logits)[0, 0]
mask_np = mask_resized.cpu().numpy()
mask_np = (mask_np > 0.5).astype(np.float32)
mask_img = Image.fromarray(mask_np)
mask_img = mask_img.resize(original_size, Image.NEAREST)
return torch.from_numpy(np.array(mask_img, dtype=np.float32))
def infer_disc_geometry(
self,
sample: RefugeSample,
scale: float = 2.5,
) -> Dict[str, float]:
if sample.mask_path and sample.mask_path.exists():
mask = _load_mask_array(sample.mask_path)
else:
mask = self.predict_mask(sample).numpy()
coords = np.argwhere(mask > 0.5)
if coords.size == 0:
raise RuntimeError(f"Unable to locate disc for sample {sample.sample_id}")
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * scale
return {
"centre_x": centre_x,
"centre_y": centre_y,
"radius": radius,
"crop_radius": crop_radius,
"crop_size": crop_radius * 2.0,
}
def batch_crops(
self,
samples: Iterable[RefugeSample],
scale: float = 2.5,
output_dir: Optional[Path] = None,
size: int = 256,
) -> Dict[str, Path]:
output_paths: Dict[str, Path] = {}
if output_dir is not None:
output_dir.mkdir(parents=True, exist_ok=True)
for sample in samples:
geom = self.infer_disc_geometry(sample, scale=scale)
image = _load_rgb(sample.image_path)
cx, cy = geom["centre_x"], geom["centre_y"]
r = geom["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(image.width, cx + r)
lower = min(image.height, cy + r)
crop = image.crop((left, upper, right, lower)).resize((size, size), Image.BILINEAR)
if output_dir is not None:
out_path = output_dir / f"{sample.sample_id}_crop.png"
crop.save(out_path)
output_paths[sample.sample_id] = out_path
return output_paths
# ------------------------------------------------------------------
@staticmethod
def _dice_coefficient(preds: torch.Tensor, targets: torch.Tensor) -> List[float]:
eps = 1e-6
dices = []
preds = preds.view(preds.size(0), -1)
targets = targets.view(targets.size(0), -1)
for p, t in zip(preds, targets):
intersection = float((p * t).sum().item())
union = float(p.sum().item() + t.sum().item())
dice = (2.0 * intersection + eps) / (union + eps)
dices.append(dice)
return dices
-123
View File
@@ -1,123 +0,0 @@
# se_block.py
import torch
import torch.nn as nn
class SEGateLogger:
"""
Lightweight stats over SE gates.
Use: logger.accumulate(gates) each batch; logger.get() at epoch end.
"""
def __init__(self, enabled: bool = True, track_channels: bool = False, dim: int | None = None):
self.enabled = enabled
self.track_channels = track_channels
self.dim = dim
self.reset()
def reset(self):
self._n = 0
self._sum = 0.0
self._sum2 = 0.0
self._lt02 = 0
self._gt08 = 0
# optional per-channel
self._ch_sum = None
self._ch_count = 0
if self.track_channels and self.dim is not None:
self._ch_sum = torch.zeros(self.dim, dtype=torch.float32)
@torch.no_grad()
def accumulate(self, gates: torch.Tensor):
if not self.enabled:
return
# gates expected shape [N, C]; if a map/sequence gate is passed, reduce to [N, C]
if gates.dim() == 4: # [N,C,H,W] gates (uncommon)
g = gates.mean(dim=(2,3))
elif gates.dim() == 3: # [N,T,C] gates (sequence)
g = gates.mean(dim=1)
elif gates.dim() == 2: # [N,C]
g = gates
else:
g = gates.view(gates.size(0), -1)
g = g.detach()
self._n += g.numel()
self._sum += g.sum().item()
self._sum2 += (g*g).sum().item()
self._lt02 += (g < 0.2).sum().item()
self._gt08 += (g > 0.8).sum().item()
if self._ch_sum is not None:
self._ch_sum += g.sum(dim=0).cpu()
self._ch_count += g.size(0)
def get(self, reset: bool = True):
if self._n == 0:
return None
mean = self._sum / self._n
var = max(0.0, self._sum2 / self._n - mean * mean)
out = {
"mean": mean,
"std": var ** 0.5,
"pct_lt_0.2": self._lt02 / self._n,
"pct_gt_0.8": self._gt08 / self._n,
}
if self._ch_sum is not None and self._ch_count > 0:
out["channel_mean"] = (self._ch_sum / float(self._ch_count)).tolist()
if reset:
self.reset()
return out
class SEBlock(nn.Module):
"""
SE-style channel gating that works for vectors and maps.
Input:
- [N, C] (vector) -> squeeze = identity
- [N, C, H, W] (image map) -> squeeze over H,W
- [N, T, C] (sequence) -> squeeze over T
Gate modes:
- residual (default): gate = 1 + tanh(MLP(s)) in (0, 2) [identity at init]
- plain: gate = sigmoid(MLP(s)) in (0, 1)
"""
def __init__(self, dim: int, reduction: int = 16, residual: bool = True, identity_init: bool = True):
super().__init__()
hid = max(1, dim // max(1, reduction))
self.fc1 = nn.Linear(dim, hid, bias=True)
self.act = nn.ReLU(inplace=True)
self.fc2 = nn.Linear(hid, dim, bias=True)
self.residual = residual
if residual and identity_init:
# make MLP output ~0 at start → gate ≈ 1.0
nn.init.zeros_(self.fc2.weight)
nn.init.zeros_(self.fc2.bias)
def _squeeze(self, x: torch.Tensor) -> torch.Tensor:
if x.dim() == 2: # [N,C]
return x
if x.dim() == 4: # [N,C,H,W]
return x.mean(dim=(2,3))
if x.dim() == 3: # [N,T,C]
return x.mean(dim=1)
# fallback: flatten non-batch dims into channels
return x.view(x.size(0), -1)
def _broadcast(self, gate: torch.Tensor, like: torch.Tensor) -> torch.Tensor:
if like.dim() == 2:
return gate
if like.dim() == 3:
return gate.unsqueeze(1) # [N,1,C]
if like.dim() == 4:
return gate.unsqueeze(-1).unsqueeze(-1) # [N,C,1,1]
return gate.view_as(like)
def forward(self, x: torch.Tensor):
s = self._squeeze(x) # [N,C]
u = self.fc2(self.act(self.fc1(s))) # [N,C]
if self.residual:
gate = 1.0 + torch.tanh(u) # (0, 2) with identity at 1.0
else:
gate = torch.sigmoid(u) # (0, 1)
y = x * self._broadcast(gate, x)
return y, gate # return both the reweighted tensor and the gate for logging
-107
View File
@@ -1,107 +0,0 @@
from .network_manager import (
FoldResult,
LoaderBundle,
NetworkManager,
PatientSplit,
)
from .split_manager import (
PatientFirstSplitManager,
SplitPlan,
build_patient_split_plans,
)
from .profiles import (
DatasetProfile,
SimpleDatasetProfile,
SlotDescriptor,
PapilaProfile,
build_papila_profile,
)
from .loader_factory import SlotLoaderFactory
from .slot_dataset import SlotDataset, slot_collate
from .papila_data import PapilaData
from .papila_builders import build_papila_data
from .data_bundle import DataBundle
from .dataset import ClinicalDataset
from .config_builder import (
ConfigAssembly,
assemble_config,
load_config,
resolve_imports,
)
from .filters import RegexFilter, ColumnFilter, apply_regex_filters, apply_column_filters
from .transforms import (
ImageTransformConfig,
backbone_transform_config,
build_backbone_transform,
build_eval_transform,
build_imagenet_transform,
ResizeTransform,
CenterCropTransform,
ROICropTransform,
JitterBundleTransform,
UnetMaskProvider,
TRANSFORM_REGISTRY,
build_transform_chain,
)
from .model_builder import V2ModelBundle, build_model_bundle
from .towers import ImageTower, MDTower, SiameseImageTower, build_backbone
from .bridges import Bridge, VoteBridge
from .models import SingleEyeHT, BilateralHT
from .v2_hypertower import V2HyperTower, V2ModeComparisonOps, V2ModeComparator
from .hypertower_logger import HypertowerLogger
__all__ = [
"NetworkManager",
"PatientSplit",
"LoaderBundle",
"FoldResult",
"PatientFirstSplitManager",
"SplitPlan",
"build_patient_split_plans",
"DatasetProfile",
"SimpleDatasetProfile",
"SlotDescriptor",
"PapilaProfile",
"build_papila_profile",
"PapilaData",
"build_papila_data",
"DataBundle",
"ClinicalDataset",
"SlotLoaderFactory",
"SlotDataset",
"slot_collate",
"ConfigAssembly",
"assemble_config",
"load_config",
"resolve_imports",
"RegexFilter",
"ColumnFilter",
"apply_regex_filters",
"apply_column_filters",
"ImageTransformConfig",
"backbone_transform_config",
"build_backbone_transform",
"build_eval_transform",
"build_imagenet_transform",
"ResizeTransform",
"CenterCropTransform",
"ROICropTransform",
"JitterBundleTransform",
"UnetMaskProvider",
"TRANSFORM_REGISTRY",
"build_transform_chain",
"V2ModelBundle",
"build_model_bundle",
"ImageTower",
"MDTower",
"SiameseImageTower",
"build_backbone",
"Bridge",
"VoteBridge",
"SingleEyeHT",
"BilateralHT",
"V2HyperTower",
"V2ModeComparisonOps",
"V2ModeComparator",
"HypertowerLogger",
]
-178
View File
@@ -1,178 +0,0 @@
# classes/backbones.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List
import torch
from torch import nn
from torchvision import models
@dataclass(frozen=True)
class BackboneSpec:
ctor: Callable # torchvision constructor
weights_default: object # torchvision Weights enum DEFAULT member
strip: Callable[[nn.Module], tuple] # fn(model)->(out_dim, model_no_head)
blocks: Callable[[nn.Module], List[nn.Module]] # fn(model)->ordered blocks for freezing
REFUGELIKE_BACKBONE_PATH = Path("models/v2/refuge/refugelike_backbone.pt")
REFUGE_DENSENET_PATH = Path("models/refuge/classifier/refuge_densenet_backbone.pt")
REFUGE_EFFICIENT_B0_PATH = Path("models/refuge/classifier/refuge_efficient_b0_backbone.pt")
REFUGE_EFFICIENT_B7_PATH = Path("models/refuge/classifier/refuge_efficient_b7_backbone.pt")
# --- strip fns ---
def _strip_efficientnet_b0(m: models.EfficientNet):
from torch import nn as _nn
out_dim = m.classifier[1].in_features
m.classifier = _nn.Identity()
return out_dim, m
def _strip_resnet(m: models.ResNet):
out_dim = m.fc.in_features
m.fc = nn.Identity()
return out_dim, m
def _strip_densenet(m: models.DenseNet):
out_dim = m.classifier.in_features
m.classifier = nn.Identity()
return out_dim, m
def _strip_vgg(m: models.VGG):
out_dim = m.classifier[0].in_features # 25088 for VGG16 at 224×224
m.classifier = nn.Identity()
return out_dim, m
def _strip_mobilenet_v2(m: models.MobileNetV2):
out_dim = m.classifier[1].in_features
m.classifier = nn.Identity()
return out_dim, m
def _strip_inception_v3(m: models.Inception3):
out_dim = m.fc.in_features
m.fc = nn.Identity()
if hasattr(m, "AuxLogits"):
m.aux_logits = False
return out_dim, m
# --- block splitters for ratio-based freezing ---
def _blocks_efficientnet_b0(m: models.EfficientNet):
return list(m.features)
def _blocks_resnet(m: models.ResNet):
stem = nn.Sequential(m.conv1, m.bn1, m.relu, m.maxpool)
return [stem, m.layer1, m.layer2, m.layer3, m.layer4]
def _blocks_densenet(m: models.DenseNet):
f = m.features
stem = nn.Sequential(f.conv0, f.norm0, f.relu0, f.pool0)
return [stem, f.denseblock1, f.transition1, f.denseblock2, f.transition2,
f.denseblock3, f.transition3, f.denseblock4, f.norm5]
def _blocks_vgg(m: models.VGG):
stages, cur = [], []
for mod in m.features:
cur.append(mod)
if isinstance(mod, nn.MaxPool2d):
stages.append(nn.Sequential(*cur)); cur = []
if cur: stages.append(nn.Sequential(*cur))
return stages
def _blocks_mobilenet_v2(m: models.MobileNetV2):
return list(m.features)
def _blocks_inception_v3(m: models.Inception3):
blocks = []
for name, child in m.named_children():
if name in ("fc", "AuxLogits"):
continue
blocks.append(child)
return blocks
# --- registry (covers paper models available in torchvision) ---
BACKBONES: Dict[str, BackboneSpec] = {
"efficientnet_b0": BackboneSpec(
ctor=models.efficientnet_b0,
weights_default=models.EfficientNet_B0_Weights.DEFAULT,
strip=_strip_efficientnet_b0,
blocks=_blocks_efficientnet_b0,
),
"resnet50": BackboneSpec(
ctor=models.resnet50,
weights_default=models.ResNet50_Weights.DEFAULT,
strip=_strip_resnet,
blocks=_blocks_resnet,
),
"densenet121": BackboneSpec(
ctor=models.densenet121,
weights_default=models.DenseNet121_Weights.DEFAULT,
strip=_strip_densenet,
blocks=_blocks_densenet,
),
"vgg16": BackboneSpec(
ctor=models.vgg16,
weights_default=models.VGG16_Weights.DEFAULT,
strip=_strip_vgg,
blocks=_blocks_vgg,
),
"mobilenet_v2": BackboneSpec(
ctor=models.mobilenet_v2,
weights_default=models.MobileNet_V2_Weights.DEFAULT,
strip=_strip_mobilenet_v2,
blocks=_blocks_mobilenet_v2,
),
"inception_v3": BackboneSpec(
ctor=models.inception_v3,
weights_default=models.Inception_V3_Weights.DEFAULT,
strip=_strip_inception_v3,
blocks=_blocks_inception_v3,
),
"refugelike": BackboneSpec(
ctor=models.resnet50,
weights_default=None,
strip=_strip_resnet,
blocks=_blocks_resnet,
),
"refuge_densenet": BackboneSpec(
ctor=models.densenet121,
weights_default=None,
strip=_strip_densenet,
blocks=_blocks_densenet,
),
"refuge_efficient_b0": BackboneSpec(
ctor=models.efficientnet_b0,
weights_default=None,
strip=_strip_efficientnet_b0,
blocks=_blocks_efficientnet_b0,
),
"refuge_efficient_b7": BackboneSpec(
ctor=models.efficientnet_b7,
weights_default=None,
strip=_strip_efficientnet_b0,
blocks=_blocks_efficientnet_b0,
),
# Xception isnt in torchvision
}
def list_names() -> List[str]:
return list(BACKBONES.keys())
def load_backbone_weights(key: str, model: nn.Module) -> None:
if key == "refugelike":
path = REFUGELIKE_BACKBONE_PATH
elif key == "refuge_densenet":
path = REFUGE_DENSENET_PATH
elif key == "refuge_efficient_b0":
path = REFUGE_EFFICIENT_B0_PATH
elif key == "refuge_efficient_b7":
path = REFUGE_EFFICIENT_B7_PATH
else:
return
if not path.exists():
raise FileNotFoundError(
"Custom REFUGE backbone not found at "
f"{path}. Export it via refuge_build.py --export-backbone first."
)
state = torch.load(path, map_location="cpu")
model.load_state_dict(state, strict=False)
-93
View File
@@ -1,93 +0,0 @@
from __future__ import annotations
import torch
import torch.nn as nn
from classes.v2.SE_attention import SEBlock, SEGateLogger
class Bridge(nn.Module):
def __init__(
self,
img_dim,
meta_dim,
num_classes,
fusion_dim=256,
mode="fused",
use_se: bool = True,
se_reduction: int = 16,
se_pre_norm: bool = True,
):
super().__init__()
self.mode = mode
self.use_se = use_se
# project towers to equal width
self.W_img = nn.Linear(img_dim, fusion_dim)
self.W_md = nn.Linear(meta_dim, fusion_dim)
# optional: layernorm before SE
self.ln_img = nn.LayerNorm(fusion_dim) if se_pre_norm else nn.Identity()
self.ln_md = nn.LayerNorm(fusion_dim) if se_pre_norm else nn.Identity()
# SE gate on the fused vector
self.se = SEBlock(fusion_dim, reduction=se_reduction, residual=True) if use_se else None
self.se_log = SEGateLogger(enabled=use_se, track_channels=False, dim=fusion_dim)
# heads
self.classifier_fused = nn.Sequential(
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(fusion_dim, num_classes),
)
self.classifier_img = nn.Linear(img_dim, num_classes)
self.classifier_md = nn.Linear(meta_dim, num_classes)
def reset_se_stats(self):
"""Call at epoch start."""
if getattr(self, "se_log", None):
self.se_log.reset()
def get_se_stats(self, reset: bool = True):
"""Call after eval. Returns dict or None."""
if getattr(self, "se_log", None) and self.se_log.enabled:
return self.se_log.get(reset=reset)
return None
def forward(self, img_feats, md_feats):
out_img = None if self.mode == "metadata_only" else self.classifier_img(img_feats)
out_md = None if self.mode == "image_only" else self.classifier_md(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)) # metadata 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
out_f = self.classifier_fused(fused)
return out_f, out_img, out_md
# if ablation modes:
if self.mode == "image_only":
return out_img, out_img, None
if self.mode == "metadata_only":
return out_md, None, out_md
class VoteBridge(nn.Module):
def __init__(self, num_classes):
super().__init__()
self.vote_combiner = nn.Linear(num_classes * 2, num_classes) # two sets of logits
def forward(self, out_img, out_md):
votes = torch.cat([out_img, out_md], dim=1)
return self.vote_combiner(votes)
-276
View File
@@ -1,276 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import json
from classes.v2.papila_data import PapilaData
@dataclass
class ImportSpec:
id: str
class_name: str
params: Dict[str, Any]
@dataclass
class DataSourceSpec:
node_id: str
label: str
output_type: str
source: Optional[Dict[str, Any]]
source_ref: Optional[Dict[str, Any]]
@dataclass
class TransformSpec:
node_id: str
label: str
transform_type: str
params: Dict[str, Any]
@dataclass
class LoaderSpec:
node_id: str
label: str
input_type: str
input_index: str
input_key: str
output_key: str
transforms: List[TransformSpec]
data_source: Optional[DataSourceSpec]
@dataclass
class TowerSpec:
node_id: str
label: str
tower_type: str
params: Dict[str, Any]
@dataclass
class BridgeSpec:
node_id: str
label: str
method: str
params: Dict[str, Any]
@dataclass
class ClassifierSpec:
node_id: str
label: str
@dataclass
class ConfigAssembly:
raw: Dict[str, Any]
imports: Dict[str, ImportSpec]
data_sources: Dict[str, DataSourceSpec]
transforms: Dict[str, TransformSpec]
loaders: Dict[str, LoaderSpec]
towers: Dict[str, TowerSpec]
bridges: Dict[str, BridgeSpec]
classifiers: Dict[str, ClassifierSpec]
def load_config(path: Path) -> Dict[str, Any]:
payload = json.loads(Path(path).read_text())
if not isinstance(payload, dict):
raise ValueError("Config JSON must be an object.")
return payload
def assemble_config(path: Path) -> ConfigAssembly:
config = load_config(path)
meta = config.get("meta", {})
imports = _build_imports(meta.get("imports", []))
nodes = {node["id"]: node for node in config.get("nodes", [])}
edges = config.get("edges", [])
data_sources: Dict[str, DataSourceSpec] = {}
transforms: Dict[str, TransformSpec] = {}
loaders: Dict[str, LoaderSpec] = {}
towers: Dict[str, TowerSpec] = {}
bridges: Dict[str, BridgeSpec] = {}
classifiers: Dict[str, ClassifierSpec] = {}
for node in nodes.values():
ntype = node.get("type")
if ntype == "data":
data_sources[node["id"]] = DataSourceSpec(
node_id=node["id"],
label=node.get("label", ""),
output_type=node.get("outputType", ""),
source=node.get("source"),
source_ref=node.get("sourceRef"),
)
elif ntype == "transform":
transforms[node["id"]] = TransformSpec(
node_id=node["id"],
label=node.get("label", ""),
transform_type=node.get("transformType", ""),
params=_extract_transform_params(node),
)
elif ntype == "loader":
loaders[node["id"]] = LoaderSpec(
node_id=node["id"],
label=node.get("label", ""),
input_type=node.get("inputType", ""),
input_index=node.get("inputIndex", ""),
input_key=node.get("inputKey", ""),
output_key=node.get("outputKey", ""),
transforms=[],
data_source=None,
)
elif ntype in ("image_tower", "metadata_tower"):
towers[node["id"]] = TowerSpec(
node_id=node["id"],
label=node.get("label", ""),
tower_type=node.get("towerType", "image" if ntype == "image_tower" else "metadata"),
params=_extract_tower_params(node),
)
elif ntype == "bridge":
bridges[node["id"]] = BridgeSpec(
node_id=node["id"],
label=node.get("label", ""),
method=node.get("bridgeMethod", "fusion"),
params=_extract_bridge_params(node),
)
elif ntype == "classifier":
classifiers[node["id"]] = ClassifierSpec(
node_id=node["id"],
label=node.get("label", ""),
)
# attach transforms + data sources to loaders by walking upstream
for loader_id, loader in loaders.items():
chain = _upstream_chain(loader_id, nodes, edges)
for node_id in reversed(chain):
if node_id in transforms:
loader.transforms.append(transforms[node_id])
if node_id in data_sources:
loader.data_source = data_sources[node_id]
return ConfigAssembly(
raw=config,
imports=imports,
data_sources=data_sources,
transforms=transforms,
loaders=loaders,
towers=towers,
bridges=bridges,
classifiers=classifiers,
)
def resolve_imports(assembly: ConfigAssembly) -> Dict[str, Any]:
resolved: Dict[str, Any] = {}
for import_id, spec in assembly.imports.items():
if spec.class_name == "PapilaData":
params = spec.params
resolved[import_id] = PapilaData.from_dirs(
image_dir=params.get("image_dir", "Papila/FundusImages"),
clinical_dir=params.get("clinical_dir", "Papila/ClinicalData"),
label_col=params.get("label_col", "Diagnosis"),
cat_cols=params.get("cat_cols", ["Gender", "Phakic/Pseudophakic"]),
)
else:
raise ValueError(f"Unsupported import class {spec.class_name!r}")
return resolved
def _build_imports(entries: Iterable[Dict[str, Any]]) -> Dict[str, ImportSpec]:
specs: Dict[str, ImportSpec] = {}
for entry in entries or []:
import_id = entry.get("id")
if not import_id:
continue
specs[import_id] = ImportSpec(
id=import_id,
class_name=entry.get("className", ""),
params=entry.get("params", {}) or {},
)
return specs
def _extract_transform_params(node: Dict[str, Any]) -> Dict[str, Any]:
return {
"transformType": node.get("transformType"),
"roiMaskSource": node.get("roiMaskSource"),
"roiScale": node.get("roiScale"),
"roiTargetSize": node.get("roiTargetSize"),
"roiFallback": node.get("roiFallback"),
"centerCropSize": node.get("centerCropSize"),
"jitterHFlip": node.get("jitterHFlip"),
"jitterVFlip": node.get("jitterVFlip"),
"jitterRotation": node.get("jitterRotation"),
"jitterColorEnabled": node.get("jitterColorEnabled"),
"jitterColor": node.get("jitterColor"),
"resizeSize": node.get("resizeSize"),
}
def _extract_tower_params(node: Dict[str, Any]) -> Dict[str, Any]:
if node.get("towerType") == "metadata":
return {
"hidden_dim": node.get("mdHiddenDim"),
"dropout": node.get("mdDropout"),
"use_se": node.get("mdUseSe"),
"se_reduction": node.get("mdSeReduction"),
"se_pre_norm": node.get("mdSePreNorm"),
"freeze_ratio": node.get("mdFreezeRatio"),
}
return {
"backbone": node.get("imageBackbone"),
"freeze_ratio": node.get("imageFreezeRatio"),
"augment": node.get("imageAugment"),
"geometry_dim": node.get("imageGeometryDim"),
"use_se": node.get("imageUseSe"),
"se_reduction": node.get("imageSeReduction"),
"se_pre_norm": node.get("imageSePreNorm"),
}
def _extract_bridge_params(node: Dict[str, Any]) -> Dict[str, Any]:
return {
"fusion_dim": node.get("bridgeFusionDim"),
"use_se": node.get("bridgeUseSe"),
"se_reduction": node.get("bridgeSeReduction"),
"se_pre_norm": node.get("bridgeSePreNorm"),
}
def _edge_from(edge: Dict[str, Any]) -> Optional[str]:
return edge.get("from") or edge.get("source")
def _edge_to(edge: Dict[str, Any]) -> Optional[str]:
return edge.get("to") or edge.get("target")
def _upstream_chain(start_id: str, nodes: Dict[str, Dict[str, Any]], edges: List[Dict[str, Any]]) -> List[str]:
chain: List[str] = []
visited = set()
current = start_id
while True:
if current in visited:
break
visited.add(current)
incoming = [edge for edge in edges if _edge_to(edge) == current]
if not incoming:
break
# prefer first incoming edge for now
current = _edge_from(incoming[0])
if not current:
break
chain.append(current)
node = nodes.get(current)
if node and node.get("type") == "data":
break
return chain
-418
View File
@@ -1,418 +0,0 @@
"""Optic-disc image croppers and preprocessor factory for V2."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Optional, Tuple
import numpy as np
import pandas as pd
import torch
from PIL import Image, ImageDraw
from torchvision import transforms
from classes.v2.geometry_features import compute_geometry_features, disc_cup_from_mask_image
from classes.v2.unet_segmenter import UNetSegmenter
def _geometry_from_mask(mask: np.ndarray, scale: float) -> Dict:
mask = np.asarray(mask) > 0
coords = np.argwhere(mask)
if coords.size == 0:
raise RuntimeError("Empty mask; cannot derive geometry")
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * scale
return {
"centre_x": centre_x,
"centre_y": centre_y,
"radius": radius,
"crop_radius": crop_radius,
"crop_size": crop_radius * 2.0,
}
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.segmenter._normalize_tensor(
self.to_tensor(resized).to(self.segmenter.device)
).unsqueeze(0)
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)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def build_image_preprocessor_from_args(args):
"""Construct the correct image cropper from CLI args, or return None."""
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
-241
View File
@@ -1,241 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Tuple
import numpy as np
import pandas as pd
class DataBundle:
"""
Generic, torch-free container for metadata and file/label bookkeeping.
Keeps feature typing, vectorization, and patient-level splits generic.
Dataset-specific preprocessing (e.g., eye canonicalization) should live
in the dataset builder (e.g., papila_builders in v2).
"""
def __init__(
self,
*,
image_dir: str,
clinical_dir: Optional[str] = None,
label_col: str,
patient_col: str = "Patient ID",
cat_cols: Optional[Iterable[str]] = None,
max_unique_for_cat: int = 4,
n_splits: int = 5,
random_seed: int = 42,
filename_template: str = "RET{pid:03d}{eye}.jpg",
image_path_fn: Optional[Callable[[pd.Series], Path]] = None,
) -> None:
self.image_dir = Path(image_dir)
self.label_col = label_col
self.patient_col = patient_col
self.max_unique_for_cat = max_unique_for_cat
self.n_splits = n_splits
self.filename_template = filename_template
self.image_path_fn = image_path_fn
self.clinical_dir = Path(clinical_dir) if clinical_dir else None
# Internal state
self.frames: List[pd.DataFrame] = []
self.df: pd.DataFrame = pd.DataFrame()
self.scalar_cols: List[str] = []
self.cat_cols: List[str] = list(cat_cols) if cat_cols is not None else []
self.scalar_stats: Dict[str, Dict[str, float]] = {}
self.cat_maps: Dict[str, Dict[object, int]] = {}
self.feature_dim: int = 0
self.folds: Dict[int, Dict[str, List[object]]] = {}
self.random_seed = int(random_seed)
# ------------------- Public API -------------------
def add_df(
self,
df: pd.DataFrame,
*,
id_column: Optional[str] = None,
exclude_cols: Optional[Iterable[str]] = None,
) -> None:
"""
Add a dataframe and re-run typing, stats, and K-fold indices.
QC rules:
- Must have patient ID column; if not provided under that name, specify id_column.
"""
df = df.copy()
self._ensure_patient_id(df, id_column)
if self.label_col not in df.columns:
raise ValueError(f"label_col '{self.label_col}' not found in added dataframe")
self.frames.append(df)
self._refresh_master_df(exclude_cols=exclude_cols)
self._infer_or_validate_feature_types(exclude_cols=exclude_cols)
self._compute_numeric_stats()
self._build_cat_maps()
self._compute_feature_dim()
self._build_kfold_indices()
def get_split_ids(self, fold: int) -> Tuple[List[object], List[object]]:
rec = self.folds.get(fold)
if not rec:
raise KeyError(f"Fold {fold} not available. Built folds: {sorted(self.folds.keys())}")
return rec["train_ids"], rec["test_ids"]
def get_split_dfs(self, fold: int) -> Tuple[pd.DataFrame, pd.DataFrame]:
train_ids, test_ids = self.get_split_ids(fold)
train_df = self.df[self.df[self.patient_col].isin(train_ids)].reset_index(drop=True)
test_df = self.df[self.df[self.patient_col].isin(test_ids)].reset_index(drop=True)
return train_df, test_df
def vectorize_row(self, row: pd.Series) -> np.ndarray:
"""Return a numpy feature vector (torch-free)."""
feats: List[float] = []
miss: List[float] = []
# numeric
for col in self.scalar_cols:
v = pd.to_numeric(row.get(col), errors="coerce")
if pd.isna(v):
miss.append(1.0)
v = self.scalar_stats[col]["median"]
else:
miss.append(0.0)
lo = self.scalar_stats[col]["min"]
hi = self.scalar_stats[col]["max"]
feats.append((float(v) - lo) / (hi - lo) if hi > lo else 0.0)
# categorical
for col in self.cat_cols:
mapping = self.cat_maps[col]
one = [0.0] * len(mapping)
key = row.get(col)
one[mapping.get(key, 0)] = 1.0 # 0 is <UNK>
feats.extend(one)
# numeric missing flags
feats.extend(miss)
return np.asarray(feats, dtype=np.float32)
def get_image_path(self, row: pd.Series) -> Path:
if self.image_path_fn is not None:
return Path(self.image_path_fn(row))
pid = int(row[self.patient_col])
eye = row.get("eyeID", "")
if eye in ("OS", "OD"):
eye_str = eye
else:
eye_str = str(eye)
return self.image_dir / self.filename_template.format(pid=pid, eye=eye_str)
def encode_metadata(self, row: pd.Series) -> np.ndarray:
return self.vectorize_row(row)
def get_label(self, row: pd.Series) -> int:
return int(row[self.label_col])
# ------------------- Internal helpers -------------------
def _ensure_patient_id(self, df: pd.DataFrame, id_column: Optional[str]) -> None:
if self.patient_col in df.columns:
return
if id_column and id_column in df.columns:
df.rename(columns={id_column: self.patient_col}, inplace=True)
return
candidates = [
c
for c in df.columns
if c.lower().replace(" ", "") in {"patientid", "patient", "pid"}
]
if len(candidates) == 1:
df.rename(columns={candidates[0]: self.patient_col}, inplace=True)
return
raise ValueError(
f"A '{self.patient_col}' column is required; provide id_column=... if it has a different name."
)
def _refresh_master_df(self, exclude_cols: Optional[Iterable[str]] = None) -> None:
self.df = pd.concat(self.frames, axis=0, ignore_index=True)
if exclude_cols:
self.df = self.df.drop(columns=[c for c in exclude_cols if c in self.df.columns])
def _infer_or_validate_feature_types(self, exclude_cols: Optional[Iterable[str]] = None) -> None:
excluded = set(exclude_cols or []) | {self.label_col, self.patient_col}
feature_candidates = [c for c in self.df.columns if c not in excluded]
cats = set(self.cat_cols) if self.cat_cols else set()
scalars = set()
for c in feature_candidates:
if c in cats:
continue
s = self.df[c]
as_num = pd.to_numeric(s, errors="coerce")
num_missing = as_num.isna().mean()
num_unique = s.dropna().nunique()
if as_num.notna().any() and num_missing < 1.0 and num_unique > self.max_unique_for_cat:
scalars.add(c)
else:
if num_unique <= self.max_unique_for_cat or as_num.isna().mean() > 0.0:
cats.add(c)
else:
scalars.add(c)
self.cat_cols = sorted(cats)
self.scalar_cols = sorted(scalars)
def _compute_numeric_stats(self) -> None:
self.scalar_stats.clear()
for col in self.scalar_cols:
s = pd.to_numeric(self.df[col], errors="coerce")
vals = s.dropna().astype(float).values
if vals.size == 0:
lo, hi, med = 0.0, 1.0, 0.0
else:
lo, hi = float(np.min(vals)), float(np.max(vals))
med = float(np.median(vals))
if hi <= lo:
hi = lo + 1.0
self.scalar_stats[col] = {"min": lo, "max": hi, "median": med}
def _build_cat_maps(self) -> None:
self.cat_maps.clear()
for col in self.cat_cols:
cats = [v for v in self.df[col].dropna().unique().tolist()]
try:
cats = sorted(cats)
except Exception:
pass
mapping = {"<UNK>": 0}
for i, v in enumerate(cats, start=1):
mapping[v] = i
self.cat_maps[col] = mapping
def _compute_feature_dim(self) -> None:
self.feature_dim = len(self.scalar_cols) + sum(len(m) for m in self.cat_maps.values()) + len(self.scalar_cols)
# ------------------- K-fold on unique patients -------------------
def _build_kfold_indices(self) -> None:
pats = self.df[self.patient_col].unique().tolist()
labels_by_pat: Dict[object, object] = {}
for pid, grp in self.df.groupby(self.patient_col):
lab = grp[self.label_col].dropna()
if len(lab) == 0:
labels_by_pat[pid] = 0
else:
labels_by_pat[pid] = lab.mode().iloc[0]
y_pat = np.array([labels_by_pat[p] for p in pats])
try:
from sklearn.model_selection import StratifiedGroupKFold
sgkf = StratifiedGroupKFold(
n_splits=self.n_splits, shuffle=True, random_state=self.random_seed
)
split_iter = sgkf.split(X=pats, y=y_pat, groups=pats)
except Exception:
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(
n_splits=self.n_splits, shuffle=True, random_state=self.random_seed
)
split_iter = skf.split(X=np.zeros(len(pats)), y=y_pat)
self.folds.clear()
for i, (train_idx, test_idx) in enumerate(split_iter):
train_ids = [pats[j] for j in train_idx]
test_ids = [pats[j] for j in test_idx]
self.folds[i] = {"train_ids": train_ids, "test_ids": test_ids}
-115
View File
@@ -1,115 +0,0 @@
from torch.utils.data import Dataset
from PIL import Image
import numpy as np
import torch
class ClinicalDataset(Dataset):
"""Generic dataset wrapping a DataBundle-like instance.
Returns (img_tensor, meta_tensor, label)."""
def __init__(
self,
clinical_data,
img_transform,
meta_transform=None,
image_preprocessor=None,
geometry_provider=None,
geometry_dim: int = 0,
image_cache: "dict | None" = None,
):
self.clinical = clinical_data
self.transform_image = img_transform
self.meta_transform = meta_transform or (lambda x: x)
self.image_preprocessor = image_preprocessor
self.geometry_provider = geometry_provider
self.geometry_dim = geometry_dim if geometry_provider is not None else 0
self.image_cache = image_cache
def __len__(self):
return len(self.clinical.df)
def __getitem__(self, idx: int):
row = self.clinical.df.iloc[idx]
# load & transform image
img_path = self.clinical.get_image_path(row)
cache_key = str(img_path)
if self.image_cache is not None and cache_key in self.image_cache:
orig_img = Image.fromarray(self.image_cache[cache_key])
else:
orig_img = Image.open(img_path).convert("RGB")
if self.image_cache is not None:
self.image_cache[cache_key] = np.asarray(orig_img, dtype=np.uint8)
img = orig_img
if self.image_preprocessor is not None:
img = self.image_preprocessor(img, img_path)
img_t = self.transform_image(img)
# encode & transform metadata
meta = self.clinical.encode_metadata(row)
meta_t = self.meta_transform(meta)
# label
label = self.clinical.get_label(row)
if self.geometry_dim > 0:
features = None
if self.geometry_provider is not None and hasattr(self.geometry_provider, "geometry_features"):
features = self.geometry_provider.geometry_features(orig_img, img_path)
if features is None:
geom_vec = torch.zeros(self.geometry_dim, dtype=torch.float32)
else:
features = np.asarray(features, dtype=np.float32)
if features.shape[0] != self.geometry_dim:
geom_vec = torch.zeros(self.geometry_dim, dtype=torch.float32)
else:
geom_vec = torch.from_numpy(features)
return img_t, meta_t, geom_vec, label
return img_t, meta_t, label
# ---------------------------------------------------------------------------
# _ClinicalView — shim used by V2HyperTower._run_fold
# ---------------------------------------------------------------------------
from .data_bundle import DataBundle # noqa: E402
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):
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):
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])
-119
View File
@@ -1,119 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, List, Sequence, Tuple, Union
import re
import pandas as pd
@dataclass
class RegexFilter:
pattern: str
flags: int = 0
def apply_paths(self, paths: Sequence[str]) -> Tuple[List[str], List[str]]:
if not self.pattern:
return list(paths), []
try:
regex = re.compile(self.pattern, self.flags)
except re.error as err:
return list(paths), [f'Invalid regex "{self.pattern}": {err}']
filtered = [p for p in paths if regex.search(p)]
return filtered, []
@dataclass
class ColumnFilter:
column: str
operator: str
value: str
case_insensitive: bool = True
def apply_df(self, df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]:
warnings: List[str] = []
if not self.column:
return df, ["Column filter missing column name."]
columns = list(df.columns)
col_index = _resolve_column_index(columns, self.column, warnings)
if col_index is None:
return df, warnings
col_name = columns[col_index]
if self.value is None or self.value == "":
return df, [f'Column filter "{self.column}" missing value.']
series = df[col_name]
mask = series.apply(
lambda cell: compare_cell(
cell, self.value, self.operator, case_insensitive=self.case_insensitive
)
)
return df[mask], warnings
FilterSpec = Union[RegexFilter, ColumnFilter]
def apply_regex_filters(paths: Sequence[str], filters: Iterable[RegexFilter]) -> Tuple[List[str], List[str]]:
filtered = list(paths)
warnings: List[str] = []
for filt in filters:
filtered, warn = filt.apply_paths(filtered)
warnings.extend(warn)
return filtered, warnings
def apply_column_filters(df: pd.DataFrame, filters: Iterable[ColumnFilter]) -> Tuple[pd.DataFrame, List[str]]:
filtered = df
warnings: List[str] = []
for filt in filters:
filtered, warn = filt.apply_df(filtered)
warnings.extend(warn)
return filtered, warnings
def compare_cell(cell, raw_value: str, operator: str, case_insensitive: bool = True) -> bool:
cell_str = "" if cell is None else str(cell).strip()
value_str = "" if raw_value is None else str(raw_value).strip()
if case_insensitive:
cell_str = cell_str.lower()
value_str = value_str.lower()
if operator == "=":
return cell_str == value_str
if operator == "!=":
return cell_str != value_str
cell_num = _to_float(cell_str)
value_num = _to_float(value_str)
if cell_num is None or value_num is None:
return False
if operator == ">":
return cell_num > value_num
if operator == ">=":
return cell_num >= value_num
if operator == "<":
return cell_num < value_num
if operator == "<=":
return cell_num <= value_num
return False
def _resolve_column_index(columns: Sequence[str], column: str, warnings: List[str]) -> int | None:
try:
return columns.index(column)
except ValueError:
lower = column.lower()
matches = [idx for idx, col in enumerate(columns) if str(col).lower() == lower]
if matches:
if len(matches) > 1:
warnings.append(
f'Column "{column}" matched multiple headers; using "{columns[matches[0]]}".'
)
return matches[0]
warnings.append(f'Column "{column}" not found.')
return None
def _to_float(value: str) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
-87
View File
@@ -1,87 +0,0 @@
"""Shared helpers for deriving disc/cup geometry features."""
from __future__ import annotations
from collections import Counter
from typing import Tuple
import numpy as np
from PIL import Image
EPS = 1e-6
FEATURE_DIM = 5
def disc_cup_from_mask_image(mask_img: Image.Image) -> Tuple[np.ndarray, np.ndarray]:
"""Return binary disc/cup masks from a REFUGE-style annotation image."""
arr = np.asarray(mask_img)
if arr.ndim == 3:
h, w, c = arr.shape
border = np.concatenate(
[arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]],
axis=0,
)
border_counts = Counter(map(tuple, border))
bg_color = border_counts.most_common(1)[0][0]
flat = arr.reshape(-1, c)
colors = Counter(map(tuple, flat))
colors.pop(bg_color, None)
disc = (~np.all(arr == bg_color, axis=-1)).astype(np.uint8)
if colors:
cup_color = min(colors.keys(), key=lambda col: sum(col))
cup = np.all(arr == cup_color, axis=-1).astype(np.uint8)
else:
cup = np.zeros((h, w), dtype=np.uint8)
else:
border = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]])
counts = Counter(border.tolist())
bg_value = counts.most_common(1)[0][0]
disc = (arr != bg_value).astype(np.uint8)
fg = arr[arr != bg_value]
if fg.size > 0:
cup_value = int(np.min(fg))
cup = (arr == cup_value).astype(np.uint8)
else:
cup = np.zeros_like(arr, dtype=np.uint8)
cup = (cup > 0) & (disc > 0)
return disc.astype(np.uint8), cup.astype(np.uint8)
def compute_geometry_features(disc_mask: np.ndarray, cup_mask: np.ndarray) -> np.ndarray:
"""Compute cup/disc geometry descriptors (area, rim, diameter ratios, centre shift)."""
disc = (disc_mask > 0).astype(np.float32)
cup = (cup_mask > 0).astype(np.float32)
disc_area = disc.sum()
cup_area = cup.sum()
area_ratio = cup_area / (disc_area + EPS)
rim_ratio = (disc_area - cup_area) / (disc_area + EPS)
disc_rows = np.any(disc > 0, axis=1)
cup_rows = np.any(cup > 0, axis=1)
disc_cols = np.any(disc > 0, axis=0)
cup_cols = np.any(cup > 0, axis=0)
disc_height = float(disc_rows.sum())
cup_height = float(cup_rows.sum())
disc_width = float(disc_cols.sum())
cup_width = float(cup_cols.sum())
vertical_ratio = cup_height / (disc_height + EPS)
horizontal_ratio = cup_width / (disc_width + EPS)
def _centre(mask: np.ndarray) -> Tuple[float, float]:
coords = np.argwhere(mask > 0)
if coords.size == 0:
return 0.5, 0.5
ys, xs = coords[:, 0], coords[:, 1]
return float(xs.mean()) / mask.shape[1], float(ys.mean()) / mask.shape[0]
disc_cx, disc_cy = _centre(disc)
cup_cx, cup_cy = _centre(cup)
centre_shift = float(np.hypot(cup_cx - disc_cx, cup_cy - disc_cy))
return np.array(
[area_ratio, rim_ratio, vertical_ratio, horizontal_ratio, centre_shift],
dtype=np.float32,
)
-128
View File
@@ -1,128 +0,0 @@
from __future__ import annotations
import csv
import json
import logging
from pathlib import Path
from typing import Optional
DEFAULT_OPTIONAL_EPOCH_COLS = [
"pct_fused",
"pct_img",
"pct_md",
"phase",
"se_mean",
"se_std",
"se_pct_lt_0.2",
"se_pct_gt_0.8",
"holdout_loss",
"holdout_acc_fused",
"holdout_acc_img",
"holdout_acc_md",
"holdout_auc_fused",
"holdout_auc_img",
"holdout_auc_md",
"best_monitor",
"best_so_far",
"best_epoch",
"early_best_so_far",
"early_bad_epochs",
"early_improved",
"early_monitor",
"holdout_best_monitor",
"holdout_best_so_far",
"holdout_best_epoch",
]
class HypertowerLogger:
"""
Shared logging utility for V2 tower workflows.
- train.log line logging
- epoch_log.csv row logging with stable header
- lightweight JSON/array artifact helpers
"""
def __init__(
self,
*,
run_dir: Path,
train_log_path: Optional[Path] = None,
epoch_log_path: Optional[Path] = None,
logger_name: Optional[str] = None,
) -> None:
self.run_dir = Path(run_dir).resolve()
self.run_dir.mkdir(parents=True, exist_ok=True)
self.train_log_path = Path(train_log_path) if train_log_path else (self.run_dir / "train.log")
self.epoch_log_path = Path(epoch_log_path) if epoch_log_path else (self.run_dir / "epoch_log.csv")
self._logger_name = logger_name or f"hypertower.{id(self)}"
self.logger = logging.getLogger(self._logger_name)
self.logger.setLevel(logging.INFO)
self.logger.handlers = []
fh = logging.FileHandler(str(self.train_log_path))
fh.setFormatter(logging.Formatter("%(asctime)s - %(message)s"))
self.logger.addHandler(fh)
self.logger.propagate = False
self._epoch_log_fp = None
self._epoch_log_writer = None
self._epoch_log_fields: list[str] | None = None
def info(self, msg: str) -> None:
self.logger.info(msg)
def warning(self, msg: str) -> None:
self.logger.warning(msg)
def error(self, msg: str) -> None:
self.logger.error(msg)
def write_epoch_row(
self,
row: dict,
*,
path: str | Path | None = None,
optional_cols: Optional[list[str]] = None,
) -> None:
optional = optional_cols if optional_cols is not None else DEFAULT_OPTIONAL_EPOCH_COLS
if self._epoch_log_writer is None:
fieldnames = list(dict.fromkeys([*row.keys(), *optional]))
target_path = Path(path) if path is not None else self.epoch_log_path
target_path.parent.mkdir(parents=True, exist_ok=True)
self._epoch_log_fp = open(target_path, "w", newline="", encoding="utf-8")
self._epoch_log_writer = csv.DictWriter(self._epoch_log_fp, fieldnames=fieldnames)
self._epoch_log_writer.writeheader()
self._epoch_log_fields = fieldnames
assert self._epoch_log_fields is not None
assert self._epoch_log_writer is not None
assert self._epoch_log_fp is not None
for key in self._epoch_log_fields:
row.setdefault(key, None)
self._epoch_log_writer.writerow({k: row.get(k) for k in self._epoch_log_fields})
self._epoch_log_fp.flush()
def write_json(self, path: str | Path, payload: dict) -> None:
target = Path(path)
if not target.is_absolute():
target = self.run_dir / target
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def close(self) -> None:
if self._epoch_log_fp is not None:
try:
self._epoch_log_fp.close()
except Exception:
pass
self._epoch_log_fp = None
self._epoch_log_writer = None
self._epoch_log_fields = None
for handler in list(self.logger.handlers):
try:
handler.close()
except Exception:
pass
self.logger.removeHandler(handler)
-244
View File
@@ -1,244 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
import torch
from torch.utils.data import DataLoader, WeightedRandomSampler
from .network_manager import LoaderBundle, PatientSplit
from .slot_dataset import SlotDataset, slot_collate
from .profiles.base import SlotDescriptor, SimpleDatasetProfile
def _default_slot_descriptors(patient_col: str, label_col: str) -> dict[str, SlotDescriptor]:
return {
"id_1": SlotDescriptor(
key="id_1",
kind="id",
description=f"Patient identifier column ({patient_col})",
required=True,
shape_hint="scalar",
),
"eye_id_1": SlotDescriptor(
key="eye_id_1",
kind="id",
description="Eye side identifier (OD/OS)",
required=False,
shape_hint="scalar",
),
"label_1": SlotDescriptor(
key="label_1",
kind="label",
description=f"Label column ({label_col})",
required=True,
shape_hint="scalar",
),
"image_1": SlotDescriptor(
key="image_1",
kind="image",
description="Primary image slot",
required=False,
shape_hint="HWC or CHW",
),
"matrix_1": SlotDescriptor(
key="matrix_1",
kind="matrix",
description="Primary matrix slot",
required=False,
shape_hint="[feature_dim]",
),
}
def _row_to_sample(
row: Any,
*,
clinical: Any,
patient_col: str,
label_col: str,
) -> dict[str, Any]:
return {
"id_1": row[patient_col],
"eye_id_1": str(row.get("eyeID", "")),
"label_1": row[label_col],
"image_1": clinical.get_image_path(row) if hasattr(clinical, "get_image_path") else None,
"matrix_1": clinical.vectorize_row(row) if hasattr(clinical, "vectorize_row") else None,
}
@dataclass
class SlotLoaderFactory:
"""
Generic loader factory that emits dict batches keyed by slot names.
"""
image_transform: Optional[Callable] = None
matrix_transform: Optional[Callable] = None
num_workers: int = 0
def build(
self,
*,
clinical: Any,
split: PatientSplit,
args: Any,
fold: int,
profile: Optional[Any] = None,
) -> LoaderBundle:
batch_size = int(getattr(args, "batch_size", 8))
slot_desc = self._resolve_slot_descriptors(clinical=clinical, profile=profile)
train_samples = self._build_samples(split.train, clinical, profile, slot_desc)
val_samples = self._build_samples(split.val, clinical, profile, slot_desc)
holdout_samples = (
self._build_samples(split.holdout, clinical, profile, slot_desc)
if split.holdout is not None
else None
)
train_loader = DataLoader(
SlotDataset(
train_samples,
slot_desc,
image_transform=self.image_transform,
matrix_transform=self.matrix_transform,
),
batch_size=batch_size,
shuffle=True,
num_workers=self.num_workers,
collate_fn=slot_collate,
)
val_loader = DataLoader(
SlotDataset(
val_samples,
slot_desc,
image_transform=self.image_transform,
matrix_transform=self.matrix_transform,
),
batch_size=batch_size,
shuffle=False,
num_workers=self.num_workers,
collate_fn=slot_collate,
)
holdout_loader = None
if holdout_samples is not None:
holdout_loader = DataLoader(
SlotDataset(
holdout_samples,
slot_desc,
image_transform=self.image_transform,
matrix_transform=self.matrix_transform,
),
batch_size=batch_size,
shuffle=False,
num_workers=self.num_workers,
collate_fn=slot_collate,
)
return LoaderBundle(train=train_loader, val=val_loader, holdout=holdout_loader)
@staticmethod
def _resolve_slot_descriptors(
*,
clinical: Any,
profile: Optional[Any],
) -> dict[str, SlotDescriptor]:
if profile is not None and hasattr(profile, "slot_descriptors"):
return profile.slot_descriptors()
patient_col = getattr(clinical, "patient_col", "Patient ID")
label_col = getattr(clinical, "label_col", "Diagnosis")
return _default_slot_descriptors(patient_col, label_col)
@staticmethod
def _build_samples(
df,
clinical: Any,
profile: Optional[Any],
slot_desc: dict[str, SlotDescriptor],
) -> list[dict[str, Any]]:
if df is None or df.empty:
return []
if profile is not None and hasattr(profile, "build_samples"):
return profile.build_samples(df=df, clinical=clinical)
patient_col = getattr(profile, "patient_col", None) if profile is not None else None
label_col = getattr(profile, "label_col", None) if profile is not None else None
pcol = patient_col or "Patient ID"
lcol = label_col or getattr(clinical, "label_col", "Diagnosis")
samples = []
for _, row in df.iterrows():
sample = _row_to_sample(row, clinical=clinical, patient_col=pcol, label_col=lcol)
for key in slot_desc.keys():
sample.setdefault(key, None)
samples.append(sample)
return samples
# ---------------------------------------------------------------------------
# V2 filter / loader helpers (used by V2HyperTower._run_fold)
# ---------------------------------------------------------------------------
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,
image_cache=None,
batch_size: int,
shuffle: bool,
num_workers: int,
sampler: Optional[WeightedRandomSampler] = None,
) -> DataLoader:
ds = SlotDataset(
samples,
slots,
image_transform=image_transform,
image_preprocessor=image_preprocessor,
image_cache=image_cache,
)
return DataLoader(
ds,
batch_size=batch_size,
shuffle=(shuffle if sampler is None else False),
sampler=sampler,
num_workers=num_workers,
collate_fn=slot_collate,
)
def build_balanced_sampler(samples: list[dict], label_key: str = "label_1") -> WeightedRandomSampler:
"""Return a WeightedRandomSampler that equalises class frequency for training."""
from collections import Counter
labels = [s[label_key] for s in samples]
counts = Counter(labels)
weights = [1.0 / counts[lbl] for lbl in labels]
return WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)
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)
-243
View File
@@ -1,243 +0,0 @@
"""Metric computation, calibration, and threshold/bias tuning for V2."""
from __future__ import annotations
from typing import Optional
import numpy as np
import torch
import torch.nn.functional as F
from sklearn.metrics import (
cohen_kappa_score,
f1_score,
matthews_corrcoef,
recall_score,
roc_auc_score,
roc_curve,
)
# ---------------------------------------------------------------------------
# Loss
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# Basic array scoring
# ---------------------------------------------------------------------------
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))
# ---------------------------------------------------------------------------
# Calibration
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Threshold / bias tuning
# ---------------------------------------------------------------------------
def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float:
"""Pick threshold via Youden's J (sensitivity + specificity 1).
This is class-distribution independent, unlike maximising raw accuracy,
which is biased toward the majority class on imbalanced validation sets.
Falls back to 0.5 if both classes are not present.
"""
if y_true.size == 0 or len(np.unique(y_true)) < 2:
return 0.5
fpr, tpr, thresholds = roc_curve(y_true, p1)
j = tpr + (1.0 - fpr) - 1.0
return float(thresholds[np.argmax(j)])
def multiclass_acc_with_bias(y_true: np.ndarray, probs: np.ndarray, bias: np.ndarray) -> float:
"""Balanced accuracy (mean per-class recall) after applying log-space bias."""
if y_true.size == 0:
return float("nan")
logits = np.log(np.clip(probs, 1e-8, 1.0)) + bias.reshape(1, -1)
preds = np.argmax(logits, axis=1)
classes = np.unique(y_true)
per_class = [(preds[y_true == c] == c).mean() for c in classes]
return float(np.mean(per_class))
def tune_multiclass_bias(y_true: np.ndarray, probs: np.ndarray, *, iters: int = 2) -> np.ndarray:
"""Grid-search per-class log-space bias to maximise balanced accuracy.
Balanced accuracy (mean per-class recall) is class-distribution independent,
unlike raw accuracy which is biased toward the majority class on imbalanced
validation sets.
"""
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]:
"""Serialise a float vector to pipe-separated string, or None if empty."""
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 _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
-148
View File
@@ -1,148 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
import torch
from torch import nn
from classes.v2.bridges import Bridge, VoteBridge
from classes.v2.towers import ImageTower, MDTower
from .config_builder import ConfigAssembly
from .transforms import build_transform_chain
@dataclass
class V2ModelBundle:
image_tower: Optional[ImageTower]
metadata_tower: Optional[MDTower]
bridge: Optional[nn.Module]
classifier: Optional[nn.Module]
image_transform: Optional[Callable]
matrix_transform: Optional[Callable]
def build_model_bundle(
assembly: ConfigAssembly,
clinical: Any,
*,
device: Optional[torch.device] = None,
strict: bool = True,
) -> V2ModelBundle:
"""
Build torch modules and input transforms from a V2 config assembly.
"""
image_tower_spec = _pick_tower(assembly, "image")
md_tower_spec = _pick_tower(assembly, "metadata")
bridge_spec = _pick_bridge(assembly)
image_loader = _pick_loader(assembly, input_type="image")
clinical_core = getattr(clinical, "clinical", clinical)
num_classes = _infer_num_classes(clinical)
img_tower = None
if image_tower_spec is not None:
img_tower = ImageTower(
backbone=image_tower_spec.params.get("backbone", "efficientnet_b0"),
freeze_ratio=float(image_tower_spec.params.get("freeze_ratio", 0.0) or 0.0),
use_se=bool(image_tower_spec.params.get("use_se", False)),
se_reduction=int(image_tower_spec.params.get("se_reduction", 16) or 16),
se_pre_norm=bool(image_tower_spec.params.get("se_pre_norm", True)),
augment=bool(image_tower_spec.params.get("augment", True)),
geometry_dim=int(image_tower_spec.params.get("geometry_dim", 0) or 0),
)
if device is not None:
img_tower = img_tower.to(device)
md_tower = None
if md_tower_spec is not None:
md_tower = MDTower(
clinical_core,
hidden_dim=int(md_tower_spec.params.get("hidden_dim", 128) or 128),
dropout=float(md_tower_spec.params.get("dropout", 0.1) or 0.1),
use_se=bool(md_tower_spec.params.get("use_se", False)),
se_reduction=int(md_tower_spec.params.get("se_reduction", 16) or 16),
se_pre_norm=bool(md_tower_spec.params.get("se_pre_norm", True)),
)
if device is not None:
md_tower = md_tower.to(device)
bridge = None
if bridge_spec is not None and img_tower is not None and md_tower is not None:
if bridge_spec.method == "consensus":
bridge = VoteBridge(num_classes=num_classes)
else:
bridge = Bridge(
img_dim=img_tower.out_dim,
meta_dim=md_tower.out_dim,
num_classes=num_classes,
fusion_dim=int(bridge_spec.params.get("fusion_dim", 256) or 256),
mode="fused",
use_se=bool(bridge_spec.params.get("use_se", True)),
se_reduction=int(bridge_spec.params.get("se_reduction", 16) or 16),
se_pre_norm=bool(bridge_spec.params.get("se_pre_norm", True)),
)
if device is not None:
bridge = bridge.to(device)
classifier = None
if assembly.classifiers:
classifier = nn.Identity()
if device is not None:
classifier = classifier.to(device)
image_transform = None
if image_loader is not None and image_tower_spec is not None:
image_transform = build_transform_chain(
image_loader.transforms,
backbone_name=image_tower_spec.params.get("backbone", "efficientnet_b0"),
augment=bool(image_tower_spec.params.get("augment", True)),
strict=strict,
)
return V2ModelBundle(
image_tower=img_tower,
metadata_tower=md_tower,
bridge=bridge,
classifier=classifier,
image_transform=image_transform,
matrix_transform=None,
)
def _pick_tower(assembly: ConfigAssembly, tower_type: str):
matches = [tower for tower in assembly.towers.values() if tower.tower_type == tower_type]
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"Multiple {tower_type} towers found; only one is supported for now.")
return matches[0]
def _pick_bridge(assembly: ConfigAssembly):
if not assembly.bridges:
return None
if len(assembly.bridges) > 1:
raise ValueError("Multiple bridges found; only one is supported for now.")
return next(iter(assembly.bridges.values()))
def _pick_loader(assembly: ConfigAssembly, input_type: str):
matches = [loader for loader in assembly.loaders.values() if loader.input_type == input_type]
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"Multiple loaders with input_type={input_type!r} found.")
return matches[0]
def _infer_num_classes(clinical: Any) -> int:
df = getattr(clinical, "df", None)
label_col = getattr(clinical, "label_col", None)
if df is None and hasattr(clinical, "clinical"):
df = clinical.clinical.df
label_col = clinical.clinical.label_col
if df is None or label_col is None or label_col not in df.columns:
return 2
return int(df[label_col].dropna().nunique())
-858
View File
@@ -1,858 +0,0 @@
"""V2 model classes and training/inference helpers."""
from __future__ import annotations
from random import random
from typing import Optional
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
from torch.utils.data import DataLoader
from classes.v2.bridges import Bridge
from classes.v2.towers import ImageTower, MDTower
# ---------------------------------------------------------------------------
# Model classes
# ---------------------------------------------------------------------------
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,
bridge_mode: str = "fused",
):
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=bridge_mode,
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 = None if self.bridge.mode == "metadata_only" else self.img_tower(x)
md_feats = None if self.bridge.mode == "image_only" else 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
class FusedEnsembleHT(nn.Module):
"""
SingleEyeHT base with a per-eye attention scorer for bilateral fusion.
The base model is trained eye-level (identical to ensemble mode).
After base training completes, the base is frozen and only the
eye_scorer is trained on bilateral (patient-level) samples.
At inference, eye_scorer is applied independently to each eye's logit
vector to produce a scalar attention score. Softmax over the two scores
gives attention weights; the final logit is a weighted sum:
score_od = eye_scorer(logit_od) # [B, 1]
score_os = eye_scorer(logit_os) # [B, 1]
alpha = softmax([score_od, score_os]) # [B, 2], sums to 1
out = alpha[:,0:1]*logit_od + alpha[:,1:2]*logit_os
Because eye_scorer is applied to each eye with the same weights, the
mechanism is permutation-equivariant — there is no left/right positional
bias. Through training on bilateral labels the scorer learns to give high
scores to logits that point strongly toward the GC class, creating the
desired asymmetry: a confidently GC eye dominates the patient prediction
more than a comparably confident healthy eye would.
"""
def __init__(self, base: SingleEyeHT, num_classes: int):
super().__init__()
self.base = base
# Applied independently to each eye's logit → scalar attention score.
# Learns the GC-direction in logit space from bilateral labels.
self.eye_scorer = nn.Linear(num_classes, 1, bias=True)
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) # [B, C]
logit_os = self.base(x_os, meta_os) # [B, C]
scores = torch.cat([self.eye_scorer(logit_od),
self.eye_scorer(logit_os)], dim=1) # [B, 2]
alpha = torch.softmax(scores, dim=1) # [B, 2]
return alpha[:, 0:1] * logit_od + alpha[:, 1:2] * logit_os # [B, C]
# ---------------------------------------------------------------------------
# Phase control
# ---------------------------------------------------------------------------
def _set_requires_grad(module: nn.Module, enabled: bool) -> None:
for p in module.parameters():
p.requires_grad = enabled
def _set_single_phase(model: SingleEyeHT, phase: str) -> None:
bridge_mode = model.bridge.mode
# Ablation modes have no fusion bridge; fused_warmup is meaningless — treat as tower_warmup
if bridge_mode in ("image_only", "metadata_only") and phase == "fused_warmup":
phase = "tower_warmup"
if phase == "md_warmup":
_set_requires_grad(model.img_tower, False)
_set_requires_grad(model.md_tower, True)
_set_requires_grad(model.bridge.classifier_img, False)
_set_requires_grad(model.bridge.classifier_md, True)
_set_requires_grad(model.bridge.W_img, False)
_set_requires_grad(model.bridge.W_md, False)
_set_requires_grad(model.bridge.classifier_fused, False)
return
if phase == "tower_warmup":
_set_requires_grad(model.img_tower, bridge_mode != "metadata_only")
_set_requires_grad(model.md_tower, bridge_mode != "image_only")
_set_requires_grad(model.bridge.classifier_img, bridge_mode != "metadata_only")
_set_requires_grad(model.bridge.classifier_md, bridge_mode != "image_only")
_set_requires_grad(model.bridge.W_img, False)
_set_requires_grad(model.bridge.W_md, False)
_set_requires_grad(model.bridge.classifier_fused, False)
return
if phase == "fused_warmup":
_set_requires_grad(model.img_tower, False)
_set_requires_grad(model.md_tower, False)
_set_requires_grad(model.bridge.classifier_img, False)
_set_requires_grad(model.bridge.classifier_md, False)
_set_requires_grad(model.bridge.W_img, True)
_set_requires_grad(model.bridge.W_md, True)
_set_requires_grad(model.bridge.classifier_fused, True)
return
_set_requires_grad(model, True)
def _set_bilateral_phase(model: BilateralHT, phase: str) -> None:
if phase == "tower_warmup":
_set_requires_grad(model.eye_img_tower, True)
_set_requires_grad(model.eye_md_tower, True)
_set_requires_grad(model.joint_img, True)
_set_requires_grad(model.joint_md, True)
_set_requires_grad(model.aux_img, True)
_set_requires_grad(model.aux_md, True)
_set_requires_grad(model.bridge, False)
return
if phase == "fused_warmup":
_set_requires_grad(model.eye_img_tower, False)
_set_requires_grad(model.eye_md_tower, False)
_set_requires_grad(model.joint_img, False)
_set_requires_grad(model.joint_md, False)
_set_requires_grad(model.aux_img, False)
_set_requires_grad(model.aux_md, False)
_set_requires_grad(model.bridge, True)
return
_set_requires_grad(model, True)
# ---------------------------------------------------------------------------
# Training helpers
# ---------------------------------------------------------------------------
def train_single_epoch(
model: SingleEyeHT,
loader: DataLoader,
opt,
device: torch.device,
*,
phase: str,
bcd_prob: float = 0.5,
tower_loss_mode: str = "bcd",
) -> tuple[float, float]:
model.train()
_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 phase == "md_warmup":
if not torch.is_tensor(m):
continue
m = m.to(device)
y = _to_label_tensor(y, device)
md_feats = model.md_tower(m)
logits = model.bridge.classifier_md(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
continue
if not torch.is_tensor(x) or not torch.is_tensor(m):
continue
x = x.to(device)
m = m.to(device)
y = _to_label_tensor(y, device)
bridge_mode = model.bridge.mode
img_feats = None if bridge_mode == "metadata_only" else model.img_tower(x)
md_feats = None if bridge_mode == "image_only" else model.md_tower(m)
if phase == "tower_warmup":
if bridge_mode == "metadata_only":
logits = model.bridge.classifier_md(md_feats)
loss = F.cross_entropy(logits, y)
elif bridge_mode == "image_only":
logits = model.bridge.classifier_img(img_feats)
loss = F.cross_entropy(logits, y)
else:
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 bridge_mode == "metadata_only":
logits = model.bridge.classifier_md(md_feats)
loss = F.cross_entropy(logits, y)
elif bridge_mode == "image_only":
logits = model.bridge.classifier_img(img_feats)
loss = F.cross_entropy(logits, y)
elif tower_loss_mode == "all":
loss_i = F.cross_entropy(model.bridge.classifier_img(img_feats), y)
loss_m = F.cross_entropy(model.bridge.classifier_md(md_feats), y)
logits, _, _ = model.bridge(img_feats, md_feats)
loss = F.cross_entropy(logits, y) + loss_i + loss_m
elif 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"),
)
def train_bilateral_epoch(
model: BilateralHT,
loader: DataLoader,
opt,
device: torch.device,
*,
phase: str,
bcd_prob: float = 0.5,
tower_loss_mode: str = "bcd",
) -> tuple[float, float]:
model.train()
_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 = _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 tower_loss_mode == "all":
loss_i = F.cross_entropy(model.aux_img(joint_img), y)
loss_m = F.cross_entropy(model.aux_md(joint_md), y)
logits, _, _ = model.bridge(joint_img, joint_md)
loss = F.cross_entropy(logits, y) + loss_i + loss_m
elif 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"),
)
def train_fusion_epoch(
model: FusedEnsembleHT,
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()
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
y_t = _to_label_tensor(y, device)
out = model(x1.to(device), m1.to(device), x2.to(device), m2.to(device))
loss = F.cross_entropy(out, y_t)
opt.zero_grad()
loss.backward()
opt.step()
bs = y_t.shape[0]
total_loss += float(loss.item()) * bs
total_correct += int((out.argmax(1) == y_t).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"),
)
# ---------------------------------------------------------------------------
# Inference helpers
# ---------------------------------------------------------------------------
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 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]).
"""
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 = _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)
def collect_probs_ensemble_pereye(
model: "SingleEyeHT",
loader: DataLoader,
device: torch.device,
*,
return_ids: bool = False,
):
"""
Per-patient, per-eye probs for all 3 heads from a bilateral loader (ensemble mode).
OD corresponds to image_1/matrix_1; OS to image_2/matrix_2.
Arrays are in patient order (not interleaved at sample level).
Returns:
(y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os)
or, when return_ids=True:
(y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os, patient_ids)
Patient-level averaged ensemble probs can be recovered as:
p_en = 0.5 * (pf_od + pf_os)
"""
model.eval()
y_chunks: list = []
pf_od_c, pi_od_c, pm_od_c = [], [], []
pf_os_c, pi_os_c, pm_os_c = [], [], []
id_chunks: list[str] = []
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 = _to_label_tensor(y, device)
def _fwd(x, m):
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
pf = F.softmax(out_f, dim=1)
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
return pf, pi, pm
pf_od, pi_od, pm_od = _fwd(x1, m1)
pf_os, pi_os, pm_os = _fwd(x2, m2)
y_chunks.append(y_t.cpu().numpy())
pf_od_c.append(pf_od.cpu().numpy()); pi_od_c.append(pi_od.cpu().numpy()); pm_od_c.append(pm_od.cpu().numpy())
pf_os_c.append(pf_os.cpu().numpy()); pi_os_c.append(pi_os.cpu().numpy()); pm_os_c.append(pm_os.cpu().numpy())
if return_ids:
ids = batch.get("id_1", [""] * len(y_t))
if torch.is_tensor(ids):
ids = ids.tolist()
id_chunks.extend([str(i) for i in ids])
if not y_chunks:
z = np.zeros((0, 0), dtype=np.float32)
empty_i = np.array([], dtype=np.int64)
base = (empty_i, z, z, z, z, z, z)
return base + (np.array([], dtype=object),) if return_ids else base
y = np.concatenate(y_chunks)
pf_od = np.concatenate(pf_od_c, axis=0); pi_od = np.concatenate(pi_od_c, axis=0); pm_od = np.concatenate(pm_od_c, axis=0)
pf_os = np.concatenate(pf_os_c, axis=0); pi_os = np.concatenate(pi_os_c, axis=0); pm_os = np.concatenate(pm_os_c, axis=0)
if return_ids:
return y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os, np.array(id_chunks, dtype=object)
return y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os
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]).
"""
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 = _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)
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])."""
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 = _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)
def collect_probs_fused(
model: FusedEnsembleHT,
loader: DataLoader,
device: torch.device,
) -> tuple[np.ndarray, np.ndarray]:
"""Patient-level fused-head eval. Returns (y_true [N], probs [N, C])."""
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 = _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)
def collect_probs_single_components(
model: SingleEyeHT,
loader: DataLoader,
device: torch.device,
*,
aggregate_patient: bool,
return_logits: bool = False,
):
"""
Collect fused/img/md probabilities (and optionally raw logits) for SingleEyeHT.
- aggregate_patient=False: eye-level (OD/OS as independent samples)
- aggregate_patient=True : patient-level (average OD/OS per head)
- return_logits=False: returns (y, probs_f, probs_i, probs_m)
- return_logits=True: returns (y, probs_f, probs_i, probs_m,
logits_f, logits_i, logits_m)
Note: logits are averaged across eyes when aggregate_patient=True,
which is equivalent to averaging in logit space (before softmax).
"""
model.eval()
y_chunks = []
pf_chunks, pi_chunks, pm_chunks = [], [], []
lf_chunks, li_chunks, lm_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 = _to_label_tensor(y, device)
def _per_eye(x, m):
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
pf = F.softmax(out_f, dim=1)
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
lf = out_f
li = out_i if out_i is not None else out_f
lm = out_m if out_m is not None else out_f
return pf, pi, pm, lf, li, lm
pf_od, pi_od, pm_od, lf_od, li_od, lm_od = _per_eye(x1, m1)
pf_os, pi_os, pm_os, lf_os, li_os, lm_os = _per_eye(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())
lf_chunks.append((0.5 * (lf_od + lf_os)).cpu().numpy())
li_chunks.append((0.5 * (li_od + li_os)).cpu().numpy())
lm_chunks.append((0.5 * (lm_od + lm_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()]
lf_chunks += [lf_od.cpu().numpy(), lf_os.cpu().numpy()]
li_chunks += [li_od.cpu().numpy(), li_os.cpu().numpy()]
lm_chunks += [lm_od.cpu().numpy(), lm_os.cpu().numpy()]
if not y_chunks:
z = np.zeros((0, 0), dtype=np.float32)
if return_logits:
return np.array([], dtype=np.int64), z, z, z, z, z, z
return np.array([], dtype=np.int64), z, z, z
y = np.concatenate(y_chunks)
pf = np.concatenate(pf_chunks, axis=0)
pi = np.concatenate(pi_chunks, axis=0)
pm = np.concatenate(pm_chunks, axis=0)
if return_logits:
lf = np.concatenate(lf_chunks, axis=0)
li = np.concatenate(li_chunks, axis=0)
lm = np.concatenate(lm_chunks, axis=0)
return y, pf, pi, pm, lf, li, lm
return y, pf, pi, pm
def collect_probs_eye_level(
model: "SingleEyeHT",
loader: DataLoader,
device: torch.device,
*,
return_ids: bool = False,
):
"""
Collect fused/img/md probabilities from a single-eye loader (image_1/matrix_1 only).
Used for eval-mode passes over the training set.
Returns (y, probs_f, probs_i, probs_m) or, when return_ids=True,
(y, probs_f, probs_i, probs_m, sample_ids) where sample_ids is an
array of strings like "2OD", "4OS".
"""
model.eval()
y_chunks, pf_chunks, pi_chunks, pm_chunks, id_chunks = [], [], [], [], []
with torch.no_grad():
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) and torch.is_tensor(m)):
continue
y_t = _to_label_tensor(y, device)
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
pf = F.softmax(out_f, dim=1)
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
y_chunks.append(y_t.cpu().numpy())
pf_chunks.append(pf.cpu().numpy())
pi_chunks.append(pi.cpu().numpy())
pm_chunks.append(pm.cpu().numpy())
if return_ids:
ids = batch.get("id_1", [""] * len(y_t))
eyes = batch.get("eye_id_1", [""] * len(y_t))
# ids/eyes may be tensors (int) or lists of strings
if torch.is_tensor(ids):
ids = ids.tolist()
if torch.is_tensor(eyes):
eyes = eyes.tolist()
id_chunks.extend(
[f"{pid}{eye}" for pid, eye in zip(ids, eyes)]
)
if not y_chunks:
z = np.zeros((0, 0), dtype=np.float32)
empty_ids = np.array([], dtype=object)
if return_ids:
return np.array([], dtype=np.int64), z, z, z, empty_ids
return np.array([], dtype=np.int64), z, z, z
y = np.concatenate(y_chunks)
pf = np.concatenate(pf_chunks, axis=0)
pi = np.concatenate(pi_chunks, axis=0)
pm = np.concatenate(pm_chunks, axis=0)
if return_ids:
return y, pf, pi, pm, np.array(id_chunks, dtype=object)
return y, pf, pi, pm
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 = _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),
)
# ---------------------------------------------------------------------------
# V2ModeComparisonOps — thin class wrapper kept for external import compat
# ---------------------------------------------------------------------------
class V2ModeComparisonOps:
"""Namespace wrapper kept for backward-compatibility imports."""
_set_requires_grad = staticmethod(_set_requires_grad)
_set_single_phase = staticmethod(_set_single_phase)
_set_bilateral_phase = staticmethod(_set_bilateral_phase)
train_single_epoch = staticmethod(train_single_epoch)
train_bilateral_epoch = staticmethod(train_bilateral_epoch)
collect_probs_classic = staticmethod(collect_probs_classic)
collect_probs_ensemble = staticmethod(collect_probs_ensemble)
collect_probs_bilateral = staticmethod(collect_probs_bilateral)
@staticmethod
def _to_label_tensor(labels, device: torch.device) -> torch.Tensor:
return _to_label_tensor(labels, device)
-200
View File
@@ -1,200 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Protocol
import pandas as pd
@dataclass
class PatientSplit:
"""Patient-disjoint split definition for a fold."""
train: pd.DataFrame
val: pd.DataFrame
holdout: Optional[pd.DataFrame] = None
@dataclass
class LoaderBundle:
"""All loaders needed by a training run."""
train: Any
val: Any
holdout: Optional[Any] = None
@dataclass
class FoldResult:
"""Normalized fold output from trainer implementations."""
fold: int
metrics: dict[str, Any]
artifacts: dict[str, Any]
class SplitManager(Protocol):
def build_plans(
self,
*,
clinical: Any,
args: Any,
profile: Optional[Any] = None,
) -> list[PatientSplit]:
...
class GraphFactory(Protocol):
def build(
self,
*,
clinical: Any,
args: Any,
fold: int,
profile: Optional[Any] = None,
) -> Any:
...
class LoaderFactory(Protocol):
def build(
self,
*,
clinical: Any,
split: PatientSplit,
args: Any,
fold: int,
profile: Optional[Any] = None,
) -> LoaderBundle:
...
class Trainer(Protocol):
def fit(
self,
*,
graph: Any,
loaders: LoaderBundle,
args: Any,
fold: int,
profile: Optional[Any] = None,
) -> FoldResult:
...
class NetworkManager:
"""
V2 orchestration entrypoint.
This class is intentionally small and modular:
- split policy is delegated to a SplitManager
- graph assembly is delegated to a GraphFactory
- dataloaders are delegated to a LoaderFactory
- train/eval/checkpoint lifecycle is delegated to a Trainer
"""
def __init__(
self,
*,
clinical: Any,
args: Any,
split_manager: SplitManager,
graph_factory: GraphFactory,
loader_factory: LoaderFactory,
trainer: Trainer,
profile: Optional[Any] = None,
) -> None:
self.clinical = clinical
self.args = args
self.split_manager = split_manager
self.graph_factory = graph_factory
self.loader_factory = loader_factory
self.trainer = trainer
self.profile = profile
self._split_plans: Optional[list[PatientSplit]] = None
def run_fold(self, fold: int) -> FoldResult:
plans = self._get_split_plans()
if fold < 0 or fold >= len(plans):
raise IndexError(f"Requested fold {fold} but only {len(plans)} fold plans are available")
split = plans[fold]
self._validate_patient_disjointness(split)
self._validate_labels(split)
graph = self.graph_factory.build(
clinical=self.clinical,
args=self.args,
fold=fold,
profile=self.profile,
)
loaders = self.loader_factory.build(
clinical=self.clinical,
split=split,
args=self.args,
fold=fold,
profile=self.profile,
)
return self.trainer.fit(
graph=graph,
loaders=loaders,
args=self.args,
fold=fold,
profile=self.profile,
)
def run_all_folds(self, n_splits: Optional[int] = None) -> list[FoldResult]:
plans = self._get_split_plans()
max_folds = len(plans)
if n_splits is None:
n = max_folds
else:
n = int(n_splits)
if n < 1:
raise ValueError("n_splits must be >= 1")
if n > max_folds:
raise ValueError(f"Requested {n} folds but only {max_folds} fold plans are available")
return [self.run_fold(fold) for fold in range(n)]
def _get_split_plans(self) -> list[PatientSplit]:
if self._split_plans is None:
self._split_plans = self.split_manager.build_plans(
clinical=self.clinical,
args=self.args,
profile=self.profile,
)
if not self._split_plans:
raise ValueError("SplitManager returned no fold plans")
return self._split_plans
def _validate_patient_disjointness(self, split: PatientSplit) -> None:
train_ids = self._patient_ids(split.train)
val_ids = self._patient_ids(split.val)
holdout_ids = self._patient_ids(split.holdout) if split.holdout is not None else set()
if train_ids & val_ids:
overlap = sorted(train_ids & val_ids)[:10]
raise ValueError(f"Patient leakage between train/val: {overlap}")
if train_ids & holdout_ids:
overlap = sorted(train_ids & holdout_ids)[:10]
raise ValueError(f"Patient leakage between train/holdout: {overlap}")
if val_ids & holdout_ids:
overlap = sorted(val_ids & holdout_ids)[:10]
raise ValueError(f"Patient leakage between val/holdout: {overlap}")
def _validate_labels(self, split: PatientSplit) -> None:
label_col = getattr(self.clinical, "label_col", None)
if not label_col:
return
for name, df in (("train", split.train), ("val", split.val), ("holdout", split.holdout)):
if df is None:
continue
if label_col not in df.columns:
raise ValueError(f"{name} split is missing label column {label_col!r}")
@staticmethod
def _patient_ids(df: Optional[pd.DataFrame]) -> set[Any]:
if df is None or df.empty:
return set()
if "Patient ID" not in df.columns:
raise ValueError("Split dataframes must include 'Patient ID'")
return set(df["Patient ID"].tolist())
-240
View File
@@ -1,240 +0,0 @@
from __future__ import annotations
from typing import Callable, Dict, List, Optional
import numpy as np
import pandas as pd
from classes.v2.data_bundle import DataBundle
# ---- Pachymetry → IOP correction (per PAPILA Table 3) ----
_PACHY_TABLE: Dict[int, int] = {
475: +5,
485: +4,
495: +4,
505: +3,
515: +2,
525: +1,
535: +1,
545: 0,
555: -1,
565: -1,
575: -2,
585: -3,
595: -4,
605: -4,
615: -5,
}
_PACHY_KEYS = np.array(sorted(_PACHY_TABLE.keys()))
def _nearest_pachy_key(x: float) -> int:
idx = int(np.argmin(np.abs(_PACHY_KEYS - float(x))))
return int(_PACHY_KEYS[idx])
def _fit_perkins_converter(
frames: List[pd.DataFrame], method: str
) -> Callable[[float, Optional[float]], float]:
"""
Fit a Perkins→Pneumatic converter from pooled paired observations across all frames.
Returns a callable: converter(perkins_value, pachymetry_value) -> float.
Supported methods: "ratio", "ols", "lad", "multi".
"""
combined = pd.concat(frames, ignore_index=True)
paired = combined.dropna(subset=["Pneumatic", "Perkins"])
pneumatic = paired["Pneumatic"].values.astype(float)
perkins = paired["Perkins"].values.astype(float)
if len(paired) == 0:
raise ValueError("No paired Pneumatic+Perkins observations found; cannot fit converter.")
if method == "ratio":
ratio = float((pneumatic / perkins).mean())
def converter_ratio(p: float, pachy: Optional[float] = None) -> float:
return p * ratio
return converter_ratio
elif method == "ols":
from scipy import stats as _stats
slope, intercept, *_ = _stats.linregress(perkins, pneumatic)
slope, intercept = float(slope), float(intercept)
def converter_ols(p: float, pachy: Optional[float] = None) -> float:
return p * slope + intercept
return converter_ols
elif method == "lad":
from scipy import stats as _stats
from scipy.optimize import minimize as _minimize
slope0, intercept0, *_ = _stats.linregress(perkins, pneumatic)
def _lad_loss(params):
a, b = params
return np.abs(pneumatic - (a * perkins + b)).mean()
res = _minimize(_lad_loss, x0=[slope0, intercept0], method="Nelder-Mead")
slope, intercept = float(res.x[0]), float(res.x[1])
def converter_lad(p: float, pachy: Optional[float] = None) -> float:
return p * slope + intercept
return converter_lad
elif method == "multi":
from numpy.linalg import lstsq as _lstsq
paired_multi = combined.dropna(subset=["Pneumatic", "Perkins", "Pachymetry"])
if len(paired_multi) == 0:
raise ValueError("No paired Pneumatic+Perkins+Pachymetry rows; cannot fit multi method.")
pneu = paired_multi["Pneumatic"].values.astype(float)
perk = paired_multi["Perkins"].values.astype(float)
pachy_vals = paired_multi["Pachymetry"].values.astype(float)
X = np.column_stack([perk, pachy_vals, np.ones(len(perk))])
coeffs, *_ = _lstsq(X, pneu, rcond=None)
slope, pachy_coef, intercept = float(coeffs[0]), float(coeffs[1]), float(coeffs[2])
pachy_fallback = float(pachy_vals.mean())
def converter_multi(p: float, pachy: Optional[float] = None) -> float:
pv = pachy if (pachy is not None and not np.isnan(pachy)) else pachy_fallback
return p * slope + pachy_coef * pv + intercept
return converter_multi
else:
raise ValueError(f"Unknown iop_corr_method: {method!r}. Choose ratio/ols/lad/multi.")
def _pick_iop(row: pd.Series, converter: Callable) -> float:
"""Prefer Pneumatic; convert Perkins to Pneumatic scale if Pneumatic is absent."""
pneumatic = row.get("Pneumatic", np.nan)
if not pd.isna(pneumatic):
return float(pneumatic)
perkins = row.get("Perkins", np.nan)
if pd.isna(perkins):
return np.nan
pachy = row.get("Pachymetry", np.nan)
return converter(float(perkins), None if pd.isna(pachy) else float(pachy))
def _correct_iop(raw_iop: float, pachy: float) -> float:
"""Return corrected IOP using nearest pachymetry bin; if pachy missing, return raw."""
if pd.isna(raw_iop):
return np.nan
if pd.isna(pachy):
return float(raw_iop)
key = _nearest_pachy_key(float(pachy))
return float(raw_iop) + float(_PACHY_TABLE[key])
def _apply_iop_and_drop_md(
df: pd.DataFrame,
converter: Callable,
drop_raw: bool = False,
) -> pd.DataFrame:
"""Add IOP_raw/IOP_corr and drop source IOP columns + VF_MD if present (in-place safe)."""
df["IOP_raw"] = df.apply(lambda row: _pick_iop(row, converter), axis=1)
pachy = df.get("Pachymetry", pd.Series(np.nan, index=df.index))
df["IOP_corr"] = [
_correct_iop(r, p) for r, p in zip(df["IOP_raw"].values, pachy.values)
]
drop_cols = [c for c in ("Pneumatic", "Perkins", "VF_MD") if c in df.columns]
if drop_raw:
drop_cols.append("IOP_raw")
if drop_cols:
df.drop(columns=drop_cols, inplace=True)
return df
def _canonicalize_eye_column(df: pd.DataFrame) -> None:
if "eyeID" in df.columns:
src = "eyeID"
else:
src = None
for c in df.columns:
if "eye" in c.lower():
src = c
break
if src is None:
df["eyeID"] = "OS"
return
s = df[src]
def norm(v):
if pd.isna(v):
return None
x = str(v).strip().upper()
if x in {"OS", "L", "LEFT", "0"}:
return "OS"
if x in {"OD", "R", "RIGHT", "1"}:
return "OD"
try:
num = int(float(x))
return "OD" if num % 2 == 1 else "OS"
except Exception:
return None
mapped = s.map(norm)
uniq = {u for u in mapped.dropna().unique().tolist()}
if not uniq.issubset({"OS", "OD"}):
raise ValueError(f"eyeID must be binary; found values {sorted(uniq)}")
df["eyeID"] = mapped.fillna("OS")
def build_papila_data(
*,
image_dir: str,
clinical_dir: str,
label_col: str,
cat_cols: List[str],
n_splits: int = 5,
random_seed: int = 42,
iop_corr_method: str = "ratio",
iop_drop_raw: bool = False,
exclude_cols: Optional[List[str]] = None,
) -> DataBundle:
"""
Build a DataBundle for PAPILA with dataset-specific preprocessing:
- load OD/OS Excel sheets
- normalize Patient ID
- canonicalize eyeID
- compute IOP_raw / IOP_corr, drop VF_MD
- build feature typing & folds
"""
_exclude = list(exclude_cols) if exclude_cols else []
# Remove excluded cols from cat_cols too so the bundle doesn't try to encode them
effective_cat_cols = [c for c in cat_cols if c not in _exclude]
bundle = DataBundle(
image_dir=image_dir,
clinical_dir=clinical_dir,
label_col=label_col,
patient_col="Patient ID",
cat_cols=effective_cat_cols,
n_splits=n_splits,
random_seed=random_seed,
filename_template="RET{pid:03d}{eye}.jpg",
)
od = pd.read_excel(f"{clinical_dir}/patient_data_od.xlsx", header=1)
od["eyeID"] = "OD"
os = pd.read_excel(f"{clinical_dir}/patient_data_os.xlsx", header=1)
os["eyeID"] = "OS"
for frame in (od, os):
if "Patient ID" not in frame.columns and "ID" in frame.columns:
frame.rename(columns={"ID": "Patient ID"}, inplace=True)
frame["Patient ID"] = frame["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
_canonicalize_eye_column(frame)
bundle.add_df(od, id_column="ID", exclude_cols=_exclude or None)
bundle.add_df(os, id_column="ID", exclude_cols=_exclude or None)
converter = _fit_perkins_converter(bundle.frames, method=iop_corr_method)
for i in range(len(bundle.frames)):
bundle.frames[i] = _apply_iop_and_drop_md(
bundle.frames[i], converter=converter, drop_raw=iop_drop_raw
)
bundle._refresh_master_df(exclude_cols=_exclude or None)
bundle._infer_or_validate_feature_types(exclude_cols=_exclude or None)
bundle._compute_numeric_stats()
bundle._build_cat_maps()
bundle._compute_feature_dim()
bundle._build_kfold_indices()
return bundle
-61
View File
@@ -1,61 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Optional
import pandas as pd
from classes.v2.data_bundle import DataBundle
from classes.v2.papila_builders import build_papila_data
@dataclass
class PapilaData:
"""
V2-friendly wrapper around the DataBundle pipeline.
Keeps all formatting/normalization behavior from build_papila_clinical,
but exposes a minimal surface area for the V2 engine.
"""
clinical: DataBundle
patient_col: str = "Patient ID"
@property
def df(self) -> pd.DataFrame:
return self.clinical.df
@property
def label_col(self) -> str:
return self.clinical.label_col
@property
def feature_dim(self) -> int:
return self.clinical.feature_dim
def get_image_path(self, row: pd.Series):
return self.clinical.get_image_path(row)
def vectorize_row(self, row: pd.Series):
return self.clinical.vectorize_row(row)
@classmethod
def from_dirs(
cls,
*,
image_dir: str,
clinical_dir: str,
label_col: str,
cat_cols: Iterable[str],
n_splits: int = 5,
random_seed: int = 42,
) -> "PapilaData":
clinical = build_papila_data(
image_dir=image_dir,
clinical_dir=clinical_dir,
label_col=label_col,
cat_cols=list(cat_cols),
n_splits=n_splits,
random_seed=random_seed,
)
return cls(clinical=clinical)
-194
View File
@@ -1,194 +0,0 @@
"""PredictionStore — unified per-epoch prediction tensor across all folds.
Tensor shape: (n_folds, n_epochs, n_samples, n_heads, n_classes)
The meaning of "sample" depends on tower_mode:
single — each eye is a sample; sample_ids like "5OD", "14OS"
ensemble — each patient is a sample; sample_ids like "5", "14"
fused — same as ensemble
bilateral— same as ensemble
Head names by mode:
single : ["fused", "img", "md"]
ensemble : ["od_fused", "od_img", "od_md", "os_fused", "os_img", "os_md"]
fused : ["od_fused", "od_img", "od_md", "os_fused", "os_img", "os_md", "bilat_fused"]
bilateral : ["fused", "img_joint", "md_joint"]
"""
from __future__ import annotations
from pathlib import Path
from typing import Sequence
import numpy as np
def head_names_for_mode(tower_mode: str, *, fused_head: bool = False) -> list[str]:
"""Return canonical head name list for a given tower_mode."""
if tower_mode in ("single", "classic"):
return ["fused", "img", "md"]
if tower_mode == "ensemble":
names = ["od_fused", "od_img", "od_md", "os_fused", "os_img", "os_md"]
return names + ["bilat_fused"] if fused_head else names
if tower_mode == "bilateral":
return ["fused", "img_joint", "md_joint"]
raise ValueError(f"Unknown tower_mode: {tower_mode!r}")
class PredictionStore:
"""
Stores per-epoch predictions for every sample, head, and fold in one tensor.
Usage
-----
# Build once before the fold loop:
store = PredictionStore(
sample_ids=all_eye_or_patient_ids,
y_true=all_labels,
head_names=head_names_for_mode(tower_mode, fused_head=args.fused_head),
n_folds=n_folds,
n_epochs=total_epochs,
n_classes=num_classes,
)
# Inside each epoch, after collecting probs:
store.record(fold, epoch, patient_ids_batch, "od_fused", probs_od)
store.set_split(fold, train_ids, "train")
store.set_split(fold, val_ids, "val")
# After all folds:
store.save(run_dir / "predictions.npz")
# Load and query:
store = PredictionStore.load("predictions.npz")
store.query("5", "od_fused", fold=0) # → (n_epochs, n_classes)
store.query("5", "od_fused") # → (n_folds, n_epochs, n_classes)
store.get_split("5", fold=0) # → "train"
"""
def __init__(
self,
sample_ids: Sequence[str],
y_true: Sequence[int],
head_names: Sequence[str],
n_folds: int,
n_epochs: int,
n_classes: int,
):
self.sample_ids = np.array(sample_ids, dtype=object)
self.y_true = np.array(y_true, dtype=np.int64)
self.head_names = np.array(head_names, dtype=object)
self.n_folds = n_folds
self.n_epochs = n_epochs
self.n_classes = n_classes
n_samples = len(self.sample_ids)
n_heads = len(self.head_names)
self.probs = np.full(
(n_folds, n_epochs, n_samples, n_heads, n_classes),
fill_value=np.nan,
dtype=np.float32,
)
self.split = np.full((n_folds, n_samples), fill_value="", dtype=object)
self._sid_index: dict[str, int] = {str(s): i for i, s in enumerate(self.sample_ids)}
self._head_index: dict[str, int] = {str(h): i for i, h in enumerate(self.head_names)}
# ------------------------------------------------------------------
# Writing
# ------------------------------------------------------------------
def record(
self,
fold: int,
epoch: int,
sample_ids: Sequence[str],
head_name: str,
probs: np.ndarray,
) -> None:
"""Record a batch of predictions for one head.
Args:
fold: 0-indexed fold number
epoch: 0-indexed epoch number
sample_ids: sequence of sample ID strings (length B)
head_name: which head — must be in self.head_names
probs: (B, n_classes) probability array
"""
head_idx = self._head_index.get(head_name)
if head_idx is None:
return # head not active in this mode — skip silently
for i, sid in enumerate(sample_ids):
s_idx = self._sid_index.get(str(sid))
if s_idx is not None:
self.probs[fold, epoch, s_idx, head_idx, :] = probs[i]
def set_split(
self,
fold: int,
sample_ids: Sequence[str],
label: str,
) -> None:
"""Label a group of samples as 'train', 'val', or 'holdout' for a fold."""
for sid in sample_ids:
s_idx = self._sid_index.get(str(sid))
if s_idx is not None:
self.split[fold, s_idx] = label
# ------------------------------------------------------------------
# Querying
# ------------------------------------------------------------------
def query(
self,
sample_id: str,
head_name: str,
fold: int | None = None,
) -> np.ndarray:
"""Return epoch-level predictions for one sample + head.
Returns:
fold=None → (n_folds, n_epochs, n_classes)
fold=int → (n_epochs, n_classes)
"""
s_idx = self._sid_index[str(sample_id)]
head_idx = self._head_index[str(head_name)]
if fold is None:
return self.probs[:, :, s_idx, head_idx, :]
return self.probs[fold, :, s_idx, head_idx, :]
def get_split(self, sample_id: str, fold: int) -> str:
"""Return the split label ('train'/'val'/'holdout') for a sample in a fold."""
s_idx = self._sid_index[str(sample_id)]
return str(self.split[fold, s_idx])
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def save(self, path: str | Path) -> None:
np.savez_compressed(
path,
probs=self.probs,
split=self.split,
sample_ids=self.sample_ids,
y_true=self.y_true,
head_names=self.head_names,
)
@classmethod
def load(cls, path: str | Path) -> "PredictionStore":
data = np.load(path, allow_pickle=True)
probs = data["probs"]
n_folds, n_epochs, _, _, n_classes = probs.shape
store = cls(
sample_ids=data["sample_ids"].tolist(),
y_true=data["y_true"],
head_names=data["head_names"].tolist(),
n_folds=n_folds,
n_epochs=n_epochs,
n_classes=n_classes,
)
store.probs = probs
store.split = data["split"]
return store
-10
View File
@@ -1,10 +0,0 @@
from .base import DatasetProfile, SimpleDatasetProfile, SlotDescriptor
from .papila import PapilaProfile, build_papila_profile
__all__ = [
"DatasetProfile",
"SimpleDatasetProfile",
"SlotDescriptor",
"PapilaProfile",
"build_papila_profile",
]
-53
View File
@@ -1,53 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, Any
import pandas as pd
@dataclass(frozen=True)
class SlotDescriptor:
"""
Metadata for a generic batch slot key (e.g., image_1, matrix_1).
"""
key: str
kind: str
description: str
required: bool = True
shape_hint: str | None = None
class DatasetProfile(Protocol):
"""
Dataset-specific wiring that stays outside the generic V2 engine.
"""
name: str
patient_col: str
label_col: str
def slot_descriptors(self) -> dict[str, SlotDescriptor]:
...
def semantic_aliases(self) -> dict[str, str]:
...
def build_samples(self, *, df: pd.DataFrame, clinical: Any) -> list[dict[str, Any]]:
...
@dataclass(frozen=True)
class SimpleDatasetProfile:
name: str
patient_col: str
label_col: str
slots: dict[str, SlotDescriptor]
aliases: dict[str, str]
def slot_descriptors(self) -> dict[str, SlotDescriptor]:
return dict(self.slots)
def semantic_aliases(self) -> dict[str, str]:
return dict(self.aliases)
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
import pandas as pd
from dataclasses import dataclass
from .base import SimpleDatasetProfile, SlotDescriptor
@dataclass(frozen=True)
class PapilaProfile(SimpleDatasetProfile):
sample_mode: str = "patient" # "patient" | "eye"
def build_samples(self, *, df: pd.DataFrame, clinical) -> list[dict[str, object]]:
samples: list[dict[str, object]] = []
patient_col = self.patient_col
label_col = self.label_col
mode = (self.sample_mode or "patient").lower()
if mode not in {"patient", "eye"}:
raise ValueError(f"Unsupported sample_mode '{self.sample_mode}'. Expected 'patient' or 'eye'.")
if mode == "eye":
for _, row in df.iterrows():
pid = row[patient_col]
label = row[label_col]
image_1 = clinical.get_image_path(row) if hasattr(clinical, "get_image_path") else None
matrix_1 = clinical.vectorize_row(row) if hasattr(clinical, "vectorize_row") else None
samples.append(
{
"id_1": pid,
"label_1": label,
"image_1": image_1,
"matrix_1": matrix_1,
}
)
return samples
for pid, grp in df.groupby(patient_col):
label_series = grp[label_col]
if label_series.empty:
continue
mode_vals = label_series.mode()
label = mode_vals.iloc[0] if not mode_vals.empty else label_series.iloc[0]
def _row_for_eye(eye: str):
if "eyeID" not in grp.columns:
return None
match = grp[grp["eyeID"].astype(str).str.upper() == eye]
if match.empty:
return None
return match.iloc[0]
row_od = _row_for_eye("OD")
row_os = _row_for_eye("OS")
row_any = grp.iloc[0]
image_1 = clinical.get_image_path(row_od) if row_od is not None else None
image_2 = clinical.get_image_path(row_os) if row_os is not None else None
matrix_1 = clinical.vectorize_row(row_od) if row_od is not None else None
matrix_2 = clinical.vectorize_row(row_os) if row_os is not None else None
if image_1 is None and hasattr(clinical, "get_image_path"):
image_1 = clinical.get_image_path(row_any)
if matrix_1 is None and hasattr(clinical, "vectorize_row"):
matrix_1 = clinical.vectorize_row(row_any)
samples.append(
{
"id_1": pid,
"label_1": label,
"image_1": image_1,
"image_2": image_2,
"matrix_1": matrix_1,
"matrix_2": matrix_2,
}
)
return samples
def build_papila_profile(
*,
patient_col: str = "Patient ID",
label_col: str = "Diagnosis",
sample_mode: str = "patient",
) -> PapilaProfile:
"""
PAPILA-specific semantic map for generic V2 slot keys.
The engine remains slot-based (image_1/image_2/matrix_1/...).
PAPILA meaning is captured here so run config stays dataset-local.
"""
slots = {
"id_1": SlotDescriptor(
key="id_1",
kind="id",
description=f"Patient identifier column ({patient_col})",
required=True,
shape_hint="scalar",
),
"label_1": SlotDescriptor(
key="label_1",
kind="label",
description=f"Diagnosis label column ({label_col})",
required=True,
shape_hint="scalar",
),
"image_1": SlotDescriptor(
key="image_1",
kind="image",
description="Fundus image slot 1 (PAPILA: OD / right eye)",
required=False,
shape_hint="HWC or CHW",
),
"image_2": SlotDescriptor(
key="image_2",
kind="image",
description="Fundus image slot 2 (PAPILA: OS / left eye)",
required=False,
shape_hint="HWC or CHW",
),
"matrix_1": SlotDescriptor(
key="matrix_1",
kind="matrix",
description="Clinical metadata feature vector",
required=False,
shape_hint="[feature_dim]",
),
"matrix_2": SlotDescriptor(
key="matrix_2",
kind="matrix",
description="Optional auxiliary tabular vector (reserved for experiments)",
required=False,
shape_hint="[feature_dim_2]",
),
}
aliases = {
"id_1": "patient_id",
"label_1": "diagnosis",
"image_1": "od_fundus",
"image_2": "os_fundus",
"matrix_1": "clinical_metadata",
"matrix_2": "aux_metadata",
}
return PapilaProfile(
name="papila",
patient_col=patient_col,
label_col=label_col,
slots=slots,
aliases=aliases,
sample_mode=sample_mode,
)
-131
View File
@@ -1,131 +0,0 @@
"""Result dataclasses and serialisation helpers for V2 fold outputs."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
# ---------------------------------------------------------------------------
# Primitive helpers
# ---------------------------------------------------------------------------
def _nan() -> float:
return float("nan")
def _f(v) -> Optional[float]:
"""Round a scalar to 6 dp, return None for nan/None."""
if v is None or (isinstance(v, float) and np.isnan(v)):
return None
return round(float(v), 6)
def _sv(vec) -> Optional[str]:
"""Serialise a vector to a pipe-separated string, or None."""
if vec is None:
return None
return "|".join(f"{float(v):.4f}" for v in vec)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@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 best val epoch; 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
# Fused head (ensemble + --fused-head; nan / None if --fused-head not used)
fused_val_auc: float = float("nan")
fused_val_acc: float = float("nan")
fused_val_kappa: float = float("nan")
fused_val_mcc: float = float("nan")
fused_val_f1: float = float("nan")
fused_val_recall: Optional[str] = None
fused_val_ece: float = float("nan")
fused_val_threshold: float = float("nan")
fused_val_bias: Optional[str] = None
fused_val_n: int = 0
fused_holdout_auc: float = float("nan")
fused_holdout_acc: float = float("nan")
@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]
y_true_fused: Optional[np.ndarray] = None
probs_fused: Optional[np.ndarray] = None
probs_ensemble_img: Optional[np.ndarray] = None
probs_ensemble_md: Optional[np.ndarray] = None
probs_classic_img: Optional[np.ndarray] = None
probs_classic_md: Optional[np.ndarray] = None
# per-eye (pre-averaged) versions for ensemble mode
y_true_ensemble_pereye: Optional[np.ndarray] = None
probs_ensemble_pereye: Optional[np.ndarray] = None
probs_ensemble_img_pereye: Optional[np.ndarray] = None
probs_ensemble_md_pereye: Optional[np.ndarray] = None
# raw logits (before softmax) — patient-level
logits_ensemble: Optional[np.ndarray] = None
logits_ensemble_img: Optional[np.ndarray] = None
logits_ensemble_md: Optional[np.ndarray] = None
logits_classic: Optional[np.ndarray] = None
logits_classic_img: Optional[np.ndarray] = None
logits_classic_md: Optional[np.ndarray] = None
# raw logits — per-eye
logits_ensemble_pereye: Optional[np.ndarray] = None
logits_ensemble_img_pereye: Optional[np.ndarray] = None
logits_ensemble_md_pereye: Optional[np.ndarray] = None
-159
View File
@@ -1,159 +0,0 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Optional
from pathlib import Path
from PIL import Image
import numpy as np
import torch
from torch.utils.data import Dataset
from torchvision import transforms
from .profiles.base import SlotDescriptor
def slot_collate(batch: list[dict[str, Any]]) -> dict[str, Any]:
if not batch:
return {}
keys = batch[0].keys()
out: dict[str, Any] = {}
for key in keys:
vals = [item.get(key) for item in batch]
if all(isinstance(v, torch.Tensor) for v in vals):
try:
out[key] = torch.stack(vals, dim=0)
except Exception:
out[key] = vals
else:
out[key] = vals
return out
class SlotDataset(Dataset):
"""
Dataset that yields dicts of slot-keyed values.
Sample records are expected to be dicts with keys matching slot descriptors.
Image slots accept filesystem paths; matrix slots accept array-like values.
"""
def __init__(
self,
samples: list[dict[str, Any]],
slot_descriptors: dict[str, SlotDescriptor],
*,
image_transform: Optional[Callable[[Image.Image], torch.Tensor]] = None,
matrix_transform: Optional[Callable[[Any], torch.Tensor]] = None,
image_preprocessor: Optional[Callable[..., Image.Image]] = None,
image_cache: Optional[dict[str, np.ndarray]] = None,
) -> None:
self.samples = samples
self.slot_descriptors = slot_descriptors
self.image_transform = image_transform or transforms.ToTensor()
self.matrix_transform = matrix_transform or self._default_matrix_transform
self.image_preprocessor = image_preprocessor
self.image_cache = image_cache
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> dict[str, Any]:
record = self.samples[idx]
out: dict[str, Any] = {}
for key, desc in self.slot_descriptors.items():
val = record.get(key)
if desc.kind == "image":
out[key] = self._load_image(val, required=desc.required)
elif desc.kind == "matrix":
out[key] = self._load_matrix(val, required=desc.required)
else:
out[key] = val
return out
def _load_image(self, value: Any, *, required: bool) -> Optional[torch.Tensor]:
if value is None:
if required:
raise ValueError("Missing required image slot")
return None
path = Path(value)
cache_key = str(value)
if self.image_cache is not None:
cached = self.image_cache.get(cache_key)
if cached is not None:
return self.image_transform(Image.fromarray(cached, mode="RGB"))
img = Image.open(path).convert("RGB")
if self.image_preprocessor is not None:
try:
img = self.image_preprocessor(img, path)
except TypeError:
img = self.image_preprocessor(img)
if self.image_cache is not None:
self.image_cache[cache_key] = np.asarray(img, dtype=np.uint8)
return self.image_transform(img)
def prebuild_image_cache(self, cache_workers: int = 0) -> None:
"""Pre-populate image_cache for all samples in this dataset."""
if self.image_cache is None:
return
paths = list({
str(record[key])
for record in self.samples
for key, desc in self.slot_descriptors.items()
if desc.kind == "image" and record.get(key) is not None
})
to_warm = [p for p in paths if p not in self.image_cache]
if not to_warm:
return
print(
f"[image_cache] warming {len(to_warm)} images "
f"({len(paths) - len(to_warm)} already cached)",
flush=True,
)
def _warm_one(path_str: str) -> None:
if path_str in self.image_cache:
return
p = Path(path_str)
img = Image.open(p).convert("RGB")
if self.image_preprocessor is not None:
try:
img = self.image_preprocessor(img, p)
except TypeError:
img = self.image_preprocessor(img)
self.image_cache[path_str] = np.asarray(img, dtype=np.uint8)
try:
from tqdm import tqdm
except ImportError:
tqdm = None
if cache_workers <= 1:
it = tqdm(to_warm, desc="Warm image cache", unit="img") if tqdm else to_warm
for path_str in it:
_warm_one(path_str)
else:
with ThreadPoolExecutor(max_workers=cache_workers) as ex:
futures = {ex.submit(_warm_one, p): p for p in to_warm}
it = tqdm(as_completed(futures), total=len(futures), desc="Warm image cache", unit="img") if tqdm else as_completed(futures)
for fut in it:
fut.result()
def _load_matrix(self, value: Any, *, required: bool) -> Optional[torch.Tensor]:
if value is None:
if required:
raise ValueError("Missing required matrix slot")
return None
return self.matrix_transform(value)
@staticmethod
def _default_matrix_transform(value: Any) -> torch.Tensor:
if isinstance(value, torch.Tensor):
return value.float()
if isinstance(value, np.ndarray):
return torch.from_numpy(value.astype(np.float32, copy=False))
return torch.as_tensor(value, dtype=torch.float32)
-197
View File
@@ -1,197 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, StratifiedKFold
from .network_manager import PatientSplit
@dataclass(frozen=True)
class SplitPlan:
train_patient_ids: set[Any]
val_patient_ids: set[Any]
holdout_patient_ids: set[Any]
def build_patient_split_plans(
patient_ids: Iterable[Any],
patient_labels: Iterable[Any],
*,
n_splits: int,
seed: int,
holdout_per_class: int = 0,
holdout_seed: int = 123,
) -> list[SplitPlan]:
"""
Core vector-based splitter.
Inputs are one row per patient:
- patient_ids: unique patient IDs
- patient_labels: one label per patient
"""
ids = np.asarray(list(patient_ids))
labels = np.asarray(list(patient_labels))
if ids.ndim != 1 or labels.ndim != 1:
raise ValueError("patient_ids and patient_labels must be 1D arrays")
if ids.size != labels.size:
raise ValueError(f"Length mismatch: ids={ids.size}, labels={labels.size}")
if ids.size == 0:
raise ValueError("No patients available for splitting")
if len(set(ids.tolist())) != ids.size:
raise ValueError("patient_ids must be unique (one label per patient)")
if n_splits < 2:
raise ValueError("n_splits must be >= 2")
holdout_ids: set[Any] = set()
if holdout_per_class > 0:
rng = np.random.default_rng(holdout_seed)
for label in np.unique(labels):
idx = np.where(labels == label)[0]
if idx.size == 0:
continue
n = min(holdout_per_class, idx.size)
chosen = rng.choice(idx, size=n, replace=False)
holdout_ids.update(ids[chosen].tolist())
keep_mask = ~np.isin(ids, list(holdout_ids))
cv_ids = ids[keep_mask]
cv_labels = labels[keep_mask]
if cv_ids.size < n_splits:
raise ValueError(
f"Not enough patients ({cv_ids.size}) for n_splits={n_splits} after holdout removal"
)
use_stratified = _can_stratify(cv_labels, n_splits)
if use_stratified:
splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
splits = list(splitter.split(cv_ids, cv_labels))
else:
splitter = KFold(n_splits=n_splits, shuffle=True, random_state=seed)
splits = list(splitter.split(cv_ids))
plans: list[SplitPlan] = []
for train_idx, val_idx in splits:
plans.append(
SplitPlan(
train_patient_ids=set(cv_ids[train_idx].tolist()),
val_patient_ids=set(cv_ids[val_idx].tolist()),
holdout_patient_ids=set(holdout_ids),
)
)
return plans
class PatientFirstSplitManager:
"""
Patient-level splitter for V2.
Behavior:
- Optional binary filtering happens first (labels in {0,1} only).
- Optional holdout is sampled at the patient level (never per-eye rows).
- K-fold split is built on remaining patients.
- Returned dataframes contain all rows for each selected patient.
"""
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
profile_patient_col = getattr(profile, "patient_col", None) if profile is not None else None
patient_col = profile_patient_col or self.patient_col
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 from SplitManager or clinical.label_col")
if not hasattr(clinical, "df"):
raise ValueError("Clinical object must expose a dataframe at .df")
df_full = clinical.df.copy()
self._validate_columns(df_full, label_col, patient_col=patient_col)
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)
holdout_per_class = int(getattr(args, "holdout_per_class", 0) or 0)
holdout_seed = int(getattr(args, "holdout_seed", 123))
n_splits = int(getattr(args, "n_splits", 5))
fold_seed = int(getattr(args, "fold_seed", 42))
patient_table = self._patient_label_table(df_full, label_col, patient_col=patient_col)
plans = build_patient_split_plans(
patient_ids=patient_table[patient_col].to_numpy(),
patient_labels=patient_table["_label"].to_numpy(),
n_splits=n_splits,
seed=fold_seed,
holdout_per_class=holdout_per_class,
holdout_seed=holdout_seed,
)
out: list[PatientSplit] = []
for plan in plans:
train_df = (
df_full[df_full[patient_col].isin(plan.train_patient_ids)]
.reset_index(drop=True)
)
val_df = (
df_full[df_full[patient_col].isin(plan.val_patient_ids)]
.reset_index(drop=True)
)
holdout_df = None
if plan.holdout_patient_ids:
holdout_df = (
df_full[df_full[patient_col].isin(plan.holdout_patient_ids)]
.reset_index(drop=True)
)
out.append(PatientSplit(train=train_df, val=val_df, holdout=holdout_df))
return out
def _validate_columns(self, df: pd.DataFrame, label_col: str, patient_col: Optional[str] = None) -> None:
pcol = patient_col or self.patient_col
if pcol not in df.columns:
raise ValueError(f"Missing required patient column: {pcol!r}")
if label_col not in df.columns:
raise ValueError(f"Missing required label column: {label_col!r}")
def _patient_label_table(
self,
df: pd.DataFrame,
label_col: str,
patient_col: Optional[str] = None,
) -> pd.DataFrame:
pcol = patient_col or self.patient_col
grouped = (
df.groupby(pcol, as_index=False)[label_col]
.agg(lambda x: x.mode().iloc[0] if not x.mode().empty else x.iloc[0])
.rename(columns={label_col: "_label"})
.sort_values(pcol)
.reset_index(drop=True)
)
if grouped.empty:
raise ValueError("No patients available for splitting")
return grouped
def _can_stratify(labels: np.ndarray, n_splits: int) -> bool:
if labels.size == 0:
return False
unique, counts = np.unique(labels, return_counts=True)
if len(unique) < 2:
return False
return bool(np.all(counts >= n_splits))
-279
View File
@@ -1,279 +0,0 @@
from __future__ import annotations
import math
from typing import Optional
import torch
from torch import nn
from torchvision import transforms
from classes.v2.backbones import BACKBONES, list_names, load_backbone_weights
from classes.v2.SE_attention import SEBlock
from classes.v2.data_bundle import DataBundle
def build_backbone(name: str, freeze_ratio: float = 0.0, augment: bool = True):
"""
Operational builder:
- instantiate with DEFAULT weights
- strip classifier → features
- apply ratio-based freezing over coarse blocks
- return (model, out_dim, transform)
"""
key = (name or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unsupported backbone '{name}'. Valid options: {list_names()}")
spec = BACKBONES[key]
m = spec.ctor(weights=spec.weights_default)
out_dim, m = spec.strip(m)
load_backbone_weights(key, m)
# transforms: use the weights mean/std, but keep your augmentation pipeline
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
if augment:
transform = transforms.Compose(
[
transforms.Resize(256),
transforms.CenterCrop(crop),
transforms.RandomHorizontalFlip(),
transforms.RandomVerticalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(0.1, 0.1, 0.1, 0.05),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
]
)
else:
transform = transforms.Compose(
[
transforms.Resize(256),
transforms.CenterCrop(crop),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
]
)
# ratio-based freezing: freeze earliest floor(N * freeze_ratio) blocks
fr = max(0.0, min(1.0, float(freeze_ratio)))
blocks = spec.blocks(m)
n = len(blocks)
freeze_n = int(math.floor(n * fr))
for b in blocks[:freeze_n]:
for p in b.parameters():
p.requires_grad = False
return m, out_dim, transform
class ImageTower(nn.Module):
"""
Vision backbone → pooled features.
- backbone: one of list_names() (default 'efficientnet_b0')
- always DEFAULT torchvision weights
- freeze_ratio ∈ [0,1] freezes earliest floor(N*freeze_ratio) blocks
- returns [N, out_dim] features from backbone forward
"""
def __init__(
self,
backbone: str = "efficientnet_b0",
freeze_ratio: float = 0.0,
use_se: bool = False,
se_reduction: int = 16,
se_pre_norm: bool = True,
augment: bool = True,
geometry_dim: int = 0,
):
super().__init__()
self.backbone, base_dim, self.transform = build_backbone(
backbone, freeze_ratio, augment=augment
)
self._name = backbone
# Keep ordered blocks for dynamic freezing/thawing
key = (self._name or "").lower()
self._spec = BACKBONES[key]
self._blocks = self._spec.blocks(self.backbone)
# Optional tower-level SE over the final feature vector
self.base_dim = base_dim
self.geometry_dim = max(0, int(geometry_dim))
self.out_dim = self.base_dim + self.geometry_dim
self.tower_ln = nn.LayerNorm(self.base_dim) if se_pre_norm else nn.Identity()
self.tower_se = (
SEBlock(self.base_dim, reduction=se_reduction, residual=True)
if use_se
else None
)
def forward(
self, x: torch.Tensor, geometry: Optional[torch.Tensor] = None
) -> torch.Tensor:
y = self.backbone(x)
# sanity: pooled features, not logits
assert y.dim() == 2 and y.size(1) == self.base_dim, (
f"Expected features [N,{self.base_dim}], got {tuple(y.shape)}"
)
if self.tower_se is not None:
y, _ = self.tower_se(self.tower_ln(y))
if self.geometry_dim > 0:
if geometry is None or geometry.numel() == 0:
geom = torch.zeros(
y.size(0), self.geometry_dim, device=y.device, dtype=y.dtype
)
else:
if geometry.dim() == 1:
geom = geometry.unsqueeze(0)
else:
geom = geometry
geom = geom.to(device=y.device, dtype=y.dtype)
if geom.size(0) != y.size(0):
raise ValueError(
f"Geometry batch size mismatch: {geom.size(0)} vs {y.size(0)}"
)
if geom.size(1) != self.geometry_dim:
raise ValueError(
f"Expected geometry dim {self.geometry_dim}, got {geom.size(1)}"
)
y = torch.cat([y, geom], dim=1)
return y
def set_freeze_ratio(self, ratio: float):
"""Dynamically freeze earliest floor(N*ratio) backbone blocks."""
r = max(0.0, min(1.0, float(ratio)))
n = len(self._blocks)
freeze_n = int(math.floor(n * r))
# Unfreeze all first
for b in self._blocks:
for p in b.parameters():
p.requires_grad = True
# Freeze earliest blocks
for b in self._blocks[:freeze_n]:
for p in b.parameters():
p.requires_grad = False
class SiameseImageTower(nn.Module):
"""
Shared-weight bilateral image tower.
Runs OD and OS images through a single shared backbone, then returns
cat([f_mean, f_delta]) where:
f_mean = (f_od + f_os) / 2 -- shared bilateral representation
f_delta = f_od - f_os -- asymmetry, signed OD-relative
out_dim = 2 * backbone_out_dim
When x_os is None (single-eye fallback):
f_mean = f_od
f_delta = zeros
so the module degrades gracefully when only one eye is available.
The shared backbone means both eyes contribute to every gradient update,
effectively doubling the training signal for the visual pathway without
doubling parameters.
"""
def __init__(
self,
backbone: str = "efficientnet_b0",
freeze_ratio: float = 0.0,
use_se: bool = False,
se_reduction: int = 16,
se_pre_norm: bool = True,
augment: bool = True,
):
super().__init__()
self._tower = ImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
use_se=use_se,
se_reduction=se_reduction,
se_pre_norm=se_pre_norm,
augment=augment,
geometry_dim=0,
)
self.out_dim = self._tower.out_dim * 2
self.transform = self._tower.transform
def forward(
self,
x_od: torch.Tensor,
x_os: Optional[torch.Tensor] = None,
) -> torch.Tensor:
f_od = self._tower(x_od)
if x_os is None:
f_mean = f_od
f_delta = torch.zeros_like(f_od)
else:
f_os = self._tower(x_os)
f_mean = (f_od + f_os) * 0.5
f_delta = f_od - f_os
return torch.cat([f_mean, f_delta], dim=1)
def set_freeze_ratio(self, ratio: float) -> None:
"""Delegates to the shared inner tower."""
self._tower.set_freeze_ratio(ratio)
class MDTower(nn.Module):
"""MLP over DataBundle.vectorize_row outputs (convert to torch inside tower)."""
def __init__(
self,
clinical_data: DataBundle,
hidden_dim: int = 128,
dropout: float = 0.1,
use_se: bool = False,
se_reduction: int = 16,
se_pre_norm: bool = True,
):
super().__init__()
self.feature_dim = clinical_data.feature_dim
self.out_dim = hidden_dim
# two-block MLP so we can optionally freeze/thaw per block
self.block0 = nn.Sequential(
nn.Linear(self.feature_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.ReLU(inplace=True),
nn.Dropout(dropout),
)
self.block1 = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(inplace=True),
)
self.net = nn.Sequential(self.block0, self.block1)
self.tower_ln = nn.LayerNorm(hidden_dim) if se_pre_norm else nn.Identity()
self.tower_se = (
SEBlock(hidden_dim, reduction=se_reduction, residual=True)
if use_se
else None
)
def forward(self, meta_np_or_torch) -> torch.Tensor:
if isinstance(meta_np_or_torch, torch.Tensor):
x = meta_np_or_torch
else:
x = torch.as_tensor(meta_np_or_torch, dtype=torch.float32)
h = self.net(x)
if self.tower_se is not None:
h, _ = self.tower_se(self.tower_ln(h))
return h
def set_freeze_ratio(self, ratio: float):
"""Optionally freeze earliest blocks of the MLP."""
r = max(0.0, min(1.0, float(ratio)))
# Unfreeze all
for p in self.block0.parameters():
p.requires_grad = True
for p in self.block1.parameters():
p.requires_grad = True
# Freeze earliest blocks based on ratio threshold
if r >= 0.5:
for p in self.block0.parameters():
p.requires_grad = False
if r >= 1.0:
for p in self.block1.parameters():
p.requires_grad = False
-320
View File
@@ -1,320 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Iterable, Optional, Tuple, Union
import numpy as np
from PIL import Image
from torchvision import transforms
from classes.v2.backbones import BACKBONES
IMAGENET_MEAN: Tuple[float, float, float] = (0.485, 0.456, 0.406)
IMAGENET_STD: Tuple[float, float, float] = (0.229, 0.224, 0.225)
@dataclass
class ImageTransformConfig:
"""
Mirrors the hypertower v1 preprocessing:
- Resize(256)
- CenterCrop(crop)
- Optional augmentations (H/V flip, rotation, color jitter)
- ToTensor + Normalize(mean/std)
"""
crop_size: int = 224
resize_size: int = 256
mean: Tuple[float, float, float] = IMAGENET_MEAN
std: Tuple[float, float, float] = IMAGENET_STD
augment: bool = True
rotation_deg: int = 15
color_jitter: Tuple[float, float, float, float] = (0.1, 0.1, 0.1, 0.05)
hflip: bool = True
vflip: bool = True
def build(self) -> transforms.Compose:
ops = [
transforms.Resize(self.resize_size),
transforms.CenterCrop(self.crop_size),
]
if self.augment:
if self.hflip:
ops.append(transforms.RandomHorizontalFlip())
if self.vflip:
ops.append(transforms.RandomVerticalFlip())
if self.rotation_deg:
ops.append(transforms.RandomRotation(self.rotation_deg))
if self.color_jitter:
ops.append(transforms.ColorJitter(*self.color_jitter))
ops.extend(
[
transforms.ToTensor(),
transforms.Normalize(mean=self.mean, std=self.std),
]
)
return transforms.Compose(ops)
def backbone_transform_config(backbone_name: str, augment: bool = True) -> ImageTransformConfig:
"""
Build a transform config that matches v1 ImageTower/backbone preprocessing.
Uses DEFAULT weights mean/std and InceptionV3 crop size when relevant.
"""
key = (backbone_name or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unsupported backbone '{backbone_name}'.")
spec = BACKBONES[key]
mean = getattr(spec.weights_default, "meta", {}).get("mean", IMAGENET_MEAN)
std = getattr(spec.weights_default, "meta", {}).get("std", IMAGENET_STD)
crop = 299 if key == "inception_v3" else 224
return ImageTransformConfig(crop_size=crop, mean=mean, std=std, augment=augment)
def build_backbone_transform(backbone_name: str, augment: bool = True) -> transforms.Compose:
return backbone_transform_config(backbone_name, augment=augment).build()
def build_eval_transform(backbone: str) -> transforms.Compose:
"""Deterministic eval transform matching backbone normalisation (no augmentation)."""
return build_backbone_transform(backbone, augment=False)
def build_imagenet_transform(augment: bool = True, crop_size: int = 224) -> transforms.Compose:
return ImageTransformConfig(crop_size=crop_size, augment=augment).build()
@dataclass
class ResizeTransform:
size: Union[int, Tuple[int, int]] = 256
interpolation: int = Image.BILINEAR
def __post_init__(self) -> None:
self._op = transforms.Resize(self.size, interpolation=self.interpolation)
def __call__(self, image: Image.Image) -> Image.Image:
return self._op(image)
@dataclass
class CenterCropTransform:
size: Union[int, Tuple[int, int]] = 224
def __post_init__(self) -> None:
self._op = transforms.CenterCrop(self.size)
def __call__(self, image: Image.Image) -> Image.Image:
return self._op(image)
class UnetMaskProvider:
"""
Placeholder for a UNet-powered mask provider.
This will be replaced once a UNet tower is wired in.
"""
def __call__(self, image: Image.Image, image_path: Optional[str] = None):
raise NotImplementedError("UNet mask provider is not wired yet.")
@dataclass
class ROICropTransform:
"""
Crop an image using a binary mask (GT or UNet).
Expects a mask of the same spatial size as the image; nonzero pixels are ROI.
"""
mask_source: str = "gt" # "gt" | "unet"
mask_provider: Optional[Callable[[Image.Image, Optional[str]], np.ndarray]] = None
scale: float = 2.5
target_size: Optional[Tuple[int, int]] = (224, 224)
fallback_to_original: bool = True
def __post_init__(self) -> None:
if self.mask_source not in {"gt", "unet"}:
raise ValueError(f"mask_source must be 'gt' or 'unet', got '{self.mask_source}'.")
def __call__(
self,
image: Image.Image,
mask: Optional[Union[np.ndarray, Image.Image]] = None,
image_path: Optional[str] = None,
) -> Image.Image:
resolved_mask = mask
if resolved_mask is None and self.mask_provider is not None:
resolved_mask = self.mask_provider(image, image_path)
if resolved_mask is None:
if self.fallback_to_original:
return image
raise ValueError("ROI crop requested but no mask provided.")
mask_arr = (
np.asarray(resolved_mask)
if not isinstance(resolved_mask, Image.Image)
else np.array(resolved_mask)
)
if mask_arr.ndim == 3:
mask_arr = mask_arr[..., 0]
mask_arr = mask_arr > 0
if not np.any(mask_arr):
return image if self.fallback_to_original else image
ys, xs = np.where(mask_arr)
y_min, y_max = ys.min(), ys.max()
x_min, x_max = xs.min(), xs.max()
cx = (x_min + x_max) / 2.0
cy = (y_min + y_max) / 2.0
width = (x_max - x_min + 1)
height = (y_max - y_min + 1)
size = max(width, height) * float(self.scale)
left = int(round(cx - size / 2))
right = int(round(cx + size / 2))
upper = int(round(cy - size / 2))
lower = int(round(cy + size / 2))
left = max(0, left)
upper = max(0, upper)
right = min(image.width, right)
lower = min(image.height, lower)
crop = image.crop((left, upper, right, lower))
if self.target_size is not None:
crop = crop.resize(self.target_size, Image.BILINEAR)
return crop
@dataclass
class JitterBundleTransform:
"""
Augmentations bundle: flips, rotation, color jitter.
"""
hflip: bool = True
vflip: bool = True
rotation_deg: int = 15
color_jitter: Optional[Tuple[float, float, float, float]] = (0.1, 0.1, 0.1, 0.05)
def __post_init__(self) -> None:
ops = []
if self.hflip:
ops.append(transforms.RandomHorizontalFlip())
if self.vflip:
ops.append(transforms.RandomVerticalFlip())
if self.rotation_deg:
ops.append(transforms.RandomRotation(self.rotation_deg))
if self.color_jitter:
ops.append(transforms.ColorJitter(*self.color_jitter))
self._op = transforms.Compose(ops) if ops else None
def __call__(self, image: Image.Image) -> Image.Image:
if self._op is None:
return image
return self._op(image)
TRANSFORM_REGISTRY = {
"resize": ResizeTransform,
"roi_crop": ROICropTransform,
"center_crop": CenterCropTransform,
"jitter_bundle": JitterBundleTransform,
}
def _parse_color_jitter(value: Optional[Union[str, Iterable[float]]]) -> Optional[Tuple[float, float, float, float]]:
if value is None:
return None
if isinstance(value, str):
parts = [p.strip() for p in value.split(",") if p.strip()]
if not parts:
return None
try:
nums = [float(p) for p in parts]
except ValueError:
return None
if len(nums) == 1:
return (nums[0], nums[0], nums[0], nums[0])
if len(nums) >= 4:
return (nums[0], nums[1], nums[2], nums[3])
return tuple(nums + [nums[-1]] * (4 - len(nums))) # pad to length 4
try:
vals = list(value)
except TypeError:
return None
if not vals:
return None
vals = [float(v) for v in vals]
if len(vals) == 1:
return (vals[0], vals[0], vals[0], vals[0])
if len(vals) >= 4:
return (vals[0], vals[1], vals[2], vals[3])
return tuple(vals + [vals[-1]] * (4 - len(vals)))
def build_transform_chain(
transform_specs: Iterable[object],
*,
backbone_name: str,
augment: bool = True,
mask_provider: Optional[Callable[[Image.Image, Optional[str]], np.ndarray]] = None,
strict: bool = True,
) -> transforms.Compose:
"""
Build an image transform pipeline from a list of transform specs plus the
standard ToTensor + Normalize steps. This mirrors the V1 preprocessing
but uses the explicit transform nodes from config.
"""
ops: list[Callable[[Image.Image], Image.Image]] = []
for spec in transform_specs:
transform_type = getattr(spec, "transform_type", None)
params = getattr(spec, "params", None)
if transform_type is None and isinstance(spec, dict):
transform_type = spec.get("transformType") or spec.get("transform_type")
params = spec
params = params or {}
if transform_type == "resize":
size = params.get("resizeSize", 256)
ops.append(ResizeTransform(size=size))
elif transform_type == "center_crop":
size = params.get("centerCropSize", 224)
ops.append(CenterCropTransform(size=size))
elif transform_type == "jitter_bundle":
if not augment:
continue
jitter = JitterBundleTransform(
hflip=bool(params.get("jitterHFlip", True)),
vflip=bool(params.get("jitterVFlip", True)),
rotation_deg=int(params.get("jitterRotation", 15) or 0),
color_jitter=_parse_color_jitter(params.get("jitterColor"))
if params.get("jitterColorEnabled", True)
else None,
)
ops.append(jitter)
elif transform_type == "roi_crop":
roi = ROICropTransform(
mask_source=params.get("roiMaskSource", "gt"),
mask_provider=mask_provider,
scale=float(params.get("roiScale", 2.5)),
target_size=(int(params.get("roiTargetSize", 224)), int(params.get("roiTargetSize", 224)))
if params.get("roiTargetSize") is not None
else None,
fallback_to_original=bool(params.get("roiFallback", True)),
)
if roi.mask_provider is None and roi.mask_source == "unet":
if strict:
raise ValueError("ROI crop requires a mask provider for 'unet' source.")
ops.append(roi)
else:
if strict:
raise ValueError(f"Unsupported transform type: {transform_type!r}")
# Always end with tensor + normalize, using backbone defaults
cfg = backbone_transform_config(backbone_name, augment=augment)
ops.extend(
[
transforms.ToTensor(),
transforms.Normalize(mean=cfg.mean, std=cfg.std),
]
)
return transforms.Compose(ops)
-894
View File
@@ -1,894 +0,0 @@
"""U-Net based optic disc/cup segmenter for REFUGE + Papila."""
from __future__ import annotations
import math
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional, Set, Tuple
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw, ImageOps
from PIL.Image import Resampling
from skimage import measure
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from tqdm import tqdm
@dataclass
class ManifestEntry:
sample_id: str
dataset: str
image_path: Path
annotation_disc: Path
annotation_cup: Path
annotation_type_disc: str
annotation_type_cup: str
split: str # train / holdout / etc.
class UNet(nn.Module):
def __init__(
self, in_channels: int = 3, base_channels: int = 32, out_channels: int = 2
):
super().__init__()
self.enc1 = self._block(in_channels, base_channels)
self.enc2 = self._block(base_channels, base_channels * 2)
self.enc3 = self._block(base_channels * 2, base_channels * 4)
self.enc4 = self._block(base_channels * 4, base_channels * 8)
self.pool = nn.MaxPool2d(2)
self.bottleneck = self._block(base_channels * 8, base_channels * 16)
self.up4 = nn.ConvTranspose2d(
base_channels * 16, base_channels * 8, 2, stride=2
)
self.dec4 = self._block(base_channels * 16, base_channels * 8)
self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2)
self.dec3 = self._block(base_channels * 8, base_channels * 4)
self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2)
self.dec2 = self._block(base_channels * 4, base_channels * 2)
self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2)
self.dec1 = self._block(base_channels * 2, base_channels)
self.out_conv = nn.Conv2d(base_channels, out_channels, kernel_size=1)
@staticmethod
def _block(in_ch: int, out_ch: int) -> nn.Module:
return nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
b = self.bottleneck(self.pool(e4))
d4 = self.up4(b)
d4 = torch.cat([d4, e4], dim=1)
d4 = self.dec4(d4)
d3 = self.up3(d4)
d3 = torch.cat([d3, e3], dim=1)
d3 = self.dec3(d3)
d2 = self.up2(d3)
d2 = torch.cat([d2, e2], dim=1)
d2 = self.dec2(d2)
d1 = self.up1(d2)
d1 = torch.cat([d1, e1], dim=1)
d1 = self.dec1(d1)
return self.out_conv(d1)
class SegmentationDataset(Dataset):
def __init__(
self,
entries: List[ManifestEntry],
segmenter: "UNetSegmenter",
augment: bool,
) -> None:
self.entries = entries
self.segmenter = segmenter
self.augment = augment
def __len__(self) -> int:
return len(self.entries)
def __getitem__(self, idx: int):
entry = self.entries[idx]
image = self.segmenter.load_preprocessed_image(entry)
disc_mask, cup_mask = self.segmenter.load_masks(entry)
if self.augment:
image = self.segmenter.jitter_image(image)
image, disc_mask, cup_mask = self.segmenter.augment_geometric(
image, disc_mask, cup_mask
)
image_tensor = transforms.ToTensor()(image)
image_tensor = self.segmenter._normalize_tensor(image_tensor)
mask = np.stack([disc_mask, cup_mask], axis=0).astype(np.float32)
mask_tensor = torch.from_numpy(mask)
return image_tensor, mask_tensor
class UNetSegmenter:
def __init__(
self,
manifest_path: Path,
device: Optional[str] = None,
cup_weight: float = 1.0,
disc_weight: float = 1.0,
target_size: int = 512,
val_ratio: float = 0.1,
train_datasets: Optional[Iterable[str]] = None,
val_datasets: Optional[Iterable[str]] = None,
holdout_datasets: Optional[Iterable[str]] = None,
normalize: str = "none",
use_stronger_aug: bool = False,
mask_cache_dir: Optional[Path] = None,
image_cache_dir: Optional[Path] = None,
in_memory_cache: bool = False,
loader_workers: int = 0,
) -> None:
self.manifest_path = manifest_path
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.cup_weight = cup_weight
self.disc_weight = disc_weight
self.target_size = target_size
self.val_ratio = val_ratio
self.normalize = (normalize or "none").lower()
self.use_stronger_aug = bool(use_stronger_aug)
self.mask_cache_dir = Path(mask_cache_dir).resolve() if mask_cache_dir else None
if self.mask_cache_dir:
self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
self.image_cache_dir = Path(image_cache_dir).resolve() if image_cache_dir else None
if self.image_cache_dir:
self.image_cache_dir.mkdir(parents=True, exist_ok=True)
self.in_memory_cache = bool(in_memory_cache)
self._mem_image_cache: dict[str, np.ndarray] = {}
self._mem_mask_cache: dict[str, Tuple[np.ndarray, np.ndarray]] = {}
self.loader_workers = max(0, int(loader_workers))
self.train_dataset_filter = self._normalize_filter(train_datasets)
self.val_dataset_filter = self._normalize_filter(val_datasets)
self.holdout_dataset_filter = self._normalize_filter(holdout_datasets)
self.model = UNet().to(self.device)
self._manifest: List[ManifestEntry] = []
self.train_entries: List[ManifestEntry] = []
self.val_entries: List[ManifestEntry] = []
self.holdout_entries: List[ManifestEntry] = []
self.read_manifest()
def prebuild_in_memory_cache(
self,
*,
cache_workers: int = 0,
include_train: bool = True,
include_val: bool = True,
include_holdout: bool = False,
) -> None:
if not self.in_memory_cache:
return
selected: List[ManifestEntry] = []
if include_train:
selected.extend(self.train_entries)
if include_val:
selected.extend(self.val_entries)
if include_holdout:
selected.extend(self.holdout_entries)
if not selected:
return
# Deduplicate by cache key.
dedup = {}
for entry in selected:
dedup[self._entry_cache_key(entry)] = entry
entries = list(dedup.values())
workers = max(0, int(cache_workers))
print(
f"[UNetSegmenter] prebuilding in-memory cache for {len(entries)} samples "
f"(cache_workers={workers})",
flush=True,
)
def _warm_one(entry: ManifestEntry) -> None:
self.load_preprocessed_image(entry)
self.load_masks(entry)
if workers <= 1:
for entry in tqdm(entries, desc="Warm cache", unit="sample"):
_warm_one(entry)
else:
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_warm_one, entry) for entry in entries]
for fut in tqdm(as_completed(futures), total=len(futures), desc="Warm cache", unit="sample"):
fut.result()
# ------------------------------------------------------------------
def read_manifest(self) -> None:
df = pd.read_csv(self.manifest_path)
entries: List[ManifestEntry] = []
for _, row in df.iterrows():
entry = ManifestEntry(
sample_id=row["sample_id"],
dataset=row["dataset"],
image_path=Path(row["image_path"]),
annotation_disc=Path(row["annotation_disc"]),
annotation_cup=Path(row["annotation_cup"]),
annotation_type_disc=row["annotation_type_disc"],
annotation_type_cup=row["annotation_type_cup"],
split=row["split"],
)
entries.append(entry)
self._manifest = entries
self.holdout_entries = [e for e in entries if e.split == "holdout"]
if self.holdout_dataset_filter is not None:
self.holdout_entries = [
e for e in self.holdout_entries if e.dataset in self.holdout_dataset_filter
]
trainable = [e for e in entries if e.split != "holdout"]
if self.train_dataset_filter is not None:
trainable = [
e for e in trainable if e.dataset in self.train_dataset_filter
]
if not trainable:
self.val_entries = []
self.train_entries = []
return
val_pool = trainable
if self.val_dataset_filter is not None:
filtered = [e for e in trainable if e.dataset in self.val_dataset_filter]
if filtered:
val_pool = filtered
if len(trainable) == 1:
val_count = 0
else:
val_count = max(1, int(len(trainable) * self.val_ratio))
val_count = min(val_count, len(val_pool), len(trainable) - 1)
selected_val: List[ManifestEntry] = []
if val_count > 0:
selected_val = list(val_pool[:val_count])
self.val_entries = selected_val
selected_ids = {id(item) for item in selected_val}
self.train_entries = [e for e in trainable if id(e) not in selected_ids]
if not self.train_entries and trainable:
# Fallback when filtering removed all train entries (e.g. val_count forced entire set)
self.train_entries = trainable
self.val_entries = []
# ------------------------------------------------------------------
def preprocess_image(self, image: Image.Image) -> Image.Image:
return image.resize((self.target_size, self.target_size), Resampling.BILINEAR)
def jitter_image(self, image: Image.Image) -> Image.Image:
# Photometric jitter only; geometric ops are applied jointly (image+mask)
return transforms.ColorJitter(0.1, 0.1, 0.1, 0.05)(image)
def augment_geometric(
self,
image: Image.Image,
disc_mask: np.ndarray,
cup_mask: np.ndarray,
) -> tuple[Image.Image, np.ndarray, np.ndarray]:
if not self.use_stronger_aug:
return image, disc_mask, cup_mask
img = image
disc_pil = Image.fromarray((disc_mask > 0).astype(np.uint8) * 255)
cup_pil = Image.fromarray((cup_mask > 0).astype(np.uint8) * 255)
# Random horizontal flip
if np.random.rand() < 0.5:
img = ImageOps.mirror(img)
disc_pil = ImageOps.mirror(disc_pil)
cup_pil = ImageOps.mirror(cup_pil)
# Random vertical flip
if np.random.rand() < 0.5:
img = ImageOps.flip(img)
disc_pil = ImageOps.flip(disc_pil)
cup_pil = ImageOps.flip(cup_pil)
# Random rotation (multiples of 90° to keep masks aligned)
rotations = np.random.choice([0, 90, 180, 270])
if rotations:
img = img.rotate(rotations, expand=False)
disc_pil = disc_pil.rotate(rotations, expand=False)
cup_pil = cup_pil.rotate(rotations, expand=False)
disc_mask = (np.array(disc_pil) > 0).astype(np.float32)
cup_mask = (np.array(cup_pil) > 0).astype(np.float32)
return img, disc_mask, cup_mask
@staticmethod
def _slugify(text: str) -> str:
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in text)
def _entry_cache_key(self, entry: ManifestEntry) -> str:
return self._slugify(f"{entry.dataset}_{entry.sample_id}_sz{self.target_size}")
def _mask_cache_path(self, entry: ManifestEntry) -> Optional[Path]:
if self.mask_cache_dir is None:
return None
slug = self._slugify(f"{entry.dataset}_{entry.sample_id}")
fname = f"{slug}_sz{self.target_size}.npz"
return self.mask_cache_dir / fname
def _image_cache_path(self, entry: ManifestEntry) -> Optional[Path]:
if self.image_cache_dir is None:
return None
slug = self._slugify(f"{entry.dataset}_{entry.sample_id}")
fname = f"{slug}_img_sz{self.target_size}.npz"
return self.image_cache_dir / fname
def _load_image_cache(self, cache_path: Path) -> Optional[Image.Image]:
try:
data = np.load(str(cache_path), allow_pickle=False)
arr = data["image"].astype(np.uint8, copy=False)
if arr.ndim != 3 or arr.shape[2] != 3:
return None
return Image.fromarray(arr, mode="RGB")
except Exception:
with suppress(OSError, FileNotFoundError):
cache_path.unlink()
return None
def _save_image_cache(self, cache_path: Optional[Path], image: Image.Image) -> None:
if cache_path is None:
return
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz")
try:
arr = np.asarray(image, dtype=np.uint8)
np.savez_compressed(tmp_path, image=arr)
os.replace(tmp_path, cache_path)
except Exception:
with suppress(OSError, FileNotFoundError):
tmp_path.unlink()
def load_preprocessed_image(self, entry: ManifestEntry) -> Image.Image:
key = self._entry_cache_key(entry)
if self.in_memory_cache:
cached = self._mem_image_cache.get(key)
if cached is not None:
return Image.fromarray(cached, mode="RGB")
cache_path = self._image_cache_path(entry)
if cache_path and cache_path.exists():
cached = self._load_image_cache(cache_path)
if cached is not None:
if self.in_memory_cache:
self._mem_image_cache[key] = np.asarray(cached, dtype=np.uint8)
return cached
image = Image.open(entry.image_path).convert("RGB")
image = self.preprocess_image(image)
if self.in_memory_cache:
self._mem_image_cache[key] = np.asarray(image, dtype=np.uint8)
self._save_image_cache(cache_path, image)
return image
def _load_mask_cache(self, cache_path: Path) -> Optional[Tuple[np.ndarray, np.ndarray]]:
try:
data = np.load(str(cache_path), allow_pickle=False)
disc = data["disc"].astype(np.float32)
cup = data["cup"].astype(np.float32)
return disc, cup
except Exception:
with suppress(OSError, FileNotFoundError):
cache_path.unlink()
return None
def _save_mask_cache(
self,
cache_path: Optional[Path],
disc_mask: np.ndarray,
cup_mask: np.ndarray,
) -> None:
if cache_path is None:
return
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz")
try:
np.savez_compressed(
tmp_path,
disc=disc_mask.astype(np.uint8),
cup=cup_mask.astype(np.uint8),
)
os.replace(tmp_path, cache_path)
except Exception:
with suppress(OSError, FileNotFoundError):
tmp_path.unlink()
def _normalize_tensor(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 extract_masks_from_image(
self,
mask_path: Path,
disc_color: Optional[tuple[int, int, int]] = None,
cup_color: Optional[tuple[int, int, int]] = None,
) -> Tuple[np.ndarray, Optional[np.ndarray], Tuple[int, int]]:
raw = Image.open(mask_path)
arr = np.array(raw)
if arr.ndim == 2:
h, w = arr.shape
flat = arr.reshape(-1).astype(np.int64, copy=False)
edges = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]], axis=0).astype(np.int64, copy=False)
edge_counts = np.bincount(edges, minlength=256)
bg_val = int(np.argmax(edge_counts))
counts = np.bincount(flat, minlength=256)
counts[bg_val] = 0
vals = np.where(counts > 0)[0]
if vals.size < 1:
raise ValueError(f"Mask {mask_path} does not contain discernible labels")
# Disc = ALL non-background pixels (full optic disc: rim + cup combined).
# Previously this was rim-only, which caused the cup structural prior
# (cup & disc) to produce empty cup masks since cup and rim don't overlap.
disc_mask = (arr != bg_val).astype(np.uint8)
# Cup = the darkest non-background value (0 in REFUGE = inner cup region).
# Using min-value rather than frequency avoids swapping when cup area > rim area.
cup_val = int(np.min(vals)) if vals.size > 1 else None
cup_mask = (arr == cup_val).astype(np.uint8) if cup_val is not None else np.zeros_like(disc_mask, dtype=np.uint8)
return disc_mask, cup_mask if cup_mask.any() else None, (w, h)
image = raw.convert("RGB")
arr = np.array(image)
h, w, c = arr.shape
if disc_color is None or cup_color is None:
# Fast color discovery via NumPy (avoid Python-level per-pixel tuple counting).
edges = np.concatenate(
[arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], axis=0
)
edge_colors, edge_counts = np.unique(edges.reshape(-1, c), axis=0, return_counts=True)
bg_color_np = edge_colors[int(np.argmax(edge_counts))]
colors_np, counts_np = np.unique(arr.reshape(-1, c), axis=0, return_counts=True)
keep = np.any(colors_np != bg_color_np.reshape(1, -1), axis=1)
colors_np = colors_np[keep]
counts_np = counts_np[keep]
if colors_np.shape[0] < 1:
raise ValueError(f"Mask {mask_path} does not contain discernible labels")
order = np.argsort(-counts_np)
colors_np = colors_np[order]
disc_color = tuple(int(v) for v in colors_np[0].tolist())
cup_color = (
tuple(int(v) for v in colors_np[1].tolist())
if colors_np.shape[0] > 1
else None
)
disc_mask = np.zeros((h, w), dtype=np.uint8)
cup_mask = np.zeros((h, w), dtype=np.uint8)
if disc_color is not None:
disc_mask[np.all(arr == disc_color, axis=-1)] = 1
if cup_color is not None:
cup_mask[np.all(arr == cup_color, axis=-1)] = 1
return disc_mask, cup_mask if cup_mask.any() else None, (w, h)
def load_contour_from_file(self, contour_path: Path) -> np.ndarray:
# Fast path: contour files are typically CSV or whitespace-delimited x,y pairs.
try:
arr = np.loadtxt(str(contour_path), delimiter=",", comments="#", dtype=np.float32)
except Exception:
try:
arr = np.loadtxt(str(contour_path), comments="#", dtype=np.float32)
except Exception:
return np.zeros((0, 2), dtype=np.float32)
if arr.size == 0:
return np.zeros((0, 2), dtype=np.float32)
if arr.ndim == 1:
if arr.shape[0] < 2:
return np.zeros((0, 2), dtype=np.float32)
arr = arr.reshape(1, -1)
if arr.shape[1] < 2:
return np.zeros((0, 2), dtype=np.float32)
return arr[:, :2].astype(np.float32, copy=False)
def coords_to_mask(
self,
coords: Optional[np.ndarray],
size: Tuple[int, int],
) -> np.ndarray:
if coords is None or len(coords) == 0:
return np.zeros((self.target_size, self.target_size), dtype=np.float32)
width, height = map(int, size)
target_shape = (height, width)
arr = np.asarray(coords)
if arr.size == 0:
return np.zeros((self.target_size, self.target_size), dtype=np.float32)
if arr.ndim == 2 and arr.shape[-1] != 2:
mask = (arr > 0).astype(np.uint8)
return self._resize_mask(mask)
if arr.ndim > 2:
arr = arr.reshape(-1, arr.shape[-1])
arr = arr.astype(float, copy=False)
if arr.shape[-1] != 2:
raise ValueError(f"Expected coordinate pairs, got shape {arr.shape}")
points = [tuple(map(float, pt)) for pt in arr]
if len(points) < 3:
return np.zeros(target_shape, dtype=np.float32)
img = Image.new("L", size, 0)
draw = ImageDraw.Draw(img)
draw.polygon(points, outline=1, fill=1)
mask = np.array(img, dtype=np.uint8)
return self._resize_mask(mask)
def _resize_mask(self, mask: np.ndarray) -> np.ndarray:
img = Image.fromarray((mask > 0).astype(np.uint8) * 255)
img = img.resize((self.target_size, self.target_size), Resampling.NEAREST)
return (np.array(img, dtype=np.uint8) > 0).astype(np.float32)
def load_masks(self, entry: ManifestEntry) -> Tuple[np.ndarray, np.ndarray]:
key = self._entry_cache_key(entry)
if self.in_memory_cache:
cached = self._mem_mask_cache.get(key)
if cached is not None:
disc_u8, cup_u8 = cached
return disc_u8.astype(np.float32), cup_u8.astype(np.float32)
cache_path = self._mask_cache_path(entry)
if cache_path and cache_path.exists():
cached = self._load_mask_cache(cache_path)
if cached is not None:
if self.in_memory_cache:
disc, cup = cached
self._mem_mask_cache[key] = (
disc.astype(np.uint8),
cup.astype(np.uint8),
)
return cached
image = Image.open(entry.image_path)
size = image.size
disc_coords = cup_coords = None
if entry.annotation_type_disc == "mask":
disc_coords, cup_coords_from_disc, size = self.extract_masks_from_image(
entry.annotation_disc
)
if cup_coords_from_disc is not None:
cup_coords = cup_coords_from_disc
else:
disc_coords = self.load_contour_from_file(entry.annotation_disc)
if entry.annotation_type_cup == "mask":
_, cup_coords_from_cup, size_cup = self.extract_masks_from_image(
entry.annotation_cup
)
if cup_coords_from_cup is not None:
cup_coords = cup_coords_from_cup
if disc_coords is None:
disc_coords, _, size = self.extract_masks_from_image(
entry.annotation_cup
)
else:
size = size_cup
else:
cup_coords = self.load_contour_from_file(entry.annotation_cup)
disc_mask = self.coords_to_mask(disc_coords, size).astype(np.float32)
cup_mask = self.coords_to_mask(cup_coords, size).astype(np.float32)
if self.in_memory_cache:
self._mem_mask_cache[key] = (
disc_mask.astype(np.uint8),
cup_mask.astype(np.uint8),
)
self._save_mask_cache(cache_path, disc_mask, cup_mask)
return disc_mask, cup_mask
# ------------------------------------------------------------------
def build_loaders(self, batch_size: int = 4, num_workers: int = 0) -> Tuple[DataLoader, DataLoader]:
train_ds = SegmentationDataset(self.train_entries, self, augment=True)
val_ds = SegmentationDataset(self.val_entries, self, augment=False)
train_loader = DataLoader(
train_ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True
)
val_loader = DataLoader(
val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True
)
return train_loader, val_loader
# ------------------------------------------------------------------
def dice_score(self, preds: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
preds = (preds > 0.5).float()
intersection = (preds * targets).sum(dim=(2, 3))
union = preds.sum(dim=(2, 3)) + targets.sum(dim=(2, 3))
dice = (2 * intersection + 1e-6) / (union + 1e-6)
return dice.mean(dim=0)
def train(
self,
epochs: int = 40,
batch_size: int = 4,
lr: float = 1e-3,
weight_decay: float = 1e-5,
checkpoint_dir: Path = Path("models/unet_segmenter"),
) -> None:
print(
f"[UNetSegmenter] training on device={self.device} "
f"(epochs={epochs}, batch_size={batch_size}, workers={self.loader_workers})"
)
train_loader, val_loader = self.build_loaders(batch_size=batch_size, num_workers=self.loader_workers)
optimizer = torch.optim.Adam(
self.model.parameters(), lr=lr, weight_decay=weight_decay
)
criterion = nn.BCEWithLogitsLoss()
best_dice = -math.inf
checkpoint_dir.mkdir(parents=True, exist_ok=True)
best_path = checkpoint_dir / "best.pt"
epoch_bar = tqdm(range(1, epochs + 1), desc="Epochs", unit="epoch")
for epoch in epoch_bar:
self.model.train()
batch_bar = tqdm(
train_loader,
desc=f"Train {epoch}/{epochs}",
leave=False,
unit="batch",
total=len(train_loader),
)
train_loss_total = 0.0
train_samples = 0
for images, masks in batch_bar:
images = images.to(self.device)
masks = masks.to(self.device)
optimizer.zero_grad()
logits = self.model(images)
loss_disc = criterion(logits[:, 0:1], masks[:, 0:1])
loss_cup = criterion(logits[:, 1:2], masks[:, 1:2])
loss = self.disc_weight * loss_disc + self.cup_weight * loss_cup
loss.backward()
optimizer.step()
batch_size = images.size(0)
train_loss_total += loss.item() * batch_size
train_samples += batch_size
train_loss = (
train_loss_total / train_samples if train_samples else float("nan")
)
self.model.eval()
dices = []
val_bar = tqdm(
val_loader,
desc="Validate",
leave=False,
unit="batch",
total=len(val_loader),
)
with torch.no_grad():
for images, masks in val_bar:
images = images.to(self.device)
masks = masks.to(self.device)
logits = self.model(images)
probs = torch.sigmoid(logits)
dice = self.dice_score(probs, masks)
dices.append(dice.cpu())
if dices:
mean_dice = torch.stack(dices).mean(dim=0)
disc_dice = mean_dice[0].item()
cup_dice = mean_dice[1].item()
weight_sum = self.disc_weight + self.cup_weight
score = (
(self.disc_weight * disc_dice + self.cup_weight * cup_dice)
/ weight_sum
if weight_sum
else 0.0
)
epoch_bar.set_postfix(
loss=f"{train_loss:.4f}",
dice_disc=f"{disc_dice:.3f}",
dice_cup=f"{cup_dice:.3f}",
dice_w=f"{score:.3f}",
)
else:
disc_dice = cup_dice = 0.0
score = 0.0
epoch_bar.set_postfix(loss=f"{train_loss:.4f}")
if score > best_dice:
best_dice = score
torch.save({"model": self.model.state_dict()}, best_path)
if best_path.exists():
state = torch.load(best_path, map_location=self.device)
self.model.load_state_dict(state["model"])
# ------------------------------------------------------------------
def evaluate_holdout(
self, output_dir: Path = Path("analysis_data/segmenter_eval")
) -> pd.DataFrame:
return self.evaluate_dataset(split_filter={"holdout"}, output_dir=output_dir)
@staticmethod
def overlay_masks(
image: Image.Image, disc: np.ndarray, cup: np.ndarray
) -> Image.Image:
overlay = image.copy()
disc_img = Image.fromarray((disc * 255).astype(np.uint8))
cup_img = Image.fromarray((cup * 255).astype(np.uint8))
disc_color = Image.new("RGBA", image.size, (255, 0, 0, 0))
cup_color = Image.new("RGBA", image.size, (0, 255, 0, 0))
disc_color.paste((255, 0, 0, 100), mask=disc_img)
cup_color.paste((0, 255, 0, 100), mask=cup_img)
overlay = overlay.convert("RGBA")
overlay = Image.alpha_composite(overlay, disc_color)
overlay = Image.alpha_composite(overlay, cup_color)
return overlay.convert("RGB")
# ------------------------------------------------------------------
@staticmethod
def _normalize_filter(values: Optional[Iterable[str]]) -> Optional[Set[str]]:
if values is None:
return None
if isinstance(values, str):
return {values}
return {str(item) for item in values}
@staticmethod
def _dice_from_masks(pred: np.ndarray, target: np.ndarray) -> float:
pred = (pred > 0).astype(np.float32)
target = (target > 0).astype(np.float32)
intersection = float((pred * target).sum())
denom = float(pred.sum() + target.sum())
return (2.0 * intersection + 1e-6) / (denom + 1e-6)
def get_entries(
self,
dataset_filter: Optional[Iterable[str]] = None,
split_filter: Optional[Iterable[str]] = None,
) -> List[ManifestEntry]:
dataset_set = self._normalize_filter(dataset_filter)
split_set = self._normalize_filter(split_filter)
entries = self._manifest
if dataset_set is not None:
entries = [e for e in entries if e.dataset in dataset_set]
if split_set is not None:
entries = [e for e in entries if e.split in split_set]
return list(entries)
def evaluate_dataset(
self,
dataset_filter: Optional[Iterable[str]] = None,
split_filter: Optional[Iterable[str]] = None,
output_dir: Path = Path("analysis_data/segmenter_eval"),
save_overlays: bool = True,
metrics_path: Optional[Path] = None,
threshold: float = 0.5,
tta: bool = False,
) -> pd.DataFrame:
entries = self.get_entries(
dataset_filter=dataset_filter, split_filter=split_filter
)
if not entries:
return pd.DataFrame(
columns=[
"sample_id",
"dataset",
"split",
"dice_disc",
"dice_cup",
]
)
output_dir.mkdir(parents=True, exist_ok=True)
if metrics_path is None:
suffix_parts = []
if dataset_filter is not None:
suffix_parts.append("-".join(sorted(self._normalize_filter(dataset_filter))))
if split_filter is not None:
suffix_parts.append("-".join(sorted(self._normalize_filter(split_filter))))
suffix = "_".join(part for part in suffix_parts if part)
csv_name = f"metrics{'_' + suffix if suffix else ''}.csv"
metrics_path = output_dir / csv_name
records = []
self.model.eval()
progress = tqdm(
entries,
desc="Evaluate",
unit="sample",
leave=False,
)
for entry in progress:
orig_image = Image.open(entry.image_path).convert("RGB")
image = self.preprocess_image(orig_image)
tensor = transforms.ToTensor()(image)
tensor = self._normalize_tensor(tensor)
tensor = tensor.unsqueeze(0).to(self.device)
with torch.no_grad():
logits = self.model(tensor)
if tta:
t_h = torch.flip(tensor, dims=[3])
log_h = self.model(t_h)
log_h = torch.flip(log_h, dims=[3])
t_v = torch.flip(tensor, dims=[2])
log_v = self.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] > threshold).astype(np.uint8)
cup_pred = (probs[1] > threshold).astype(np.uint8)
# Structural prior: cup within disc
cup_pred = (cup_pred > 0) & (disc_pred > 0)
cup_pred = cup_pred.astype(np.uint8)
disc_gt, cup_gt = self.load_masks(entry)
disc_gt = disc_gt.astype(np.uint8)
cup_gt = cup_gt.astype(np.uint8)
dice_disc = self._dice_from_masks(disc_pred, disc_gt)
dice_cup = self._dice_from_masks(cup_pred, cup_gt)
records.append(
{
"sample_id": entry.sample_id,
"dataset": entry.dataset,
"split": entry.split,
"dice_disc": dice_disc,
"dice_cup": dice_cup,
}
)
progress.set_postfix(
dice_disc=f"{dice_disc:.3f}", dice_cup=f"{dice_cup:.3f}"
)
if save_overlays:
overlay_gt = self.overlay_masks(image, disc_gt, cup_gt)
overlay_pred = self.overlay_masks(image, disc_pred, cup_pred)
combined = Image.new("RGB", (image.width * 2, image.height))
combined.paste(overlay_gt, (0, 0))
combined.paste(overlay_pred, (image.width, 0))
combined.save(output_dir / f"{entry.sample_id}_eval.png")
metrics_df = pd.DataFrame(records)
summary = metrics_df[["dice_disc", "dice_cup"]].mean()
summary_row = {
"sample_id": "__mean__",
"dataset": "summary",
"split": "summary",
"dice_disc": summary["dice_disc"],
"dice_cup": summary["dice_cup"],
}
metrics_with_summary = pd.concat(
[metrics_df, pd.DataFrame([summary_row])], ignore_index=True
)
metrics_with_summary.to_csv(metrics_path, index=False)
return metrics_with_summary
-47
View File
@@ -1,47 +0,0 @@
"""General-purpose utilities for the V2 hypertower pipeline."""
from __future__ import annotations
import random as pyrandom
from pathlib import Path
import numpy as np
import pandas as pd
import torch
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)
def choose_device(device_arg: str | None) -> torch.device:
if device_arg and device_arg != "auto":
return torch.device(device_arg)
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def _drop_mixed_label_patients(df: pd.DataFrame, *, patient_col: str, label_col: str):
"""Remove patients whose rows carry conflicting labels. Returns (clean_df, mixed_pids)."""
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: pd.DataFrame, *, 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
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
# Future Directions
Deferred ideas that are out of scope for the current manuscript but worth revisiting once the paper is out. Nothing here changes headline numbers; the fused HyperTower already beats the Kovalyk-Borodyak reproduction comfortably. These are things we noticed while building `binocular_analog/` that could tighten the image-only or bilateral tower further.
## Image tower / bilateral fusion (from Kovalyk repro comparison)
Context: their end-to-end binocular ResNet-50 reproduction lands at ~0.86 bilateral AUC on PAPILA, which is above what our own `img_solo` bilateral image tower gets. The gap is not evidence that their architecture is better; it points at four training choices we haven't yet copied. Ranked by expected effect:
1. **Backbone: plain ImageNet V1 ResNet-50 instead of REFUGE-pretrained `refugelike`.**
In the Kovalyk repro we saw V1 > V2 on PAPILA (opposite of ImageNet-accuracy prediction). Same logic could apply to REFUGE pretraining: the REFUGE checkpoint may over-specialize early features to a different fundus camera / cohort, and plain ImageNet may transfer more cleanly. Try `backbone: "resnet50"` on `img_solo` with everything else held fixed.
2. **Backpropagate the patient-level loss through the backbone.**
In `img_solo`, the `hb` stage does not set `train_towers: true`, so the patient-level loss can only reshape the 4096→256 HyperBridge projection and the head. Kovalyk's BinoCNN is jointly trained end-to-end. Enable tower training on the `hb` stage (probably with a smaller LR to avoid destabilising the eye-level warmup) and see if the bilateral AUC moves.
3. **Wider patient-level fusion.**
HyperBridge `embedding_mlp` compresses 4096→256. Kovalyk's head goes 4096→512→2 with a real hidden layer. Try `hidden_dim: 512` and stack a small MLP head; cheap ablation.
4. **Label smoothing + explicit class weighting in the head loss.**
Kovalyk uses `CrossEntropyLoss(weight=inv_freq, label_smoothing=0.14)`. Ours is BCE-with-logits, no smoothing, no class weight. On a 3.5:1 imbalance at this dataset size, adding smoothing (~0.1) and a pos_weight tends to stabilise the minimizer. Cheap to try.
**NOT on the list:** disc-region ROI cropping (GT contour bbox or U-Net-derived bbox). Kovalyk pre-crops to a 299×299 square ROI, but our own crop-sweep experiments (`v4/results/experiments/backbone_replication/gtcrop_refugelike`, `unetcrop_refugelike`) showed cropping hurts on PAPILA. Do not add it back as an image-tower improvement.
## Compute / infrastructure (already documented)
See `memory/project_future_v4_compute_fixes.md` for the two deferred v4 speedups:
- Lazy BCD tower forward (~33% nt savings)
- Frozen-prefix embedding cache for hb/val (foundation for a future feature-cache service)
## Reproduction extensions (nice-to-have, not blocking)
- Fill in the remaining 6 CNN configs from Kovalyk-Borodyak (VGG16, InceptionV3, MobileNetV2, mono + bino each) if we ever want to report a fuller reproduction table rather than a single ResNet-50 headline.
- Try the paper's freeze-count under a proper Ray Tune HPO context; our 0.86-0.88 vs their 0.764 is the persistent unexplained residual, and the freeze-mode ablation did not close it.
-293
View File
@@ -1,293 +0,0 @@
{
"nodes": [
{
"id": "node_1770737490980_3a7n",
"type": "data",
"label": "Papila Images",
"inputKey": "",
"outputKey": "",
"inputType": "",
"inputIndex": "",
"outputType": "image",
"source": {
"mode": "directory",
"path": "FundusImages",
"count": null
},
"selectedPath": null,
"sourceRef": {
"importId": "import_1770737490980_00lw",
"slot": "image_dir",
"mode": "directory"
},
"x": 160,
"y": 760
},
{
"id": "node_1770737490980_az3a",
"type": "data",
"label": "Papila Metadata",
"inputKey": "",
"outputKey": "",
"inputType": "",
"inputIndex": "",
"outputType": "matrix",
"source": {
"mode": "dataframe",
"name": "clinical.df",
"rows": 488,
"cols": 15
},
"selectedPath": null,
"sourceRef": {
"importId": "import_1770737490980_00lw",
"slot": "clinical_df",
"mode": "dataframe"
},
"x": 340,
"y": 760
},
{
"id": "node_1770737490980_xnkx",
"type": "transform",
"label": "Resize",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"transformType": "resize",
"roiMaskSource": "gt",
"roiScale": 2.5,
"roiTargetSize": 224,
"roiFallback": true,
"centerCropSize": 224,
"jitterHFlip": true,
"jitterVFlip": true,
"jitterRotation": 15,
"jitterColorEnabled": true,
"jitterColor": "0.1,0.1,0.1,0.05",
"resizeSize": 256,
"x": 155.29958733477324,
"y": 622.2110067583806
},
{
"id": "node_1770737490981_bjwe",
"type": "transform",
"label": "Center Crop",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"transformType": "center_crop",
"roiMaskSource": "gt",
"roiScale": 2.5,
"roiTargetSize": 224,
"roiFallback": true,
"centerCropSize": 224,
"jitterHFlip": true,
"jitterVFlip": true,
"jitterRotation": 15,
"jitterColorEnabled": true,
"jitterColor": "0.1,0.1,0.1,0.05",
"resizeSize": 256,
"x": 139.05693245845418,
"y": 550.529525335558
},
{
"id": "node_1770737490981_ybod",
"type": "transform",
"label": "Jitter",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"transformType": "jitter_bundle",
"roiMaskSource": "gt",
"roiScale": 2.5,
"roiTargetSize": 224,
"roiFallback": true,
"centerCropSize": 224,
"jitterHFlip": true,
"jitterVFlip": true,
"jitterRotation": 15,
"jitterColorEnabled": true,
"jitterColor": "0.1,0.1,0.1,0.05",
"resizeSize": 256,
"x": 128.68982314179192,
"y": 467.09707170591474
},
{
"id": "node_1770737490981_ogmq",
"type": "loader",
"label": "Image Loader",
"inputKey": "image_1",
"outputKey": "image_1",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"x": 160,
"y": 370
},
{
"id": "node_1770737490981_i8qg",
"type": "loader",
"label": "Metadata Loader",
"inputKey": "matrix_1",
"outputKey": "matrix_1",
"inputType": "matrix",
"inputIndex": "1",
"outputType": "matrix",
"source": null,
"selectedPath": null,
"x": 382.30379326203655,
"y": 374.70041266522685
},
{
"id": "node_1770737490981_f1rl",
"type": "image_tower",
"label": "Tower 1",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"towerType": "image",
"imageBackbone": "efficientnet_b0",
"imageFreezeRatio": 0,
"imageAugment": true,
"imageGeometryDim": 0,
"imageUseSe": false,
"imageSeReduction": 16,
"imageSePreNorm": true,
"x": 105.94513543739873,
"y": 210.62237129546043
},
{
"id": "node_1770737490982_53hy",
"type": "metadata_tower",
"label": "Tower 2",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"towerType": "metadata",
"mdHiddenDim": 128,
"mdDropout": 0.1,
"mdUseSe": false,
"mdSeReduction": 16,
"mdSePreNorm": true,
"mdFreezeRatio": 0,
"x": 446.7257671413001,
"y": 189.90718376822076
},
{
"id": "node_1770737490982_xmc1",
"type": "bridge",
"label": "Bridge",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"bridgeMethod": "fusion",
"bridgeFusionDim": 256,
"bridgeUseSe": true,
"bridgeSeReduction": 16,
"bridgeSePreNorm": true,
"x": 311.5886354629202,
"y": 9.377640626755708
},
{
"id": "node_1770737490982_e9g4",
"type": "classifier",
"label": "Classifier",
"inputKey": "",
"outputKey": "",
"inputType": "image",
"inputIndex": "1",
"outputType": "image",
"source": null,
"selectedPath": null,
"x": 308.0632962358768,
"y": -139.42405168449085
}
],
"edges": [
{
"from": "node_1770737490980_3a7n",
"to": "node_1770737490980_xnkx"
},
{
"from": "node_1770737490980_xnkx",
"to": "node_1770737490981_bjwe"
},
{
"from": "node_1770737490981_bjwe",
"to": "node_1770737490981_ybod"
},
{
"from": "node_1770737490981_ybod",
"to": "node_1770737490981_ogmq"
},
{
"from": "node_1770737490980_az3a",
"to": "node_1770737490981_i8qg"
},
{
"from": "node_1770737490981_ogmq",
"to": "node_1770737490981_f1rl"
},
{
"from": "node_1770737490981_i8qg",
"to": "node_1770737490982_53hy"
},
{
"from": "node_1770737490981_f1rl",
"to": "node_1770737490982_xmc1"
},
{
"from": "node_1770737490982_53hy",
"to": "node_1770737490982_xmc1"
},
{
"from": "node_1770737490982_xmc1",
"to": "node_1770737490982_e9g4"
}
],
"meta": {
"generated_at": "2026-02-10T15:32:18.476Z",
"imports": [
{
"id": "import_1770737490980_00lw",
"className": "PapilaData",
"params": {
"image_dir": "FundusImages",
"clinical_dir": "ClinicalData",
"label_col": "Diagnosis",
"cat_cols": [
"Gender",
"Phakic/Pseudophakic"
]
}
}
]
}
}
-351
View File
@@ -1,351 +0,0 @@
"""
portable_versions/image_loader.py
==================================
A self-contained image loader with in-memory caching, an optional
preprocessing pipeline (e.g. disc cropping), and composable augmentations.
Returns plain NumPy arrays works with PyTorch, TensorFlow, JAX, or
anything else that can consume an ndarray.
Dependencies: Pillow, numpy (nothing else)
Quickstart
----------
from portable_versions.image_loader import ImageLoader, RandomHorizontalFlip, RandomRotation, ColorJitter
# 1. Build the loader (once per run)
loader = ImageLoader(
target_size=(200, 200),
normalize=True, # float32 in [0, 1] with ImageNet mean/std
cache=True, # each image decoded from disk only once
workers=4, # parallel cache warm-up threads
preprocessor=my_crop_fn, # optional callable(PIL.Image) -> PIL.Image
)
# 2. Attach augmentations (applied randomly and independently per call)
loader.augmentation = [
RandomHorizontalFlip(p=0.5),
RandomRotation(degrees=15),
ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.05),
]
# 3. Warm the cache up front (optional but fast)
loader.warm(all_paths)
# 4. Fetch images by path list — call as many times as you like
# Returns ndarray of shape (N, H, W, 3), dtype float32
imgs = loader.get_img(train_paths)
# For TensorFlow:
import tensorflow as tf
tensor = tf.constant(imgs) # (N, H, W, 3)
# For PyTorch:
import torch
tensor = torch.from_numpy(imgs).permute(0, 3, 1, 2) # (N, C, H, W)
Augmentations reference
-----------------------
All augmentation classes live in this file and depend only on PIL + numpy.
RandomHorizontalFlip(p=0.5)
RandomVerticalFlip(p=0.5)
RandomRotation(degrees=15)
ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.05)
RandomGrayscale(p=0.1)
You can also pass any callable(PIL.Image.Image) -> PIL.Image.Image as an
augmentation step.
"""
from __future__ import annotations
import random
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Callable, Iterable, List, Optional, Tuple, Union
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
# ImageNet channel statistics (RGB)
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
PathLike = Union[str, Path]
def _call_preprocessor(
fn: Callable[..., Image.Image],
img: Image.Image,
path: Path,
) -> Image.Image:
"""Call preprocessor as fn(img, path) if it accepts two args, else fn(img)."""
try:
return fn(img, path)
except TypeError:
return fn(img)
# ---------------------------------------------------------------------------
# Core loader
# ---------------------------------------------------------------------------
class ImageLoader:
"""
Preprocessing pipeline + in-memory cache + augmentation, returning NumPy.
Parameters
----------
target_size : (height, width)
Output spatial dimensions. Applied after ``preprocessor`` (if any).
Ignored when a ``preprocessor`` already resizes to the right size.
normalize : bool
When True, output is float32 with ImageNet mean/std subtraction.
When False, output is uint8 in [0, 255].
cache : bool
Store decoded+preprocessed images in RAM so each file is read from
disk at most once. The cache persists across ``get_img`` calls.
workers : int
Thread count for ``warm()``. 0 or 1 = single-threaded.
preprocessor : callable, optional
Called as ``preprocessor(img: PIL.Image) -> PIL.Image`` before
resizing and caching. Use this for disc cropping, padding, etc.
augmentation : list of callables
Each element is called as ``fn(img: PIL.Image) -> PIL.Image``.
Applied **after** cache retrieval, so augmentations are NOT cached
they are re-sampled independently on every ``get_img`` call.
"""
def __init__(
self,
target_size: Tuple[int, int] = (224, 224),
*,
normalize: bool = True,
cache: bool = True,
workers: int = 4,
preprocessor: Optional[Callable[[Image.Image], Image.Image]] = None,
) -> None:
self.target_size = target_size
self.normalize = normalize
self.workers = workers
self.preprocessor = preprocessor
self.augmentation: List[Callable[[Image.Image], Image.Image]] = []
self._cache: Optional[dict[str, np.ndarray]] = {} if cache else None
self._lock = threading.Lock()
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def warm(self, paths: Iterable[PathLike]) -> None:
"""
Pre-load all *paths* into the cache in parallel.
Already-cached paths are skipped, so calling ``warm`` multiple
times (e.g. once per fold) is safe and only loads new images.
"""
if self._cache is None:
return
paths = [str(p) for p in paths]
to_warm = [p for p in paths if p not in self._cache]
if not to_warm:
return
already = len(paths) - len(to_warm)
print(
f"[ImageLoader] warming {len(to_warm)} images"
+ (f" ({already} already cached)" if already else ""),
flush=True,
)
def _load_one(path_str: str) -> None:
arr = self._decode(path_str)
with self._lock:
self._cache.setdefault(path_str, arr)
if self.workers <= 1:
for p in to_warm:
_load_one(p)
else:
with ThreadPoolExecutor(max_workers=self.workers) as ex:
futures = {ex.submit(_load_one, p): p for p in to_warm}
for fut in as_completed(futures):
fut.result()
def get_img(
self,
paths: Iterable[PathLike],
augment: bool = True,
) -> np.ndarray:
"""
Return images for the given paths as a single NumPy array.
Parameters
----------
paths : iterable of path-like
File paths to load. If the cache is enabled and a path has
been warmed (or loaded before), it is served from RAM.
augment : bool
Apply ``self.augmentation`` pipeline. Set to False at eval time.
Returns
-------
np.ndarray, shape (N, H, W, 3)
float32 in [0, 1] (or normalised) if ``self.normalize`` is True,
otherwise uint8 in [0, 255].
"""
imgs = []
for p in paths:
img = self._get_one(str(p), augment=augment)
imgs.append(img)
return np.stack(imgs, axis=0)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _decode(self, path_str: str) -> np.ndarray:
"""Open, preprocess, and resize → uint8 HWC ndarray (for the cache)."""
img = Image.open(path_str).convert("RGB")
if self.preprocessor is not None:
img = _call_preprocessor(self.preprocessor, img, Path(path_str))
# Only resize here if preprocessor didn't already produce target_size
if img.size != (self.target_size[1], self.target_size[0]):
img = img.resize((self.target_size[1], self.target_size[0]), Image.BILINEAR)
return np.asarray(img, dtype=np.uint8)
def _get_one(self, path_str: str, augment: bool) -> np.ndarray:
if self._cache is not None:
arr = self._cache.get(path_str)
if arr is None:
arr = self._decode(path_str)
with self._lock:
self._cache.setdefault(path_str, arr)
img = Image.fromarray(arr, mode="RGB")
else:
img = Image.open(path_str).convert("RGB")
if self.preprocessor is not None:
img = self.preprocessor(img)
if img.size != (self.target_size[1], self.target_size[0]):
img = img.resize((self.target_size[1], self.target_size[0]), Image.BILINEAR)
if augment and self.augmentation:
for fn in self.augmentation:
img = fn(img)
arr = np.asarray(img, dtype=np.float32) / 255.0
if self.normalize:
arr = (arr - _IMAGENET_MEAN) / _IMAGENET_STD
else:
arr = (arr * 255).clip(0, 255).astype(np.uint8)
return arr
def __len__(self) -> int:
"""Number of images currently in the cache."""
return len(self._cache) if self._cache is not None else 0
def __repr__(self) -> str:
return (
f"ImageLoader(target_size={self.target_size}, "
f"normalize={self.normalize}, "
f"cached={len(self)}, "
f"augmentations={len(self.augmentation)})"
)
# ---------------------------------------------------------------------------
# Augmentation primitives (PIL-only, no torch/tf dependencies)
# ---------------------------------------------------------------------------
class RandomHorizontalFlip:
"""Flip image left-right with probability *p*."""
def __init__(self, p: float = 0.5):
self.p = p
def __call__(self, img: Image.Image) -> Image.Image:
return ImageOps.mirror(img) if random.random() < self.p else img
class RandomVerticalFlip:
"""Flip image top-bottom with probability *p*."""
def __init__(self, p: float = 0.5):
self.p = p
def __call__(self, img: Image.Image) -> Image.Image:
return ImageOps.flip(img) if random.random() < self.p else img
class RandomRotation:
"""Rotate by a uniformly-sampled angle in [-degrees, +degrees]."""
def __init__(self, degrees: float = 15):
self.degrees = degrees
def __call__(self, img: Image.Image) -> Image.Image:
angle = random.uniform(-self.degrees, self.degrees)
return img.rotate(angle, resample=Image.BILINEAR, expand=False)
class ColorJitter:
"""
Randomly jitter brightness, contrast, saturation, and hue.
Each factor is sampled uniformly from [1 - amount, 1 + amount].
Hue shift is sampled from [-hue, +hue] (range 00.5).
Pass 0 for any channel to leave it unchanged.
"""
def __init__(
self,
brightness: float = 0.2,
contrast: float = 0.2,
saturation: float = 0.1,
hue: float = 0.05,
):
self.brightness = brightness
self.contrast = contrast
self.saturation = saturation
self.hue = hue
def __call__(self, img: Image.Image) -> Image.Image:
ops = []
if self.brightness:
ops.append(("brightness", self.brightness))
if self.contrast:
ops.append(("contrast", self.contrast))
if self.saturation:
ops.append(("saturation", self.saturation))
if self.hue:
ops.append(("hue", self.hue))
random.shuffle(ops)
for kind, amount in ops:
factor = random.uniform(1 - amount, 1 + amount)
if kind == "brightness":
img = ImageEnhance.Brightness(img).enhance(factor)
elif kind == "contrast":
img = ImageEnhance.Contrast(img).enhance(factor)
elif kind == "saturation":
img = ImageEnhance.Color(img).enhance(factor)
elif kind == "hue":
# PIL has no direct hue enhancer — shift via HSV in numpy
arr = np.asarray(img.convert("HSV"), dtype=np.int16)
shift = int(random.uniform(-self.hue, self.hue) * 255)
arr[:, :, 0] = (arr[:, :, 0] + shift) % 256
img = Image.fromarray(arr.astype(np.uint8), mode="HSV").convert("RGB")
return img
class RandomGrayscale:
"""Convert to grayscale (keeping 3 channels) with probability *p*."""
def __init__(self, p: float = 0.1):
self.p = p
def __call__(self, img: Image.Image) -> Image.Image:
if random.random() < self.p:
img = ImageOps.grayscale(img).convert("RGB")
return img
-270
View File
@@ -1,270 +0,0 @@
"""
portable_versions/refuge_mask_adapter.py
=========================================
Optic-disc cropper for REFUGE (and REFUGE2) fundus images, designed as a
drop-in ``preprocessor`` for ``ImageLoader``.
Given an image and its corresponding segmentation mask, it:
1. Extracts the optic disc region from the mask
2. Computes a padded bounding box around it
3. Crops and resizes the original image
Dependencies: Pillow, numpy (nothing else)
Quickstart
----------
from portable_versions.image_loader import ImageLoader, RandomHorizontalFlip, RandomRotation, ColorJitter
from portable_versions.refuge_mask_adapter import RefugeMaskCropper
cropper = RefugeMaskCropper(
mask_dir="REFUGE/Annotations/Training400/Disc_Cup_Masks",
scale=1.5, # context around disc (1.0 = tight, 2.0 = lots of context)
target_size=(200, 200), # output size — should match ImageLoader target_size
mask_suffix=".bmp", # REFUGE1 uses .bmp; REFUGE2 uses .png
)
loader = ImageLoader(
target_size=(200, 200),
normalize=True,
preprocessor=cropper,
)
loader.augmentation = [
RandomHorizontalFlip(),
RandomRotation(15),
ColorJitter(0.2, 0.2, 0.1, 0.05),
]
imgs = loader.get_img(image_paths, augment=True) # (N, 200, 200, 3)
REFUGE mask formats
-------------------
REFUGE1 Grayscale BMP: background=128, disc=255, cup=0
REFUGE2 RGB PNG: background detected from image borders, disc/cup by colour
Both are handled automatically.
Directory structure assumption
------------------------------
The cropper looks for the mask with the same stem as the image file, inside
``mask_dir``. If your layout differs, pass a custom ``mask_path_fn``:
cropper = RefugeMaskCropper(
mask_path_fn=lambda img_path: img_path.with_suffix(".bmp"),
scale=1.5,
target_size=(200, 200),
)
"""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
from PIL import Image
_MASK_DIR_NAMES = {"Disc_Cup_Masks", "Disc_Masks", "Disc_Mask"}
_MASK_SUFFIXES = {".bmp", ".png"}
class RefugeMaskCropper:
"""
Crop a fundus image to the optic disc region using its segmentation mask.
Pass the REFUGE root directory and the cropper will automatically index
all masks underneath it no need to specify which subdirectory or
file extension.
cropper = RefugeMaskCropper("REFUGE/", scale=1.5)
loader = ImageLoader(target_size=(200, 200), preprocessor=cropper)
imgs = loader.get_img(test_set) # test_set = any list of image paths
Parameters
----------
refuge_root : str or Path
Top-level REFUGE directory. All mask files under directories named
``Disc_Cup_Masks``, ``Disc_Masks``, or ``Disc_Mask`` are indexed
automatically (supports both .bmp and .png).
scale : float
Padding multiplier applied to the disc radius.
1.0 = tight crop, 1.5 = moderate context, 2.5 = lots of context.
target_size : (height, width)
Output size after cropping. Should match ``ImageLoader.target_size``.
"""
def __init__(
self,
refuge_root: str | Path,
*,
scale: float = 1.5,
target_size: Tuple[int, int] = (200, 200),
) -> None:
self.refuge_root = Path(refuge_root)
self.scale = scale
self.target_size = target_size
self._index: dict[str, list[Path]] = {}
self._build_index()
def _build_index(self) -> None:
"""Walk refuge_root and index all mask files by stem (stem → [paths])."""
for mask_dir in self.refuge_root.rglob("*"):
if mask_dir.is_dir() and mask_dir.name in _MASK_DIR_NAMES:
for f in mask_dir.rglob("*"):
if f.is_file() and f.suffix.lower() in _MASK_SUFFIXES:
self._index.setdefault(f.stem, []).append(f)
if not self._index:
raise FileNotFoundError(
f"No mask files found under {self.refuge_root!r}. "
f"Expected directories named: {_MASK_DIR_NAMES}"
)
n_masks = sum(len(v) for v in self._index.values())
print(f"[RefugeMaskCropper] indexed {n_masks} masks ({len(self._index)} unique stems)", flush=True)
# ------------------------------------------------------------------
# Callable interface — drop-in preprocessor for ImageLoader
# ------------------------------------------------------------------
def __call__(
self,
img: Image.Image,
img_path: Optional[str | Path] = None,
) -> Image.Image:
stem = Path(img_path).stem if img_path else None
mask_path = self._lookup(stem, img_path)
disc_mask = _load_disc_mask(mask_path, img.size)
box = _mask_to_crop_box(disc_mask, scale=self.scale, img_size=img.size)
cropped = img.crop(box)
return cropped.resize(
(self.target_size[1], self.target_size[0]), Image.Resampling.BILINEAR
)
def _lookup(self, stem: Optional[str], img_path: Optional[str | Path] = None) -> Path:
if stem is None:
raise ValueError("img_path is required to match the mask.")
candidates = self._index.get(stem)
if not candidates:
raise KeyError(
f"No mask found for image stem {stem!r}. "
f"Available stems (sample): {list(self._index)[:5]}"
)
if len(candidates) == 1:
return candidates[0]
# Pick the mask whose directory components best overlap with img_path
# (ignores the filename itself to handle extension differences)
img_parts = set(Path(img_path).parent.parts) if img_path else set()
return max(candidates, key=lambda m: len(set(m.parent.parts) & img_parts))
def __repr__(self) -> str:
return (
f"RefugeMaskCropper(refuge_root={str(self.refuge_root)!r}, "
f"scale={self.scale}, target_size={self.target_size}, "
f"masks_indexed={len(self._index)})"
)
# ---------------------------------------------------------------------------
# Mask parsing
# ---------------------------------------------------------------------------
def _load_disc_mask(mask_path: Path, img_size: Tuple[int, int]) -> np.ndarray:
"""
Return a binary disc mask (uint8, 1=disc) from a REFUGE mask file.
Handles:
- Grayscale BMP (REFUGE1): background128, disc=255, cup=0
- RGB PNG (REFUGE2): background detected from image borders
"""
mask_img = Image.open(mask_path)
if mask_img.mode == "L" or mask_img.mode == "P":
arr = np.asarray(mask_img.convert("L"), dtype=np.uint8)
bg = _border_mode(arr)
disc_mask = (arr != bg).astype(np.uint8)
else:
arr = np.asarray(mask_img.convert("RGB"), dtype=np.uint8)
bg = _border_mode_rgb(arr)
# disc = any non-background pixel
bg_mask = np.all(arr == bg, axis=2)
disc_mask = (~bg_mask).astype(np.uint8)
# Ensure mask matches image spatial size
mh, mw = disc_mask.shape
iw, ih = img_size
if (mw, mh) != (iw, ih):
disc_img = Image.fromarray(disc_mask * 255).resize((iw, ih), Image.NEAREST)
disc_mask = (np.asarray(disc_img) > 0).astype(np.uint8)
return disc_mask
def _border_mode(arr: np.ndarray, border: int = 5) -> int:
"""Most common pixel value along the image border (grayscale)."""
h, w = arr.shape
border_pixels = np.concatenate([
arr[:border, :].ravel(),
arr[-border:, :].ravel(),
arr[:, :border].ravel(),
arr[:, -border:].ravel(),
])
return int(Counter(border_pixels.tolist()).most_common(1)[0][0])
def _border_mode_rgb(arr: np.ndarray, border: int = 5) -> np.ndarray:
"""Most common RGB colour along the image border."""
h, w, _ = arr.shape
border_pixels = np.concatenate([
arr[:border, :].reshape(-1, 3),
arr[-border:, :].reshape(-1, 3),
arr[:, :border].reshape(-1, 3),
arr[:, -border:].reshape(-1, 3),
], axis=0)
tuples = [tuple(row) for row in border_pixels.tolist()]
most_common = Counter(tuples).most_common(1)[0][0]
return np.array(most_common, dtype=np.uint8)
# ---------------------------------------------------------------------------
# Bounding box from mask
# ---------------------------------------------------------------------------
def _mask_to_crop_box(
disc_mask: np.ndarray,
scale: float,
img_size: Tuple[int, int],
) -> Tuple[int, int, int, int]:
"""
Compute a square crop box centred on the disc with padding = scale * radius.
Returns (left, upper, right, lower) ready for PIL Image.crop().
Falls back to the full image if no disc pixels are found.
"""
coords = np.argwhere(disc_mask > 0) # (N, 2) in (row, col) order
if coords.size == 0:
w, h = img_size
return (0, 0, w, h)
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
radius = max(float(xs.max() - xs.min()), float(ys.max() - ys.min())) / 2.0
crop_radius = radius * scale
iw, ih = img_size
left = int(max(0, centre_x - crop_radius))
upper = int(max(0, centre_y - crop_radius))
right = int(min(iw, centre_x + crop_radius))
lower = int(min(ih, centre_y + crop_radius))
# Make square by expanding the shorter side
cw, ch = right - left, lower - upper
if cw < ch:
diff = ch - cw
left = max(0, left - diff // 2)
right = min(iw, right + diff // 2)
elif ch < cw:
diff = cw - ch
upper = max(0, upper - diff // 2)
lower = min(ih, lower + diff // 2)
return (left, upper, right, lower)
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

@@ -1,61 +0,0 @@
{
"figure": "/home/rpotter/hypertower/results/poster/papila_binary_roc_comparison.png",
"curves": [
{
"label": "Clinical Data Only",
"source_kind": "legacy_npy",
"mode_dir": "analysis_data/pipeline_mdonly_500ep/binary/single",
"run": null,
"tower_path": null,
"score_col": null,
"probs_stem": "probs_classic",
"auc_mean": 0.7311688311688311,
"auc_std": 0.08461127279353979,
"auc_pooled": 0.6812987012987013,
"fold_count": 5,
"n_total": 400
},
{
"label": "Image Only",
"source_kind": "v3_csv",
"mode_dir": null,
"run": "phase2/imageonly_resnet50_proper",
"tower_path": "binary/single",
"score_col": "prob_img_c1",
"probs_stem": null,
"auc_mean": 0.819264705882353,
"auc_std": 0.0740851608018641,
"auc_pooled": 0.8099584558823529,
"fold_count": 50,
"n_total": 4200
},
{
"label": "Single Fusion",
"source_kind": "v3_csv",
"mode_dir": null,
"run": "phase5/single_fused",
"tower_path": "binary/single",
"score_col": "prob_fused_c1",
"probs_stem": null,
"auc_mean": 0.8506801470588234,
"auc_std": 0.07392522372615995,
"auc_pooled": 0.8402091911764706,
"fold_count": 50,
"n_total": 4200
},
{
"label": "Ensemble Fusion",
"source_kind": "v3_csv",
"mode_dir": null,
"run": "phase5/logit_mlp_head",
"tower_path": "binary/ensemble",
"score_col": "prob_fused_c1",
"probs_stem": null,
"auc_mean": 0.8988235294117648,
"auc_std": 0.06435919826554772,
"auc_pooled": 0.8855705882352942,
"fold_count": 50,
"n_total": 2100
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

-757
View File
@@ -1,757 +0,0 @@
#!/usr/bin/env python3
"""Basic analytics helpers for PAPILA clinical data."""
import re
from pathlib import Path
from typing import Iterable, List, Tuple
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.base import clone
from sklearn.metrics import roc_curve, auc, roc_auc_score, accuracy_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from classes import build_papila_clinical
class basic_analytics:
def __init__(
self,
image_dir: str = "Papila/FundusImages",
clinical_dir: str = "Papila/ClinicalData",
label_col: str = "Diagnosis",
cat_cols: Iterable[str] | None = None,
exclude_cols: Iterable[str] | None = None,
positive_label: int = 1,
negative_label: int = 0,
drop_labels: Iterable[int] = (2,),
output_dir: Path | str = Path("analysis_data/basic_analysis"),
debug: bool = False,
) -> None:
self.image_dir = image_dir
self.clinical_dir = clinical_dir
self.label_col = label_col
self.cat_cols = (
list(cat_cols)
if cat_cols is not None
else ["Gender", "Phakic/Pseudophakic"]
)
base_excludes = {"Pneumatic", "Perkins"}
self.exclude_cols = base_excludes | set(exclude_cols or [])
self.positive_label = positive_label
self.negative_label = negative_label
self.drop_labels = list(drop_labels or [])
self.output_dir = Path(output_dir)
self.debug = debug
@staticmethod
def _sanitize(name: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_")
return safe or "var"
def _build_clinical(self):
return build_papila_clinical(
image_dir=self.image_dir,
clinical_dir=self.clinical_dir,
label_col=self.label_col,
cat_cols=self.cat_cols,
)
def _select_binary_labels(
self,
labels: pd.Series,
) -> Tuple[np.ndarray, np.ndarray]:
labels_num = pd.to_numeric(labels, errors="coerce")
use_num = labels_num.notna().any()
lab = labels_num if use_num else labels.astype(str)
drop_set = set(self.drop_labels or [])
keep = lab.isin([self.positive_label, self.negative_label])
if drop_set:
keep &= ~lab.isin(drop_set)
y = (lab == self.positive_label).astype(int)
return y.values, keep.values
def _base_exclude(self) -> set:
base_exclude = (
{self.label_col, "Patient ID"} | self.exclude_cols | set(self.cat_cols)
)
if "eyeID" not in self.cat_cols:
base_exclude.add("eyeID")
return base_exclude
def _numeric_columns(self, df: pd.DataFrame) -> List[str]:
base_exclude = self._base_exclude()
candidate_cols = [c for c in df.columns if c not in base_exclude]
numeric_cols: List[str] = []
for col in candidate_cols:
s = pd.to_numeric(df[col], errors="coerce")
if s.notna().any():
numeric_cols.append(col)
return numeric_cols
@staticmethod
def _compute_roc(
y: np.ndarray, scores: np.ndarray
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
fpr, tpr, thresholds = roc_curve(y, scores, pos_label=1)
auc_val = float(auc(fpr, tpr))
return fpr, tpr, thresholds, auc_val
@staticmethod
def _best_threshold(
fpr: np.ndarray, tpr: np.ndarray, thresholds: np.ndarray
) -> Tuple[float, float, float]:
youden = tpr - fpr
idx = int(np.nanargmax(youden))
return float(thresholds[idx]), float(tpr[idx]), float(fpr[idx])
@staticmethod
def _plot_overlay(curves, title: str, out_path: Path) -> None:
fig, ax = plt.subplots(figsize=(7, 5.5))
cmap = plt.get_cmap("tab20")
for i, (name, fpr, tpr, auc_val) in enumerate(curves):
color = cmap(i % cmap.N)
ax.plot(fpr, tpr, lw=1.6, color=color, label=f"{name} (AUC={auc_val:.3f})")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="upper left", fontsize="small")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
@staticmethod
def _plot_per_feature(
fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path
) -> None:
fig, ax = plt.subplots(figsize=(5.5, 4.5))
ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
@staticmethod
def _plot_roc_line(
fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path
) -> None:
fig, ax = plt.subplots(figsize=(5.5, 4.5))
ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
def _oof_scores(
self,
model,
X: np.ndarray,
y: np.ndarray,
n_splits: int,
random_state: int,
) -> Tuple[np.ndarray, np.ndarray]:
skf = StratifiedKFold(
n_splits=n_splits, shuffle=True, random_state=random_state
)
scores = np.zeros(len(y), dtype=float)
for train_idx, test_idx in skf.split(X, y):
X_train, X_test = X[train_idx], X[test_idx]
y_train = y[train_idx]
if np.unique(y_train).size < 2:
continue
fitted = clone(model)
fitted.fit(X_train, y_train)
if hasattr(fitted, "predict_proba"):
fold_scores = fitted.predict_proba(X_test)[:, 1]
elif hasattr(fitted, "decision_function"):
fold_scores = fitted.decision_function(X_test)
else:
fold_scores = fitted.predict(X_test)
scores[test_idx] = fold_scores
return y.astype(int), scores
def _cv_roc_curves(
self,
model,
X: np.ndarray,
y: np.ndarray,
n_splits: int,
random_state: int,
) -> List[Tuple[np.ndarray, np.ndarray, float]]:
skf = StratifiedKFold(
n_splits=n_splits, shuffle=True, random_state=random_state
)
curves = []
for train_idx, test_idx in skf.split(X, y):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
if np.unique(y_train).size < 2 or np.unique(y_test).size < 2:
continue
fitted = clone(model)
fitted.fit(X_train, y_train)
if hasattr(fitted, "predict_proba"):
scores = fitted.predict_proba(X_test)[:, 1]
elif hasattr(fitted, "decision_function"):
scores = fitted.decision_function(X_test)
else:
scores = fitted.predict(X_test)
fpr, tpr, _ = roc_curve(y_test, scores, pos_label=1)
auc_val = float(auc(fpr, tpr))
curves.append((fpr, tpr, auc_val))
return curves
@staticmethod
def _plot_mean_roc(
curves: List[Tuple[np.ndarray, np.ndarray, float]],
title: str,
out_path: Path,
) -> None:
if not curves:
return
mean_fpr = np.linspace(0.0, 1.0, 200)
tprs = []
aucs = []
for fpr, tpr, auc_val in curves:
tpr_interp = np.interp(mean_fpr, fpr, tpr)
tpr_interp[0] = 0.0
tprs.append(tpr_interp)
aucs.append(auc_val)
mean_tpr = np.mean(tprs, axis=0)
mean_tpr[-1] = 1.0
std_tpr = np.std(tprs, axis=0)
mean_auc = float(np.mean(aucs))
std_auc = float(np.std(aucs, ddof=0))
fig, ax = plt.subplots(figsize=(5.8, 4.6))
ax.plot(mean_fpr, mean_tpr, lw=2, label=f"AUC={mean_auc:.3f}±{std_auc:.3f}")
ax.fill_between(
mean_fpr,
np.maximum(mean_tpr - std_tpr, 0),
np.minimum(mean_tpr + std_tpr, 1),
color="grey",
alpha=0.2,
)
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
def _iter_categorical(self, df: pd.DataFrame, cols: List[str]):
for col in cols:
if col not in df.columns:
continue
s = df[col]
vals = s.dropna().unique().tolist()
try:
vals = sorted(vals)
except Exception:
pass
for v in vals:
name = f"{col}=={v}"
ind = (s == v).astype(int)
yield name, ind
def _feature_matrix(
self, df: pd.DataFrame, include_categorical: bool
) -> Tuple[np.ndarray, np.ndarray, List[str]]:
numeric_cols = self._numeric_columns(df)
X_num = df[numeric_cols].apply(pd.to_numeric, errors="coerce")
for col in numeric_cols:
med = pd.to_numeric(X_num[col], errors="coerce").median()
X_num[col] = pd.to_numeric(X_num[col], errors="coerce").fillna(med)
parts = [X_num]
feat_names = list(X_num.columns)
if include_categorical and self.cat_cols:
cat_cols = [c for c in self.cat_cols if c in df.columns]
if cat_cols:
df_cats = pd.get_dummies(
df[cat_cols].astype("category"), drop_first=False, prefix=cat_cols
)
parts.append(df_cats)
feat_names.extend(list(df_cats.columns))
X = pd.concat(parts, axis=1).values.astype(np.float32)
labels = df[self.label_col]
y_all, keep_mask = self._select_binary_labels(labels)
y = y_all[keep_mask]
X = X[keep_mask]
return X, y.astype(int), feat_names
def univariate_roc(
self, merge: bool = False, include_categorical: bool = False
) -> pd.DataFrame:
clinical = self._build_clinical()
df = clinical.df.copy()
labels = df[self.label_col]
y_all, keep_mask = self._select_binary_labels(labels)
if self.debug:
for col in ("IOP_raw", "IOP_corr"):
if col not in df.columns:
print(f"[debug] {col} missing from df")
continue
s = pd.to_numeric(df[col], errors="coerce")
print(
f"[debug] {col}: non-null={int(s.notna().sum())}, unique={int(s.nunique(dropna=True))}"
)
plot_dir = self.output_dir / "papila_univariate_roc" / "plots"
plot_dir.mkdir(parents=True, exist_ok=True)
rows = []
curves = []
numeric_cols = self._numeric_columns(df)
for col in numeric_cols:
series = pd.to_numeric(df[col], errors="coerce")
mask = keep_mask & series.notna().values
y = y_all[mask]
scores = series.values[mask].astype(float)
if y.size < 2 or np.unique(y).size < 2:
continue
if np.nanmin(scores) == np.nanmax(scores):
continue
fpr, tpr, thresholds, auc_val = self._compute_roc(y, scores)
thr, best_tpr, best_fpr = self._best_threshold(fpr, tpr, thresholds)
direction = "high" if auc_val >= 0.5 else "low"
title = f"{col} (n={y.size}, direction={direction})"
if merge:
out_path = plot_dir / f"roc_{self._sanitize(col)}.png"
self._plot_per_feature(fpr, tpr, auc_val, title, out_path)
curves.append((col, fpr, tpr, auc_val))
rows.append(
{
"feature": col,
"kind": "numeric",
"n": int(y.size),
"auc": auc_val,
"direction": direction,
"best_threshold": thr,
"best_tpr": best_tpr,
"best_fpr": best_fpr,
"best_specificity": 1.0 - best_fpr,
}
)
if include_categorical:
cat_cols_use = [c for c in self.cat_cols if c not in self.exclude_cols]
for name, ind in self._iter_categorical(df, cat_cols_use):
mask = keep_mask & ind.notna().values
y = y_all[mask]
scores = ind.values[mask].astype(float)
if y.size < 2 or np.unique(y).size < 2:
continue
if np.nanmin(scores) == np.nanmax(scores):
continue
fpr, tpr, thresholds, auc_val = self._compute_roc(y, scores)
thr, best_tpr, best_fpr = self._best_threshold(fpr, tpr, thresholds)
direction = "high" if auc_val >= 0.5 else "low"
title = f"{name} (n={y.size}, direction={direction})"
if merge:
out_path = plot_dir / f"roc_{self._sanitize(name)}.png"
self._plot_per_feature(fpr, tpr, auc_val, title, out_path)
curves.append((name, fpr, tpr, auc_val))
rows.append(
{
"feature": name,
"kind": "categorical",
"n": int(y.size),
"auc": auc_val,
"direction": direction,
"best_threshold": thr,
"best_tpr": best_tpr,
"best_fpr": best_fpr,
"best_specificity": 1.0 - best_fpr,
}
)
if not rows:
raise SystemExit(
"No valid features produced ROC curves. Check labels and feature columns."
)
overlay_path = plot_dir / "roc_overlay.png"
if not merge:
self._plot_overlay(curves, "Univariate ROC curves", overlay_path)
out_df = pd.DataFrame(rows).sort_values(by="auc", ascending=False)
out_csv = self.output_dir / "papila_univariate_roc" / "summary.csv"
out_csv.parent.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out_csv, index=False)
return out_df
def random_forest(
self,
include_categorical: bool = True,
n_estimators: int = 500,
max_depth: int | None = None,
min_samples_leaf: int = 1,
max_features: str | None = "sqrt",
class_weight: str | None = "balanced",
max_samples: float | None = None,
random_state: int = 42,
top_n: int = 25,
n_splits: int = 5,
drop_missing: bool = False,
nerf: bool = False,
drop_age: bool = False,
) -> pd.DataFrame:
clinical = self._build_clinical()
df = clinical.df.copy()
original_exclude = set(self.exclude_cols)
if nerf:
self.exclude_cols = set(self.exclude_cols)
drop_missing = True
if drop_age:
self.exclude_cols = set(self.exclude_cols) | {"Age"}
if drop_missing:
numeric_cols = self._numeric_columns(df)
df = df.dropna(subset=numeric_cols)
X, y, feat_names = self._feature_matrix(
df, include_categorical=include_categorical
)
if X.size == 0 or np.unique(y).size < 2:
raise SystemExit(
"Not enough data after filtering labels for Random Forest."
)
if nerf:
n_estimators = 200
max_depth = 5
min_samples_leaf = 5
max_features = "sqrt"
class_weight = None
max_samples = 0.7
self.exclude_cols = original_exclude
clf = RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
class_weight=class_weight,
max_samples=max_samples,
random_state=random_state,
n_jobs=-1,
)
clf.fit(X, y)
importances = clf.feature_importances_.astype(float)
rows = []
for name, val in zip(feat_names, importances):
rows.append({"feature": name, "importance": float(val)})
out_df = pd.DataFrame(rows).sort_values(by="importance", ascending=False)
out_dir = self.output_dir / (
"papila_random_forest_nerfed" if nerf else "papila_random_forest"
)
out_dir.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out_dir / "feature_importance.csv", index=False)
top_df = out_df.head(top_n)
fig, ax = plt.subplots(figsize=(7, 6))
ax.barh(top_df["feature"], top_df["importance"], color="steelblue")
ax.invert_yaxis()
ax.set_xlabel("Importance (Gini)")
ax.set_title(f"Random Forest Feature Importance")
fig.tight_layout()
fig.savefig(out_dir / "feature_importance_top.png", dpi=170)
plt.close(fig)
if self.debug:
age_rows = out_df[out_df["feature"] == "Age"]
if not age_rows.empty:
age_imp = float(age_rows["importance"].iloc[0])
print(f"[debug] RF importance Age = {age_imp:.4f}")
y_oof, scores_oof = self._oof_scores(clf, X, y, n_splits, random_state)
fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof)
curves = self._cv_roc_curves(clf, X, y, n_splits, random_state)
self._plot_mean_roc(
curves,
"Random Forest ROC (mean ± SD)",
out_dir / "roc_mean.png",
)
return out_df
def _cv_binary_metrics(
self,
model,
X: np.ndarray,
y: np.ndarray,
n_splits: int,
random_state: int,
) -> pd.DataFrame:
skf = StratifiedKFold(
n_splits=n_splits, shuffle=True, random_state=random_state
)
rows = []
for fold, (train_idx, test_idx) in enumerate(skf.split(X, y), start=1):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
if np.unique(y_train).size < 2 or np.unique(y_test).size < 2:
continue
model.fit(X_train, y_train)
if hasattr(model, "predict_proba"):
scores = model.predict_proba(X_test)[:, 1]
elif hasattr(model, "decision_function"):
scores = model.decision_function(X_test)
else:
scores = model.predict(X_test)
preds = model.predict(X_test)
auc_val = float(roc_auc_score(y_test, scores))
acc_val = float(accuracy_score(y_test, preds))
rows.append(
{
"fold": int(fold),
"n": int(len(y_test)),
"auc": auc_val,
"acc": acc_val,
}
)
return pd.DataFrame(rows)
def svm(
self,
include_categorical: bool = True,
kernel: str = "rbf",
C: float = 1.0,
gamma: str = "scale",
n_splits: int = 5,
random_state: int = 42,
) -> pd.DataFrame:
clinical = self._build_clinical()
df = clinical.df.copy()
X, y, feat_names = self._feature_matrix(
df, include_categorical=include_categorical
)
if X.size == 0 or np.unique(y).size < 2:
raise SystemExit("Not enough data after filtering labels for SVM.")
model = Pipeline(
[
("scale", StandardScaler()),
(
"svm",
SVC(
kernel=kernel,
C=C,
gamma=gamma,
probability=True,
class_weight="balanced",
random_state=random_state,
),
),
]
)
fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state)
if fold_df.empty:
raise SystemExit("SVM produced no valid folds (check class balance).")
y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state)
fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof)
curves = self._cv_roc_curves(model, X, y, n_splits, random_state)
summary = pd.DataFrame(
[
{
"metric": "auc",
"mean": float(fold_df["auc"].mean()),
"std": float(fold_df["auc"].std(ddof=0)),
"oof_auc": float(auc_val),
},
{
"metric": "acc",
"mean": float(fold_df["acc"].mean()),
"std": float(fold_df["acc"].std(ddof=0)),
},
]
)
out_dir = self.output_dir / "papila_svm"
out_dir.mkdir(parents=True, exist_ok=True)
fold_df.to_csv(out_dir / "fold_metrics.csv", index=False)
summary.to_csv(out_dir / "summary.csv", index=False)
self._plot_mean_roc(
curves,
"SVM ROC (mean ± SD)",
out_dir / "roc_mean.png",
)
return fold_df
def knn(
self,
include_categorical: bool = True,
n_neighbors: int = 5,
weights: str = "distance",
n_splits: int = 5,
random_state: int = 42,
) -> pd.DataFrame:
clinical = self._build_clinical()
df = clinical.df.copy()
X, y, feat_names = self._feature_matrix(
df, include_categorical=include_categorical
)
if X.size == 0 or np.unique(y).size < 2:
raise SystemExit("Not enough data after filtering labels for KNN.")
model = Pipeline(
[
("scale", StandardScaler()),
("knn", KNeighborsClassifier(n_neighbors=n_neighbors, weights=weights)),
]
)
fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state)
if fold_df.empty:
raise SystemExit("KNN produced no valid folds (check class balance).")
y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state)
fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof)
curves = self._cv_roc_curves(model, X, y, n_splits, random_state)
summary = pd.DataFrame(
[
{
"metric": "auc",
"mean": float(fold_df["auc"].mean()),
"std": float(fold_df["auc"].std(ddof=0)),
"oof_auc": float(auc_val),
},
{
"metric": "acc",
"mean": float(fold_df["acc"].mean()),
"std": float(fold_df["acc"].std(ddof=0)),
},
]
)
out_dir = self.output_dir / "papila_knn"
out_dir.mkdir(parents=True, exist_ok=True)
fold_df.to_csv(out_dir / "fold_metrics.csv", index=False)
summary.to_csv(out_dir / "summary.csv", index=False)
self._plot_mean_roc(
curves,
"KNN ROC (mean ± SD)",
out_dir / "roc_mean.png",
)
return fold_df
def logistic_regression(
self,
include_categorical: bool = True,
C: float = 1.0,
max_iter: int = 1000,
n_splits: int = 5,
random_state: int = 42,
drop_age: bool = False,
nerf: bool = False,
) -> pd.DataFrame:
clinical = self._build_clinical()
df = clinical.df.copy()
original_exclude = set(self.exclude_cols)
if drop_age:
self.exclude_cols = set(self.exclude_cols) | {"Age"}
X, y, feat_names = self._feature_matrix(
df, include_categorical=include_categorical
)
self.exclude_cols = original_exclude
if X.size == 0 or np.unique(y).size < 2:
raise SystemExit(
"Not enough data after filtering labels for Logistic Regression."
)
class_weight = "balanced"
penalty = "l2"
solver = "lbfgs"
if nerf:
C = 0.05
class_weight = None
penalty = "l1"
solver = "liblinear"
model = Pipeline(
[
("scale", StandardScaler()),
(
"logreg",
LogisticRegression(
C=C,
max_iter=max_iter,
class_weight=class_weight,
penalty=penalty,
solver=solver,
),
),
]
)
fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state)
if fold_df.empty:
raise SystemExit(
"Logistic Regression produced no valid folds (check class balance)."
)
y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state)
fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof)
curves = self._cv_roc_curves(model, X, y, n_splits, random_state)
summary = pd.DataFrame(
[
{
"metric": "auc",
"mean": float(fold_df["auc"].mean()),
"std": float(fold_df["auc"].std(ddof=0)),
"oof_auc": float(auc_val),
},
{
"metric": "acc",
"mean": float(fold_df["acc"].mean()),
"std": float(fold_df["acc"].std(ddof=0)),
},
]
)
out_dir = self.output_dir / "papila_logistic_regression"
out_dir.mkdir(parents=True, exist_ok=True)
fold_df.to_csv(out_dir / "fold_metrics.csv", index=False)
summary.to_csv(out_dir / "summary.csv", index=False)
self._plot_mean_roc(
curves,
"Logistic Regression ROC (mean ± SD)",
out_dir / "roc_mean.png",
)
return fold_df
ba = basic_analytics()
roc_df = ba.univariate_roc(merge=False, include_categorical=False)
rf_df = ba.random_forest(include_categorical=True, nerf=False)
svm_df = ba.svm(include_categorical=True)
knn_df = ba.knn(include_categorical=True)
lr_df = ba.logistic_regression(include_categorical=True, nerf=True)
clinical = ba._build_clinical()
clinical.df["Diagnosis"].value_counts()
-327
View File
@@ -1,327 +0,0 @@
#!/usr/bin/env python3
"""Train a CNN (resnet50 backbone), extract logits, and train RF on logits+metadata with 5-fold CV."""
from __future__ import annotations
import random
from pathlib import Path
import sys
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes import build_papila_clinical
from classes.backbones import BACKBONES, load_backbone_weights
# ---------------------------
# Config (edit in IDE)
# ---------------------------
IMAGE_DIR = "Papila/FundusImages"
CLINICAL_DIR = "Papila/ClinicalData"
LABEL_COL = "Diagnosis"
CAT_COLS = ["Gender", "Phakic/Pseudophakic"]
EVAL_MODE = "binary" # "binary" or "multiclass"
N_SPLITS = 5
FOLD_SEED = 42
HOLDOUT_SEED = 123
HOLDOUT_PATIENTS_PER_CLASS = 6
BACKBONE_NAME = "resnet50"
BATCH_SIZE = 8
EPOCHS = 40
LR = 1e-4
WEIGHT_DECAY = 1e-5
RF_TREES = 500
RF_MAX_DEPTH = None
RF_MIN_SAMPLES_LEAF = 1
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
OUTPUT_DIR = Path("analysis_data/basic_analysis/cnn_logits_rf_cv")
PRINT_EPOCH_REPORT = True
EPOCH_REPORT_EVERY = 1
class PapilaImageDataset(Dataset):
def __init__(
self, clinical, df: pd.DataFrame, label_col: str, img_transform
) -> None:
self.clinical = clinical
self.df = df.reset_index(drop=True)
self.label_col = label_col
self.img_transform = img_transform
def __len__(self) -> int:
return len(self.df)
def __getitem__(self, idx: int):
row = self.df.iloc[idx]
img_path = self.clinical.get_image_path(row)
image = Image.open(img_path).convert("RGB")
x_img = self.img_transform(image)
y = int(row[self.label_col])
x_md = self.clinical.vectorize_row(row).astype(np.float32)
return x_img, y, x_md
class CNNHead(nn.Module):
def __init__(self, backbone_name: str, num_classes: int) -> None:
super().__init__()
spec = BACKBONES[backbone_name]
backbone = spec.ctor(weights=spec.weights_default)
if backbone_name.startswith("refuge"):
load_backbone_weights(backbone_name, backbone)
out_dim, backbone = spec.strip(backbone)
self.backbone = backbone
self.head = nn.Linear(out_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
feats = self.backbone(x)
return self.head(feats)
def _set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def _auc_score(y_true: np.ndarray, probs: np.ndarray, num_classes: int) -> float:
try:
if num_classes == 2:
return float(roc_auc_score(y_true, probs[:, 1]))
return float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro"))
except Exception:
return float("nan")
def _prepare_clinical() -> Tuple[object, pd.DataFrame]:
clinical = build_papila_clinical(
image_dir=IMAGE_DIR,
clinical_dir=CLINICAL_DIR,
label_col=LABEL_COL,
cat_cols=CAT_COLS,
n_splits=N_SPLITS,
random_seed=FOLD_SEED,
)
df = clinical.df.copy()
if EVAL_MODE == "binary":
df = df[df[LABEL_COL].isin([0, 1])].reset_index(drop=True)
return clinical, df
def _split_holdout_by_patient(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
rng = np.random.default_rng(HOLDOUT_SEED)
patient_label = (
df.groupby("Patient ID")[LABEL_COL]
.agg(lambda s: int(s.mode().iloc[0]))
.reset_index()
)
holdout_patients = []
for lbl, grp in patient_label.groupby(LABEL_COL):
candidates = grp["Patient ID"].to_numpy()
n = min(HOLDOUT_PATIENTS_PER_CLASS, len(candidates))
if n <= 0:
continue
selected = rng.choice(candidates, size=n, replace=False)
holdout_patients.extend(selected.tolist())
holdout_patients = sorted(set(holdout_patients))
holdout_df = df[df["Patient ID"].isin(holdout_patients)].reset_index(drop=True)
train_df = df[~df["Patient ID"].isin(holdout_patients)].reset_index(drop=True)
return train_df, holdout_df
def _rebuild_clinical_from_df(clinical, df: pd.DataFrame) -> object:
clinical.frames = [df.copy()]
clinical.df = df.copy()
clinical._infer_or_validate_feature_types()
clinical._compute_numeric_stats()
clinical._build_cat_maps()
clinical._compute_feature_dim()
clinical._build_kfold_indices()
return clinical
def _train_cnn(
model: nn.Module, loader: DataLoader, num_classes: int, fold: int
) -> None:
model.train()
optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
criterion = nn.CrossEntropyLoss()
for epoch in range(EPOCHS):
running_loss = 0.0
correct = 0
total = 0
for x_img, y, _x_md in loader:
x_img = x_img.to(DEVICE)
y = y.to(DEVICE)
optimizer.zero_grad()
logits = model(x_img)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
running_loss += float(loss.item()) * int(y.size(0))
pred = torch.argmax(logits, dim=1)
correct += int((pred == y).sum().item())
total += int(y.size(0))
if PRINT_EPOCH_REPORT and ((epoch + 1) % EPOCH_REPORT_EVERY == 0):
avg_loss = running_loss / max(total, 1)
train_acc = correct / max(total, 1)
print(
f"[fold {fold + 1}/{N_SPLITS}] epoch {epoch + 1}/{EPOCHS} "
f"train_loss={avg_loss:.4f} train_acc={train_acc:.4f}"
)
def _infer_logits(
model: nn.Module, loader: DataLoader
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
model.eval()
logits_all, probs_all, y_all, md_all = [], [], [], []
with torch.no_grad():
for x_img, y, x_md in loader:
x_img = x_img.to(DEVICE)
logits = model(x_img).cpu().numpy()
probs = torch.softmax(torch.from_numpy(logits), dim=1).numpy()
logits_all.append(logits)
probs_all.append(probs)
y_all.append(y.numpy())
md_all.append(x_md.numpy())
return (
np.concatenate(y_all, axis=0),
np.concatenate(logits_all, axis=0),
np.concatenate(md_all, axis=0),
)
def main() -> None:
_set_seed(FOLD_SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
num_classes = 2 if EVAL_MODE == "binary" else 3
train_tf = transforms.Compose(
[
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
eval_tf = transforms.Compose(
[
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
clinical, df = _prepare_clinical()
train_df, holdout_df = _split_holdout_by_patient(df)
clinical = _rebuild_clinical_from_df(clinical, train_df)
holdout_df.to_csv(OUTPUT_DIR / "holdout_patients.csv", index=False)
rows: List[Dict[str, object]] = []
holdout_rows: List[Dict[str, object]] = []
for fold in range(N_SPLITS):
print(f"\n[info] Starting fold {fold + 1}/{N_SPLITS}")
fold_train_df, fold_val_df = clinical.get_split_dfs(fold)
ds_train = PapilaImageDataset(clinical, fold_train_df, LABEL_COL, train_tf)
ds_val = PapilaImageDataset(clinical, fold_val_df, LABEL_COL, eval_tf)
ds_holdout = PapilaImageDataset(clinical, holdout_df, LABEL_COL, eval_tf)
dl_train = DataLoader(
ds_train, batch_size=BATCH_SIZE, shuffle=True, num_workers=0
)
dl_val = DataLoader(ds_val, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
dl_holdout = DataLoader(
ds_holdout, batch_size=BATCH_SIZE, shuffle=False, num_workers=0
)
model = CNNHead(BACKBONE_NAME, num_classes=num_classes).to(DEVICE)
_train_cnn(model, dl_train, num_classes=num_classes, fold=fold)
y_tr, log_tr, md_tr = _infer_logits(
model,
DataLoader(ds_train, batch_size=BATCH_SIZE, shuffle=False, num_workers=0),
)
y_va, log_va, md_va = _infer_logits(model, dl_val)
y_ho, log_ho, md_ho = _infer_logits(model, dl_holdout)
np.save(OUTPUT_DIR / f"fold{fold}_train_logits.npy", log_tr)
np.save(OUTPUT_DIR / f"fold{fold}_val_logits.npy", log_va)
np.save(OUTPUT_DIR / f"fold{fold}_holdout_logits.npy", log_ho)
X_tr = np.concatenate([log_tr, md_tr], axis=1)
X_va = np.concatenate([log_va, md_va], axis=1)
X_ho = np.concatenate([log_ho, md_ho], axis=1)
rf = RandomForestClassifier(
n_estimators=RF_TREES,
max_depth=RF_MAX_DEPTH,
min_samples_leaf=RF_MIN_SAMPLES_LEAF,
class_weight="balanced",
random_state=FOLD_SEED + fold,
n_jobs=-1,
)
rf.fit(X_tr, y_tr)
p_va = rf.predict_proba(X_va)
p_ho = rf.predict_proba(X_ho)
pred_va = np.argmax(p_va, axis=1)
pred_ho = np.argmax(p_ho, axis=1)
rows.append(
{
"fold": fold,
"val_acc": float(accuracy_score(y_va, pred_va)),
"val_auc": _auc_score(y_va, p_va, num_classes),
"n_val": int(len(y_va)),
}
)
holdout_rows.append(
{
"fold": fold,
"holdout_acc": float(accuracy_score(y_ho, pred_ho)),
"holdout_auc": _auc_score(y_ho, p_ho, num_classes),
"n_holdout": int(len(y_ho)),
}
)
print(
f"[info] Fold {fold + 1} RF: val_acc={rows[-1]['val_acc']:.4f} val_auc={rows[-1]['val_auc']:.4f} "
f"| holdout_acc={holdout_rows[-1]['holdout_acc']:.4f} holdout_auc={holdout_rows[-1]['holdout_auc']:.4f}"
)
fold_df = pd.DataFrame(rows)
holdout_df = pd.DataFrame(holdout_rows)
fold_df.to_csv(OUTPUT_DIR / "rf_val_metrics.csv", index=False)
holdout_df.to_csv(OUTPUT_DIR / "rf_holdout_metrics.csv", index=False)
print("\nRF validation metrics:")
print(fold_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print("\nRF holdout metrics:")
print(holdout_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(
f"\nMeans: val_acc={fold_df['val_acc'].mean():.4f}, val_auc={fold_df['val_auc'].mean():.4f}, "
f"holdout_acc={holdout_df['holdout_acc'].mean():.4f}, holdout_auc={holdout_df['holdout_auc'].mean():.4f}"
)
print(f"\nSaved outputs to: {OUTPUT_DIR}")
if __name__ == "__main__":
main()
@@ -1,399 +0,0 @@
#!/usr/bin/env python3
"""v2 port of cnn_logits_rf_cv: CNN logit extraction + RF CV using the v2 PAPILA stack.
Replaces the hardcoded-config v1 version. Data loading, fold splitting, and
feature preparation all go through the v2 stack so that --exclude-cols,
--iop-corr-method, etc. are first-class options.
Usage:
python scripts/basic_analysis/cnn_logits_rf_cv_v2.py \
--eval-mode binary --backbone resnet50 --epochs 40 \
--exclude-cols Phakic/Pseudophakic Axial_Length \
--run-name cnn_rf_nocrop_binary
"""
from __future__ import annotations
import argparse
import random
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import sys
from types import SimpleNamespace
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.papila_builders import build_papila_data
from classes.v2.backbones import BACKBONES, load_backbone_weights
from classes.v2.dataset import ClinicalDataset, _ClinicalView
from classes.v2.split_manager import PatientFirstSplitManager
from classes.v2.transforms import build_backbone_transform, build_eval_transform
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class CNNHead(nn.Module):
def __init__(self, backbone_name: str, num_classes: int) -> None:
super().__init__()
spec = BACKBONES[backbone_name]
backbone = spec.ctor(weights=spec.weights_default)
if backbone_name.startswith("refuge"):
load_backbone_weights(backbone_name, backbone)
out_dim, backbone = spec.strip(backbone)
self.backbone = backbone
self.head = nn.Linear(out_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.head(self.backbone(x))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _auc_score(y_true: np.ndarray, probs: np.ndarray, num_classes: int) -> float:
try:
if num_classes == 2:
return float(roc_auc_score(y_true, probs[:, 1]))
return float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro"))
except Exception:
return float("nan")
def _train_cnn(
model: nn.Module,
loader: DataLoader,
args,
fold: int,
device: torch.device,
) -> None:
model.train()
optimizer = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
criterion = nn.CrossEntropyLoss()
for epoch in range(args.epochs):
running_loss = total = correct = 0
for x_img, _meta, y in loader:
x_img = x_img.to(device)
y = y.to(device=device, dtype=torch.long)
optimizer.zero_grad()
logits = model(x_img)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
running_loss += float(loss.item()) * int(y.size(0))
correct += int((logits.argmax(1) == y).sum().item())
total += int(y.size(0))
if (epoch + 1) % args.log_every == 0:
print(
f" [fold {fold+1}] epoch {epoch+1}/{args.epochs} "
f"loss={running_loss/max(total,1):.4f} acc={correct/max(total,1):.4f}",
flush=True,
)
def _infer_logits(
model: nn.Module, loader: DataLoader, device: torch.device
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Returns (y_true, logits, metadata_vectors)."""
model.eval()
y_all, logits_all, md_all = [], [], []
with torch.no_grad():
for x_img, x_md, y in loader:
logits = model(x_img.to(device)).cpu().numpy()
y_np = y.numpy() if torch.is_tensor(y) else np.asarray(y)
md_np = x_md.numpy() if torch.is_tensor(x_md) else np.asarray(x_md)
y_all.append(y_np)
logits_all.append(logits)
md_all.append(md_np)
return (
np.concatenate(y_all),
np.concatenate(logits_all),
np.concatenate(md_all),
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
description="CNN logit extraction + RF CV (v2 PAPILA stack)"
)
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("--exclude-cols", nargs="*", default=[],
help="Feature columns to drop from the clinical feature matrix.")
ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="binary")
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=6,
help="Patients per class reserved for holdout (0 disables).")
ap.add_argument("--holdout-seed", type=int, default=123)
ap.add_argument("--backbone", default="resnet50")
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--epochs", type=int, default=40)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--weight-decay", type=float, default=1e-5)
ap.add_argument("--rf-trees", type=int, default=500)
ap.add_argument("--rf-max-depth", type=int, default=None)
ap.add_argument("--rf-min-samples-leaf", type=int, default=1)
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("--log-every", type=int, default=5)
ap.add_argument("--run-name", default=None)
ap.add_argument("--output-root", default="analysis_data/basic_analysis/cnn_logits_rf_cv_v2")
ap.add_argument("--iop-corr-method", choices=["ratio", "ols", "lad", "multi"], default="ratio")
ap.add_argument("--iop-drop-raw", action="store_true", default=False)
ap.add_argument("--in-memory-cache", action="store_true", default=True,
help="Cache all images in RAM before training (default: on).")
ap.add_argument("--no-in-memory-cache", action="store_false", dest="in_memory_cache",
help="Disable in-memory image cache.")
ap.add_argument("--cache-workers", type=int, default=4,
help="Threads for prebuilding image cache (default: 4).")
return ap
def _prebuild_image_cache(df: "pd.DataFrame", data, n_workers: int,
resize: int = 256) -> dict:
"""Load, convert to RGB, resize to `resize`px short edge, and cache as uint8 arrays.
Storing pre-resized images means the per-batch transform only has to do
CenterCrop + augmentation + ToTensor + Normalize on a small image rather
than resizing a full-resolution fundus image every step.
"""
paths = list({str(data.get_image_path(row)) for _, row in df.iterrows()})
cache: dict = {}
print(f"[cache] Preloading {len(paths)} images (resize={resize}px) "
f"with {n_workers} threads...", flush=True)
def _load(p: str):
img = Image.open(p)
if img.mode != "RGB":
img = img.convert("RGB")
w, h = img.size
scale = resize / min(w, h)
img = img.resize((round(w * scale), round(h * scale)), Image.BILINEAR)
return p, np.asarray(img, dtype=np.uint8)
with ThreadPoolExecutor(max_workers=max(1, n_workers)) as ex:
for path, arr in ex.map(_load, paths):
cache[path] = arr
ex_shape = cache[paths[0]].shape
print(f"[cache] Done — {len(cache)} images in RAM "
f"({ex_shape[1]}×{ex_shape[0]} each).", flush=True)
return cache
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
args = build_parser().parse_args()
random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(args.seed)
if args.device == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(args.device)
print(f"Device: {device}", flush=True)
run_name = args.run_name or time.strftime("%Y%m%d_%H%M%S")
out_dir = Path(args.output_root) / run_name
out_dir.mkdir(parents=True, exist_ok=True)
# ---------- data ----------
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,
iop_corr_method=args.iop_corr_method,
iop_drop_raw=args.iop_drop_raw,
exclude_cols=list(args.exclude_cols or []),
)
print(f"Loaded: {len(data.df)} rows feature_dim={data.feature_dim}", flush=True)
df_mode = data.df.copy()
if args.eval_mode == "binary":
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
num_classes = 2 if args.eval_mode == "binary" else int(df_mode[args.label_col].nunique())
print(
f"eval_mode={args.eval_mode} num_classes={num_classes} "
f"rows={len(df_mode)} patients={df_mode['Patient ID'].nunique()}",
flush=True,
)
# ---------- splits ----------
split_manager = PatientFirstSplitManager(
patient_col="Patient ID", label_col=args.label_col
)
split_args = SimpleNamespace(
eval_mode=args.eval_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)
n_folds = min(args.n_splits, len(plans))
if plans and plans[0].holdout is not None:
plans[0].holdout.to_csv(out_dir / "holdout_patients.csv", index=False)
# ---------- image cache ----------
image_cache = None
if args.in_memory_cache:
image_cache = _prebuild_image_cache(df_mode, data, args.cache_workers)
# ---------- transforms ----------
train_tf = build_backbone_transform(args.backbone, augment=True)
eval_tf = build_eval_transform(args.backbone)
rows: List[Dict] = []
holdout_rows: List[Dict] = []
for fold, split in enumerate(plans[:n_folds]):
print(f"\n[info] Fold {fold+1}/{n_folds}", flush=True)
random.seed(args.seed + fold * 100)
np.random.seed(args.seed + fold * 100)
torch.manual_seed(args.seed + fold * 100)
view_train = _ClinicalView(data, split.train)
view_val = _ClinicalView(data, split.val)
dl_train = DataLoader(
ClinicalDataset(view_train, train_tf, image_cache=image_cache),
batch_size=args.batch_size, shuffle=True, num_workers=args.num_workers,
)
dl_val = DataLoader(
ClinicalDataset(view_val, eval_tf, image_cache=image_cache),
batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers,
)
dl_holdout = None
if split.holdout is not None and not split.holdout.empty:
dl_holdout = DataLoader(
ClinicalDataset(_ClinicalView(data, split.holdout), eval_tf,
image_cache=image_cache),
batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers,
)
# Train CNN
model = CNNHead(args.backbone, num_classes=num_classes).to(device)
_train_cnn(model, dl_train, args, fold=fold, device=device)
# Extract logits (re-run train without augmentation for RF features)
dl_train_eval = DataLoader(
ClinicalDataset(view_train, eval_tf, image_cache=image_cache),
batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers,
)
y_tr, log_tr, md_tr = _infer_logits(model, dl_train_eval, device)
y_va, log_va, md_va = _infer_logits(model, dl_val, device)
np.save(out_dir / f"fold{fold}_train_logits.npy", log_tr)
np.save(out_dir / f"fold{fold}_val_logits.npy", log_va)
X_tr = np.concatenate([log_tr, md_tr], axis=1)
X_va = np.concatenate([log_va, md_va], axis=1)
rf = RandomForestClassifier(
n_estimators=args.rf_trees,
max_depth=args.rf_max_depth,
min_samples_leaf=args.rf_min_samples_leaf,
class_weight="balanced",
random_state=args.fold_seed + fold,
n_jobs=-1,
)
rf.fit(X_tr, y_tr)
p_va = rf.predict_proba(X_va)
pred_va = np.argmax(p_va, axis=1)
rows.append({
"fold": fold,
"val_acc": float(accuracy_score(y_va, pred_va)),
"val_auc": _auc_score(y_va, p_va, num_classes),
"n_val": int(len(y_va)),
})
if dl_holdout is not None:
y_ho, log_ho, md_ho = _infer_logits(model, dl_holdout, device)
np.save(out_dir / f"fold{fold}_holdout_logits.npy", log_ho)
X_ho = np.concatenate([log_ho, md_ho], axis=1)
p_ho = rf.predict_proba(X_ho)
pred_ho = np.argmax(p_ho, axis=1)
holdout_rows.append({
"fold": fold,
"holdout_acc": float(accuracy_score(y_ho, pred_ho)),
"holdout_auc": _auc_score(y_ho, p_ho, num_classes),
"n_holdout": int(len(y_ho)),
})
print(
f"[info] Fold {fold+1} RF: val_acc={rows[-1]['val_acc']:.4f}"
f" val_auc={rows[-1]['val_auc']:.4f}"
f" | holdout_acc={holdout_rows[-1]['holdout_acc']:.4f}"
f" holdout_auc={holdout_rows[-1]['holdout_auc']:.4f}",
flush=True,
)
else:
print(
f"[info] Fold {fold+1} RF: val_acc={rows[-1]['val_acc']:.4f}"
f" val_auc={rows[-1]['val_auc']:.4f}",
flush=True,
)
# ---------- save + summarise ----------
fold_df = pd.DataFrame(rows)
fold_df.to_csv(out_dir / "rf_val_metrics.csv", index=False)
print("\nRF validation metrics:")
print(fold_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
if holdout_rows:
ho_df = pd.DataFrame(holdout_rows)
ho_df.to_csv(out_dir / "rf_holdout_metrics.csv", index=False)
print("\nRF holdout metrics:")
print(ho_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(
f"\nMeans: val_acc={fold_df['val_acc'].mean():.4f}"
f" val_auc={fold_df['val_auc'].mean():.4f}"
f" holdout_acc={ho_df['holdout_acc'].mean():.4f}"
f" holdout_auc={ho_df['holdout_auc'].mean():.4f}"
)
else:
print(
f"\nMeans: val_acc={fold_df['val_acc'].mean():.4f}"
f" val_auc={fold_df['val_auc'].mean():.4f}"
)
print(f"\nSaved outputs to: {out_dir}", flush=True)
if __name__ == "__main__":
main()
@@ -1,264 +0,0 @@
#!/usr/bin/env python3
"""Univariate ROC curves for PAPILA clinical variables."""
import re
from pathlib import Path
from typing import Iterable, List, Tuple
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve, auc
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes import build_papila_clinical
# ---------------------------
# Config (edit in IDE)
# ---------------------------
IMAGE_DIR = "Papila/FundusImages"
CLINICAL_DIR = "Papila/ClinicalData"
LABEL_COL = "Diagnosis"
CAT_COLS = ["Gender", "Phakic/Pseudophakic"]
EXCLUDE_COLS = {"Pneumatic", "Perkins"}
INCLUDE_CATEGORICAL = False
POSITIVE_LABEL = 1
NEGATIVE_LABEL = 0
DROP_LABELS = [2]
OUTPUT_DIR = Path("analysis_data/basic_analysis/papila_univariate_roc")
DEBUG_PRINTS = False
PLOT_PER_FEATURE = False
DI_OPTRE_COL_PREFIXES = ("dioptre",)
ADD_DIOPTRE_ABS = True
ADD_DIOPTRE_SQUARED = True
def _sanitize(name: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_")
return safe or "var"
def _select_binary_labels(labels: pd.Series,
positive_label: int,
negative_label: int,
drop_labels: Iterable[int]) -> Tuple[np.ndarray, np.ndarray]:
labels_num = pd.to_numeric(labels, errors="coerce")
use_num = labels_num.notna().any()
lab = labels_num if use_num else labels.astype(str)
drop_set = set(drop_labels or [])
keep = lab.isin([positive_label, negative_label])
if drop_set:
keep &= ~lab.isin(drop_set)
y = (lab == positive_label).astype(int)
return y.values, keep.values
def _compute_roc(y: np.ndarray, scores: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
fpr, tpr, thresholds = roc_curve(y, scores, pos_label=1)
auc_val = float(auc(fpr, tpr))
return fpr, tpr, thresholds, auc_val
def _best_threshold(fpr: np.ndarray, tpr: np.ndarray, thresholds: np.ndarray) -> Tuple[float, float, float]:
youden = tpr - fpr
idx = int(np.nanargmax(youden))
return float(thresholds[idx]), float(tpr[idx]), float(fpr[idx])
def _plot_roc(fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path) -> None:
fig, ax = plt.subplots(figsize=(5.5, 4.5))
ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
def _plot_overlay(curves, title: str, out_path: Path) -> None:
fig, ax = plt.subplots(figsize=(7, 5.5))
cmap = plt.get_cmap("tab20")
for i, (name, fpr, tpr, auc_val) in enumerate(curves):
color = cmap(i % cmap.N)
ax.plot(fpr, tpr, lw=1.6, color=color, label=f"{name} (AUC={auc_val:.3f})")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="upper left", fontsize="small")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
def _iter_numeric(df: pd.DataFrame, cols: List[str]):
for col in cols:
if col not in df.columns:
continue
s = pd.to_numeric(df[col], errors="coerce")
yield col, s
def _is_dioptre_col(col: str) -> bool:
name = str(col).strip().lower()
return any(name.startswith(prefix) for prefix in DI_OPTRE_COL_PREFIXES)
def _iter_numeric_with_transforms(df: pd.DataFrame, cols: List[str]):
for col, s in _iter_numeric(df, cols):
yield col, s
if _is_dioptre_col(col):
if ADD_DIOPTRE_ABS:
yield f"{col}_abs", s.abs()
if ADD_DIOPTRE_SQUARED:
yield f"{col}_sq", s.pow(2)
def _include_in_overlay(feature_name: str) -> bool:
name = str(feature_name).strip().lower()
if _is_dioptre_col(name) and not name.endswith("_abs"):
return False
return True
def _iter_categorical(df: pd.DataFrame, cols: List[str]):
for col in cols:
if col not in df.columns:
continue
s = df[col]
vals = s.dropna().unique().tolist()
try:
vals = sorted(vals)
except Exception:
pass
for v in vals:
name = f"{col}=={v}"
ind = (s == v).astype(int)
yield name, ind
def main() -> None:
clinical = build_papila_clinical(
image_dir=IMAGE_DIR,
clinical_dir=CLINICAL_DIR,
label_col=LABEL_COL,
cat_cols=CAT_COLS,
)
df = clinical.df.copy()
labels = df[LABEL_COL]
y_all, keep_mask = _select_binary_labels(labels, POSITIVE_LABEL, NEGATIVE_LABEL, DROP_LABELS)
out_dir = OUTPUT_DIR
plot_dir = out_dir / "plots"
plot_dir.mkdir(parents=True, exist_ok=True)
rows = []
curves = []
base_exclude = {LABEL_COL, "Patient ID"} | EXCLUDE_COLS | set(CAT_COLS)
if "eyeID" not in CAT_COLS:
base_exclude.add("eyeID")
candidate_cols = [c for c in df.columns if c not in base_exclude]
numeric_cols = []
for col in candidate_cols:
s = pd.to_numeric(df[col], errors="coerce")
if s.notna().any():
numeric_cols.append(col)
if DEBUG_PRINTS:
for col in ("IOP_raw", "IOP_corr"):
if col not in df.columns:
print(f"[debug] {col} missing from df")
continue
s = pd.to_numeric(df[col], errors="coerce")
print(f"[debug] {col}: non-null={int(s.notna().sum())}, unique={int(s.nunique(dropna=True))}")
for col, series in _iter_numeric_with_transforms(df, numeric_cols):
mask = keep_mask & series.notna().values
y = y_all[mask]
scores = series.values[mask].astype(float)
if y.size < 2 or np.unique(y).size < 2:
continue
if np.nanmin(scores) == np.nanmax(scores):
continue
fpr, tpr, thresholds, auc_val = _compute_roc(y, scores)
thr, best_tpr, best_fpr = _best_threshold(fpr, tpr, thresholds)
direction = "high" if auc_val >= 0.5 else "low"
title = f"{col} (n={y.size}, direction={direction})"
if PLOT_PER_FEATURE:
out_path = plot_dir / f"roc_{_sanitize(col)}.png"
_plot_roc(fpr, tpr, auc_val, title, out_path)
if _include_in_overlay(col):
curves.append((col, fpr, tpr, auc_val))
rows.append({
"feature": col,
"kind": "numeric",
"n": int(y.size),
"auc": auc_val,
"direction": direction,
"best_threshold": thr,
"best_tpr": best_tpr,
"best_fpr": best_fpr,
"best_specificity": 1.0 - best_fpr,
})
if INCLUDE_CATEGORICAL:
cat_cols_use = [c for c in clinical.cat_cols if c not in EXCLUDE_COLS]
for name, ind in _iter_categorical(df, cat_cols_use):
mask = keep_mask & ind.notna().values
y = y_all[mask]
scores = ind.values[mask].astype(float)
if y.size < 2 or np.unique(y).size < 2:
continue
if np.nanmin(scores) == np.nanmax(scores):
continue
fpr, tpr, thresholds, auc_val = _compute_roc(y, scores)
thr, best_tpr, best_fpr = _best_threshold(fpr, tpr, thresholds)
direction = "high" if auc_val >= 0.5 else "low"
title = f"{name} (n={y.size}, direction={direction})"
if PLOT_PER_FEATURE:
out_path = plot_dir / f"roc_{_sanitize(name)}.png"
_plot_roc(fpr, tpr, auc_val, title, out_path)
if _include_in_overlay(name):
curves.append((name, fpr, tpr, auc_val))
rows.append({
"feature": name,
"kind": "categorical",
"n": int(y.size),
"auc": auc_val,
"direction": direction,
"best_threshold": thr,
"best_tpr": best_tpr,
"best_fpr": best_fpr,
"best_specificity": 1.0 - best_fpr,
})
if not rows:
raise SystemExit("No valid features produced ROC curves. Check labels and feature columns.")
overlay_path = plot_dir / "roc_overlay.png"
_plot_overlay(curves, "Univariate ROC curves", overlay_path)
out_df = pd.DataFrame(rows).sort_values(by="auc", ascending=False)
out_dir.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out_dir / "summary.csv", index=False)
print(out_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(f"\nSaved overlay plot to: {overlay_path}")
if PLOT_PER_FEATURE:
print(f"Saved per-feature plots to: {plot_dir}")
print(f"Saved summary to: {out_dir / 'summary.csv'}")
if __name__ == "__main__":
main()
-142
View File
@@ -1,142 +0,0 @@
"""Generate ground-truth mask overlays for REFUGE and Papila samples."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT))
import numpy as np
from collections import Counter
from PIL import Image
from PIL.Image import Resampling
from classes.unet_segmenter import UNetSegmenter
REFUGE_ROOT = Path("REFUGE")
DEFAULT_MANIFEST = Path("manifest.csv")
OUTPUT_DIR = Path("temp/gt_test")
def to_mask_colors(disc: np.ndarray, cup: np.ndarray) -> Image.Image:
h, w = disc.shape
canvas = np.ones((h, w, 3), dtype=np.uint8) * 255
disc_mask = disc.astype(bool)
cup_mask = cup.astype(bool)
canvas[disc_mask] = [128, 128, 128]
canvas[cup_mask] = [0, 0, 0]
return Image.fromarray(canvas)
def overlay(
original: Image.Image, mask_rgb: Image.Image, alpha: float = 0.6
) -> Image.Image:
mask_rgba = mask_rgb.convert("RGBA")
updates = np.array(mask_rgba, dtype=np.float32)
updates[..., 3] = alpha * 255 * (updates[..., :3] != 255).any(axis=-1)
base = original.convert("RGBA")
return Image.alpha_composite(
base, Image.fromarray(updates.astype(np.uint8))
).convert("RGB")
def original_mask_to_rgb(mask_path: Path) -> Image.Image:
mask_img = Image.open(mask_path)
arr = np.asarray(mask_img)
h, w = arr.shape[:2]
canvas = np.ones((h, w, 3), dtype=np.uint8) * 255
if arr.ndim == 2:
border = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]])
bg_value = Counter(border.tolist()).most_common(1)[0][0]
disc_mask = arr != bg_value
fg_counts = Counter(arr[arr != bg_value].flatten())
if fg_counts:
# For REFUGE-style masks: cup should be the darkest (minimum value)
cup_value = min(fg_counts.keys())
cup_mask = arr == cup_value
else:
cup_mask = np.zeros_like(arr, dtype=bool)
else:
edges = np.concatenate(
[arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], axis=0
)
bg_color = Counter(map(tuple, edges)).most_common(1)[0][0]
disc_mask = ~np.all(arr == bg_color, axis=-1)
color_counts = Counter(map(tuple, arr.reshape(-1, arr.shape[2])))
cup_mask = np.zeros((h, w), dtype=bool)
candidates = {}
for color, count in color_counts.items():
if color == bg_color:
continue
mask = np.all(arr == color, axis=-1)
candidates[color] = mask
if candidates:
# For REFUGE-style masks: cup should be the darkest color (closest to black)
cup_color = min(candidates.keys(), key=lambda color: sum(color))
cup_mask = candidates[cup_color]
disc_mask = disc_mask.astype(bool)
cup_mask = cup_mask & disc_mask
canvas[disc_mask] = [128, 128, 128]
canvas[cup_mask] = [0, 0, 0]
return Image.fromarray(canvas)
def process_entries(
segmenter: UNetSegmenter, entries, prefix: str, count: int, dest: Path
) -> None:
for entry in entries[:count]:
img_path = Path(entry.image_path)
if not img_path.exists():
continue
orig = Image.open(img_path).convert("RGB")
image = segmenter.preprocess_image(orig)
disc, cup = segmenter.load_masks(entry)
disc_coords = segmenter._mask_to_coords(disc)
cup_coords = segmenter._mask_to_coords(cup)
print(
f"{entry.sample_id}: disc coords {disc_coords.shape[0] if disc_coords is not None else 0}, "
f"cup coords {cup_coords.shape[0] if cup_coords is not None else 0}"
)
mask_rgb = to_mask_colors(disc, cup)
overlay_img = overlay(image, mask_rgb)
mask_rgb.save(dest / f"{prefix}_{entry.sample_id}_mask.png")
overlay_img.save(dest / f"{prefix}_{entry.sample_id}_overlay.png")
if entry.annotation_type_disc == "mask":
gt_mask_rgb = original_mask_to_rgb(entry.annotation_disc)
gt_overlay = overlay(
orig.resize(gt_mask_rgb.size, Resampling.BILINEAR), gt_mask_rgb
)
gt_mask_rgb.save(dest / f"{prefix}_{entry.sample_id}_gt_mask.png")
gt_overlay.save(dest / f"{prefix}_{entry.sample_id}_gt_overlay.png")
def main() -> None:
parser = argparse.ArgumentParser(
description="Inspect ground-truth masks for REFUGE and Papila"
)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--output", type=Path, default=OUTPUT_DIR)
parser.add_argument(
"--count", type=int, default=10, help="Number of samples per dataset"
)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
segmenter = UNetSegmenter(args.manifest)
refuge_entries = [e for e in segmenter._manifest if e.dataset == "refuge"]
papila_entries = [e for e in segmenter._manifest if e.dataset == "papila"]
process_entries(segmenter, refuge_entries, "refuge", args.count, args.output)
process_entries(segmenter, papila_entries, "papila", args.count, args.output)
print(f"Saved overlays to {args.output}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -1,725 +0,0 @@
#!/usr/bin/env python3
"""
Compare single-eye OD baseline vs SiameseImageTower bilateral model.
Key differences from compare_dual_eye_towers.py:
- Uses SiameseImageTower (shared backbone, f_mean + f_delta output).
- Reports BEST-epoch val metrics per fold (not final-epoch), with the
corresponding holdout metrics snapped at the same checkpoint.
- Both models are always evaluated on patient-level samples (matched n).
- Optionally includes two-single-merge as a second reference point.
"""
from __future__ import annotations
import argparse
import copy
import csv
import json
import random
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
import numpy as np
import torch
import torch.nn.functional as F
from sklearn.metrics import roc_auc_score
from torch import nn
from torch.utils.data import DataLoader
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2 import (
ImageTower,
PatientFirstSplitManager,
SiameseImageTower,
SlotDataset,
build_papila_data,
build_papila_profile,
slot_collate,
)
# ---------------------------------------------------------------------------
# Reproducibility
# ---------------------------------------------------------------------------
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class BaselineCNN(nn.Module):
"""Single-eye (OD) image tower with a linear head."""
def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool):
super().__init__()
self.tower = ImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
)
self.head = nn.Linear(self.tower.out_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.head(self.tower(x))
class SiameseCNN(nn.Module):
"""
Bilateral image model using SiameseImageTower.
forward(x_od, x_os) -> logits
"""
def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool):
super().__init__()
self.tower = SiameseImageTower(
backbone=backbone,
freeze_ratio=freeze_ratio,
augment=augment,
use_se=False,
)
self.head = nn.Linear(self.tower.out_dim, num_classes)
def forward(self, x_od: torch.Tensor, x_os: torch.Tensor) -> torch.Tensor:
return self.head(self.tower(x_od, x_os))
# ---------------------------------------------------------------------------
# Data helpers
# ---------------------------------------------------------------------------
def filter_od_samples(patient_samples: list[dict]) -> list[dict]:
"""Extract patient-level OD-only samples (image_1 = OD)."""
out = []
for s in patient_samples:
if s.get("image_1") is not None and s.get("label_1") is not None:
out.append({"id_1": s.get("id_1"), "image_1": s["image_1"], "label_1": s["label_1"]})
return out
def filter_bilateral_samples(patient_samples: list[dict]) -> list[dict]:
return [
s for s in patient_samples
if s.get("image_1") is not None
and s.get("image_2") is not None
and s.get("label_1") is not None
]
def make_loader(samples, slots, *, image_transform, batch_size, shuffle, num_workers) -> DataLoader:
ds = SlotDataset(samples, slots, image_transform=image_transform)
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):
import pandas as pd
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
# ---------------------------------------------------------------------------
# Score helpers
# ---------------------------------------------------------------------------
def _score(y_true_chunks, y_prob_chunks, num_classes: int):
if not y_true_chunks:
return float("nan"), float("nan"), 0
y = np.concatenate(y_true_chunks)
p = np.concatenate(y_prob_chunks)
acc = float((p.argmax(1) == y).mean())
try:
auc = (
float(roc_auc_score(y, p[:, 1]))
if num_classes == 2
else float(roc_auc_score(y, p, multi_class="ovr", average="macro"))
)
except Exception:
auc = float("nan")
return acc, auc, int(len(y))
# ---------------------------------------------------------------------------
# Train / evaluate
# ---------------------------------------------------------------------------
def train_baseline_epoch(model, loader, opt, device):
model.train()
total_loss = total_correct = total_n = 0
for batch in loader:
x = batch.get("image_1")
y = batch.get("label_1")
if not torch.is_tensor(x):
continue
y = to_label_tensor(y, device)
x = x.to(device)
logits = model(x)
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 train_siamese_epoch(model, loader, opt, device):
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) or not torch.is_tensor(x2):
continue
y = to_label_tensor(y, device)
logits = model(x1.to(device), x2.to(device))
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 evaluate_baseline(model, loader, device, num_classes):
model.eval()
y_true, y_prob = [], []
with torch.no_grad():
for batch in loader:
x = batch.get("image_1")
y = batch.get("label_1")
if not torch.is_tensor(x):
continue
y_t = to_label_tensor(y, device)
p = F.softmax(model(x.to(device)), dim=1).cpu().numpy()
y_true.append(y_t.cpu().numpy())
y_prob.append(p)
return _score(y_true, y_prob, num_classes)
def evaluate_siamese(model, loader, device, num_classes):
model.eval()
y_true, y_prob = [], []
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) or not torch.is_tensor(x2):
continue
y_t = to_label_tensor(y, device)
p = F.softmax(model(x1.to(device), x2.to(device)), dim=1).cpu().numpy()
y_true.append(y_t.cpu().numpy())
y_prob.append(p)
return _score(y_true, y_prob, num_classes)
# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------
@dataclass
class FoldResult:
mode: str
fold: int
# Best-epoch validation metrics
best_epoch: int
baseline_best_val_auc: float
baseline_best_val_acc: float
siamese_best_val_auc: float
siamese_best_val_acc: float
# Holdout metrics at the respective best-epoch checkpoint
baseline_holdout_auc: float
baseline_holdout_acc: float
baseline_holdout_n: int
siamese_holdout_auc: float
siamese_holdout_acc: float
siamese_holdout_n: int
# Sample sizes
baseline_n: int
siamese_n: int
def _nan() -> float:
return float("nan")
# ---------------------------------------------------------------------------
# Main fold runner
# ---------------------------------------------------------------------------
def run_fold(
fold: int,
split,
mode: str,
args,
device: torch.device,
data,
num_classes: int,
profile_od,
profile_patient,
fold_dir: Path,
) -> FoldResult:
holdout_df = split.holdout
# ---- build samples ----
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))
od_train = filter_od_samples(bilat_train)
od_val = filter_od_samples(bilat_val)
bilat_holdout = []
od_holdout = []
if holdout_df is not None and not holdout_df.empty:
bilat_holdout = filter_bilateral_samples(profile_patient.build_samples(df=holdout_df, clinical=data))
od_holdout = filter_od_samples(bilat_holdout)
# ---- models ----
baseline = BaselineCNN(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
num_classes=num_classes, augment=args.augment,
).to(device)
siamese = SiameseCNN(
backbone=args.backbone, freeze_ratio=args.freeze_ratio,
num_classes=num_classes, augment=args.augment,
).to(device)
slots_od = profile_od.slot_descriptors()
slots_patient = profile_patient.slot_descriptors()
# ---- loaders ----
loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers)
train_base = make_loader(od_train, slots_od, image_transform=baseline.tower.transform, shuffle=True, **loader_kw)
val_base = make_loader(od_val, slots_od, image_transform=baseline.tower.transform, shuffle=False, **loader_kw)
train_siam = make_loader(bilat_train, slots_patient, image_transform=siamese.tower.transform, shuffle=True, **loader_kw)
val_siam = make_loader(bilat_val, slots_patient, image_transform=siamese.tower.transform, shuffle=False, **loader_kw)
ho_base = (
make_loader(od_holdout, slots_od, image_transform=baseline.tower.transform, shuffle=False, **loader_kw)
if od_holdout else None
)
ho_siam = (
make_loader(bilat_holdout, slots_patient, image_transform=siamese.tower.transform, shuffle=False, **loader_kw)
if bilat_holdout else None
)
opt_base = torch.optim.Adam(baseline.parameters(), lr=args.lr)
opt_siam = torch.optim.Adam(siamese.parameters(), lr=args.lr)
# ---- epoch log ----
epoch_log_path = fold_dir / "epoch_log.csv"
epoch_fields = [
"fold", "epoch",
"base_train_loss", "base_train_acc",
"base_val_auc", "base_val_acc", "base_val_n",
"siam_train_loss", "siam_train_acc",
"siam_val_auc", "siam_val_acc", "siam_val_n",
"ho_base_auc", "ho_base_acc", "ho_base_n",
"ho_siam_auc", "ho_siam_acc", "ho_siam_n",
]
epoch_fp = epoch_log_path.open("w", newline="", encoding="utf-8")
epoch_writer = csv.DictWriter(epoch_fp, fieldnames=epoch_fields)
epoch_writer.writeheader()
def _f(v):
return None if (v is None or (isinstance(v, float) and np.isnan(v))) else round(float(v), 6)
# ---- best-epoch tracking ----
best_base_auc = -1.0
best_siam_auc = -1.0
best_base_state: Optional[dict] = None
best_siam_state: Optional[dict] = None
best_base_val_acc = _nan()
best_siam_val_acc = _nan()
# Holdout metrics snapped at best-val checkpoint
snap_ho_base_auc = _nan()
snap_ho_base_acc = _nan()
snap_ho_base_n = 0
snap_ho_siam_auc = _nan()
snap_ho_siam_acc = _nan()
snap_ho_siam_n = 0
best_epoch = 0
print(
f" [fold {fold+1}] training {args.epochs} epochs | "
f"baseline n_train={len(od_train)} n_val={len(od_val)} | "
f"siamese n_train={len(bilat_train)} n_val={len(bilat_val)}",
flush=True,
)
for epoch in range(args.epochs):
bl_loss, bl_acc = train_baseline_epoch(baseline, train_base, opt_base, device)
si_loss, si_acc = train_siamese_epoch(siamese, train_siam, opt_siam, device)
b_val_acc, b_val_auc, b_val_n = evaluate_baseline(baseline, val_base, device, num_classes)
s_val_acc, s_val_auc, s_val_n = evaluate_siamese( siamese, val_siam, device, num_classes)
# Holdout at this epoch (always evaluated for logging, cheaply)
hb_auc, hb_acc, hb_n = (_nan(), _nan(), 0)
hs_auc, hs_acc, hs_n = (_nan(), _nan(), 0)
if ho_base is not None:
hb_acc, hb_auc, hb_n = evaluate_baseline(baseline, ho_base, device, num_classes)
if ho_siam is not None:
hs_acc, hs_auc, hs_n = evaluate_siamese(siamese, ho_siam, device, num_classes)
# Best-epoch tracking: snapshot state independently per model
if not np.isnan(b_val_auc) and b_val_auc > best_base_auc:
best_base_auc = b_val_auc
best_base_val_acc = b_val_acc
best_base_state = copy.deepcopy(baseline.state_dict())
snap_ho_base_auc = hb_auc
snap_ho_base_acc = hb_acc
snap_ho_base_n = hb_n
if not np.isnan(s_val_auc) and s_val_auc > best_siam_auc:
best_siam_auc = s_val_auc
best_siam_val_acc = s_val_acc
best_siam_state = copy.deepcopy(siamese.state_dict())
snap_ho_siam_auc = hs_auc
snap_ho_siam_acc = hs_acc
snap_ho_siam_n = hs_n
best_epoch = epoch + 1
row = {
"fold": fold, "epoch": epoch + 1,
"base_train_loss": _f(bl_loss), "base_train_acc": _f(bl_acc),
"base_val_auc": _f(b_val_auc), "base_val_acc": _f(b_val_acc), "base_val_n": b_val_n,
"siam_train_loss": _f(si_loss), "siam_train_acc": _f(si_acc),
"siam_val_auc": _f(s_val_auc), "siam_val_acc": _f(s_val_acc), "siam_val_n": s_val_n,
"ho_base_auc": _f(hb_auc), "ho_base_acc": _f(hb_acc), "ho_base_n": hb_n,
"ho_siam_auc": _f(hs_auc), "ho_siam_acc": _f(hs_acc), "ho_siam_n": hs_n,
}
epoch_writer.writerow(row)
epoch_fp.flush()
if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
print(
f" ep {epoch+1:>3}/{args.epochs} "
f"base val AUC={b_val_auc:.4f} siam val AUC={s_val_auc:.4f} "
f"(best base={best_base_auc:.4f} best siam={best_siam_auc:.4f})",
flush=True,
)
epoch_fp.close()
# Save best checkpoints
if best_base_state is not None:
torch.save(best_base_state, fold_dir / "best_baseline.pt")
if best_siam_state is not None:
torch.save(best_siam_state, fold_dir / "best_siamese.pt")
result = FoldResult(
mode=mode, fold=fold,
best_epoch=best_epoch,
baseline_best_val_auc=best_base_auc,
baseline_best_val_acc=best_base_val_acc,
siamese_best_val_auc=best_siam_auc,
siamese_best_val_acc=best_siam_val_acc,
baseline_holdout_auc=snap_ho_base_auc,
baseline_holdout_acc=snap_ho_base_acc,
baseline_holdout_n=snap_ho_base_n,
siamese_holdout_auc=snap_ho_siam_auc,
siamese_holdout_acc=snap_ho_siam_acc,
siamese_holdout_n=snap_ho_siam_n,
baseline_n=len(od_val),
siamese_n=len(bilat_val),
)
print(
f" [fold {fold+1}] BEST "
f"base val AUC={best_base_auc:.4f} acc={best_base_val_acc:.4f} "
f"siam val AUC={best_siam_auc:.4f} acc={best_siam_val_acc:.4f} "
f"(siam best epoch={best_epoch})",
flush=True,
)
if snap_ho_base_n > 0 or snap_ho_siam_n > 0:
print(
f" [fold {fold+1}] HOUT "
f"base AUC={snap_ho_base_auc:.4f} acc={snap_ho_base_acc:.4f} (n={snap_ho_base_n}) "
f"siam AUC={snap_ho_siam_auc:.4f} acc={snap_ho_siam_acc:.4f} (n={snap_ho_siam_n})",
flush=True,
)
return result
# ---------------------------------------------------------------------------
# Summary helpers
# ---------------------------------------------------------------------------
def _summary(results: list[FoldResult]) -> dict:
def _means(vals):
v = np.array([x for x in vals if not np.isnan(x)], dtype=float)
return (float(np.mean(v)) if len(v) else None,
float(np.std(v)) if len(v) else None)
b_val_aucs = [r.baseline_best_val_auc for r in results]
s_val_aucs = [r.siamese_best_val_auc for r in results]
b_ho_aucs = [r.baseline_holdout_auc for r in results]
s_ho_aucs = [r.siamese_holdout_auc for r in results]
b_val_accs = [r.baseline_best_val_acc for r in results]
s_val_accs = [r.siamese_best_val_acc for r in results]
b_ho_accs = [r.baseline_holdout_acc for r in results]
s_ho_accs = [r.siamese_holdout_acc for r in results]
deltas_val_auc = [s - b for b, s in zip(b_val_aucs, s_val_aucs)
if not np.isnan(b) and not np.isnan(s)]
deltas_ho_auc = [s - b for b, s in zip(b_ho_aucs, s_ho_aucs)
if not np.isnan(b) and not np.isnan(s)]
bva_m, bva_s = _means(b_val_aucs)
sva_m, sva_s = _means(s_val_aucs)
bha_m, bha_s = _means(b_ho_aucs)
sha_m, sha_s = _means(s_ho_aucs)
return {
"baseline_best_val": {"auc_mean": bva_m, "auc_std": bva_s, "acc_mean": _means(b_val_accs)[0]},
"siamese_best_val": {"auc_mean": sva_m, "auc_std": sva_s, "acc_mean": _means(s_val_accs)[0]},
"delta_val_auc": {"mean": float(np.mean(deltas_val_auc)) if deltas_val_auc else None,
"std": float(np.std(deltas_val_auc)) if deltas_val_auc else None},
"baseline_holdout": {"auc_mean": bha_m, "auc_std": bha_s, "acc_mean": _means(b_ho_accs)[0]},
"siamese_holdout": {"auc_mean": sha_m, "auc_std": sha_s, "acc_mean": _means(s_ho_accs)[0]},
"delta_holdout_auc": {"mean": float(np.mean(deltas_ho_auc)) if deltas_ho_auc else None,
"std": float(np.std(deltas_ho_auc)) if deltas_ho_auc else None},
}
def _print_summary(mode: str, s: dict) -> None:
def f(v):
return "nan" if v is None else f"{v:.4f}"
bv = s["baseline_best_val"]
sv = s["siamese_best_val"]
dv = s["delta_val_auc"]
bh = s["baseline_holdout"]
sh = s["siamese_holdout"]
dh = s["delta_holdout_auc"]
print(f"\n=== Summary [{mode}] (best-epoch metrics) ===")
print(f" val baseline AUC={f(bv['auc_mean'])}±{f(bv['auc_std'])} acc={f(bv['acc_mean'])}")
print(f" val siamese AUC={f(sv['auc_mean'])}±{f(sv['auc_std'])} acc={f(sv['acc_mean'])}")
print(f" val delta AUC={f(dv['mean'])}±{f(dv['std'])}")
print(f" hout baseline AUC={f(bh['auc_mean'])}±{f(bh['auc_std'])} acc={f(bh['acc_mean'])}")
print(f" hout siamese AUC={f(sh['auc_mean'])}±{f(sh['auc_std'])} acc={f(sh['acc_mean'])}")
print(f" hout delta AUC={f(dh['mean'])}±{f(dh['std'])}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
ap = argparse.ArgumentParser(
description="Baseline single-eye vs SiameseImageTower bilateral comparison."
)
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="binary")
ap.add_argument(
"--eval-modes", nargs="+", choices=["binary", "multiclass"], default=None,
help="Run multiple modes in one pass, e.g. --eval-modes binary multiclass",
)
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=0)
ap.add_argument("--holdout-seed", type=int, default=123)
ap.add_argument("--folds", type=int, default=5)
ap.add_argument("--epochs", type=int, default=40)
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--lr", type=float, default=1e-4)
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/basic_analysis")
ap.add_argument(
"--exclude-mixed-patients", action="store_true",
help="Drop patients whose two eyes have different labels before splitting.",
)
ap.add_argument(
"--log-every", type=int, default=5,
help="Print epoch progress every N epochs (0 to disable).",
)
return ap.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 main():
args = parse_args()
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", flush=True)
ts = time.strftime("%Y%m%d_%H%M%S")
run_name = args.run_name or f"siamese_compare_{ts}"
out_dir = Path(args.output_root) / run_name
out_dir.mkdir(parents=True, exist_ok=True)
eval_modes = args.eval_modes if args.eval_modes else [args.eval_mode]
all_results: dict[str, list[FoldResult]] = {}
summaries: dict[str, dict] = {}
for mode in eval_modes:
import pandas as pd
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)
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)
n_folds = min(args.folds, len(plans))
profile_od = 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"
)
mode_dir = out_dir / mode
mode_dir.mkdir(exist_ok=True)
fold_results: list[FoldResult] = []
for fold in range(n_folds):
fold_seed = args.seed + fold * 100
seed_everything(fold_seed)
fold_dir = mode_dir / f"fold{fold}"
fold_dir.mkdir(exist_ok=True)
print(f"\n[{mode}] fold {fold+1}/{n_folds}", flush=True)
result = run_fold(
fold=fold,
split=plans[fold],
mode=mode,
args=args,
device=device,
data=data,
num_classes=num_classes,
profile_od=profile_od,
profile_patient=profile_patient,
fold_dir=fold_dir,
)
fold_results.append(result)
# Write per-mode CSV
fold_csv = out_dir / f"{mode}_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(mode, summary)
all_results[mode] = fold_results
summaries[mode] = summary
payload = {
"run_name": run_name,
"timestamp": ts,
"config": vars(args),
"summaries": summaries,
}
(out_dir / "summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"\nOutputs written to: {out_dir}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""Rank multiclass runs by mean holdout AUC (fused) across folds."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import numpy as np
import pandas as pd
# ---------------------------
# Config (edit in IDE)
# ---------------------------
ANALYSIS_DIR = Path("analysis_data/grid_search")
TOP_N = 20
HEAD = "fused" # fused | image | metadata
OUTPUT_CSV = Path("analysis_data/grid_search/plots/best_holdout_multiclass.csv")
def _read_json(path: Path) -> Optional[Dict[str, Any]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
return data if isinstance(data, dict) else None
def _infer_mode(summary: Optional[Dict[str, Any]]) -> Optional[str]:
if not summary:
return None
eval_mode = summary.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = summary.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
class_names = summary.get("class_names")
if isinstance(class_names, list) and class_names:
return "binary" if len(class_names) <= 2 else "multiclass"
return None
def _simple_fields(summary: Dict[str, Any]) -> Dict[str, Any]:
keep: Dict[str, Any] = {}
for key, val in summary.items():
if key == "fold_metrics":
continue
if isinstance(val, (str, int, float, bool)) or val is None:
keep[key] = val
return keep
def _collect_fold_values(summary: Dict[str, Any], metric_key: str) -> List[float]:
values: List[float] = []
for entry in summary.get("fold_metrics") or []:
if not isinstance(entry, dict):
continue
stats = entry.get("stats") if isinstance(entry.get("stats"), dict) else {}
val = stats.get(metric_key)
if isinstance(val, (int, float)):
values.append(float(val))
return values
def main() -> None:
metric_key = f"holdout_auc_{HEAD}"
rows: List[Dict[str, Any]] = []
for run_dir in sorted(ANALYSIS_DIR.iterdir()):
if not run_dir.is_dir():
continue
summary = _read_json(run_dir / "summary.json")
mode = _infer_mode(summary)
if mode != "multiclass":
continue
values = _collect_fold_values(summary, metric_key)
if not values:
continue
mean_val = float(np.mean(values))
std_val = float(np.std(values, ddof=1)) if len(values) > 1 else float("nan")
row = {
"run_id": summary.get("run_id", run_dir.name),
"run_dir": str(run_dir),
"metric": metric_key,
"mean": mean_val,
"std": std_val,
"n_folds": len(values),
**_simple_fields(summary),
}
rows.append(row)
if not rows:
raise SystemExit("No multiclass runs with holdout AUC found.")
df = pd.DataFrame(rows).sort_values(by="mean", ascending=False)
top_df = df.head(TOP_N) if TOP_N else df
OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(OUTPUT_CSV, index=False)
print(top_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(f"\nSaved full ranking to: {OUTPUT_CSV}")
if __name__ == "__main__":
main()
@@ -1,376 +0,0 @@
#!/usr/bin/env python3
"""Compare cached crop bounds/features vs GT-derived crops from the manifest."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import sys
import numpy as np
import pandas as pd
from PIL import Image
import torch
from torchvision import transforms
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.hypertower import ManifestImageCropper, UNetImageCropper
from classes.refuge_segmentation import UNet as RefugeUNet
# ---------------------------
# Config (edit in IDE)
# ---------------------------
CACHE_DIR = Path("analysis_data/hypertower_crops")
MANIFEST_PATH = Path("manifest.csv")
IMAGE_DIR = Path("Papila/FundusImages")
SCALE = 2.5
MAX_SAMPLES = 200 # set None to scan all
TOL_BOUNDS = 1.0 # pixels
TOL_FEATURES = 1e-3
UNET_VARIANTS = [
("norm_imagenet", Path("models/unet_segmenter/norm_imagenet/best.pt"), "imagenet"),
("normalize_none", Path("models/unet_segmenter/normalize_none/best.pt"), "none"),
("norm_per_image", Path("models/unet_segmenter/norm_per_image/best.pt"), "per_image"),
]
REFUGE_SEG_WEIGHTS = Path("models/refuge/segmentation/refuge_segmentation_best.pt")
def _load_cache(path: Path) -> Optional[Dict[str, np.ndarray]]:
try:
data = np.load(path, allow_pickle=False)
except Exception:
return None
return {k: data[k] for k in data.files}
def _parse_stem(path: Path) -> str:
# expects RET###OS_s250.npz -> RET###OS
stem = path.stem
if "_s" in stem:
stem = stem.split("_s")[0]
return stem
def _image_path_from_stem(stem: str) -> Optional[Path]:
cand = IMAGE_DIR / f"{stem}.jpg"
if cand.exists():
return cand
cand = IMAGE_DIR / f"{stem}.png"
if cand.exists():
return cand
return None
def _gt_info(
cropper: ManifestImageCropper, image_path: Path
) -> Optional[Dict[str, float]]:
try:
image = Image.open(image_path).convert("RGB")
except Exception:
return None
info = cropper._compute_crop_info(image, image_path)
return info
def _unet_info(
cropper: UNetImageCropper, image_path: Path
) -> Optional[Dict[str, float]]:
try:
image = Image.open(image_path).convert("RGB")
except Exception:
return None
info = cropper._compute_crop_info(image, image_path)
return info
def _load_refuge_model(device: str) -> Optional[RefugeUNet]:
if not REFUGE_SEG_WEIGHTS.exists():
return None
model = RefugeUNet()
try:
state = torch.load(REFUGE_SEG_WEIGHTS, map_location=device)
except Exception:
return None
state_dict = state.get("model", state) if isinstance(state, dict) else state
try:
model.load_state_dict(state_dict)
except Exception:
return None
model.to(device)
model.eval()
return model
def _refuge_seg_info(
model: RefugeUNet, device: str, image_path: Path
) -> Optional[Dict[str, float]]:
try:
image = Image.open(image_path).convert("RGB")
except Exception:
return None
original_size = image.size
image_resized = image.resize((512, 512), Image.BILINEAR)
tensor = transforms.ToTensor()(image_resized).unsqueeze(0).to(device)
with torch.no_grad():
logits = model(tensor)
mask = torch.sigmoid(logits)[0, 0]
mask_np = (mask.cpu().numpy() > 0.5).astype(np.float32)
mask_img = Image.fromarray(mask_np)
mask_img = mask_img.resize(original_size, Image.NEAREST)
mask_np = np.array(mask_img, dtype=np.float32)
coords = np.argwhere(mask_np > 0.5)
if coords.size == 0:
return None
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * SCALE
left = max(0.0, centre_x - crop_radius)
upper = max(0.0, centre_y - crop_radius)
right = min(float(image.width), centre_x + crop_radius)
lower = min(float(image.height), centre_y + crop_radius)
return {
"left": left,
"upper": upper,
"right": right,
"lower": lower,
}
def _diff_bounds(cache: Dict[str, np.ndarray], gt: Dict[str, float]) -> Optional[float]:
keys = ("left", "upper", "right", "lower")
if not all(k in cache for k in keys):
return None
diffs = [abs(float(cache[k]) - float(gt[k])) for k in keys]
return float(max(diffs))
def _diff_features(
cache: Dict[str, np.ndarray], gt: Dict[str, float]
) -> Optional[float]:
if "features" not in cache or "features" not in gt:
return None
cf = np.asarray(cache["features"], dtype=float).ravel()
gf = np.asarray(gt["features"], dtype=float).ravel()
if cf.shape != gf.shape:
return None
return float(np.max(np.abs(cf - gf)))
def main() -> None:
if not CACHE_DIR.exists():
raise SystemExit(f"Cache dir not found: {CACHE_DIR}")
if not MANIFEST_PATH.exists():
raise SystemExit(f"Manifest not found: {MANIFEST_PATH}")
cache_files = sorted(CACHE_DIR.glob(f"*_s{int(SCALE * 100)}.npz"))
if MAX_SAMPLES is not None:
cache_files = cache_files[:MAX_SAMPLES]
print(f"[debug] cache files found: {len(cache_files)}")
try:
manifest_df = pd.read_csv(MANIFEST_PATH)
except Exception as exc:
raise SystemExit(f"Failed to read manifest: {exc}")
manifest_images = manifest_df.get("image_path")
if manifest_images is None:
raise SystemExit("Manifest is missing image_path column.")
manifest_images = manifest_images.dropna().astype(str)
manifest_stems = {Path(p).stem for p in manifest_images}
print(f"[debug] manifest image_path count: {len(manifest_images)}")
print(f"[debug] manifest unique stems: {len(manifest_stems)}")
cache_stems = {_parse_stem(p) for p in cache_files}
overlap = cache_stems & manifest_stems
print(
f"[debug] cache stems: {len(cache_stems)} overlap with manifest stems: {len(overlap)}"
)
if cache_files:
print(f"[debug] example cache stems: {sorted(list(cache_stems))[:5]}")
if manifest_stems:
print(f"[debug] example manifest stems: {sorted(list(manifest_stems))[:5]}")
cropper = ManifestImageCropper(
manifest_path=MANIFEST_PATH,
scale=SCALE,
target_size=224,
cache_dir=None,
)
rows: List[Dict[str, object]] = []
for cache_path in cache_files:
cache = _load_cache(cache_path)
if cache is None:
continue
stem = _parse_stem(cache_path)
image_path = _image_path_from_stem(stem)
if image_path is None:
continue
gt = _gt_info(cropper, image_path)
if gt is None:
continue
bounds_diff = _diff_bounds(cache, gt)
feat_diff = _diff_features(cache, gt)
rows.append(
{
"file": cache_path.name,
"bounds_diff": bounds_diff,
"features_diff": feat_diff,
"bounds_match": bounds_diff is not None and bounds_diff <= TOL_BOUNDS,
"features_match": feat_diff is not None and feat_diff <= TOL_FEATURES,
}
)
if not rows:
print("[warn] No cache entries matched GT manifest entries.")
else:
df = pd.DataFrame(rows)
print(df.head(10).to_string(index=False))
print("\nSummary:")
print(df[["bounds_diff", "features_diff"]].describe().to_string())
if df["bounds_match"].notna().any():
match_rate = df["bounds_match"].mean()
print(f"\nBounds match rate (<= {TOL_BOUNDS}px): {match_rate:.3f}")
if df["features_match"].notna().any():
match_rate = df["features_match"].mean()
print(f"Features match rate (<= {TOL_FEATURES}): {match_rate:.3f}")
print("\nUNet variant comparisons (no cache writes):")
for name, weights, normalize in UNET_VARIANTS:
if not weights.exists():
print(f"[warn] {name}: weights not found at {weights}")
continue
unet = UNetImageCropper(
manifest_path=MANIFEST_PATH,
weights_path=weights,
normalize=normalize,
threshold=0.5,
tta=False,
scale=SCALE,
target_size=224,
cache_dir=None, # ensure no cache writes
)
u_rows: List[Dict[str, object]] = []
missing_images = 0
unet_none = 0
cache_missing = 0
exceptions = 0
for cache_path in cache_files:
cache = _load_cache(cache_path)
if cache is None:
cache_missing += 1
continue
stem = _parse_stem(cache_path)
image_path = _image_path_from_stem(stem)
if image_path is None:
missing_images += 1
continue
try:
info = _unet_info(unet, image_path)
except Exception:
exceptions += 1
continue
if info is None:
unet_none += 1
continue
bounds_diff = _diff_bounds(cache, info)
feat_diff = _diff_features(cache, info)
u_rows.append(
{
"bounds_diff": bounds_diff,
"features_diff": feat_diff,
"bounds_match": bounds_diff is not None
and bounds_diff <= TOL_BOUNDS,
"features_match": feat_diff is not None
and feat_diff <= TOL_FEATURES,
}
)
if not u_rows:
print(
f"[warn] {name}: no comparisons computed "
f"(cache_missing={cache_missing}, missing_images={missing_images}, "
f"unet_none={unet_none}, exceptions={exceptions})"
)
continue
u_df = pd.DataFrame(u_rows)
b_mean = float(u_df["bounds_diff"].mean())
f_mean = float(u_df["features_diff"].mean())
b_match = float(u_df["bounds_match"].mean())
f_match = float(u_df["features_match"].mean())
print(
f"{name}: mean bounds diff={b_mean:.3f}, mean feat diff={f_mean:.6f}, "
f"bounds match rate={b_match:.3f}, features match rate={f_match:.3f}"
)
print("\nRefuge segmentation model comparison (bounds only, no cache writes):")
device = "cuda" if torch.cuda.is_available() else "cpu"
refuge_model = _load_refuge_model(device)
if refuge_model is None:
print(f"[warn] refuge_segmentation_best.pt not found or failed to load at {REFUGE_SEG_WEIGHTS}")
return
r_rows: List[Dict[str, object]] = []
missing_images = 0
cache_missing = 0
model_none = 0
exceptions = 0
for cache_path in cache_files:
cache = _load_cache(cache_path)
if cache is None:
cache_missing += 1
continue
stem = _parse_stem(cache_path)
image_path = _image_path_from_stem(stem)
if image_path is None:
missing_images += 1
continue
try:
info = _refuge_seg_info(refuge_model, device, image_path)
except Exception:
exceptions += 1
continue
if info is None:
model_none += 1
continue
bounds_diff = _diff_bounds(cache, info)
r_rows.append(
{
"bounds_diff": bounds_diff,
"bounds_match": bounds_diff is not None and bounds_diff <= TOL_BOUNDS,
}
)
if not r_rows:
print(
"[warn] refuge_segmentation_best: no comparisons computed "
f"(cache_missing={cache_missing}, missing_images={missing_images}, "
f"model_none={model_none}, exceptions={exceptions})"
)
return
r_df = pd.DataFrame(r_rows)
b_mean = float(r_df["bounds_diff"].mean())
b_match = float(r_df["bounds_match"].mean())
print(
f"refuge_segmentation_best: mean bounds diff={b_mean:.3f}, "
f"bounds match rate={b_match:.3f}"
)
if __name__ == "__main__":
main()
@@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""Compare suspect AUC from image tower vs crop-derived geometry (CDR)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import sys
import numpy as np
import pandas as pd
from PIL import Image
from sklearn.metrics import roc_auc_score
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes import build_papila_clinical
from classes.hypertower import UNetImageCropper, ManifestImageCropper
# ---------------------------
# Config (edit in IDE)
# ---------------------------
RUN_DIRS = [
Path("analysis_data/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_091842"),
Path("analysis_data/1030_Balanced_GT_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_GT_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_113730"),
]
GEOM_CACHE_ROOT = Path("analysis_data/geometry_cache")
SUSPECT_LABEL = 2
def _load_json(path: Path) -> Dict:
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except Exception:
return {}
def _drop_holdout_rows(clinical, holdout_path: Path) -> None:
if not holdout_path.exists():
return
holdout = pd.read_csv(holdout_path)
if holdout.empty:
return
if "Patient ID" not in holdout.columns or "eyeID" not in holdout.columns:
return
holdout_keys = set(zip(holdout["Patient ID"].astype(int), holdout["eyeID"].astype(str)))
df = clinical.df.copy()
df["_key"] = list(zip(df["Patient ID"].astype(int), df["eyeID"].astype(str)))
df = df[~df["_key"].isin(holdout_keys)].drop(columns=["_key"]).reset_index(drop=True)
clinical.frames = [df.copy()]
clinical.df = df.copy()
clinical._infer_or_validate_feature_types()
clinical._compute_numeric_stats()
clinical._build_cat_maps()
clinical._compute_feature_dim()
clinical._build_kfold_indices()
def _make_cropper(args: Dict, cache_dir: Path):
manifest = args.get("img_crop_manifest")
if not manifest:
raise RuntimeError("img_crop_manifest missing; cannot compute geometry features.")
scale = float(args.get("img_crop_scale", 2.5))
target_size = int(args.get("img_crop_size", 224))
use_gt = bool(args.get("img_crop_gt", False))
if use_gt:
return ManifestImageCropper(
manifest_path=Path(manifest),
scale=scale,
target_size=target_size,
cache_dir=cache_dir,
)
weights = args.get("img_crop_weights")
if not weights:
raise RuntimeError("img_crop_weights missing for UNet cropper.")
normalize = args.get("img_crop_normalize", "per_image")
threshold = float(args.get("img_crop_threshold", 0.5))
tta = bool(args.get("img_crop_tta", False))
return UNetImageCropper(
manifest_path=Path(manifest),
weights_path=Path(weights),
normalize=normalize,
threshold=threshold,
tta=tta,
scale=scale,
target_size=target_size,
cache_dir=cache_dir,
)
def _geometry_scores(
clinical,
cropper,
test_df: pd.DataFrame,
) -> Tuple[np.ndarray, np.ndarray]:
scores: List[float] = []
keep_mask: List[bool] = []
for _, row in test_df.iterrows():
img_path = clinical.get_image_path(row)
try:
image = Image.open(img_path).convert("RGB")
except Exception:
scores.append(float("nan"))
keep_mask.append(False)
continue
feats = cropper.geometry_features(image, img_path)
if feats is None or len(feats) == 0:
scores.append(float("nan"))
keep_mask.append(False)
else:
scores.append(float(feats[0])) # area_ratio (CDR)
keep_mask.append(True)
return np.asarray(scores, dtype=float), np.asarray(keep_mask, dtype=bool)
def _suspect_auc(y_true: np.ndarray, scores: np.ndarray) -> float:
y = (y_true == SUSPECT_LABEL).astype(int)
if y.sum() == 0 or y.sum() == len(y):
return float("nan")
return float(roc_auc_score(y, scores))
def main() -> None:
rows: List[Dict[str, object]] = []
for run_dir in RUN_DIRS:
cli_path = run_dir / "cli_args.json"
cli_args = _load_json(cli_path)
if not cli_args:
print(f"[warn] Missing cli_args.json in {run_dir}")
continue
label_col = cli_args.get("label_col", "Diagnosis")
cat_cols = cli_args.get("cat_cols", ["Gender", "Phakic/Pseudophakic"])
n_splits = int(cli_args.get("n_splits", 5))
fold_seed = int(cli_args.get("fold_seed", 42))
eval_mode = str(cli_args.get("eval_mode", "multiclass")).lower()
clinical = build_papila_clinical(
image_dir=cli_args.get("image_dir", "Papila/FundusImages"),
clinical_dir=cli_args.get("clinical_dir", "Papila/ClinicalData"),
label_col=label_col,
cat_cols=cat_cols,
n_splits=n_splits,
random_seed=fold_seed,
)
if eval_mode == "binary":
clinical.df = clinical.df[clinical.df[label_col].isin([0, 1])].reset_index(drop=True)
clinical.frames = [clinical.df.copy()]
clinical._infer_or_validate_feature_types()
clinical._compute_numeric_stats()
clinical._build_cat_maps()
clinical._compute_feature_dim()
clinical._build_kfold_indices()
_drop_holdout_rows(clinical, run_dir / "holdout.csv")
cache_dir = GEOM_CACHE_ROOT / run_dir.name
cache_dir.mkdir(parents=True, exist_ok=True)
cropper = _make_cropper(cli_args, cache_dir=cache_dir)
all_geom_scores: List[float] = []
all_img_scores: List[float] = []
all_y: List[int] = []
for fold in range(n_splits):
y_path = run_dir / f"fold{fold}_y_true.npy"
p_img_path = run_dir / f"fold{fold}_probs_img.npy"
if not y_path.exists() or not p_img_path.exists():
continue
y_true = np.load(y_path)
probs_img = np.load(p_img_path)
if probs_img.ndim != 2 or probs_img.shape[1] <= SUSPECT_LABEL:
continue
_, test_df = clinical.get_split_dfs(fold)
if len(test_df) != len(y_true):
print(
f"[warn] {run_dir.name} fold{fold}: test_df len {len(test_df)} != y_true len {len(y_true)}"
)
geom_scores, keep_mask = _geometry_scores(clinical, cropper, test_df)
if keep_mask.sum() == 0:
print(f"[warn] {run_dir.name} fold{fold}: no valid geometry features")
continue
y_fold = y_true[: len(geom_scores)][keep_mask]
geom_fold = geom_scores[keep_mask]
img_fold = probs_img[: len(geom_scores), SUSPECT_LABEL][keep_mask]
geom_auc = _suspect_auc(y_fold, geom_fold)
img_auc = _suspect_auc(y_fold, img_fold)
rows.append(
{
"run": run_dir.name,
"fold": fold,
"metric": "suspect_auc",
"image_auc": img_auc,
"geometry_auc": geom_auc,
"n": int(len(y_fold)),
}
)
all_geom_scores.append(geom_fold)
all_img_scores.append(img_fold)
all_y.append(y_fold)
if all_y:
y_all = np.concatenate(all_y)
geom_all = np.concatenate(all_geom_scores)
img_all = np.concatenate(all_img_scores)
rows.append(
{
"run": run_dir.name,
"fold": "all",
"metric": "suspect_auc",
"image_auc": _suspect_auc(y_all, img_all),
"geometry_auc": _suspect_auc(y_all, geom_all),
"n": int(len(y_all)),
}
)
if not rows:
raise SystemExit("No results produced; check run paths and files.")
df = pd.DataFrame(rows)
out_path = GEOM_CACHE_ROOT / "suspect_auc_geometry_vs_image.csv"
out_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(out_path, index=False)
print(df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(f"\nSaved: {out_path}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -1,314 +0,0 @@
#!/usr/bin/env python3
"""
Shared helpers for building grid search analytics.
The class below will gradually accumulate reusable utilities for working with
grid search outputs (summary.json, cli_args.json, etc).
"""
from __future__ import annotations
import json
import csv
import re
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional
import numpy as np
DEFAULT_EXCLUDE_KEYS = {
"run_id",
"fold_metrics",
"best_metric",
"best_metric_mode",
"best_metric_mean",
"best_metric_std",
"eval_mode",
"n_splits",
"num_classes",
}
class GridSearchAnalytics:
"""Utility wrapper for inspecting grid search result directories."""
def __init__(self,
analysis_dir: Path | str,
exclude_keys: Optional[Iterable[str]] = None) -> None:
self.analysis_dir = Path(analysis_dir)
if not self.analysis_dir.exists():
raise FileNotFoundError(f"analysis_dir does not exist: {self.analysis_dir}")
self.exclude_keys = set(exclude_keys or DEFAULT_EXCLUDE_KEYS)
def iter_run_dirs(self, shallow: bool = True) -> Iterator[Path]:
"""
Yield run directories containing grid search artifacts.
Shallow iteration only walks direct children. Deep iteration scans the
entire subtree.
"""
candidates: Iterable[Path]
if shallow:
candidates = (p for p in sorted(self.analysis_dir.iterdir()) if p.is_dir())
else:
candidates = (p for p in self.analysis_dir.rglob("*") if p.is_dir())
for run_dir in candidates:
summary = run_dir / "summary.json"
cli = run_dir / "cli_args.json"
if summary.exists() or cli.exists():
yield run_dir
def read_summary(self, run_dir: Path) -> Optional[Dict[str, object]]:
"""Load summary.json for a run directory."""
return self._read_json(run_dir / "summary.json")
def read_cli_args(self, run_dir: Path) -> Optional[Dict[str, object]]:
"""Load cli_args.json for a run directory."""
return self._read_json(run_dir / "cli_args.json")
def read_run_id(self,
run_dir: Path,
summary: Optional[Dict[str, object]]) -> str:
if summary:
rid = summary.get("run_id")
if isinstance(rid, str) and rid:
return rid
return run_dir.name
def task_from_summary(self, summary: Optional[Dict[str, object]]) -> Optional[str]:
"""Infer task (binary vs multiclass) from a summary payload."""
if not summary:
return None
eval_mode = summary.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = summary.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
return None
def flatten_config(self,
data: Dict[str, object],
prefix: str = "",
exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]:
"""Flatten nested CLI args or config dictionaries for analysis."""
out: Dict[str, object] = {}
excludes = set(exclude_keys or self.exclude_keys)
for key, value in data.items():
if key in excludes or key.startswith("best_"):
continue
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(self.flatten_config(value, full_key, exclude_keys=excludes))
continue
if isinstance(value, list):
continue
out[full_key] = value
return out
def fusion_correction_events(self,
output_csv: Path | str | None = None,
shallow: bool = True) -> Path:
"""
Build a table of cases where the fused head is correct while both towers
are wrong. Rows are written to CSV for downstream analysis.
"""
output_path = Path(output_csv) if output_csv else Path("analysis_data/grid_search_analytics/fusion_corrections.csv")
output_path.parent.mkdir(parents=True, exist_ok=True)
rows: List[Dict[str, object]] = []
for run_dir in self.iter_run_dirs(shallow=shallow):
summary = self.read_summary(run_dir)
run_id = self.read_run_id(run_dir, summary)
folds = self._available_folds(run_dir, summary)
for fold in folds:
y_true = self._load_y_true(run_dir, fold)
if y_true is None:
continue
epoch_prob_paths = self._collect_epoch_prob_paths(run_dir, fold)
if not epoch_prob_paths:
# Per-epoch dumps were not found; fall back to the saved fold-level probabilities.
base_paths = self._collect_base_prob_paths(run_dir, fold)
if base_paths:
epoch_hint = self._fold_epoch_hint(summary, fold)
epoch_prob_paths = {epoch_hint if epoch_hint is not None else 0: base_paths}
for epoch, paths in epoch_prob_paths.items():
arrays = {head: self._load_probs_array(path) for head, path in paths.items()}
if not self._has_all_heads(arrays):
continue
events = self._fusion_corrections_for_probs(y_true, arrays, run_id, fold, epoch)
rows.extend(events)
if rows:
fieldnames = [
"run_id",
"fold",
"epoch",
"index",
"y_true",
"pred_fused",
"pred_img",
"pred_md",
"conf_fused",
"conf_img",
"conf_md",
]
with output_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
else:
output_path.write_text("")
return output_path
@staticmethod
def _read_json(path: Path) -> Optional[Dict[str, object]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
@staticmethod
def _fold_epoch_hint(summary: Optional[Dict[str, object]], fold: int) -> Optional[int]:
if not summary:
return None
fold_metrics = summary.get("fold_metrics") or []
for entry in fold_metrics:
if not isinstance(entry, dict):
continue
if entry.get("fold") == fold:
stats = entry.get("stats") if isinstance(entry.get("stats"), dict) else {}
epoch = stats.get("epoch") or entry.get("best_epoch")
if isinstance(epoch, (int, float)):
return int(epoch)
return None
@staticmethod
def _available_folds(run_dir: Path, summary: Optional[Dict[str, object]]) -> List[int]:
folds: List[int] = []
if summary:
for entry in summary.get("fold_metrics") or []:
if not isinstance(entry, dict):
continue
fold_idx = entry.get("fold")
if isinstance(fold_idx, int):
folds.append(fold_idx)
if not folds:
pattern = re.compile(r"fold(\d+)_y_true\.npy$")
for path in run_dir.glob("fold*_y_true.npy"):
match = pattern.match(path.name)
if match:
folds.append(int(match.group(1)))
return sorted(set(folds))
@staticmethod
def _load_y_true(run_dir: Path, fold: int) -> Optional[np.ndarray]:
path = run_dir / f"fold{fold}_y_true.npy"
if not path.exists():
return None
try:
return np.load(path)
except Exception:
return None
@staticmethod
def _collect_epoch_prob_paths(run_dir: Path, fold: int) -> Dict[int, Dict[str, Path]]:
pattern = re.compile(rf"fold{fold}_epoch(\d+)_probs_(\w+)\.npy$")
epoch_paths: Dict[int, Dict[str, Path]] = {}
for path in run_dir.glob(f"fold{fold}_epoch*_probs_*.npy"):
match = pattern.match(path.name)
if not match:
continue
epoch = int(match.group(1))
head = match.group(2)
epoch_paths.setdefault(epoch, {})[head] = path
return epoch_paths
@staticmethod
def _collect_base_prob_paths(run_dir: Path, fold: int) -> Dict[str, Path]:
paths: Dict[str, Path] = {}
for head in ("fused", "img", "md"):
candidate = run_dir / f"fold{fold}_probs_{head}.npy"
if candidate.exists():
paths[head] = candidate
return paths
@staticmethod
def _load_probs_array(path: Path) -> Optional[np.ndarray]:
try:
return np.load(path)
except Exception:
return None
@staticmethod
def _prepare_probs(arr: np.ndarray) -> Optional[np.ndarray]:
if arr is None:
return None
probs = np.asarray(arr, dtype=float)
if probs.ndim == 1:
probs = np.stack([1.0 - probs, probs], axis=1)
if probs.ndim != 2:
return None
return probs
@staticmethod
def _has_all_heads(arrays: Dict[str, Optional[np.ndarray]]) -> bool:
needed = ("fused", "img", "md")
return all(arrays.get(head) is not None for head in needed)
def _fusion_corrections_for_probs(self,
y_true: np.ndarray,
arrays: Dict[str, np.ndarray],
run_id: str,
fold: int,
epoch: int) -> List[Dict[str, object]]:
fused = self._prepare_probs(arrays.get("fused"))
img = self._prepare_probs(arrays.get("img"))
md = self._prepare_probs(arrays.get("md"))
if fused is None or img is None or md is None:
return []
if not (len(fused) == len(img) == len(md) == len(y_true)):
return []
fused_pred = fused.argmax(axis=1)
img_pred = img.argmax(axis=1)
md_pred = md.argmax(axis=1)
fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1)
img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1)
md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1)
mask = (fused_pred == y_true) & (img_pred != y_true) & (md_pred != y_true)
indices = np.nonzero(mask)[0]
events: List[Dict[str, object]] = []
for idx in indices:
events.append({
"run_id": run_id,
"fold": fold,
"epoch": epoch,
"index": int(idx),
"y_true": int(y_true[idx]),
"pred_fused": int(fused_pred[idx]),
"pred_img": int(img_pred[idx]),
"pred_md": int(md_pred[idx]),
"conf_fused": float(fused_conf[idx]),
"conf_img": float(img_conf[idx]),
"conf_md": float(md_conf[idx]),
})
return events
if __name__ == "__main__":
analytics = GridSearchAnalytics(Path("analysis_data/grid_search"))
output = analytics.fusion_correction_events()
print(f"Fusion correction events written to {output}")
@@ -1,729 +0,0 @@
#!/usr/bin/env python3
"""
Build an HTML heatmap-style grid for grid search runs.
Each column is a run. The header shows mean metrics (auc, acc, holdout_auc,
holdout_acc). Rows encode hyperparameter options as red/green boxes.
Example:
python scripts/grid_search_analytics/grid_search_heatmap.py \
--analysis-dir analysis_data/grid_search \
--task binary \
--sort-by holdout_auc --desc \
--top 40 \
--format plot \
--output analysis_data/grid_search_heatmap.png
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
from html import escape
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
DEFAULT_EXCLUDE_KEYS = {
"run_id",
"fold_metrics",
"best_metric",
"best_metric_mode",
"best_metric_mean",
"best_metric_std",
"eval_mode",
"n_splits",
"num_classes",
}
def to_float(value: Optional[object]) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
num = float(value)
if math.isnan(num):
return None
return num
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
try:
num = float(value)
except ValueError:
return None
if math.isnan(num):
return None
return num
def mean(values: List[float]) -> Optional[float]:
return (sum(values) / len(values)) if values else None
def read_summary(run_dir: Path) -> Optional[Dict[str, object]]:
summary_path = run_dir / "summary.json"
if not summary_path.exists():
return None
try:
data = json.loads(summary_path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
def read_cli_args(run_dir: Path) -> Optional[Dict[str, object]]:
cli_path = run_dir / "cli_args.json"
if not cli_path.exists():
return None
try:
data = json.loads(cli_path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
def read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str:
if summary:
rid = summary.get("run_id")
if isinstance(rid, str) and rid:
return rid
return run_dir.name
def task_from_summary(summary: Optional[Dict[str, object]]) -> Optional[str]:
if not summary:
return None
eval_mode = summary.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = summary.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
return None
def metric_from_stats(stats: Dict[str, object], metric: str) -> Optional[float]:
if metric.startswith("holdout_") and stats.get("holdout_best_monitor") == metric:
best_val = to_float(stats.get("holdout_best_so_far"))
if best_val is not None:
return best_val
return to_float(stats.get(metric))
def mean_metric(summary: Dict[str, object], metric: str) -> Optional[float]:
folds = summary.get("fold_metrics") or []
if not isinstance(folds, list) or not folds:
return None
values = []
for fold in folds:
stats = fold.get("stats") if isinstance(fold, dict) else None
if not isinstance(stats, dict):
return None
val = metric_from_stats(stats, metric)
if val is None:
return None
values.append(val)
return mean(values)
def flatten_config(data: Dict[str, object],
prefix: str = "",
exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]:
out: Dict[str, object] = {}
excludes = set(exclude_keys or [])
for key, value in data.items():
if key in excludes or key.startswith("best_"):
continue
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(flatten_config(value, full_key, exclude_keys=excludes))
continue
if isinstance(value, list):
continue
out[full_key] = value
return out
def sort_value_key(value: object) -> Tuple[int, object]:
if value is None:
return (2, "")
if isinstance(value, bool):
return (0, int(value))
if isinstance(value, (int, float)):
return (0, value)
return (1, str(value))
def format_value(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return f"{value:.6g}"
return str(value)
def format_metric(value: Optional[float]) -> str:
if value is None:
return ""
return f"{value:.4f}"
def render_progress(current: int, total: Optional[int], matched: int) -> str:
if total:
width = 30
filled = int(width * current / total)
bar = "#" * filled + "-" * (width - filled)
return f"[{bar}] {current}/{total} matched {matched}"
return f"Scanned {current} dirs, matched {matched}"
def iter_run_dirs(root: Path, shallow: bool, show_progress: bool) -> Iterable[Path]:
if shallow:
entries = [entry for entry in root.iterdir() if entry.is_dir()]
entries.sort(key=lambda p: p.name)
total = len(entries)
matched = 0
last_update = 0.0
for idx, entry in enumerate(entries, start=1):
if show_progress:
now = time.monotonic()
if now - last_update >= 0.1 or idx == total:
msg = render_progress(idx, total, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
last_update = now
if (entry / "summary.json").is_file():
matched += 1
yield entry
if show_progress:
print(file=sys.stderr)
return
matched = 0
scanned = 0
last_update = 0.0
for dirpath, dirnames, filenames in os.walk(root):
scanned += 1
if show_progress:
now = time.monotonic()
if now - last_update >= 0.2:
msg = render_progress(scanned, None, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
last_update = now
if "summary.json" in filenames:
matched += 1
yield Path(dirpath)
if show_progress:
msg = render_progress(scanned, None, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
print(file=sys.stderr)
def build_html(runs: List[Dict[str, object]],
row_specs: List[Tuple[str, object]],
title: str,
filters: List[str]) -> str:
lines: List[str] = []
lines.append("<!doctype html>")
lines.append("<html lang=\"en\">")
lines.append("<head>")
lines.append("<meta charset=\"utf-8\">")
lines.append(f"<title>{escape(title)}</title>")
lines.append("<style>")
lines.append(":root { --green: #4caf50; --red: #d9534f; --grid: #d0d0d0; --header: #f0f0f0; }")
lines.append("body { margin: 0; padding: 16px; font-family: \"Courier New\", monospace; }")
lines.append(".wrap { overflow-x: auto; }")
lines.append("table { border-collapse: collapse; font-size: 12px; }")
lines.append("th, td { border: 1px solid var(--grid); padding: 4px; text-align: center; }")
lines.append("th.row-label { text-align: left; background: var(--header); position: sticky; left: 0; }")
lines.append("thead th { background: var(--header); position: sticky; top: 0; z-index: 1; }")
lines.append("th.run-id { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; }")
lines.append("td.cell { width: 14px; height: 14px; padding: 0; }")
lines.append("td.on { background: var(--green); }")
lines.append("td.off { background: var(--red); }")
lines.append(".meta { margin-bottom: 12px; }")
lines.append("</style>")
lines.append("</head>")
lines.append("<body>")
lines.append(f"<h2>{escape(title)}</h2>")
if filters:
lines.append("<div class=\"meta\">")
for item in filters:
lines.append(f"<div>{escape(item)}</div>")
lines.append("</div>")
lines.append("<div class=\"wrap\">")
lines.append("<table>")
lines.append("<thead>")
lines.append("<tr>")
lines.append("<th>run_id</th>")
for run in runs:
run_id = escape(str(run.get("run_id", "")))
rel_path = escape(str(run.get("relative_path", "")))
title_attr = f" title=\"{rel_path}\"" if rel_path else ""
lines.append(f"<th class=\"run-id\"{title_attr}>{run_id}</th>")
lines.append("</tr>")
for metric_key, label in [
("auc", "auc"),
("acc", "acc"),
("holdout_auc", "holdout_auc"),
("holdout_acc", "holdout_acc"),
]:
lines.append("<tr>")
lines.append(f"<th>{label}</th>")
for run in runs:
metrics = run.get("metrics", {})
value = metrics.get(metric_key) if isinstance(metrics, dict) else None
lines.append(f"<td>{escape(format_metric(value))}</td>")
lines.append("</tr>")
lines.append("</thead>")
lines.append("<tbody>")
for key, value in row_specs:
label = f"{key}={format_value(value)}"
lines.append("<tr>")
lines.append(f"<th class=\"row-label\">{escape(label)}</th>")
for run in runs:
config = run.get("config", {})
current = config.get(key) if isinstance(config, dict) else None
cell_class = "on" if current == value else "off"
lines.append(f"<td class=\"cell {cell_class}\"></td>")
lines.append("</tr>")
lines.append("</tbody>")
lines.append("</table>")
lines.append("</div>")
lines.append("</body>")
lines.append("</html>")
return "\n".join(lines)
def truncate(text: str, width: int) -> str:
if len(text) <= width:
return text
if width <= 3:
return text[:width]
return text[:width - 3] + "..."
def build_text_grid(runs: List[Dict[str, object]],
row_specs: List[Tuple[str, object]],
filters: List[str],
col_width: int,
row_width: int,
color: bool) -> str:
sep = " "
lines: List[str] = []
if filters:
lines.extend(filters)
lines.append("")
def pad(text: str, width: int) -> str:
return truncate(text, width).ljust(width)
def colorize(text: str, enabled: bool) -> str:
if not color:
return text
color_code = "\x1b[32m" if enabled else "\x1b[31m"
return f"{color_code}{text}\x1b[0m"
def row_line(label: str, values: List[str]) -> str:
return pad(label, row_width) + sep + sep.join(pad(v, col_width) for v in values)
run_ids = [str(run.get("run_id", "")) for run in runs]
lines.append(row_line("run_id", run_ids))
for metric_key, label in [
("auc", "auc"),
("acc", "acc"),
("holdout_auc", "holdout_auc"),
("holdout_acc", "holdout_acc"),
]:
values = []
for run in runs:
metrics = run.get("metrics", {})
value = metrics.get(metric_key) if isinstance(metrics, dict) else None
values.append(format_metric(value))
lines.append(row_line(label, values))
divider = "-" * row_width + sep + sep.join("-" * col_width for _ in runs)
lines.append(divider)
for key, value in row_specs:
label = f"{key}={format_value(value)}"
cells: List[str] = []
for run in runs:
config = run.get("config", {})
current = config.get(key) if isinstance(config, dict) else None
enabled = current == value
cell = colorize("##", enabled) if enabled else colorize("..", enabled)
cells.append(cell)
lines.append(row_line(label, cells))
lines.append("")
lines.append("Legend: ##=on ..=off")
if color:
lines.append("Colors: green=on red=off")
return "\n".join(lines)
def parse_figsize(value: Optional[str], n_cols: int, n_rows: int) -> Tuple[float, float]:
if value:
parts = [p.strip() for p in value.split(",") if p.strip()]
if len(parts) == 2:
try:
return float(parts[0]), float(parts[1])
except ValueError:
pass
width = min(40.0, max(8.0, n_cols * 0.3))
height = min(40.0, max(6.0, (n_rows + 6) * 0.3))
return width, height
def plot_heatmap(runs: List[Dict[str, object]],
row_specs: List[Tuple[str, object]],
filters: List[str],
output_path: Optional[Path],
figsize: Tuple[float, float],
dpi: int,
show: bool) -> None:
if not show:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
try:
import seaborn as sns
except ImportError:
sns = None
metric_labels = ["auc", "acc", "holdout_auc", "holdout_acc"]
metric_matrix: List[List[float]] = []
for label in metric_labels:
row: List[float] = []
for run in runs:
metrics = run.get("metrics", {})
value = metrics.get(label) if isinstance(metrics, dict) else None
row.append(float(value) if value is not None else float("nan"))
metric_matrix.append(row)
param_labels = [f"{key}={format_value(value)}" for key, value in row_specs]
param_matrix: List[List[int]] = []
for key, value in row_specs:
row = []
for run in runs:
config = run.get("config", {})
current = config.get(key) if isinstance(config, dict) else None
row.append(1 if current == value else 0)
param_matrix.append(row)
fig = plt.figure(figsize=figsize, dpi=dpi)
grid_rows = 2 if param_matrix else 1
height_ratios = [2, max(2, len(param_matrix) * 0.5)] if param_matrix else [2]
gs = fig.add_gridspec(grid_rows, 1, height_ratios=height_ratios, hspace=0.05)
ax_metrics = fig.add_subplot(gs[0, 0])
if sns:
sns.heatmap(
metric_matrix,
ax=ax_metrics,
cmap="viridis",
annot=True,
fmt=".3f",
cbar=True,
yticklabels=metric_labels,
xticklabels=False,
)
else:
im = ax_metrics.imshow(metric_matrix, aspect="auto", cmap="viridis")
ax_metrics.set_yticks(range(len(metric_labels)))
ax_metrics.set_yticklabels(metric_labels)
fig.colorbar(im, ax=ax_metrics, fraction=0.02, pad=0.01)
for i, row in enumerate(metric_matrix):
for j, value in enumerate(row):
if math.isnan(value):
continue
ax_metrics.text(j, i, f"{value:.3f}", ha="center", va="center", fontsize=7, color="white")
ax_metrics.set_ylabel("metrics")
if param_matrix:
ax_params = fig.add_subplot(gs[1, 0], sharex=ax_metrics)
cmap = ListedColormap(["#d9534f", "#4caf50"])
if sns:
sns.heatmap(
param_matrix,
ax=ax_params,
cmap=cmap,
cbar=False,
yticklabels=param_labels,
xticklabels=[run.get("run_id", "") for run in runs],
vmin=0,
vmax=1,
)
else:
ax_params.imshow(param_matrix, aspect="auto", cmap=cmap, vmin=0, vmax=1)
ax_params.set_yticks(range(len(param_labels)))
ax_params.set_yticklabels(param_labels)
ax_params.set_xticks(range(len(runs)))
ax_params.set_xticklabels([run.get("run_id", "") for run in runs], rotation=90)
ax_params.set_xlabel("runs")
else:
ax_metrics.set_xticks(range(len(runs)))
ax_metrics.set_xticklabels([run.get("run_id", "") for run in runs], rotation=90)
ax_metrics.set_xlabel("runs")
if filters:
fig.suptitle("Grid Search Heatmap\n" + " | ".join(filters), fontsize=10)
else:
fig.suptitle("Grid Search Heatmap", fontsize=10)
if output_path is not None:
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, bbox_inches="tight")
if show:
plt.show()
plt.close(fig)
def main() -> None:
ap = argparse.ArgumentParser(description="Build an HTML heatmap grid for grid search runs.")
ap.add_argument("--analysis-dir", type=Path, default=Path("analysis_data/grid_search"),
help="Directory containing run subdirectories")
ap.add_argument("--format", choices=["text", "html", "plot"], default="text",
help="Output format (default: text)")
ap.add_argument("--task", choices=["binary", "multiclass", "all"], default="all",
help="Filter runs by task type (default: all)")
ap.add_argument("--sort-by", choices=["auc", "acc", "holdout_auc", "holdout_acc"], default=None,
help="Metric to sort columns by (default: none)")
ap.add_argument("--asc", action="store_true",
help="Sort in ascending order (default: descending)")
ap.add_argument("--desc", action="store_true",
help="Sort in descending order (default: descending)")
ap.add_argument("--top", type=int, default=None,
help="Limit to the top N runs after sorting")
ap.add_argument("--cluster-rows", dest="cluster_rows", action="store_true",
help="Order parameter rows by prevalence in the selected runs (default)")
ap.add_argument("--no-cluster-rows", dest="cluster_rows", action="store_false",
help="Keep parameter rows sorted alphabetically")
ap.set_defaults(cluster_rows=True)
ap.add_argument("--output", type=Path, default=None,
help="Optional path to write output")
ap.add_argument("--params", default=None,
help="Comma-separated list of parameter keys to include")
ap.add_argument("--exclude", default=None,
help="Comma-separated list of parameter keys to exclude")
ap.add_argument("--match", default=None,
help="Only include run directories whose name contains this substring")
ap.add_argument("--shallow", action="store_true",
help="Only scan directories directly under analysis-dir")
ap.add_argument("--no-progress", action="store_true",
help="Disable progress output")
ap.add_argument("--col-width", type=int, default=13,
help="Column width for text output (default: 13)")
ap.add_argument("--row-width", type=int, default=36,
help="Row label width for text output (default: 36)")
ap.add_argument("--color", action="store_true",
help="Use ANSI colors in text output")
ap.add_argument("--figsize", default=None,
help="Figure size as 'width,height' (inches), for plot output")
ap.add_argument("--dpi", type=int, default=140,
help="Figure DPI for plot output")
ap.add_argument("--show", action="store_true",
help="Display plot window (only for format=plot)")
args = ap.parse_args()
root = args.analysis_dir
if not root.exists():
raise SystemExit(f"Analysis directory not found: {root}")
exclude_keys = set(DEFAULT_EXCLUDE_KEYS)
if args.exclude:
for item in args.exclude.split(","):
item = item.strip()
if item:
exclude_keys.add(item)
runs: List[Dict[str, object]] = []
values_by_key: Dict[str, List[object]] = {}
missing_summary = 0
unknown_task = 0
missing_cli = 0
for run_dir in iter_run_dirs(root, shallow=args.shallow, show_progress=not args.no_progress):
if args.match and args.match not in run_dir.name:
continue
summary = read_summary(run_dir)
if summary is None:
missing_summary += 1
continue
task_label = task_from_summary(summary)
if args.task != "all":
if task_label is None:
unknown_task += 1
continue
if task_label != args.task:
continue
metrics = {
"auc": mean_metric(summary, "auc_fused"),
"acc": mean_metric(summary, "acc_fused"),
"holdout_auc": mean_metric(summary, "holdout_auc_fused"),
"holdout_acc": mean_metric(summary, "holdout_acc_fused"),
}
if any(val is None for val in metrics.values()):
continue
cli_args = read_cli_args(run_dir)
if cli_args is None:
missing_cli += 1
config_source = cli_args if cli_args is not None else summary
config = flatten_config(config_source, exclude_keys=exclude_keys)
run_id = read_run_id(run_dir, summary)
runs.append({
"run_id": run_id,
"relative_path": str(run_dir.relative_to(root)),
"task": task_label,
"metrics": metrics,
"config": config,
})
for key, value in config.items():
values_by_key.setdefault(key, []).append(value)
if not runs:
print("No matching runs found.")
return
if args.params:
param_keys = [p.strip() for p in args.params.split(",") if p.strip()]
else:
param_keys = []
for key, values in values_by_key.items():
unique_values = {format_value(v) for v in values}
if len(unique_values) > 1:
param_keys.append(key)
param_keys.sort()
row_specs: List[Tuple[str, object]] = []
for key in param_keys:
values = values_by_key.get(key, [])
unique_values = []
seen = set()
for val in values:
marker = (type(val), val)
if marker in seen:
continue
seen.add(marker)
unique_values.append(val)
unique_values.sort(key=sort_value_key)
for value in unique_values:
row_specs.append((key, value))
if args.asc and args.desc:
raise SystemExit("Choose only one of --asc or --desc.")
if args.sort_by:
def sort_key(item: Dict[str, object]) -> float:
metrics = item.get("metrics", {})
val = metrics.get(args.sort_by) if isinstance(metrics, dict) else None
if val is None:
return float("inf") if args.asc else float("-inf")
return float(val)
runs.sort(key=sort_key, reverse=not args.asc)
else:
runs.sort(key=lambda r: str(r.get("run_id", "")))
if args.top is not None:
runs = runs[:args.top]
if args.cluster_rows and runs:
total = len(runs)
counts_by_spec: Dict[Tuple[str, object], int] = {}
for key, value in row_specs:
counts_by_spec[(key, value)] = 0
for run in runs:
config = run.get("config", {})
if not isinstance(config, dict):
continue
for key, value in row_specs:
if config.get(key) == value:
counts_by_spec[(key, value)] += 1
def row_sort(spec: Tuple[str, object]) -> Tuple[float, str, str]:
count = counts_by_spec.get(spec, 0)
score = count / total if total else 0.0
key, value = spec
return (-score, str(key), format_value(value))
row_specs.sort(key=row_sort)
filters = []
if args.task != "all":
filters.append(f"Task filter: {args.task}")
if args.match:
filters.append(f"Name filter: {args.match}")
filters.append(f"Runs: {len(runs)}")
filters.append(f"Params: {len(row_specs)}")
filters.append(f"Row clustering: {'on' if args.cluster_rows else 'off'}")
if missing_summary or unknown_task:
filters.append(f"Skipped: {missing_summary} missing summary, {unknown_task} unknown task")
if missing_cli:
filters.append(f"Missing cli_args: {missing_cli}")
title = "Grid Search Heatmap"
if args.format == "html":
html = build_html(runs, row_specs, title=title, filters=filters)
output_path = args.output or Path("analysis_data/grid_search_heatmap.html")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html)
print(f"Wrote {output_path}")
elif args.format == "plot":
output_path = args.output
if output_path is None and not args.show:
output_path = Path("analysis_data/grid_search_heatmap.png")
figsize = parse_figsize(args.figsize, n_cols=len(runs), n_rows=len(row_specs))
plot_heatmap(
runs,
row_specs,
filters=filters,
output_path=output_path,
figsize=figsize,
dpi=args.dpi,
show=args.show,
)
if output_path is not None:
print(f"Wrote {output_path}")
else:
text = build_text_grid(
runs,
row_specs,
filters=filters,
col_width=max(4, args.col_width),
row_width=max(12, args.row_width),
color=args.color,
)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text)
print(f"Wrote {args.output}")
else:
print(text)
if __name__ == "__main__":
main()
@@ -1,572 +0,0 @@
#!/usr/bin/env python3
"""Plot holdout ROC curves for a specific grid search run."""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Dict, List, Tuple
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import auc, roc_curve
# ---------------------------
# Config (edit in IDE)
# ---------------------------
RUN_ID = "20251129-0312"
ANALYSIS_ROOT = Path("analysis_data/grid_search")
HEADS = ["fused", "image", "metadata"]
OUTPUT_SUBDIR = Path("plots/holdout_rocs")
BEST_OUTPUT_SUBDIR = Path("plots/best_rocs")
POSITIVE_CLASS = 1
DEBUG = True
USE_JSON_ROC = True
USE_HOLDOUT_PROBS = True
ALLOW_FALLBACK_TO_VALIDATION = False
PLOT_VALIDATION_FROM_HOLDOUT_EPOCH = True
PLOT_BEST_EPOCH = True
PLOT_HOLDOUT_FROM_BEST_EPOCH = True
PLOT_ALL_CLASSES = True
FORCE_PROBS_FOR_BEST_BINARY = True
FORCE_PROBS_FOR_HOLDOUT_BINARY = False
HEAD_FILE_KEYS = {
"fused": "fused",
"image": "img",
"metadata": "md",
}
JSON_DIR_NAMES = [
"roc_curves_holdout_best",
"roc_curves",
]
def _epoch_from_name(path: Path) -> int:
m = re.search(r"epoch(\d+)", path.name)
return int(m.group(1)) if m else -1
def _load_json(path: Path) -> Dict:
try:
return json.loads(path.read_text())
except Exception:
return {}
def _infer_run_info(run_dir: Path) -> Tuple[str | None, int | None, List[str] | None]:
cli = _load_json(run_dir / "cli_args.json")
summary = _load_json(run_dir / "summary.json")
payloads = [cli, summary]
eval_mode = None
num_classes = None
class_names = None
for payload in payloads:
if not payload:
continue
if eval_mode is None:
em = payload.get("eval_mode")
if isinstance(em, str):
eval_mode = em.strip().lower()
if num_classes is None:
nc = payload.get("num_classes")
if isinstance(nc, (int, float)):
num_classes = int(nc)
if class_names is None:
cn = payload.get("class_names")
if isinstance(cn, list) and cn:
class_names = [str(x) for x in cn]
if num_classes is None and eval_mode:
num_classes = 2 if eval_mode == "binary" else 3
return eval_mode, num_classes, class_names
def _collect_holdout_json_files(run_dir: Path, head: str) -> Dict[int, Path]:
fold_files: Dict[int, Path] = {}
# Fold-scoped folders
for folder_name in JSON_DIR_NAMES:
for fold_dir in run_dir.glob(f"fold*_{folder_name}"):
fold_match = re.search(r"fold(\d+)_", fold_dir.name)
if not fold_match:
continue
fold_idx = int(fold_match.group(1))
candidates = list(fold_dir.glob(f"epoch*_holdout_{head}.json"))
if not candidates:
candidates = list(fold_dir.glob(f"epoch*_{head}.json"))
if candidates:
candidates.sort(key=_epoch_from_name)
fold_files[fold_idx] = candidates[-1]
if fold_files:
return fold_files
# Fallback: unscoped roc_curves in run_dir (single-fold or in-progress)
for folder_name in JSON_DIR_NAMES:
base_dir = run_dir / folder_name
if not base_dir.exists():
continue
candidates = list(base_dir.glob(f"epoch*_holdout_{head}.json"))
if not candidates:
candidates = list(base_dir.glob(f"epoch*_{head}.json"))
if candidates:
candidates.sort(key=_epoch_from_name)
fold_files[0] = candidates[-1]
break
return fold_files
def _collect_validation_json_files(
holdout_files: Dict[int, Path], head: str
) -> Dict[int, Path]:
validation_files: Dict[int, Path] = {}
for fold_idx, holdout_path in holdout_files.items():
epoch = _epoch_from_name(holdout_path)
if epoch < 0:
continue
candidate = holdout_path.parent / f"epoch{epoch}_{head}.json"
if candidate.exists():
validation_files[fold_idx] = candidate
continue
# Fallback: try the same epoch under roc_curves (if holdout_best folder omitted it).
for folder_name in JSON_DIR_NAMES:
alt_dir = holdout_path.parent.parent / f"fold{fold_idx}_{folder_name}"
alt_candidate = alt_dir / f"epoch{epoch}_{head}.json"
if alt_candidate.exists():
validation_files[fold_idx] = alt_candidate
break
return validation_files
def _collect_holdout_from_validation_files(
validation_files: Dict[int, Path], head: str
) -> Dict[int, Path]:
holdout_files: Dict[int, Path] = {}
for fold_idx, val_path in validation_files.items():
epoch = _epoch_from_name(val_path)
if epoch < 0:
continue
candidate = val_path.parent / f"epoch{epoch}_holdout_{head}.json"
if candidate.exists():
holdout_files[fold_idx] = candidate
continue
for folder_name in ("roc_curves", "roc_curves_holdout_best"):
alt_dir = val_path.parent.parent / f"fold{fold_idx}_{folder_name}"
alt_candidate = alt_dir / f"epoch{epoch}_holdout_{head}.json"
if alt_candidate.exists():
holdout_files[fold_idx] = alt_candidate
break
return holdout_files
def _collect_best_json_files(run_dir: Path, head: str) -> Dict[int, Path]:
fold_files: Dict[int, Path] = {}
for fold_dir in run_dir.glob("fold*_roc_curves_best"):
fold_match = re.search(r"fold(\d+)_", fold_dir.name)
if not fold_match:
continue
fold_idx = int(fold_match.group(1))
candidates = list(fold_dir.glob(f"epoch*_{head}.json"))
if candidates:
candidates.sort(key=_epoch_from_name)
fold_files[fold_idx] = candidates[-1]
return fold_files
def _extract_curves(data: Dict) -> Dict[str, Tuple[List[float], List[float], float]]:
curves: Dict[str, Tuple[List[float], List[float], float]] = {}
per_class = data.get("per_class") if isinstance(data, dict) else None
if not isinstance(per_class, dict):
return curves
for cls, entry in per_class.items():
if not isinstance(entry, dict):
continue
fpr = entry.get("fpr")
tpr = entry.get("tpr")
auc_val = entry.get("auc")
if not isinstance(fpr, list) or not isinstance(tpr, list):
continue
try:
auc_f = float(auc_val) if auc_val is not None else float("nan")
except Exception:
auc_f = float("nan")
curves[str(cls)] = (fpr, tpr, auc_f)
return curves
def _derive_positive_from_class0(
curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]],
positive_class: int,
) -> None:
zero_key = "0"
if zero_key not in curves_by_class:
return
derived = []
for fold_idx, fpr0, tpr0, auc0 in curves_by_class.get(zero_key, []):
# The JSON for binary currently stores class-1 labels with class-0 scores,
# so invert the curve to recover the true class-1 ROC.
fpr1 = [1.0 - float(x) for x in fpr0]
tpr1 = [1.0 - float(x) for x in tpr0]
# Ensure increasing FPR for plotting.
if len(fpr1) > 1 and fpr1[0] > fpr1[-1]:
fpr1 = list(reversed(fpr1))
tpr1 = list(reversed(tpr1))
auc1 = 1.0 - auc0 if auc0 == auc0 else auc0
derived.append((fold_idx, fpr1, tpr1, auc1))
curves_by_class[str(positive_class)] = derived
def _needs_positive_derivation(
curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]],
positive_class: int,
) -> bool:
curves = curves_by_class.get(str(positive_class))
if not curves:
return True
for _, fpr, tpr, auc_val in curves:
if auc_val == auc_val and len(fpr) > 2 and len(tpr) > 2:
return False
return True
def _collect_prob_files(run_dir: Path, suffix: str) -> Dict[int, Dict[str, Path]]:
files: Dict[int, Dict[str, Path]] = {}
for y_file in run_dir.glob(f"fold*_y_true{suffix}.npy"):
fold_str = y_file.stem.split("_")[0].replace("fold", "")
try:
fold_idx = int(fold_str)
except ValueError:
continue
files.setdefault(fold_idx, {})["y_true"] = y_file
for head, key in HEAD_FILE_KEYS.items():
for p_file in run_dir.glob(f"fold*_probs_{key}{suffix}.npy"):
fold_str = p_file.stem.split("_")[0].replace("fold", "")
try:
fold_idx = int(fold_str)
except ValueError:
continue
files.setdefault(fold_idx, {})[head] = p_file
return files
def _load_array(path: Path) -> np.ndarray | None:
try:
return np.load(path)
except Exception:
return None
def _compute_binary_curve(
y_true: np.ndarray, probs: np.ndarray, positive_class: int
) -> Tuple[List[float], List[float], float] | None:
if probs.ndim == 1:
scores = probs
elif probs.ndim == 2 and probs.shape[1] > positive_class:
scores = probs[:, positive_class]
else:
return None
y_bin = (y_true == positive_class).astype(int)
if y_bin.sum() == 0 or y_bin.sum() == len(y_bin):
return None
fpr, tpr, _ = roc_curve(y_bin, scores)
auc_val = float(auc(fpr, tpr))
return fpr.tolist(), tpr.tolist(), auc_val
def _compute_multiclass_curves(
y_true: np.ndarray, probs: np.ndarray
) -> Dict[str, Tuple[List[float], List[float], float]]:
curves: Dict[str, Tuple[List[float], List[float], float]] = {}
if probs.ndim != 2:
return curves
num_classes = probs.shape[1]
for cls in range(num_classes):
y_bin = (y_true == cls).astype(int)
if y_bin.sum() == 0 or y_bin.sum() == len(y_bin):
continue
fpr, tpr, _ = roc_curve(y_bin, probs[:, cls])
curves[str(cls)] = (fpr.tolist(), tpr.tolist(), float(auc(fpr, tpr)))
return curves
def _plot_overlays(
curves_by_fold: List[Tuple[int, List[float], List[float], float]],
title: str,
out_path: Path,
) -> None:
fig, ax = plt.subplots(figsize=(6, 5))
for fold_idx, fpr, tpr, auc_val in curves_by_fold:
label = (
f"fold{fold_idx} AUC={auc_val:.3f}"
if auc_val == auc_val
else f"fold{fold_idx}"
)
ax.plot(fpr, tpr, lw=1.4, label=label)
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(title)
ax.legend(loc="lower right", fontsize="small")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, dpi=170)
plt.close(fig)
def main() -> None:
run_dir = ANALYSIS_ROOT / RUN_ID
if not run_dir.exists():
raise SystemExit(f"Run not found: {run_dir}")
eval_mode, num_classes, class_names = _infer_run_info(run_dir)
is_binary = eval_mode == "binary" or num_classes == 2
def _plot_set(
label: str,
head: str,
json_files: Dict[int, Path],
out_dir: Path,
paired_files: Dict[int, Path] | None,
paired_suffix: str,
class_names: List[str] | None,
) -> None:
if not json_files:
return
curves_by_class: Dict[
str, List[Tuple[int, List[float], List[float], float]]
] = {}
paired_curves_by_class: Dict[
str, List[Tuple[int, List[float], List[float], float]]
] = {}
for fold_idx, path in sorted(json_files.items()):
if DEBUG:
print(f"[debug] {label} head={head} fold={fold_idx} json={path}")
data = _load_json(path)
curves = _extract_curves(data)
for cls, (fpr, tpr, auc_val) in curves.items():
curves_by_class.setdefault(cls, []).append(
(fold_idx, fpr, tpr, auc_val)
)
if paired_files:
p_path = paired_files.get(fold_idx)
if p_path is not None:
if DEBUG:
print(
f"[debug] {label} head={head} fold={fold_idx} paired_json={p_path}"
)
p_data = _load_json(p_path)
p_curves = _extract_curves(p_data)
for cls, (fpr, tpr, auc_val) in p_curves.items():
paired_curves_by_class.setdefault(cls, []).append(
(fold_idx, fpr, tpr, auc_val)
)
if _needs_positive_derivation(curves_by_class, POSITIVE_CLASS):
_derive_positive_from_class0(curves_by_class, POSITIVE_CLASS)
if paired_curves_by_class and _needs_positive_derivation(
paired_curves_by_class, POSITIVE_CLASS
):
_derive_positive_from_class0(paired_curves_by_class, POSITIVE_CLASS)
if PLOT_ALL_CLASSES:
classes = list(curves_by_class.keys())
else:
classes = (
[str(POSITIVE_CLASS)]
if str(POSITIVE_CLASS) in curves_by_class
else list(curves_by_class.keys())
)
if not classes:
return
for cls in classes:
fold_curves = curves_by_class.get(cls, [])
if not fold_curves:
continue
class_label = cls
if class_names is not None:
try:
idx = int(cls)
if 0 <= idx < len(class_names):
class_label = f"{cls} ({class_names[idx]})"
except Exception:
pass
title = f"{RUN_ID} {label} ROC — head={head} class={class_label}"
out_path = out_dir / f"{label}_{head}_class{cls}.png"
_plot_overlays(fold_curves, title, out_path)
print(f"[ok] {out_path}")
if paired_curves_by_class:
p_curves = paired_curves_by_class.get(cls, [])
if p_curves:
p_title = f"{RUN_ID} {label} {paired_suffix} ROC — head={head} class={class_label}"
p_path = out_dir / f"{label}_{head}_class{cls}_{paired_suffix}.png"
_plot_overlays(p_curves, p_title, p_path)
print(f"[ok] {p_path}")
def _plot_from_probs(
label: str,
head: str,
out_dir: Path,
suffix: str,
class_names: List[str] | None,
) -> None:
curves_by_class: Dict[
str, List[Tuple[int, List[float], List[float], float]]
] = {}
files = _collect_prob_files(run_dir, suffix)
if not files and suffix and ALLOW_FALLBACK_TO_VALIDATION:
files = _collect_prob_files(run_dir, "")
if files:
print(
"[warn] Holdout probability dumps not found; using validation probabilities instead."
)
if not files:
return
for fold_idx in sorted(files.keys()):
fold_files = files[fold_idx]
y_path = fold_files.get("y_true")
p_path = fold_files.get(head)
if y_path is None or p_path is None:
continue
y_true = _load_array(y_path)
probs = _load_array(p_path)
if y_true is None or probs is None:
continue
curves = _compute_multiclass_curves(y_true, probs)
for cls, payload in curves.items():
curves_by_class.setdefault(cls, []).append((fold_idx, *payload))
if not curves_by_class:
return
classes = list(curves_by_class.keys())
for cls in classes:
fold_curves = curves_by_class.get(cls, [])
if not fold_curves:
continue
class_label = cls
if class_names is not None:
try:
idx = int(cls)
if 0 <= idx < len(class_names):
class_label = f"{cls} ({class_names[idx]})"
except Exception:
pass
title = f"{RUN_ID} {label} ROC — head={head} class={class_label}"
out_path = out_dir / f"{label}_{head}_class{cls}.png"
_plot_overlays(fold_curves, title, out_path)
print(f"[ok] {out_path}")
any_holdout_json = False
if USE_JSON_ROC:
out_dir = run_dir / OUTPUT_SUBDIR
for head in HEADS:
files = _collect_holdout_json_files(run_dir, head)
if files:
any_holdout_json = True
paired = (
_collect_validation_json_files(files, head)
if PLOT_VALIDATION_FROM_HOLDOUT_EPOCH
else None
)
if is_binary and FORCE_PROBS_FOR_HOLDOUT_BINARY:
_plot_from_probs("holdout", head, out_dir, "_holdout", class_names)
else:
_plot_set(
"holdout",
head,
files,
out_dir,
paired,
"validation",
class_names,
)
if not any_holdout_json and DEBUG:
print("[debug] no JSON ROC files found; falling back to probs")
if PLOT_BEST_EPOCH:
best_out_dir = run_dir / BEST_OUTPUT_SUBDIR
for head in HEADS:
if is_binary and FORCE_PROBS_FOR_BEST_BINARY:
_plot_from_probs("best", head, best_out_dir, "", class_names)
continue
best_files = _collect_best_json_files(run_dir, head)
if best_files:
paired = (
_collect_holdout_from_validation_files(best_files, head)
if PLOT_HOLDOUT_FROM_BEST_EPOCH
else None
)
_plot_set(
"best",
head,
best_files,
best_out_dir,
paired,
"holdout",
class_names,
)
# Fallback to probs for holdout plots if JSON wasn't found.
if USE_JSON_ROC and any_holdout_json:
return
out_dir = run_dir / OUTPUT_SUBDIR
for head in HEADS:
curves_by_class: Dict[
str, List[Tuple[int, List[float], List[float], float]]
] = {}
suffix = "_holdout" if USE_HOLDOUT_PROBS else ""
files = _collect_prob_files(run_dir, suffix)
if not files and USE_HOLDOUT_PROBS and ALLOW_FALLBACK_TO_VALIDATION:
suffix = ""
files = _collect_prob_files(run_dir, suffix)
if files:
print(
"[warn] Holdout probability dumps not found; using validation probabilities instead."
)
if not files:
raise SystemExit(
"No saved probability dumps found. If you want holdout ROC curves, "
"run scripts/rebuild_run_best_plots.py with --use-holdout --overwrite "
"to generate fold*_y_true_holdout.npy and fold*_probs_*_holdout.npy files."
)
for fold_idx in sorted(files.keys()):
fold_files = files[fold_idx]
y_path = fold_files.get("y_true")
p_path = fold_files.get(head)
if y_path is None or p_path is None:
continue
y_true = _load_array(y_path)
probs = _load_array(p_path)
if y_true is None or probs is None:
continue
curves = _compute_multiclass_curves(y_true, probs)
for cls, payload in curves.items():
if payload is None:
continue
fpr, tpr, auc_val = payload
curves_by_class.setdefault(cls, []).append(
(fold_idx, fpr, tpr, auc_val)
)
if not curves_by_class:
continue
classes = sorted(curves_by_class.keys(), key=lambda x: (float(x), str(x)))
for cls in classes:
fold_curves = curves_by_class.get(cls, [])
if not fold_curves:
continue
class_label = cls
if class_names is not None:
try:
idx = int(cls)
if 0 <= idx < len(class_names):
class_label = f"{cls} ({class_names[idx]})"
except Exception:
pass
title = f"{RUN_ID} holdout ROC — head={head} class={class_label}"
out_path = out_dir / f"holdout_{head}_class{cls}.png"
_plot_overlays(fold_curves, title, out_path)
print(f"[ok] {out_path}")
if __name__ == "__main__":
main()
@@ -1,514 +0,0 @@
#!/usr/bin/env python3
"""
Analyze correlations between grid search parameters and performance metrics.
Example:
python scripts/grid_search_analytics/param_perf_correlations.py \
--analysis-dir analysis_data/grid_search \
--task binary \
--metric holdout_auc \
--top 30
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
DEFAULT_EXCLUDE_KEYS = {
"run_id",
"fold_metrics",
"best_metric",
"best_metric_mode",
"best_metric_mean",
"best_metric_std",
"eval_mode",
"n_splits",
"num_classes",
}
METRIC_MAP = {
"auc": "auc_fused",
"acc": "acc_fused",
"holdout_auc": "holdout_auc_fused",
"holdout_acc": "holdout_acc_fused",
}
def to_float(value: Optional[object]) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)) and not isinstance(value, bool):
num = float(value)
if math.isnan(num):
return None
return num
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
try:
num = float(value)
except ValueError:
return None
if math.isnan(num):
return None
return num
def mean(values: List[float]) -> Optional[float]:
return (sum(values) / len(values)) if values else None
def read_json(path: Path) -> Optional[Dict[str, object]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
def read_summary(run_dir: Path) -> Optional[Dict[str, object]]:
return read_json(run_dir / "summary.json")
def read_cli_args(run_dir: Path) -> Optional[Dict[str, object]]:
return read_json(run_dir / "cli_args.json")
def read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str:
if summary:
rid = summary.get("run_id")
if isinstance(rid, str) and rid:
return rid
return run_dir.name
def task_from_summary(summary: Optional[Dict[str, object]]) -> Optional[str]:
if not summary:
return None
eval_mode = summary.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = summary.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
return None
def metric_from_stats(stats: Dict[str, object], metric: str) -> Optional[float]:
if metric.startswith("holdout_") and stats.get("holdout_best_monitor") == metric:
best_val = to_float(stats.get("holdout_best_so_far"))
if best_val is not None:
return best_val
return to_float(stats.get(metric))
def mean_metric(summary: Dict[str, object], metric: str) -> Optional[float]:
folds = summary.get("fold_metrics") or []
if not isinstance(folds, list) or not folds:
return None
values = []
for fold in folds:
stats = fold.get("stats") if isinstance(fold, dict) else None
if not isinstance(stats, dict):
return None
val = metric_from_stats(stats, metric)
if val is None:
return None
values.append(val)
return mean(values)
def flatten_config(data: Dict[str, object],
prefix: str = "",
exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]:
out: Dict[str, object] = {}
excludes = set(exclude_keys or [])
for key, value in data.items():
if key in excludes or key.startswith("best_"):
continue
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(flatten_config(value, full_key, exclude_keys=excludes))
continue
if isinstance(value, list):
continue
out[full_key] = value
return out
def rankdata(values: List[float]) -> List[float]:
order = sorted(range(len(values)), key=lambda i: values[i])
ranks = [0.0] * len(values)
i = 0
while i < len(values):
j = i
while j + 1 < len(values) and values[order[j + 1]] == values[order[i]]:
j += 1
avg_rank = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[order[k]] = avg_rank
i = j + 1
return ranks
def pearson(x: List[float], y: List[float]) -> Optional[float]:
if len(x) != len(y) or len(x) < 2:
return None
mean_x = sum(x) / len(x)
mean_y = sum(y) / len(y)
num = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(x, y))
den_x = sum((xi - mean_x) ** 2 for xi in x)
den_y = sum((yi - mean_y) ** 2 for yi in y)
if den_x <= 0 or den_y <= 0:
return None
return num / math.sqrt(den_x * den_y)
def spearman(x: List[float], y: List[float]) -> Optional[float]:
rx = rankdata(x)
ry = rankdata(y)
return pearson(rx, ry)
def correlation_ratio(categories: List[object], values: List[float]) -> Optional[float]:
if len(categories) != len(values) or len(values) < 2:
return None
overall = mean(values)
if overall is None:
return None
total = sum((v - overall) ** 2 for v in values)
if total <= 0:
return None
sums: Dict[object, List[float]] = {}
for cat, val in zip(categories, values):
sums.setdefault(cat, []).append(val)
between = 0.0
for vals in sums.values():
avg = mean(vals)
if avg is None:
continue
between += len(vals) * (avg - overall) ** 2
return math.sqrt(between / total)
def format_value(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return f"{value:.6g}"
return str(value)
def format_metric(value: Optional[float]) -> str:
if value is None:
return ""
return f"{value:.4f}"
def format_table(rows: List[Dict[str, object]], columns: List[str]) -> str:
col_widths = {
col: max(len(col), max((len(str(row.get(col, ""))) for row in rows), default=0))
for col in columns
}
header = " | ".join(col.ljust(col_widths[col]) for col in columns)
divider = "-+-".join("-" * col_widths[col] for col in columns)
body = [
" | ".join(str(row.get(col, "")).ljust(col_widths[col]) for col in columns)
for row in rows
]
return "\n".join([header, divider, *body])
def render_progress(current: int, total: Optional[int], matched: int) -> str:
if total:
width = 30
filled = int(width * current / total)
bar = "#" * filled + "-" * (width - filled)
return f"[{bar}] {current}/{total} matched {matched}"
return f"Scanned {current} dirs, matched {matched}"
def iter_run_dirs(root: Path, shallow: bool, show_progress: bool) -> Iterable[Path]:
if shallow:
entries = [entry for entry in root.iterdir() if entry.is_dir()]
entries.sort(key=lambda p: p.name)
total = len(entries)
matched = 0
last_update = 0.0
for idx, entry in enumerate(entries, start=1):
if show_progress:
now = time.monotonic()
if now - last_update >= 0.1 or idx == total:
msg = render_progress(idx, total, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
last_update = now
if (entry / "summary.json").is_file():
matched += 1
yield entry
if show_progress:
print(file=sys.stderr)
return
matched = 0
scanned = 0
last_update = 0.0
for dirpath, dirnames, filenames in os.walk(root):
scanned += 1
if show_progress:
now = time.monotonic()
if now - last_update >= 0.2:
msg = render_progress(scanned, None, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
last_update = now
if "summary.json" in filenames:
matched += 1
yield Path(dirpath)
if show_progress:
msg = render_progress(scanned, None, matched)
print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True)
print(file=sys.stderr)
def main() -> None:
ap = argparse.ArgumentParser(description="Correlate grid search parameters with performance.")
ap.add_argument("--analysis-dir", type=Path, default=Path("analysis_data/grid_search"),
help="Directory containing run subdirectories")
ap.add_argument("--task", choices=["binary", "multiclass", "all"], default="all",
help="Filter runs by task type (default: all)")
ap.add_argument("--metric", choices=sorted(METRIC_MAP.keys()), default="holdout_auc",
help="Performance metric to analyze (default: holdout_auc)")
ap.add_argument("--sort-by", choices=["score", "abs_rho", "rho", "r", "eta"], default="score",
help="Sorting key for results (default: score)")
ap.add_argument("--asc", action="store_true",
help="Sort ascending (default: descending)")
ap.add_argument("--desc", action="store_true",
help="Sort descending (default: descending)")
ap.add_argument("--top", type=int, default=30,
help="Limit output to top N parameters (default: 30)")
ap.add_argument("--params", default=None,
help="Comma-separated list of parameter keys to include")
ap.add_argument("--exclude", default=None,
help="Comma-separated list of parameter keys to exclude")
ap.add_argument("--min-count", type=int, default=10,
help="Minimum runs required to analyze a parameter (default: 10)")
ap.add_argument("--min-unique", type=int, default=2,
help="Minimum unique values required (default: 2)")
ap.add_argument("--match", default=None,
help="Only include run directories whose name contains this substring")
ap.add_argument("--shallow", action="store_true",
help="Only scan directories directly under analysis-dir")
ap.add_argument("--no-progress", action="store_true",
help="Disable progress output")
args = ap.parse_args()
if args.asc and args.desc:
raise SystemExit("Choose only one of --asc or --desc.")
root = args.analysis_dir
if not root.exists():
raise SystemExit(f"Analysis directory not found: {root}")
exclude_keys = set(DEFAULT_EXCLUDE_KEYS)
if args.exclude:
for item in args.exclude.split(","):
item = item.strip()
if item:
exclude_keys.add(item)
runs: List[Dict[str, object]] = []
values_by_key: Dict[str, List[object]] = {}
missing_summary = 0
unknown_task = 0
missing_cli = 0
metric_key = METRIC_MAP[args.metric]
for run_dir in iter_run_dirs(root, shallow=args.shallow, show_progress=not args.no_progress):
if args.match and args.match not in run_dir.name:
continue
summary = read_summary(run_dir)
if summary is None:
missing_summary += 1
continue
task_label = task_from_summary(summary)
if args.task != "all":
if task_label is None:
unknown_task += 1
continue
if task_label != args.task:
continue
metric_value = mean_metric(summary, metric_key)
if metric_value is None:
continue
cli_args = read_cli_args(run_dir)
if cli_args is None:
missing_cli += 1
config_source = cli_args if cli_args is not None else summary
config = flatten_config(config_source, exclude_keys=exclude_keys)
run_id = read_run_id(run_dir, summary)
runs.append({
"run_id": run_id,
"metric": metric_value,
"config": config,
})
for key, value in config.items():
values_by_key.setdefault(key, []).append(value)
if not runs:
print("No matching runs found.")
return
if args.params:
param_keys = [p.strip() for p in args.params.split(",") if p.strip()]
else:
param_keys = []
for key, values in values_by_key.items():
unique_values = {format_value(v) for v in values}
if len(unique_values) >= args.min_unique:
param_keys.append(key)
param_keys.sort()
rows: List[Dict[str, object]] = []
for key in param_keys:
values = []
metrics = []
for run in runs:
config = run.get("config", {})
if key not in config:
continue
values.append(config[key])
metrics.append(run["metric"])
if len(values) < args.min_count:
continue
unique_values = {format_value(v) for v in values}
if len(unique_values) < args.min_unique:
continue
numeric_values: List[float] = []
numeric_ok = True
for v in values:
num = to_float(v)
if num is None or isinstance(v, bool):
numeric_ok = False
break
numeric_values.append(num)
groups: Dict[object, List[float]] = {}
for val, metric in zip(values, metrics):
groups.setdefault(val, []).append(metric)
group_means = {k: mean(v) for k, v in groups.items()}
best_group = max(group_means.items(), key=lambda item: item[1] or float("-inf"))
worst_group = min(group_means.items(), key=lambda item: item[1] or float("inf"))
if numeric_ok and len(set(numeric_values)) >= 3:
rho = spearman(numeric_values, metrics)
r = pearson(numeric_values, metrics)
score = abs(rho) if rho is not None else None
row = {
"param": key,
"type": "numeric",
"n": len(values),
"distinct": len(unique_values),
"score": format_metric(score) if score is not None else "",
"rho": format_metric(rho),
"r": format_metric(r),
"best_value": format_value(best_group[0]),
"best_mean": format_metric(best_group[1]),
"worst_value": format_value(worst_group[0]),
"worst_mean": format_metric(worst_group[1]),
}
else:
eta = correlation_ratio(values, metrics)
score = eta
row = {
"param": key,
"type": "categorical",
"n": len(values),
"distinct": len(unique_values),
"score": format_metric(score) if score is not None else "",
"rho": "",
"r": "",
"best_value": format_value(best_group[0]),
"best_mean": format_metric(best_group[1]),
"worst_value": format_value(worst_group[0]),
"worst_mean": format_metric(worst_group[1]),
}
rows.append(row)
if not rows:
print("No parameters met the minimum requirements.")
return
def sort_key(row: Dict[str, object]) -> float:
raw = row.get(args.sort_by)
if isinstance(raw, str):
val = to_float(raw)
else:
val = to_float(raw)
if val is None:
return float("inf") if args.asc else float("-inf")
return float(val)
rows.sort(key=sort_key, reverse=not args.asc)
if args.top is not None:
rows = rows[:args.top]
header_lines = []
header_lines.append(f"Metric: {args.metric} (mean over folds)")
if args.task != "all":
header_lines.append(f"Task filter: {args.task}")
if args.match:
header_lines.append(f"Name filter: {args.match}")
header_lines.append(f"Runs: {len(runs)}")
if missing_cli:
header_lines.append(f"Missing cli_args: {missing_cli}")
if missing_summary or unknown_task:
header_lines.append(f"Skipped: {missing_summary} missing summary, {unknown_task} unknown task")
header_lines.append("")
print("\n".join(header_lines))
columns = [
"param",
"type",
"n",
"distinct",
"score",
"rho",
"r",
"best_value",
"best_mean",
"worst_value",
"worst_mean",
]
print(format_table(rows, columns))
if __name__ == "__main__":
main()
@@ -1,212 +0,0 @@
#!/usr/bin/env python3
"""Re-run a single grid-search configuration into analysis_data/re_runs."""
from __future__ import annotations
import argparse
import csv
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List
# ---------------------------
# Config (edit in IDE)
# ---------------------------
RUN_ID = "20251129-0063" # fallback if --run-number is not provided
OUTPUT_RUN_ID = RUN_ID # fallback output run id
SHORTNAME = "re_runs" # output root under analysis_data/ and models/
GRID_PLAN = Path("analysis_data/grid_search/grid_plan.csv")
MANIFEST = Path("manifest.csv")
RUN_SCRIPT = Path("scripts/run_multifold.py")
REBUILD_SCRIPT = Path("scripts/rebuild_run_best_plots.py")
PLOT_HEADS = ["fused", "image", "metadata"]
USE_HOLDOUT_BEST_FOR_PLOTS = True
OVERWRITE_HOLDOUT_PROBS = True
ALLOW_EXISTING_RUN_DIR = False
def _parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description="Re-run a single grid-search item.")
ap.add_argument(
"--run-number",
type=str,
default=None,
help="Last 4 digits of run_id (e.g., 0063).",
)
ap.add_argument(
"--output-run-id",
type=str,
default=None,
help="Optional output run id; defaults to matched run_id.",
)
return ap.parse_args()
def _read_plan(path: Path) -> List[Dict[str, str]]:
if not path.exists():
raise FileNotFoundError(f"Grid plan not found: {path}")
with path.open(newline="") as fh:
reader = csv.DictReader(fh)
return list(reader)
def _find_row(rows: List[Dict[str, str]], run_id: str) -> Dict[str, str]:
for row in rows:
if row.get("run_id") == run_id:
return row
raise ValueError(f"run_id not found in grid plan: {run_id}")
def _resolve_run_id(rows: List[Dict[str, str]], run_number: str | None) -> str:
if not run_number:
return RUN_ID
run_number = str(run_number).strip()
if run_number.isdigit():
run_number = run_number.zfill(4)
matches = [
r.get("run_id", "")
for r in rows
if str(r.get("run_id", "")).endswith(f"-{run_number}")
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(
f"Multiple run_ids matched run-number '{run_number}': {matches[:5]}{' ...' if len(matches) > 5 else ''}"
)
raise ValueError(f"No run_id found ending with '-{run_number}'")
def _build_run_command(row: Dict[str, str], output_run_id: str) -> List[str]:
cmd = [
sys.executable,
str(RUN_SCRIPT),
"--backbone",
"resnet50",
"--fusion-mode",
"fused",
"--epochs",
"40",
"--batch-size",
"8",
"--img-crop-manifest",
str(MANIFEST),
"--img-crop-weights",
row["crop_weights"],
"--img-crop-normalize",
row["crop_normalize"],
"--eval_mode",
row["eval_mode"],
"--holdout-per-class",
"12",
"--run-id",
output_run_id,
"--shortname",
SHORTNAME,
]
if row.get("crop_tta") == "True":
cmd.append("--img-crop-tta")
loss_mode = row.get("loss_mode")
if loss_mode == "focal":
cmd.extend(["--focal-gamma", "2.0"])
elif loss_mode == "balanced":
cmd.append("--balanced-sampler")
thaw_mode = row.get("thaw_mode")
if thaw_mode == "gradual":
cmd.append("--gradual-thaw")
cmd.extend(["--thaw-ratio", "0.33"])
cmd.extend(["--thaw-start-epoch", "10"])
cmd.extend(["--thaw-target", "image"])
se_mode = row.get("se_mode")
if se_mode == "none":
cmd.append("--no-se")
else:
cmd.extend(["--se-reduction", "16"])
cmd.extend(["--se-reduction-tower", "16"])
cmd.extend(["--se-where", se_mode])
bridge_pre = row.get("se_bridge_pre_norm")
tower_pre = row.get("se_tower_pre_norm")
if bridge_pre == "True":
cmd.append("--se-pre-norm")
elif bridge_pre == "False":
cmd.append("--no-se-pre-norm")
if tower_pre == "True":
cmd.append("--se-pre-norm-tower")
elif tower_pre == "False":
cmd.append("--no-se-pre-norm-tower")
return cmd
def _swap_in_holdout_best(models_dir: Path) -> None:
for fold_dir in sorted(models_dir.glob("fold*")):
if not fold_dir.is_dir():
continue
holdout_best = fold_dir / "model_holdout_best.pt"
model_best = fold_dir / "model_best.pt"
if not holdout_best.exists():
print(f"[warn] {holdout_best} missing; skipping.")
continue
if model_best.exists():
backup = fold_dir / "model_best_from_train.pt"
if not backup.exists():
try:
shutil.copy2(model_best, backup)
except Exception:
pass
try:
shutil.copy2(holdout_best, model_best)
except Exception as exc:
print(f"[warn] failed to replace {model_best}: {exc}")
def _run_rebuild(run_dir: Path) -> None:
for head in PLOT_HEADS:
cmd = [
sys.executable,
str(REBUILD_SCRIPT),
"--run-dir",
str(run_dir),
"--head",
head,
"--use-holdout",
]
if OVERWRITE_HOLDOUT_PROBS:
cmd.append("--overwrite")
print("[rerun] Rebuilding holdout ROC plots:", " ".join(cmd))
subprocess.run(cmd, check=True)
def main() -> None:
args = _parse_args()
rows = _read_plan(GRID_PLAN)
run_id = _resolve_run_id(rows, args.run_number)
row = _find_row(rows, run_id)
output_run_id = args.output_run_id or run_id
run_dir = Path("analysis_data") / SHORTNAME / output_run_id
if run_dir.exists() and not ALLOW_EXISTING_RUN_DIR:
raise SystemExit(
f"Run directory already exists: {run_dir} (set ALLOW_EXISTING_RUN_DIR=True to reuse)"
)
cmd = _build_run_command(row, output_run_id=output_run_id)
print("[rerun] Launching:", " ".join(cmd))
subprocess.run(cmd, check=True)
models_dir = Path("models") / SHORTNAME / output_run_id
if USE_HOLDOUT_BEST_FOR_PLOTS:
print("[rerun] Swapping in holdout-best checkpoints for plotting.")
_swap_in_holdout_best(models_dir)
_run_rebuild(run_dir)
print(f"[rerun] Done. Outputs in {run_dir}")
if __name__ == "__main__":
main()
@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""Re-run a single grid-search configuration using the V2 loader pipeline."""
from __future__ import annotations
import argparse
import csv
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List
# ---------------------------
# Config (edit in IDE)
# ---------------------------
RUN_ID = "20251129-0063" # fallback if --run-number is not provided
OUTPUT_RUN_ID = RUN_ID # fallback output run id
SHORTNAME = "re_runs_v2" # output root under analysis_data/ and models/
GRID_PLAN = Path("analysis_data/grid_search/grid_plan.csv")
MANIFEST = Path("manifest.csv")
RUN_SCRIPT = Path("scripts/run_multifold_v2.py")
REBUILD_SCRIPT = Path("scripts/rebuild_run_best_plots.py")
PLOT_HEADS = ["fused", "image", "metadata"]
USE_HOLDOUT_BEST_FOR_PLOTS = True
OVERWRITE_HOLDOUT_PROBS = True
ALLOW_EXISTING_RUN_DIR = False
SAMPLE_MODE = "eye" # eye-level for parity with v1 grid runs
def _parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description="Re-run a single grid-search item with V2 loaders.")
ap.add_argument(
"--run-number",
type=str,
default=None,
help="Last 4 digits of run_id (e.g., 0063).",
)
ap.add_argument(
"--output-run-id",
type=str,
default=None,
help="Optional output run id; defaults to matched run_id.",
)
return ap.parse_args()
def _read_plan(path: Path) -> List[Dict[str, str]]:
if not path.exists():
raise FileNotFoundError(f"Grid plan not found: {path}")
with path.open(newline="") as fh:
reader = csv.DictReader(fh)
return list(reader)
def _find_row(rows: List[Dict[str, str]], run_id: str) -> Dict[str, str]:
for row in rows:
if row.get("run_id") == run_id:
return row
raise ValueError(f"run_id not found in grid plan: {run_id}")
def _resolve_run_id(rows: List[Dict[str, str]], run_number: str | None) -> str:
if not run_number:
return RUN_ID
run_number = str(run_number).strip()
if run_number.isdigit():
run_number = run_number.zfill(4)
matches = [
r.get("run_id", "")
for r in rows
if str(r.get("run_id", "")).endswith(f"-{run_number}")
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(
f"Multiple run_ids matched run-number '{run_number}': {matches[:5]}{' ...' if len(matches) > 5 else ''}"
)
raise ValueError(f"No run_id found ending with '-{run_number}'")
def _build_run_command(row: Dict[str, str], output_run_id: str) -> List[str]:
cmd = [
sys.executable,
str(RUN_SCRIPT),
"--backbone",
"resnet50",
"--fusion-mode",
"fused",
"--epochs",
"40",
"--batch-size",
"8",
"--img-crop-manifest",
str(MANIFEST),
"--img-crop-weights",
row["crop_weights"],
"--img-crop-normalize",
row["crop_normalize"],
"--eval_mode",
row["eval_mode"],
"--holdout-per-class",
"12",
"--run-id",
output_run_id,
"--shortname",
SHORTNAME,
"--sample-mode",
SAMPLE_MODE,
]
if row.get("crop_tta") == "True":
cmd.append("--img-crop-tta")
loss_mode = row.get("loss_mode")
if loss_mode == "focal":
cmd.extend(["--focal-gamma", "2.0"])
elif loss_mode == "balanced":
cmd.append("--balanced-sampler")
thaw_mode = row.get("thaw_mode")
if thaw_mode == "gradual":
cmd.append("--gradual-thaw")
cmd.extend(["--thaw-ratio", "0.33"])
cmd.extend(["--thaw-start-epoch", "10"])
cmd.extend(["--thaw-target", "image"])
se_mode = row.get("se_mode")
if se_mode == "none":
cmd.append("--no-se")
else:
cmd.extend(["--se-reduction", "16"])
cmd.extend(["--se-reduction-tower", "16"])
cmd.extend(["--se-where", se_mode])
bridge_pre = row.get("se_bridge_pre_norm")
tower_pre = row.get("se_tower_pre_norm")
if bridge_pre == "True":
cmd.append("--se-pre-norm")
elif bridge_pre == "False":
cmd.append("--no-se-pre-norm")
if tower_pre == "True":
cmd.append("--se-pre-norm-tower")
elif tower_pre == "False":
cmd.append("--no-se-pre-norm-tower")
return cmd
def _swap_in_holdout_best(models_dir: Path) -> None:
for fold_dir in sorted(models_dir.glob("fold*")):
if not fold_dir.is_dir():
continue
holdout_best = fold_dir / "model_holdout_best.pt"
model_best = fold_dir / "model_best.pt"
if not holdout_best.exists():
print(f"[warn] {holdout_best} missing; skipping.")
continue
if model_best.exists():
backup = fold_dir / "model_best_from_train.pt"
if not backup.exists():
try:
shutil.copy2(model_best, backup)
except Exception:
pass
try:
shutil.copy2(holdout_best, model_best)
except Exception as exc:
print(f"[warn] failed to replace {model_best}: {exc}")
def _run_rebuild(run_dir: Path) -> None:
for head in PLOT_HEADS:
cmd = [
sys.executable,
str(REBUILD_SCRIPT),
"--run-dir",
str(run_dir),
"--head",
head,
"--use-holdout",
]
if OVERWRITE_HOLDOUT_PROBS:
cmd.append("--overwrite")
print("[rerun] Rebuilding holdout ROC plots:", " ".join(cmd))
subprocess.run(cmd, check=True)
def main() -> None:
args = _parse_args()
rows = _read_plan(GRID_PLAN)
run_id = _resolve_run_id(rows, args.run_number)
row = _find_row(rows, run_id)
output_run_id = args.output_run_id or run_id
run_dir = Path("analysis_data") / SHORTNAME / output_run_id
if run_dir.exists() and not ALLOW_EXISTING_RUN_DIR:
raise SystemExit(
f"Run directory already exists: {run_dir} (set ALLOW_EXISTING_RUN_DIR=True to reuse)"
)
cmd = _build_run_command(row, output_run_id=output_run_id)
print("[rerun] Launching:", " ".join(cmd))
subprocess.run(cmd, check=True)
models_dir = Path("models") / SHORTNAME / output_run_id
if USE_HOLDOUT_BEST_FOR_PLOTS:
print("[rerun] Swapping in holdout-best checkpoints for plotting.")
_swap_in_holdout_best(models_dir)
_run_rebuild(run_dir)
print(f"[rerun] Done. Outputs in {run_dir}")
if __name__ == "__main__":
main()
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from scripts.grid_search_analytics.derived_analysis import derived_analysis
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(
description="Generate derived grid-search analytics artifacts (fusion/error + param-performance)."
)
ap.add_argument("--analysis-dir", default="analysis_data/grid_search")
ap.add_argument("--mode", choices=["binary", "multiclass"], default="multiclass")
ap.add_argument("--method", choices=["pearson", "spearman"], default="spearman")
ap.add_argument(
"--x-metric",
choices=["fusion_corrections", "fusion_corrections_per_opportunity"],
default="fusion_corrections_per_opportunity",
help="Fusion-correlation x-axis metric for summary bar plot.",
)
ap.add_argument(
"--cat-method",
choices=["eta", "anova", "kruskal"],
default="kruskal",
help="Categorical-test method for param-performance correlations.",
)
ap.add_argument("--top-n", type=int, default=None, help="Optional cap for per-run plots.")
ap.add_argument(
"--recompute",
action="store_true",
help="Recompute from run artifacts instead of preferring cached CSVs.",
)
ap.add_argument(
"--deep-scan",
action="store_true",
help="Scan nested directories instead of direct children only.",
)
return ap.parse_args()
def main() -> int:
args = parse_args()
analysis = derived_analysis(
Path(args.analysis_dir),
classification_mode=args.mode,
)
shallow = not args.deep_scan
existing = not args.recompute
analysis.identify_fusion_corrections(shallow=shallow, existing=existing)
analysis.populate_primary_metrics(shallow=shallow, existing=existing)
analysis.write_fusion_corrections()
analysis.write_fusion_errors()
analysis.write_primary_metrics()
analysis.plot_fusion_corrections_errors(
shallow=shallow, existing=existing, top_n=args.top_n
)
analysis.plot_conf_delta_boxplot(
shallow=shallow, existing=existing, top_n=args.top_n
)
corr_df = analysis.param_performance_correlations(
shallow=shallow,
existing=existing,
method=args.method,
cat_method=args.cat_method,
)
analysis.plot_param_perf_corr_panels(corr_df)
corr_acc = analysis.fusion_corrections_correlation(
method=args.method, metric_type="acc"
)
corr_auc = analysis.fusion_corrections_correlation(
method=args.method, metric_type="auc"
)
try:
analysis.plot_fusion_perf_summary(
corr_acc,
corr_auc,
method=args.method,
x_metric=args.x_metric,
)
except RuntimeError:
analysis.plot_fusion_perf_summary(
corr_acc,
corr_auc,
method=args.method,
x_metric="fusion_corrections",
)
print(f"Done. Outputs written under: {Path(args.analysis_dir) / 'plots'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,269 +0,0 @@
#!/usr/bin/env python3
"""
Compare PerkinsPneumatic IOP conversion methods:
- Current: fixed ratio (1.158)
- OLS linear regression (minimises MSE)
- LAD linear regression (minimises MAE robust to outliers)
- Multiple regression: Perkins + Pachymetry (OLS)
Also reports on the impact of dropping IOP_raw.
Run from repo root:
python scripts/exploratory/iop_correction_analysis.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
CURRENT_RATIO = 1.158
# ── load raw clinical data ────────────────────────────────────────────────────
def load_raw() -> pd.DataFrame:
dfs = []
for fname in ("patient_data_od.xlsx", "patient_data_os.xlsx"):
df = pd.read_excel(REPO / "Papila/ClinicalData" / fname, header=1)
eye = "OD" if "od" in fname else "OS"
df["eyeID"] = eye
dfs.append(df)
return pd.concat(dfs, ignore_index=True)
df = load_raw()
print(f"Total rows: {len(df)}")
print(f"Columns with IOP: {[c for c in df.columns if 'iop' in c.lower() or c in ('Pneumatic','Perkins')]}")
# ── find paired rows ──────────────────────────────────────────────────────────
paired = df.dropna(subset=["Pneumatic", "Perkins", "Pachymetry"]).copy()
pneumatic = paired["Pneumatic"].values.astype(float)
perkins = paired["Perkins"].values.astype(float)
pachymetry = paired["Pachymetry"].values.astype(float)
print(f"\nPaired obs (Pneumatic + Perkins + Pachymetry): n={len(paired)}")
print(f" Pneumatic: mean={pneumatic.mean():.2f} std={pneumatic.std():.2f} "
f"range=[{pneumatic.min():.1f}, {pneumatic.max():.1f}]")
print(f" Perkins: mean={perkins.mean():.2f} std={perkins.std():.2f} "
f"range=[{perkins.min():.1f}, {perkins.max():.1f}]")
# ── current ratio ─────────────────────────────────────────────────────────────
ratio_obs = pneumatic / perkins
ratio_mean = ratio_obs.mean()
ratio_pred = perkins * CURRENT_RATIO
ratio_resid = pneumatic - ratio_pred
ratio_mae = np.abs(ratio_resid).mean()
ratio_rmse = np.sqrt((ratio_resid ** 2).mean())
print(f"\n── Current ratio approach ────────────────────────────────")
print(f" Observed Pneumatic/Perkins ratio: mean={ratio_mean:.4f} "
f"std={ratio_obs.std():.4f} range=[{ratio_obs.min():.3f}, {ratio_obs.max():.3f}]")
print(f" Hardcoded ratio used: {CURRENT_RATIO}")
print(f" MAE: {ratio_mae:.3f} mmHg")
print(f" RMSE: {ratio_rmse:.3f} mmHg")
# ── OLS regression ────────────────────────────────────────────────────────────
slope, intercept, r, p, se = stats.linregress(perkins, pneumatic)
reg_pred = slope * perkins + intercept
reg_resid = pneumatic - reg_pred
reg_mae = np.abs(reg_resid).mean()
reg_rmse = np.sqrt((reg_resid ** 2).mean())
print(f"\n── OLS regression (minimises MSE): Pneumatic = slope * Perkins + intercept ──")
print(f" slope={slope:.4f} intercept={intercept:.4f}")
print(f" R²={r**2:.4f} p={p:.4e}")
print(f" MAE: {reg_mae:.3f} mmHg")
print(f" RMSE: {reg_rmse:.3f} mmHg")
print(f" Improvement over ratio — MAE: {ratio_mae - reg_mae:+.3f} RMSE: {ratio_rmse - reg_rmse:+.3f}")
# LAD regression (minimises MAE) — more robust to outliers
from scipy.optimize import minimize
def lad_loss(params):
a, b = params
return np.abs(pneumatic - (a * perkins + b)).mean()
lad_res = minimize(lad_loss, x0=[slope, intercept], method="Nelder-Mead")
lad_slope, lad_intercept = lad_res.x
lad_pred = lad_slope * perkins + lad_intercept
lad_resid = pneumatic - lad_pred
lad_mae = np.abs(lad_resid).mean()
lad_rmse = np.sqrt((lad_resid ** 2).mean())
print(f"\n── LAD regression (minimises MAE — robust to outliers) ──────")
print(f" slope={lad_slope:.4f} intercept={lad_intercept:.4f}")
print(f" MAE: {lad_mae:.3f} mmHg")
print(f" RMSE: {lad_rmse:.3f} mmHg")
print(f" Improvement over ratio — MAE: {ratio_mae - lad_mae:+.3f} RMSE: {ratio_rmse - lad_rmse:+.3f}")
# ── Multiple regression: Perkins + Pachymetry ─────────────────────────────────
# Pachymetry affects Perkins (applanation) more than Pneumatic, so including
# CCT should absorb some instrument-specific bias.
# Design matrix: [Perkins, Pachymetry, 1]
from numpy.linalg import lstsq
X_multi = np.column_stack([perkins, pachymetry, np.ones(len(perkins))])
coeffs, _, _, _ = lstsq(X_multi, pneumatic, rcond=None)
coef_perkins, coef_pachy, coef_intercept = coeffs
multi_pred = X_multi @ coeffs
multi_resid = pneumatic - multi_pred
multi_mae = np.abs(multi_resid).mean()
multi_rmse = np.sqrt((multi_resid ** 2).mean())
# R² for the multiple model
ss_res = (multi_resid ** 2).sum()
ss_tot = ((pneumatic - pneumatic.mean()) ** 2).sum()
multi_r2 = 1 - ss_res / ss_tot
# Partial correlation of Pachymetry with residual after removing Perkins effect
perkins_resid = pneumatic - (slope * perkins + intercept)
pachy_r, pachy_p = stats.pearsonr(pachymetry, perkins_resid)
print(f"\n── Multiple OLS (Perkins + Pachymetry, n={len(paired)}) ──────────────")
print(f" Pneumatic = {coef_perkins:.4f}×Perkins + {coef_pachy:.5f}×Pachymetry + {coef_intercept:.4f}")
print(f" R²={multi_r2:.4f} (vs simple OLS R²={r**2:.4f})")
print(f" Pachymetry partial corr with OLS residuals: r={pachy_r:.3f} p={pachy_p:.4f}")
print(f" MAE: {multi_mae:.3f} mmHg (vs ratio {ratio_mae:.3f})")
print(f" RMSE: {multi_rmse:.3f} mmHg (vs ratio {ratio_rmse:.3f})")
print(f" Improvement over ratio — MAE: {ratio_mae - multi_mae:+.3f} RMSE: {ratio_rmse - multi_rmse:+.3f}")
print(f" NOTE: n={len(paired)} with 3 parameters — interpret with caution.")
# How much does the intercept matter at typical IOP values?
typical_iop = np.array([10, 15, 20, 25])
print(f"\n Comparison at typical Perkins values:")
print(f" {'Perkins':>8} {'Ratio':>10} {'OLS':>10} {'LAD':>10}")
for v in typical_iop:
ratio_v = v * CURRENT_RATIO
ols_v = slope * v + intercept
lad_v = lad_slope * v + lad_intercept
print(f" {v:>8.1f} {ratio_v:>10.2f} {ols_v:>10.2f} {lad_v:>10.2f}")
# ── IOP_raw coverage ──────────────────────────────────────────────────────────
pneumatic_only = df["Pneumatic"].notna() & df["Perkins"].isna()
perkins_only = df["Pneumatic"].isna() & df["Perkins"].notna()
both = df["Pneumatic"].notna() & df["Perkins"].notna()
neither = df["Pneumatic"].isna() & df["Perkins"].isna()
print(f"\n── IOP measurement coverage ──────────────────────────────")
print(f" Pneumatic only: {pneumatic_only.sum()}")
print(f" Perkins only: {perkins_only.sum()}")
print(f" Both: {both.sum()}")
print(f" Neither: {neither.sum()}")
print(f" Rows where IOP_raw == IOP_corr (no pachy correction): ", end="")
# ── plot ──────────────────────────────────────────────────────────────────────
fig = plt.figure(figsize=(16, 5), layout="constrained")
gs = gridspec.GridSpec(1, 3, figure=fig)
x_line = np.linspace(perkins.min() - 1, perkins.max() + 1, 100)
# ── Row 1: conversion fits ────────────────────────────────────────────────────
# 1a. Scatter with all four fits
ax1 = fig.add_subplot(gs[0, :2]) # spans first two columns
ax1.scatter(perkins, pneumatic, alpha=0.6, s=35, color="gray", zorder=3, label="Observed pairs (n=41)")
ax1.plot(x_line, x_line * CURRENT_RATIO, "r--", lw=2, label=f"Ratio ×{CURRENT_RATIO} MAE={ratio_mae:.2f}")
ax1.plot(x_line, slope * x_line + intercept, "b-", lw=2, label=f"OLS (slope={slope:.3f}, int={intercept:.2f}) MAE={reg_mae:.2f}")
ax1.plot(x_line, lad_slope * x_line + lad_intercept, "g-", lw=2, label=f"LAD (slope={lad_slope:.3f}, int={lad_intercept:.2f}) MAE={lad_mae:.2f}")
# Multi-reg projected at mean CCT
pachy_mean, pachy_sd = pachymetry.mean(), pachymetry.std()
multi_mean_line = coef_perkins * x_line + coef_pachy * pachy_mean + coef_intercept
ax1.plot(x_line, multi_mean_line, color="purple", lw=2, ls="-.",
label=f"Multi (Perkins+CCT) @ mean CCT MAE={multi_mae:.2f}")
ax1.set_xlabel("Perkins IOP (mmHg)")
ax1.set_ylabel("Pneumatic IOP (mmHg)")
ax1.set_title("Perkins → Pneumatic conversion: all methods")
ax1.legend(fontsize=8)
# 1b. MAE / RMSE bar chart
ax_bar = fig.add_subplot(gs[0, 2]) # third column
bar_methods = ["Ratio", "OLS", "LAD", "Multi\n(+CCT)"]
maes = [ratio_mae, reg_mae, lad_mae, multi_mae]
rmses = [ratio_rmse, reg_rmse, lad_rmse, multi_rmse]
bar_colors = ["#e05c5c", "#5c7de0", "#5cc97c", "#9b59b6"]
bx = np.arange(4)
w = 0.35
ax_bar.bar(bx - w/2, maes, w, label="MAE", color=bar_colors)
ax_bar.bar(bx + w/2, rmses, w, label="RMSE", color=bar_colors, alpha=0.5)
ax_bar.set_xticks(bx); ax_bar.set_xticklabels(bar_methods, fontsize=8)
ax_bar.set_ylabel("Error (mmHg)")
ax_bar.set_title("MAE & RMSE comparison")
ax_bar.legend(fontsize=9)
ax_bar.set_ylim(0, max(rmses) * 1.25)
for i, (mae, rmse) in enumerate(zip(maes, rmses)):
ax_bar.text(i - w/2, mae + 0.05, f"{mae:.2f}", ha="center", va="bottom", fontsize=8)
ax_bar.text(i + w/2, rmse + 0.05, f"{rmse:.2f}", ha="center", va="bottom", fontsize=8)
out = REPO / "analysis_data/iop_correction_comparison.png"
fig.savefig(out, dpi=150)
print(f"\nPlot saved to {out}")
# ── Figure 2: Pachymetry analysis ─────────────────────────────────────────────
fig2, axes2 = plt.subplots(1, 3, figsize=(15, 5))
pachy_norm = (pachymetry - pachymetry.mean()) / pachymetry.std()
# 2a. Pachymetry vs PneumaticPerkins difference
ax = axes2[0]
diff = pneumatic - perkins
m, b, rr, pp, _ = stats.linregress(pachymetry, diff)
ax.scatter(pachymetry, diff, alpha=0.6, s=35, color="steelblue")
px = np.linspace(pachymetry.min() - 5, pachymetry.max() + 5, 100)
ax.plot(px, m * px + b, "r-", lw=2, label=f"r={rr:.2f} p={pp:.3f}")
ax.axhline(0, color="k", lw=0.7, ls="--")
ax.set_xlabel("Pachymetry (μm)")
ax.set_ylabel("Pneumatic Perkins (mmHg)")
ax.set_title("Does CCT predict the instrument gap?")
ax.legend(fontsize=9)
# 2b. Residuals from ratio vs Pachymetry (coloured by size)
ax = axes2[1]
sc = ax.scatter(pachymetry, ratio_resid, c=perkins, cmap="viridis", alpha=0.7, s=35)
plt.colorbar(sc, ax=ax, label="Perkins IOP")
m2, b2, rr2, pp2, _ = stats.linregress(pachymetry, ratio_resid)
ax.plot(px, m2 * px + b2, "r-", lw=2, label=f"r={rr2:.2f} p={pp2:.3f}")
ax.axhline(0, color="k", lw=0.7, ls="--")
ax.set_xlabel("Pachymetry (μm)")
ax.set_ylabel("Ratio residual (mmHg)")
ax.set_title("Ratio residuals vs Pachymetry\n(colour = Perkins IOP)")
ax.legend(fontsize=9)
# 2c. Multiple regression residuals vs Pachymetry (should be flat if absorbed)
ax = axes2[2]
m3, b3, rr3, pp3, _ = stats.linregress(pachymetry, multi_resid)
ax.scatter(pachymetry, multi_resid, alpha=0.6, s=35, color="darkorange")
ax.plot(px, m3 * px + b3, "r-", lw=2, label=f"r={rr3:.2f} p={pp3:.3f}")
ax.axhline(0, color="k", lw=0.7, ls="--")
ax.set_xlabel("Pachymetry (μm)")
ax.set_ylabel("Multi-reg residual (mmHg)")
ax.set_title("Multi-reg residuals vs Pachymetry\n(flat = Pachymetry effect absorbed)")
ax.legend(fontsize=9)
# shared y-axis
ylim2 = max(np.abs(diff).max(), np.abs(ratio_resid).max(), np.abs(multi_resid).max()) + 1
for ax in axes2[1:]:
ax.set_ylim(-ylim2, ylim2)
fig2.suptitle(
f"Pachymetry as a covariate (n={len(paired)}, CCT mean={pachymetry.mean():.0f}±{pachymetry.std():.0f} μm)",
fontsize=11,
)
fig2.tight_layout()
out2 = REPO / "analysis_data/iop_pachymetry_analysis.png"
fig2.savefig(out2, dpi=150)
print(f"Plot saved to {out2}")
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
"""Build manifest for U-Net segmenter combining REFUGE and Papila annotations."""
from __future__ import annotations
import argparse
import random
from pathlib import Path
from typing import Optional
import pandas as pd
import sys
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.refuge_preprocessing import RefugePreprocessing
REFUGE_ROOT = Path("REFUGE")
PAPILA_IMAGES = Path("Papila/FundusImages")
PAPILA_CONTOURS = Path("Papila/ExpertsSegmentations/Contours")
DEFAULT_OUTPUT = Path("manifest.csv")
def pick_contour(base: str, kind: str) -> Optional[Path]:
"""Return contour path for Papila image (disc/cup)."""
candidates = [
PAPILA_CONTOURS / f"{base}_{kind}_exp2.txt",
PAPILA_CONTOURS / f"{base}_{kind}_exp1.txt",
]
for path in candidates:
if path.exists():
return path
return None
def collect_refuge() -> pd.DataFrame:
pre = RefugePreprocessing(REFUGE_ROOT)
samples = []
for sample in pre.build_manifest(refresh=True):
if sample.mask_path is None:
continue
split = sample.split
if split == "test":
split = "holdout"
samples.append(
{
"sample_id": sample.sample_id,
"dataset": "refuge",
"image_path": sample.image_path.resolve(),
"annotation_disc": sample.mask_path.resolve(),
"annotation_cup": sample.mask_path.resolve(),
"annotation_type_disc": "mask",
"annotation_type_cup": "mask",
"split": split,
}
)
return pd.DataFrame(samples)
def collect_papila() -> pd.DataFrame:
samples = []
if not PAPILA_IMAGES.exists():
return pd.DataFrame(samples)
for img_path in sorted(PAPILA_IMAGES.glob("RET*")):
base = img_path.stem
disc = pick_contour(base, "disc")
cup = pick_contour(base, "cup")
if disc is None or cup is None:
continue
samples.append(
{
"sample_id": f"papila_{base}",
"dataset": "papila",
"image_path": img_path.resolve(),
"annotation_disc": disc.resolve(),
"annotation_cup": cup.resolve(),
"annotation_type_disc": "contour",
"annotation_type_cup": "contour",
}
)
return pd.DataFrame(samples)
def assign_splits(df: pd.DataFrame, holdout_ratio: float, seed: int) -> pd.DataFrame:
rng = random.Random(seed)
df = df.copy()
if "split" not in df.columns:
df["split"] = None
for dataset, group in df.groupby("dataset"):
indices = list(group.index)
# Preserve provided splits (e.g., REFUGE train/val/test); only populate
# missing entries with "train" so downstream code has a default.
split_series = df.loc[indices, "split"]
missing = split_series.isna() | (split_series.astype(str).str.strip() == "")
if missing.any():
df.loc[missing[missing].index, "split"] = "train"
split_series = df.loc[indices, "split"]
if dataset != "papila":
continue
if holdout_ratio <= 0:
continue
desired_holdout = max(1, int(len(indices) * holdout_ratio))
split_series = df.loc[indices, "split"]
current_holdout_mask = split_series == "holdout"
current_holdout = int(current_holdout_mask.sum())
remaining = desired_holdout - current_holdout
if remaining <= 0:
continue
candidate_indices = list(split_series[split_series == "train"].index)
rng.shuffle(candidate_indices)
selected = candidate_indices[:remaining]
df.loc[selected, "split"] = "holdout"
return df
def main() -> None:
parser = argparse.ArgumentParser(description="Build U-Net manifest")
parser.add_argument("--holdout", type=float, default=0.05)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
refuge_df = collect_refuge()
papila_df = collect_papila()
combined = pd.concat([refuge_df, papila_df], ignore_index=True)
combined = assign_splits(combined, holdout_ratio=args.holdout, seed=args.seed)
args.output.parent.mkdir(parents=True, exist_ok=True)
combined.to_csv(args.output, index=False)
print(f"Manifest saved to {args.output} with {len(combined)} entries")
if __name__ == "__main__":
main()
-138
View File
@@ -1,138 +0,0 @@
"""Build manifest for U-Net segmenter combining REFUGE and Papila annotations."""
from __future__ import annotations
import argparse
import random
from pathlib import Path
from typing import Optional
import sys
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT))
from classes.refuge_preprocessing import RefugePreprocessing
REFUGE_ROOT = Path("REFUGE")
PAPILA_IMAGES = Path("FundusImages")
PAPILA_CONTOURS = Path("Papila/ExpertsSegmentations/Contours")
DEFAULT_OUTPUT = Path("Papila/analysis_data/unet_manifest.csv")
def pick_contour(base: str, kind: str) -> Optional[Path]:
"""Return contour path for Papila image (disc/cup)."""
candidates = [
PAPILA_CONTOURS / f"{base}_{kind}_exp2.txt",
PAPILA_CONTOURS / f"{base}_{kind}_exp1.txt",
]
for path in candidates:
if path.exists():
return path
return None
def collect_refuge() -> pd.DataFrame:
pre = RefugePreprocessing(REFUGE_ROOT)
samples = []
for sample in pre.build_manifest(refresh=True):
if sample.mask_path is None:
continue
split = sample.split
if split == "test":
split = "holdout"
samples.append(
{
"sample_id": sample.sample_id,
"dataset": "refuge",
"image_path": sample.image_path.resolve(),
"annotation_disc": sample.mask_path.resolve(),
"annotation_cup": sample.mask_path.resolve(),
"annotation_type_disc": "mask",
"annotation_type_cup": "mask",
"split": split,
}
)
return pd.DataFrame(samples)
def collect_papila() -> pd.DataFrame:
samples = []
if not PAPILA_IMAGES.exists():
return pd.DataFrame(samples)
for img_path in sorted(PAPILA_IMAGES.glob("RET*")):
base = img_path.stem
disc = pick_contour(base, "disc")
cup = pick_contour(base, "cup")
if disc is None or cup is None:
continue
samples.append(
{
"sample_id": f"papila_{base}",
"dataset": "papila",
"image_path": img_path.resolve(),
"annotation_disc": disc.resolve(),
"annotation_cup": cup.resolve(),
"annotation_type_disc": "contour",
"annotation_type_cup": "contour",
}
)
return pd.DataFrame(samples)
def assign_splits(df: pd.DataFrame, holdout_ratio: float, seed: int) -> pd.DataFrame:
rng = random.Random(seed)
df = df.copy()
if "split" not in df.columns:
df["split"] = None
for dataset, group in df.groupby("dataset"):
indices = list(group.index)
# Preserve provided splits (e.g., REFUGE train/val/test); only populate
# missing entries with "train" so downstream code has a default.
split_series = df.loc[indices, "split"]
missing = split_series.isna() | (split_series.astype(str).str.strip() == "")
if missing.any():
df.loc[missing[missing].index, "split"] = "train"
split_series = df.loc[indices, "split"]
if dataset != "papila":
continue
if holdout_ratio <= 0:
continue
desired_holdout = max(1, int(len(indices) * holdout_ratio))
split_series = df.loc[indices, "split"]
current_holdout_mask = split_series == "holdout"
current_holdout = int(current_holdout_mask.sum())
remaining = desired_holdout - current_holdout
if remaining <= 0:
continue
candidate_indices = list(split_series[split_series == "train"].index)
rng.shuffle(candidate_indices)
selected = candidate_indices[:remaining]
df.loc[selected, "split"] = "holdout"
return df
def main() -> None:
parser = argparse.ArgumentParser(description="Build U-Net manifest")
parser.add_argument("--holdout", type=float, default=0.05)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
refuge_df = collect_refuge()
papila_df = collect_papila()
combined = pd.concat([refuge_df, papila_df], ignore_index=True)
combined = assign_splits(combined, holdout_ratio=args.holdout, seed=args.seed)
combined.to_csv(args.output, index=False)
print(f"Manifest saved to {args.output} with {len(combined)} entries")
if __name__ == "__main__":
main()
-966
View File
@@ -1,966 +0,0 @@
"""REFUGE training/evaluation helper.
Usage examples (after activating .venv_refuge):
python refuge_build.py --train-clf
python refuge_build.py --eval --with-ttt
The script expects the REFUGE folder and writes checkpoints under
models/v2/refuge/segmentation and models/v2/refuge/classifier.
"""
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, Tuple
import shutil
import sys
import torch
import numpy as np
from PIL import Image, ImageDraw
from torch.utils.data import DataLoader
from sklearn.metrics import roc_auc_score
from tqdm import tqdm
from torch import nn
from torchvision import models
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
from classes.refuge_segmentation import RefugeSegmentation
from classes.refuge_classification import (
RefugeClassification,
RefugeClassificationRecord,
RefugeClassificationDataset,
_default_image_transform,
_geometry_from_mask,
UNetGeometryProvider,
)
from classes.unet_segmenter import UNetSegmenter
from classes.papila_builders import build_papila_clinical
REFUGE_ROOT = Path("REFUGE")
SEG_CKPT = Path("models/refuge/segmentation/refuge_segmentation_best.pt")
CLF_DIR = Path("models/v2/refuge/classifier")
UNET_WEIGHT_CANDIDATES = (
Path("models/v2/refuge/segmentation/per_image/best.pt"),
Path("models/v2/refuge/segmentation/best.pt"),
Path("models/unet_segmenter/best.pt"),
)
CLASSIFIER_BACKBONES = {
"resnet50": models.ResNet50_Weights.DEFAULT,
"densenet121": models.DenseNet121_Weights.DEFAULT,
"efficientnet_b0": models.EfficientNet_B0_Weights.DEFAULT,
"efficientnet_b7": models.EfficientNet_B7_Weights.DEFAULT,
}
def build_classifier_backbone(name: str) -> nn.Module:
name = name.lower()
if name not in CLASSIFIER_BACKBONES:
raise ValueError(f"Unsupported classifier backbone '{name}'")
weights = CLASSIFIER_BACKBONES[name]
if name == "resnet50":
model = models.resnet50(weights=weights)
feat_dim = model.fc.in_features
model.fc = nn.Identity()
elif name == "densenet121":
model = models.densenet121(weights=weights)
feat_dim = model.classifier.in_features
model.classifier = nn.Identity()
elif name == "efficientnet_b0":
model = models.efficientnet_b0(weights=weights)
feat_dim = model.classifier[-1].in_features # type: ignore[index]
model.classifier = nn.Identity()
elif name == "efficientnet_b7":
model = models.efficientnet_b7(weights=weights)
feat_dim = model.classifier[-1].in_features # type: ignore[index]
model.classifier = nn.Identity()
else: # pragma: no cover
raise ValueError(f"Unsupported classifier backbone '{name}'")
setattr(model, "_feature_dim", int(feat_dim))
return model
def classifier_checkpoint_dir(backbone_name: str) -> Path:
return CLF_DIR / backbone_name
def classifier_checkpoint_path(backbone_name: str) -> Path:
return classifier_checkpoint_dir(backbone_name) / "refuge_classifier_best.pt"
def resolve_unet_weights(explicit: Optional[Path]) -> Path:
if explicit is not None:
return explicit
for cand in UNET_WEIGHT_CANDIDATES:
if cand.exists():
return cand
return UNET_WEIGHT_CANDIDATES[0]
def ensure_preprocessing() -> RefugePreprocessing:
if not REFUGE_ROOT.exists():
raise FileNotFoundError(f"REFUGE directory not found at {REFUGE_ROOT}")
return RefugePreprocessing(REFUGE_ROOT)
def load_allowed_ids(
csv_path: Optional[Path], dice_threshold: float
) -> Optional[Set[str]]:
if csv_path is None or not csv_path.exists():
return None
allowed: Set[str] = set()
with csv_path.open(newline="") as fh:
reader = csv.DictReader(fh)
for row in reader:
sample_id = row.get("sample_id")
if not sample_id or sample_id == "__mean__":
continue
try:
disc = float(row.get("dice_disc", "nan"))
cup = float(row.get("dice_cup", "nan"))
except (TypeError, ValueError):
continue
if disc < dice_threshold and cup < dice_threshold:
continue
allowed.add(sample_id)
return allowed
def build_papila_samples(
image_dir: Path,
clinical_dir: Path,
label_col: str,
positive_labels: Sequence[str],
allowed_ids: Optional[Set[str]],
) -> List[RefugeSample]:
clinical = build_papila_clinical(
image_dir=str(image_dir),
clinical_dir=str(clinical_dir),
label_col=label_col,
cat_cols=[],
)
positives = {lbl.lower() for lbl in positive_labels}
samples: Dict[str, RefugeSample] = {}
for _, row in clinical.df.iterrows():
image_path = clinical.get_image_path(row)
sample_id = f"papila_{Path(image_path).stem}"
if allowed_ids is not None and sample_id not in allowed_ids:
continue
if sample_id in samples:
continue
value = row.get(label_col)
if value is None or (isinstance(value, float) and np.isnan(value)):
continue
try:
label_int = int(value)
if label_int == 2:
continue
label = 1 if label_int > 0 else 0
except (TypeError, ValueError):
label = 1 if str(value).strip().lower() in positives else 0
samples[sample_id] = RefugeSample(
sample_id=sample_id,
dataset="papila",
split="holdout",
image_path=Path(image_path),
label=label,
device=None,
mask_path=None,
fovea_coord=None,
)
return list(samples.values())
def load_contour(path: Path) -> np.ndarray:
coords = np.loadtxt(path)
if coords.ndim == 1:
coords = coords.reshape(-1, 2)
return coords
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)
class PapilaGTGeometryProvider:
def __init__(self, contours_dir: Path) -> None:
self.contours_dir = contours_dir
def _pick(self, base: str, kind: str) -> Optional[Path]:
for exp in ("exp2", "exp1"):
cand = self.contours_dir / f"{base}_{kind}_{exp}.txt"
if cand.exists():
return cand
return None
def __call__(self, sample: RefugeSample, scale: float):
base = Path(sample.image_path).stem
disc_path = self._pick(base, "disc")
cup_path = self._pick(base, "cup")
if disc_path is None or cup_path is None:
raise RuntimeError(f"Missing ground-truth contours for {sample.sample_id}")
image = Image.open(sample.image_path).convert("RGB")
disc_coords = load_contour(disc_path)
cup_coords = load_contour(cup_path)
disc_mask = contour_to_mask(disc_coords, image.size)
cup_mask = contour_to_mask(cup_coords, image.size)
cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8)
geom = _geometry_from_mask(disc_mask, scale)
return geom, disc_mask.astype(np.uint8), cup_mask.astype(np.uint8)
def build_papila_records(
args: argparse.Namespace,
pre: RefugePreprocessing,
checkpoint_path: Path,
) -> Tuple[List[RefugeClassificationRecord], Optional[RefugeClassification]]:
allowed = load_allowed_ids(
getattr(args, "papila_metrics", None),
getattr(args, "papila_dice_threshold", 0.01),
)
samples = build_papila_samples(
args.papila_image_dir,
args.papila_clinical_dir,
args.papila_label_col,
args.papila_positive_labels,
allowed,
)
if not samples:
return [], None
cache_dir = args.clf_cache_dir
if cache_dir is not None and getattr(args, "papila_use_gt", False):
cache_dir = cache_dir / "gt"
if getattr(args, "papila_use_gt", False):
geometry_fn = PapilaGTGeometryProvider(args.papila_contours_dir)
provider = geometry_fn
else:
seg_manifest = getattr(args, "seg_manifest", None)
seg_weights = resolve_unet_weights(getattr(args, "seg_weights", None))
if seg_manifest is None or seg_weights is None:
raise SystemExit(
"Papila evaluation without GT masks requires --seg-manifest and --seg-weights"
)
segmenter = UNetSegmenter(
manifest_path=seg_manifest,
device=args.device,
normalize=args.seg_normalize,
)
seg_state = torch.load(seg_weights, map_location=args.device)
seg_state_dict = seg_state.get("model", seg_state)
segmenter.model.load_state_dict(seg_state_dict)
segmenter.model.to(args.device)
provider = UNetGeometryProvider(
segmenter=segmenter,
threshold=args.segmenter_threshold,
tta=args.segmenter_tta,
)
geometry_fn = provider
papila_seg = RefugeSegmentation(pre)
backbone = build_classifier_backbone(args.clf_backbone)
papila_clf = RefugeClassification(
pre,
papila_seg,
backbone=backbone,
geometry_fn=provider,
cache_dir=cache_dir,
)
papila_clf.crop_scale = args.crop_scale
papila_clf.crop_size = args.crop_size
papila_clf.eval_transform = _default_image_transform(args.crop_size)
papila_clf.ttt_transform = papila_clf.eval_transform
papila_state = torch.load(checkpoint_path, map_location=args.device)
papila_clf.backbone.load_state_dict(papila_state["backbone"])
papila_clf.classifier_head.load_state_dict(papila_state["classifier"])
papila_clf.rotation_head.load_state_dict(papila_state["rotation"])
papila_clf.backbone.to(args.device)
papila_clf.classifier_head.to(args.device)
papila_clf.rotation_head.to(args.device)
if getattr(args, "clear_clf_cache", False):
papila_clf.clear_disk_cache()
records = papila_clf.build_records_for_samples(
samples, crop_scale=args.crop_scale, progress_prefix="papila"
)
print(f"[eval] Prepared {len(records)} PAPILA records")
return records, papila_clf
def train_unet_segmenter(args: argparse.Namespace) -> None:
manifest_path = args.seg_manifest or Path("manifest.csv")
mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir
image_cache_dir = None if args.in_memory_cache else args.image_cache_dir
if args.in_memory_cache and (args.mask_cache_dir or args.image_cache_dir):
print("[unet-seg] in_memory_cache enabled: disk caches disabled for this run.")
segmenter = UNetSegmenter(
manifest_path=manifest_path,
device=args.device,
target_size=args.seg_image_size,
normalize=args.seg_normalize,
use_stronger_aug=args.seg_strong_aug,
train_datasets=args.seg_train_datasets,
val_datasets=args.seg_val_datasets,
holdout_datasets=args.seg_holdout_datasets,
mask_cache_dir=mask_cache_dir,
image_cache_dir=image_cache_dir,
in_memory_cache=args.in_memory_cache,
loader_workers=args.loader_workers,
)
if mask_cache_dir:
print(f"[unet-seg] mask_cache_dir={mask_cache_dir}")
if image_cache_dir:
print(f"[unet-seg] image_cache_dir={image_cache_dir}")
if args.in_memory_cache:
print("[unet-seg] prebuilding in-memory cache")
segmenter.prebuild_in_memory_cache(
cache_workers=max(0, int(args.cache_workers)),
include_train=True,
include_val=True,
include_holdout=False,
)
segmenter.train(
epochs=args.seg_epochs,
batch_size=args.seg_batch_size,
lr=args.seg_lr,
weight_decay=args.seg_weight_decay,
checkpoint_dir=args.seg_checkpoint_dir,
)
print(
"[unet-seg] Training complete. Best checkpoint stored at",
(args.seg_checkpoint_dir / "best.pt").resolve(),
)
def _load_segmentation(
pre: RefugePreprocessing, args: argparse.Namespace
) -> RefugeSegmentation:
seg = RefugeSegmentation(pre)
seg.build_datasets(
image_size=args.seg_image_size,
batch_size=args.seg_batch_size,
num_workers=args.num_workers,
)
if not SEG_CKPT.exists():
raise FileNotFoundError(f"Segmentation checkpoint missing: {SEG_CKPT}")
state = torch.load(SEG_CKPT, map_location=args.device)
seg.model.load_state_dict(state)
seg.model.to(args.device)
return seg
def train_classifier(args: argparse.Namespace) -> None:
pre = ensure_preprocessing()
seg = _load_segmentation(pre, args)
backbone = build_classifier_backbone(args.clf_backbone)
print(f"[classifier] Using backbone: {args.clf_backbone}")
clf = RefugeClassification(
pre,
seg,
backbone=backbone,
cache_dir=args.clf_cache_dir,
use_all_labeled=args.clf_use_all,
auto_val_ratio=args.clf_auto_val_ratio,
)
if args.clear_clf_cache:
clf.clear_disk_cache()
clf.build_datasets(
crop_scale=args.crop_scale,
crop_size=args.crop_size,
batch_size=args.clf_batch_size,
num_workers=args.num_workers,
)
default_ckpt_path = classifier_checkpoint_path(args.clf_backbone)
ckpt_path = args.clf_checkpoint_path or default_ckpt_path
ckpt_dir = ckpt_path.parent
history = clf.train(
epochs=args.clf_epochs,
lr=args.clf_lr,
weight_decay=args.clf_weight_decay,
rotation_weight=args.rotation_weight,
checkpoint_dir=ckpt_dir,
device=args.device,
)
print("Classifier training complete. Best AUC:", history.get("best_auc"))
print(f"Checkpoint directory: {ckpt_dir}")
saved_path = ckpt_dir / "refuge_classifier_best.pt"
if ckpt_path != saved_path:
ckpt_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(saved_path, ckpt_path)
print(f"Checkpoint copied to: {ckpt_path}")
def _load_classifier(
pre: RefugePreprocessing, seg: RefugeSegmentation, args: argparse.Namespace
) -> Tuple[RefugeClassification, Path]:
backbone = build_classifier_backbone(args.clf_backbone)
clf = RefugeClassification(
pre,
seg,
backbone=backbone,
cache_dir=args.clf_cache_dir,
use_all_labeled=args.clf_use_all,
auto_val_ratio=args.clf_auto_val_ratio,
)
clf.build_datasets(
crop_scale=args.crop_scale,
crop_size=args.crop_size,
batch_size=args.clf_batch_size,
num_workers=args.num_workers,
)
ckpt_path = args.clf_checkpoint_path or classifier_checkpoint_path(
args.clf_backbone
)
if not ckpt_path.exists():
raise FileNotFoundError(f"Classifier checkpoint missing: {ckpt_path}")
print(f"[classifier] Loading checkpoint: {ckpt_path}")
state = torch.load(ckpt_path, map_location=args.device)
clf.backbone.load_state_dict(state["backbone"])
clf.classifier_head.load_state_dict(state["classifier"])
clf.rotation_head.load_state_dict(state["rotation"])
clf.backbone.to(args.device)
clf.classifier_head.to(args.device)
clf.rotation_head.to(args.device)
return clf, ckpt_path
def _collect_records(
pre: RefugePreprocessing,
seg: RefugeSegmentation,
clf: RefugeClassification,
dataset_name: str,
split: str,
scale: float,
) -> List[RefugeClassificationRecord]:
manifest = pre.build_manifest()
samples = [
sample
for sample in manifest
if sample.dataset == dataset_name
and sample.split == split
and sample.label is not None
]
if not samples:
return []
print(f"[eval] Preparing {len(samples)} samples for {dataset_name.upper()} {split}")
return clf.build_records_for_samples(
samples, crop_scale=scale, progress_prefix=f"{dataset_name}_{split}"
)
def _auc_for_records(
clf: RefugeClassification,
records: List[RefugeClassificationRecord],
device: str,
) -> float:
if not records:
return float("nan")
dataset = RefugeClassificationDataset(
records,
transform=clf.eval_transform,
polar_transform=clf.polar_transform,
size=clf.crop_size,
)
loader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=0)
clf.backbone.to(device).eval()
clf.classifier_head.to(device).eval()
preds: List[float] = []
targets: List[int] = []
with torch.no_grad():
for batch in tqdm(loader, desc="Eval", leave=False, unit="batch"):
images = batch["image"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
labels = batch["label"].cpu().numpy().tolist()
feats_img = clf.backbone(images)
feats = feats_img
if getattr(clf, "use_polar", False):
feats_polar = clf.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if getattr(clf, "extra_feature_dim", 0) > 0:
feats = torch.cat([feats, extra_feats], dim=1)
logits = clf.classifier_head(feats)
probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist()
preds.extend(probs)
targets.extend(labels)
if len(set(targets)) < 2:
return float("nan")
return float(roc_auc_score(targets, preds))
def evaluate(args: argparse.Namespace) -> None:
pre = ensure_preprocessing()
seg = _load_segmentation(pre, args)
clf, clf_ckpt = _load_classifier(pre, seg, args)
if args.clear_clf_cache:
clf.clear_disk_cache()
def evaluate_subset(
clf_obj: RefugeClassification,
records: List[RefugeClassificationRecord],
label: str,
) -> None:
if not records:
print(f"[eval] No samples found for {label}; skipping.")
return
base_state = {
"backbone": clf_obj.backbone.state_dict(),
"rotation": clf_obj.rotation_head.state_dict(),
}
auc_no_ttt = _auc_for_records(clf_obj, records, device=args.device)
auc_ttt = float("nan")
if args.with_ttt:
ttt_loader = DataLoader(
RefugeClassificationDataset(
records,
transform=clf_obj.ttt_transform,
polar_transform=clf_obj.polar_transform,
size=clf_obj.crop_size,
),
batch_size=16,
shuffle=False,
num_workers=0,
)
ttt_iter = tqdm(range(args.ttt_steps), desc="TTT", unit="step")
for _ in ttt_iter:
clf_obj.apply_ttt(ttt_loader, device=args.device, steps=1)
auc_ttt = _auc_for_records(clf_obj, records, device=args.device)
clf_obj.backbone.load_state_dict(base_state["backbone"])
clf_obj.rotation_head.load_state_dict(base_state["rotation"])
print(
f"{label}: AUC (no TTT) = {auc_no_ttt:.4f}"
+ (f", AUC (TTT) = {auc_ttt:.4f}" if args.with_ttt else "")
)
if args.eval_datasets:
for dataset_name in dict.fromkeys(args.eval_datasets):
if dataset_name.lower() == "papila":
papila_records, papila_clf = build_papila_records(args, pre, clf_ckpt)
if papila_clf is None:
print("[eval] Papila evaluation aborted; no samples built.")
else:
evaluate_subset(papila_clf, papila_records, "PAPILA holdout")
else:
records = _collect_records(
pre,
seg,
clf,
dataset_name,
"holdout",
scale=args.crop_scale,
)
evaluate_subset(clf, records, f"{dataset_name.upper()} holdout")
return
# Do not mix splits: report per dataset + split
subsets = [
("refuge1", "val"),
("refuge2", "val"),
("refuge2", "test"),
]
for dataset_name, split in subsets:
records = _collect_records(
pre, seg, clf, dataset_name, split, scale=args.crop_scale
)
evaluate_subset(clf, records, f"{dataset_name.upper()} {split}")
if args.dump_masks and dataset_name == "refuge1" and split == "val":
out_dir = Path(args.dump_masks)
out_dir.mkdir(parents=True, exist_ok=True)
for rec in records:
sample = rec.sample
if sample is None:
continue
pred = seg.predict_mask(sample, device=args.device).numpy()
Image.fromarray((pred * 255).astype(np.uint8)).save(
out_dir / f"{sample.sample_id}_pred.png"
)
if sample.mask_path and sample.mask_path.exists():
Image.open(sample.mask_path).convert("L").save(
out_dir / f"{sample.sample_id}_gt.png"
)
def evaluate_segmentation(args: argparse.Namespace) -> None:
manifest_path = args.seg_manifest or Path("manifest.csv")
mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir
image_cache_dir = None if args.in_memory_cache else args.image_cache_dir
segmenter = UNetSegmenter(
manifest_path=manifest_path,
normalize=args.seg_normalize,
device=args.device,
mask_cache_dir=mask_cache_dir,
image_cache_dir=image_cache_dir,
in_memory_cache=args.in_memory_cache,
loader_workers=args.loader_workers,
)
if args.seg_weights is None:
raise SystemExit(
"--seg-weights must be specified for --eval-seg; "
"e.g. --seg-weights models/v2/refuge/segmentation/per_image_refuge_build/best.pt"
)
ckpt = args.seg_weights
if not ckpt.exists():
raise FileNotFoundError(f"Segmentation weights not found at {ckpt}")
state = torch.load(ckpt, map_location=segmenter.device)
state_dict = state.get("model", state)
segmenter.model.load_state_dict(state_dict, strict=False)
print(f"[seg-eval] Loaded weights from {ckpt}")
if args.in_memory_cache:
segmenter.prebuild_in_memory_cache(
cache_workers=max(0, int(args.cache_workers)),
include_train=False,
include_val="val" in args.eval_seg_splits,
include_holdout="holdout" in args.eval_seg_splits,
)
dataset_filter = args.eval_seg_datasets
split_filter = args.eval_seg_splits
output_dir = args.eval_seg_output or Path("analysis_data/segmenter_eval")
metrics_path = args.eval_seg_metrics_path
segmenter.evaluate_dataset(
dataset_filter=dataset_filter,
split_filter=split_filter,
output_dir=output_dir,
save_overlays=not args.eval_seg_no_overlays,
metrics_path=metrics_path,
threshold=args.eval_seg_threshold,
tta=args.eval_seg_tta,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="REFUGE pipeline helper")
parser.add_argument(
"--train-unet-seg",
action="store_true",
help="Train the UNet segmenter (replacement for scripts/run_unet_segmenter.py)",
)
parser.add_argument(
"--train-clf", action="store_true", help="Train the classification model"
)
parser.add_argument(
"--eval", action="store_true", help="Run evaluation on stored checkpoints"
)
parser.add_argument(
"--with-ttt",
action="store_true",
help="Apply test-time training during evaluation",
)
parser.add_argument(
"--ttt-steps", type=int, default=1, help="TTT epochs over evaluation loader"
)
parser.add_argument(
"--export-backbone",
type=Path,
default=None,
help="Optional path to export the trained backbone weights",
)
parser.add_argument(
"--dump-masks",
type=Path,
default=None,
help="Optional directory to dump predicted/GT masks during eval",
)
parser.add_argument(
"--device", default="cuda" if torch.cuda.is_available() else "cpu"
)
parser.add_argument("--num-workers", type=int, default=4)
# Segmentation hyperparameters
parser.add_argument("--seg-epochs", type=int, default=40)
parser.add_argument("--seg-lr", type=float, default=1e-3)
parser.add_argument("--seg-weight-decay", type=float, default=1e-5)
parser.add_argument("--seg-image-size", type=int, default=512)
parser.add_argument("--seg-batch-size", type=int, default=4)
parser.add_argument(
"--seg-manifest",
type=Path,
default=Path("manifest.csv"),
help="Manifest CSV for the UNet segmenter (default: manifest.csv)",
)
parser.add_argument(
"--seg-weights",
type=Path,
default=None,
help="Path to UNet segmenter weights (default: models/unet_segmenter/best.pt)",
)
parser.add_argument(
"--seg-normalize",
choices=["none", "imagenet", "per_image"],
default="none",
help="Normalization mode used when running the UNet segmenter",
)
parser.add_argument(
"--seg-strong-aug",
action="store_true",
help="Enable stronger geometric augmentations when training the UNet segmenter",
)
parser.add_argument(
"--seg-train-datasets",
nargs="+",
default=["refuge"],
help="Datasets to use for UNet segmenter training (default: refuge)",
)
parser.add_argument(
"--seg-val-datasets",
nargs="+",
default=["refuge"],
help="Datasets eligible for validation sampling (default: refuge)",
)
parser.add_argument(
"--seg-holdout-datasets",
nargs="+",
default=["refuge"],
help="Datasets reserved for holdout set during UNet segmenter training (default: refuge)",
)
parser.add_argument(
"--seg-checkpoint-dir",
type=Path,
default=Path("models/v2/refuge/segmentation/per_image"),
help="Directory to store UNet segmenter checkpoints",
)
parser.add_argument(
"--loader-workers",
type=int,
default=0,
help="DataLoader workers for UNet segmenter train/eval.",
)
parser.add_argument(
"--mask-cache-dir",
type=Path,
default=None,
help="Optional cache dir for parsed/resized disc+cup masks.",
)
parser.add_argument(
"--image-cache-dir",
type=Path,
default=None,
help="Optional cache dir for resized RGB images before augmentation.",
)
parser.add_argument(
"--in-memory-cache",
action="store_true",
help="Cache preprocessed images and masks in RAM (per DataLoader worker process).",
)
parser.add_argument(
"--cache-workers",
type=int,
default=0,
help="Worker threads for prebuilding in-memory cache before training/eval.",
)
# Classification hyperparameters
parser.add_argument("--clf-epochs", type=int, default=30)
parser.add_argument("--clf-lr", type=float, default=1e-4)
parser.add_argument("--clf-weight-decay", type=float, default=1e-4)
parser.add_argument("--clf-batch-size", type=int, default=16)
parser.add_argument(
"--clf-backbone",
choices=sorted(CLASSIFIER_BACKBONES.keys()),
default="resnet50",
help="Backbone architecture for the REFUGE classifier",
)
parser.add_argument("--rotation-weight", type=float, default=0.5)
parser.add_argument("--crop-scale", type=float, default=2.5)
parser.add_argument("--crop-size", type=int, default=224)
parser.add_argument(
"--clf-cache-dir",
type=Path,
default=Path("cache_data/classifier_cache"),
help="Directory to cache classifier preprocessing artifacts",
)
parser.add_argument(
"--clear-clf-cache",
action="store_true",
help="Delete all cached geometry/mask files before running (use when segmenter weights have changed)",
)
parser.add_argument(
"--clf-use-all",
action="store_true",
help="Use all labelled samples (train+val) when building classifier dataset",
)
parser.add_argument(
"--clf-auto-val-ratio",
type=float,
default=0.1,
help="Fraction for automatic validation split when no explicit val set is used",
)
parser.add_argument(
"--clf-checkpoint-path",
type=Path,
default=None,
help="Optional explicit path for the classifier checkpoint (defaults to models/v2/refuge/classifier/<backbone>/refuge_classifier_best.pt)",
)
parser.add_argument(
"--eval-datasets",
nargs="+",
help="Datasets to evaluate during --eval (e.g. papila). Defaults to REFUGE splits.",
)
# Segmentation evaluation parameters
parser.add_argument(
"--eval-seg",
action="store_true",
help="Evaluate the segmentation model on specified datasets/splits",
)
parser.add_argument(
"--eval-seg-datasets",
nargs="+",
default=["refuge"],
help="Segmentation datasets to evaluate (default: refuge)",
)
parser.add_argument(
"--eval-seg-splits",
nargs="+",
choices=["train", "val", "holdout"],
default=["holdout"],
help="Segmentation splits to evaluate (default: holdout)",
)
parser.add_argument(
"--eval-seg-output",
type=Path,
default=Path("analysis_data/segmenter_eval"),
help="Directory to store segmentation metrics CSVs",
)
parser.add_argument(
"--eval-seg-threshold",
type=float,
default=0.5,
help="Threshold for binarising predicted masks during segmentation eval",
)
parser.add_argument(
"--eval-seg-metrics-path",
type=Path,
default=None,
help="Optional explicit CSV path for segmentation metrics output",
)
parser.add_argument(
"--eval-seg-no-overlays",
action="store_true",
help="Skip saving GT/pred overlay images during segmentation evaluation",
)
parser.add_argument(
"--eval-seg-tta",
action="store_true",
help="Enable horizontal/vertical flip TTA during segmentation evaluation",
)
parser.add_argument(
"--papila-metrics",
type=Path,
default=None,
help="Optional CSV of Papila Dice metrics used to filter samples",
)
parser.add_argument(
"--papila-dice-threshold",
type=float,
default=0.01,
help="Minimum Dice required (disc or cup) when filtering Papila metrics",
)
parser.add_argument(
"--papila-positive-labels",
nargs="+",
default=["glaucoma", "glaucoma suspect", "suspect"],
help="Papila label values treated as positive when labels are non-numeric",
)
parser.add_argument(
"--papila-image-dir",
type=Path,
default=Path("Papila/FundusImages"),
help="Path to Papila fundus images",
)
parser.add_argument(
"--papila-clinical-dir",
type=Path,
default=Path("Papila/ClinicalData"),
help="Path to Papila clinical CSVs",
)
parser.add_argument(
"--papila-label-col",
type=str,
default="Diagnosis",
help="Column name containing Papila labels",
)
parser.add_argument(
"--papila-use-gt",
action="store_true",
help="Use Papila ground-truth contours when evaluating classifiers",
)
parser.add_argument(
"--papila-contours-dir",
type=Path,
default=Path("Papila/ExpertsSegmentations/Contours"),
help="Directory containing Papila contour text files",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not any(
[
args.train_unet_seg,
args.train_clf,
args.eval,
args.eval_seg,
args.export_backbone,
]
):
raise SystemExit(
"Specify at least one action: --train-unet-seg, --train-clf, --eval, --eval-seg, or --export-backbone"
)
if args.train_unet_seg:
train_unet_segmenter(args)
if args.train_clf:
train_classifier(args)
if args.eval:
evaluate(args)
if args.eval_seg:
evaluate_segmentation(args)
if args.export_backbone:
pre = ensure_preprocessing()
seg = _load_segmentation(pre, args)
clf = _load_classifier(pre, seg, args)
out_path = args.export_backbone
out_path.parent.mkdir(parents=True, exist_ok=True)
torch.save(clf.extract_backbone().state_dict(), out_path)
print(f"Backbone weights exported to {out_path}")
if __name__ == "__main__":
main()
-156
View File
@@ -1,156 +0,0 @@
#!/usr/bin/env python3
"""Train and evaluate the U-Net optic disc/cup segmenter."""
from __future__ import annotations
import argparse
from pathlib import Path
import torch
import sys
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.unet_segmenter import UNetSegmenter
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="UNet segmenter runner")
parser.add_argument("--manifest", type=Path, required=True, help="Path to manifest CSV")
parser.add_argument("--train", action="store_true", help="Train the segmenter")
parser.add_argument("--evaluate", action="store_true", help="Evaluate on holdout set")
parser.add_argument("--epochs", type=int, default=40)
parser.add_argument("--batch-size", type=int, default=4)
parser.add_argument("--lr", type=float, default=1e-3)
parser.add_argument("--weight-decay", type=float, default=1e-5)
parser.add_argument("--disc-weight", type=float, default=1.0)
parser.add_argument("--cup-weight", type=float, default=1.0)
parser.add_argument("--checkpoint-dir", type=Path, default=Path("models/unet_segmenter"))
parser.add_argument("--eval-output", type=Path, default=Path("analysis_data/segmenter_eval"))
parser.add_argument(
"--normalize",
choices=["none", "imagenet", "per_image"],
default="none",
help="Image normalization mode for train/eval",
)
parser.add_argument(
"--strong-aug",
action="store_true",
help="Enable stronger train-time augmentations (flips/rotations)",
)
parser.add_argument("--train-datasets", nargs="+", help="Datasets to use for training/validation (default: all)")
parser.add_argument("--val-datasets", nargs="+", help="Datasets eligible for validation sampling (default: match training)")
parser.add_argument("--holdout-datasets", nargs="+", help="Restrict holdout entries to these datasets (default: all)")
parser.add_argument(
"--val-ratio",
type=float,
default=0.1,
help="Fraction of training data reserved for validation (default: 0.1)",
)
parser.add_argument("--eval-datasets", nargs="+", help="Datasets to evaluate (default: holdout split only)")
parser.add_argument("--eval-splits", nargs="+", help="Splits to evaluate (default: holdout or all when --eval-datasets is set)")
parser.add_argument("--eval-metrics-path", type=Path, help="Optional CSV path for evaluation metrics output")
parser.add_argument("--no-eval-overlays", action="store_true", help="Skip writing overlay images during evaluation")
parser.add_argument("--threshold", type=float, default=0.5, help="Probability threshold for binarizing predictions")
parser.add_argument("--tta", action="store_true", help="Enable simple test-time augmentation (H/V flips) during evaluation")
parser.add_argument(
"--weights",
type=Path,
help="Optional model weights (.pt) for eval-only runs; defaults to <checkpoint-dir>/best.pt",
)
parser.add_argument("--device", choices=["auto", "cuda", "cpu"], default="auto", help="Execution device for UNet (default: auto).")
parser.add_argument("--loader-workers", type=int, default=0, help="DataLoader workers for train/eval.")
parser.add_argument("--mask-cache-dir", type=Path, default=None, help="Optional cache dir for parsed/resized disc+cup masks.")
parser.add_argument("--image-cache-dir", type=Path, default=None, help="Optional cache dir for resized RGB images before augmentation.")
parser.add_argument("--in-memory-cache", action="store_true", help="Cache preprocessed images and masks in RAM (per DataLoader worker process).")
parser.add_argument("--cache-workers", type=int, default=0, help="Worker threads for prebuilding in-memory cache before training/eval.")
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.device == "auto":
selected_device = "cuda" if torch.cuda.is_available() else "cpu"
else:
selected_device = args.device
if selected_device == "cuda" and not torch.cuda.is_available():
raise RuntimeError("Requested --device cuda but CUDA is not available.")
print(
f"[UNet] device={selected_device} "
f"(cuda_available={torch.cuda.is_available()}, workers={args.loader_workers})"
)
if selected_device == "cuda":
idx = torch.cuda.current_device()
print(f"[UNet] gpu={torch.cuda.get_device_name(idx)}")
mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir
image_cache_dir = None if args.in_memory_cache else args.image_cache_dir
if args.in_memory_cache and (args.mask_cache_dir or args.image_cache_dir):
print("[UNet] in_memory_cache enabled: disk caches disabled for this run.")
segmenter = UNetSegmenter(
manifest_path=args.manifest,
device=selected_device,
cup_weight=args.cup_weight,
disc_weight=args.disc_weight,
val_ratio=args.val_ratio,
train_datasets=args.train_datasets,
val_datasets=args.val_datasets,
holdout_datasets=args.holdout_datasets,
normalize=args.normalize,
use_stronger_aug=args.strong_aug,
mask_cache_dir=mask_cache_dir,
image_cache_dir=image_cache_dir,
in_memory_cache=args.in_memory_cache,
loader_workers=args.loader_workers,
)
if mask_cache_dir:
print(f"[UNet] mask_cache_dir={mask_cache_dir}")
if image_cache_dir:
print(f"[UNet] image_cache_dir={image_cache_dir}")
if args.in_memory_cache:
print("[UNet] in_memory_cache=enabled (note: memory use scales with loader workers)")
segmenter.prebuild_in_memory_cache(
cache_workers=max(0, int(args.cache_workers)),
include_train=bool(args.train),
include_val=bool(args.train),
include_holdout=bool(args.evaluate),
)
if args.train:
segmenter.train(
epochs=args.epochs,
batch_size=args.batch_size,
lr=args.lr,
weight_decay=args.weight_decay,
checkpoint_dir=args.checkpoint_dir,
)
if args.evaluate:
if not args.train:
ckpt = args.weights or (args.checkpoint_dir / "best.pt")
if ckpt and ckpt.exists():
state = torch.load(ckpt, map_location=segmenter.device)
state_dict = state.get("model", state)
segmenter.model.load_state_dict(state_dict, strict=False)
print(f"Loaded weights from {ckpt}")
else:
print(f"[warn] No checkpoint found at {ckpt}. Evaluating untrained weights.")
split_filter = {"holdout"} if args.eval_splits is None else args.eval_splits
segmenter.evaluate_dataset(
dataset_filter=args.eval_datasets,
split_filter=split_filter,
output_dir=args.eval_output,
save_overlays=not args.no_eval_overlays,
metrics_path=args.eval_metrics_path,
threshold=args.threshold,
tta=args.tta,
)
if __name__ == "__main__":
main()
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""CLI wrapper that delegates to classes.frontend.Multifold."""
from pathlib import Path
import sys
# ensure repo root on path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.frontend import Multifold
def run_cli(cli_args=None):
parser = Multifold.build_parser()
args = parser.parse_args(cli_args)
runner = Multifold(args)
runner.run()
def main():
run_cli()
if __name__ == "__main__":
main()
-431
View File
@@ -1,431 +0,0 @@
#!/usr/bin/env python3
"""
Grid-search runner for run_multifold experiments.
Features:
* Enumerates the requested configuration grid and writes grid_plan.csv.
* Picks the next incomplete run, marks it running, executes run_multifold.py.
* Records AUC/accuracy metrics per fold into grid_report.csv.
* Removes model checkpoints for runs dominated (80%+ metrics worse) by others.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import fcntl
REPO_ROOT = Path(__file__).resolve().parents[2]
RUN_SCRIPT = REPO_ROOT / "scripts" / "run_multifold.py"
MANIFEST = REPO_ROOT / "manifest.csv"
GRID_DIR = REPO_ROOT / "analysis_data" / "grid_search"
PLAN_PATH = GRID_DIR / "grid_plan.csv"
REPORT_PATH = GRID_DIR / "grid_report.csv"
LOCK_PATH = GRID_DIR / ".grid_lock"
MODELS_ROOT = REPO_ROOT / "models" / "grid_search"
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description="Grid-search orchestrator for run_multifold.")
ap.add_argument("--plan-date", default=datetime.now().strftime("%Y%m%d"),
help="Date prefix used when generating run IDs (default: today).")
ap.add_argument("--regen-plan", action="store_true",
help="Rebuild the grid plan from scratch (overwrites existing plan).")
ap.add_argument("--manifest", type=Path, default=MANIFEST,
help="UNet manifest CSV for cropper.")
ap.add_argument("--weights-dir", type=Path, default=REPO_ROOT / "models" / "unet_segmenter",
help="Directory containing norm_* subfolders with best.pt.")
ap.add_argument("--dry-run", action="store_true", help="Enumerate next run without executing.")
ap.add_argument("--max-runs", type=int, default=1,
help="Maximum runs to execute in this invocation (default: 1).")
ap.add_argument("--run-all", action="store_true",
help="Execute runs sequentially until plan is exhausted (overrides --max-runs).")
return ap.parse_args()
@contextmanager
def file_lock(lock_path: Path):
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
def read_csv(path: Path) -> List[Dict[str, str]]:
if not path.exists():
return []
with path.open(newline="") as fh:
reader = csv.DictReader(fh)
return list(reader)
def write_csv(path: Path, rows: List[Dict[str, str]], headers: List[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=headers)
writer.writeheader()
for row in rows:
writer.writerow(row)
def grid_configs(base_date: str, weights_dir: Path) -> List[Dict[str, str]]:
eval_modes = ["binary", "multiclass"]
crop_variants = [
("norm_imagenet", "imagenet"),
("normalize_none", "none"),
("norm_per_image", "per_image"),
]
tta_opts = [False, True]
loss_modes = ["focal", "balanced", "none"]
thaw_modes = ["none", "gradual"]
se_configs = []
# none
se_configs.append(("none", {"se_enabled": False}))
# bridge only
for pre in (True, False):
se_configs.append((
"bridge",
{"se_enabled": True, "se_where": "bridge", "bridge_pre_norm": pre, "tower_pre_norm": None},
))
# tower only
for pre in (True, False):
se_configs.append((
"tower",
{"se_enabled": True, "se_where": "tower", "bridge_pre_norm": None, "tower_pre_norm": pre},
))
# both (four combos)
for b_pre in (True, False):
for t_pre in (True, False):
se_configs.append((
"both",
{
"se_enabled": True,
"se_where": "both",
"bridge_pre_norm": b_pre,
"tower_pre_norm": t_pre,
},
))
combos = []
idx = 0
for eval_mode in eval_modes:
for variant, norm in crop_variants:
weights_path = weights_dir / variant / "best.pt"
for tta in tta_opts:
for loss in loss_modes:
for thaw in thaw_modes:
for se_name, se_opts in se_configs:
run_id = f"{base_date}-{idx:04d}"
combos.append({
"run_id": run_id,
"status": "incomplete",
"eval_mode": eval_mode,
"crop_variant": variant,
"crop_normalize": norm,
"crop_weights": str(weights_path),
"crop_tta": str(tta),
"loss_mode": loss,
"thaw_mode": thaw,
"se_mode": se_name,
"se_bridge_pre_norm": str(se_opts.get("bridge_pre_norm")),
"se_tower_pre_norm": str(se_opts.get("tower_pre_norm")),
})
idx += 1
return combos
PLAN_HEADERS = [
"run_id",
"status",
"eval_mode",
"crop_variant",
"crop_normalize",
"crop_weights",
"crop_tta",
"loss_mode",
"thaw_mode",
"se_mode",
"se_bridge_pre_norm",
"se_tower_pre_norm",
]
def ensure_plan(args: argparse.Namespace) -> None:
if args.regen_plan or not PLAN_PATH.exists():
combos = grid_configs(args.plan_date, args.weights_dir)
write_csv(PLAN_PATH, combos, PLAN_HEADERS)
print(f"[grid] Plan created with {len(combos)} runs at {PLAN_PATH}")
def select_next_run() -> Optional[Dict[str, str]]:
rows = read_csv(PLAN_PATH)
for row in rows:
if row["status"] == "incomplete":
row["status"] = "running"
write_csv(PLAN_PATH, rows, PLAN_HEADERS)
return row
return None
def update_run_status(run_id: str, new_status: str) -> None:
rows = read_csv(PLAN_PATH)
for row in rows:
if row["run_id"] == run_id:
row["status"] = new_status
break
write_csv(PLAN_PATH, rows, PLAN_HEADERS)
def build_run_command(row: Dict[str, str], manifest: Path) -> List[str]:
cmd = [
sys.executable,
str(RUN_SCRIPT),
"--backbone",
"resnet50",
"--fusion-mode",
"fused",
"--epochs",
"40",
"--batch-size",
"8",
"--img-crop-manifest",
str(manifest),
"--img-crop-weights",
row["crop_weights"],
"--img-crop-normalize",
row["crop_normalize"],
"--eval_mode",
row["eval_mode"],
"--holdout-per-class",
"12",
"--run-id",
row["run_id"],
"--shortname",
"grid_search",
]
if row["crop_tta"] == "True":
cmd.append("--img-crop-tta")
# Loss/balancing modes
if row["loss_mode"] == "focal":
cmd.extend(["--focal-gamma", "2.0"])
elif row["loss_mode"] == "balanced":
cmd.append("--balanced-sampler")
# Thaw schedule
if row["thaw_mode"] == "gradual":
cmd.append("--gradual-thaw")
cmd.extend(["--thaw-ratio", "0.33"])
cmd.extend(["--thaw-start-epoch", "10"])
cmd.extend(["--thaw-target", "image"])
# SE settings
if row["se_mode"] == "none":
cmd.append("--no-se")
else:
cmd.extend(["--se-reduction", "16"])
cmd.extend(["--se-reduction-tower", "16"])
cmd.extend(["--se-where", row["se_mode"]])
bridge_pre = row["se_bridge_pre_norm"]
tower_pre = row["se_tower_pre_norm"]
if bridge_pre == "True":
cmd.append("--se-pre-norm")
elif bridge_pre == "False":
cmd.append("--no-se-pre-norm")
if tower_pre == "True":
cmd.append("--se-pre-norm-tower")
elif tower_pre == "False":
cmd.append("--no-se-pre-norm-tower")
return cmd
def run_command(cmd: List[str]) -> None:
print("[grid] Launching:", " ".join(cmd))
subprocess.run(cmd, check=True)
METRIC_KEYS = ["auc_fused", "auc_img", "auc_md", "acc_fused", "acc_img", "acc_md"]
def extract_metrics(run_id: str) -> Dict[str, str]:
summary_path = REPO_ROOT / "analysis_data" / "grid_search" / run_id / "summary.json"
if not summary_path.exists():
raise FileNotFoundError(f"Missing summary.json for run {run_id}")
with summary_path.open() as fh:
summary = json.load(fh)
rows = {}
for fold in summary.get("fold_metrics", []):
if not isinstance(fold, dict):
continue
f_idx = fold.get("fold")
stats = fold.get("stats") or {}
if not isinstance(stats, dict):
continue
for key in METRIC_KEYS:
val = stats.get(key)
if val is None:
continue
rows[f"metric_fold{f_idx}_{key}"] = str(val)
best_mean = summary.get("best_metric_mean")
if best_mean is not None:
rows["metric_best_mean"] = str(best_mean)
return rows
def update_report(row: Dict[str, str], metrics: Dict[str, str]) -> None:
existing = read_csv(REPORT_PATH)
# Remove existing entry for run_id
existing = [r for r in existing if r.get("run_id") != row["run_id"]]
record = {**row, **metrics}
existing.append(record)
headers = sorted({key for r in existing for key in r.keys()})
write_csv(REPORT_PATH, existing, headers)
def load_report_rows() -> List[Dict[str, str]]:
return read_csv(REPORT_PATH)
def metric_columns(rows: List[Dict[str, str]]) -> List[str]:
keys = set()
for row in rows:
for key in row:
if key.startswith("metric_"):
keys.add(key)
return sorted(keys)
def _to_float(val: str) -> Optional[float]:
try:
f = float(val)
if math.isnan(f):
return None
return f
except Exception:
return None
def prune_dominated(rows: List[Dict[str, str]]) -> None:
"""
Remove model directories for runs that are clearly dominated by another run.
A run is dominated if:
* Another run has a strictly higher metric_best_mean, OR
* Another run is >= on >=80% of overlapping metrics and strictly better on at least one.
"""
metrics = metric_columns(rows)
if not metrics:
return
dominated = set()
for row in rows:
run_id = row["run_id"]
row_vals = {m: row.get(m) for m in metrics}
row_best = _to_float(row_vals.get("metric_best_mean"))
for other in rows:
if other["run_id"] == run_id:
continue
other_vals = {m: other.get(m) for m in metrics}
other_best = _to_float(other_vals.get("metric_best_mean"))
# Fast path: compare aggregate best mean if both have it
if row_best is not None and other_best is not None and other_best > row_best:
dominated.add(run_id)
break
# Fallback: overlap-wise dominance
comparisons = []
better = 0
for key in metrics:
v1 = _to_float(row_vals.get(key))
v2 = _to_float(other_vals.get(key))
if v1 is None or v2 is None:
continue
comparisons.append(v2 >= v1)
if v2 > v1:
better += 1
if not comparisons:
continue
fraction = sum(comparisons) / len(comparisons)
if fraction >= 0.8 and better > 0:
dominated.add(run_id)
break
for run_id in dominated:
model_dir = MODELS_ROOT / run_id
if model_dir.exists():
print(f"[grid] Removing dominated model artifacts for {run_id}")
try:
shutil.rmtree(model_dir)
except OSError as exc:
# Don't fail the grid run if cleanup isn't permitted (e.g., locked SMB dirs).
print(f"[grid] Warning: could not remove {model_dir}: {exc}")
def main():
args = parse_args()
ensure_plan(args)
if args.dry_run:
with file_lock(LOCK_PATH):
next_run = select_next_run()
if next_run is None:
print("[grid] No incomplete runs remaining.")
return
update_run_status(next_run["run_id"], "incomplete")
print("[grid] Next run:", next_run)
return
max_runs = None if args.run_all else args.max_runs
runs_done = 0
while True:
with file_lock(LOCK_PATH):
next_run = select_next_run()
if next_run is None:
if runs_done == 0:
print("[grid] All runs completed.")
else:
print(f"[grid] No more runs remaining after {runs_done} run(s).")
return
run_id = next_run["run_id"]
try:
cmd = build_run_command(next_run, args.manifest)
run_command(cmd)
metrics = extract_metrics(run_id)
with file_lock(LOCK_PATH):
update_run_status(run_id, "completed")
update_report(next_run, metrics)
report_rows = load_report_rows()
prune_dominated(report_rows)
print(f"[grid] Run {run_id} completed.")
except Exception as exc:
with file_lock(LOCK_PATH):
update_run_status(run_id, "incomplete")
raise SystemExit(f"[grid] Run {run_id} failed: {exc}") from exc
runs_done += 1
if max_runs is not None and runs_done >= max_runs:
print(f"[grid] Reached run limit ({max_runs}); stopping.")
return
if __name__ == "__main__":
main()
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""Thin CLI wrapper that runs V2 hypertower modes sequentially."""
from __future__ import annotations
from pathlib import Path
import sys
import argparse
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.v2_hypertower import V2HyperTower
def parse_args():
ap = argparse.ArgumentParser(
description="Run selected eval/tower mode combinations sequentially."
)
ap.add_argument(
"--eval-modes",
nargs="+",
choices=["binary", "multiclass"],
default=["binary", "multiclass"],
)
ap.add_argument(
"--tower-modes",
nargs="+",
choices=["single", "ensemble", "bilateral", "classic"],
default=["single", "ensemble", "bilateral"],
)
return ap.parse_known_args()
def main():
seq_args, remaining = parse_args()
base_parser = V2HyperTower.build_parser()
first_run = True
for eval_mode in seq_args.eval_modes:
for tower_mode in seq_args.tower_modes:
tower_mode = "single" if tower_mode == "classic" else tower_mode
cli = list(remaining) + ["--eval-mode", eval_mode, "--tower-mode", tower_mode]
# Clear cache only on the first run; reuse it for all subsequent runs.
if not first_run:
cli.append("--persist-img-crop-cache")
args = base_parser.parse_args(cli)
# Skip if this mode is already fully complete.
if args.run_name:
tm_dir = Path(args.output_root) / args.run_name / eval_mode / tower_mode
if (tm_dir / "summary.json").exists():
print(f"[compare] {eval_mode}:{tower_mode} already complete — skipping.")
first_run = False # treat as done so cache is preserved for later runs
continue
V2HyperTower(args).run()
first_run = False
if __name__ == "__main__":
main()
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
10× repeated 5-fold CV runner for the best hypertower configuration
(nocrop, ensemble mode, both binary and multiclass).
Each repetition uses a different fold-seed so the 5 folds are split
differently, giving 50 folds per eval-mode total. Holdout composition
is kept identical across repetitions (same --holdout-seed).
Results land under:
{output-root}/rep{N:02d}/{eval_mode}/ensemble/fold{K}/
Usage
-----
python scripts/main/v2/run_10x5cv.py \
--n-reps 10 \
--eval-modes binary multiclass \
--output-root analysis_data/pipeline_10x5 \
--epochs 40 --fused-head \
--backbone refugelike
Any extra flags are forwarded directly to V2HyperTower.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.v2_hypertower import V2HyperTower
# Base fold seed for rep 0; rep N uses BASE_SEED + N * SEED_STRIDE
_BASE_SEED = 100
_SEED_STRIDE = 100
def _parse_own(argv=None):
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False,
)
ap.add_argument("--n-reps", type=int, default=10,
help="Number of repetitions (default: 10).")
ap.add_argument("--eval-modes", nargs="+",
choices=["binary", "multiclass"],
default=["binary", "multiclass"])
ap.add_argument("--output-root", default="analysis_data/pipeline_10x5",
help="Parent directory for all rep sub-runs.")
ap.add_argument("-h", "--help", action="store_true")
return ap.parse_known_args(argv)
def main(argv=None):
own, remaining = _parse_own(argv)
if own.help:
print(__doc__)
base_parser = V2HyperTower.build_parser()
base_parser.print_help()
return
base_parser = V2HyperTower.build_parser()
output_root = Path(own.output_root)
first_run = True
for rep in range(own.n_reps):
fold_seed = _BASE_SEED + rep * _SEED_STRIDE
rep_label = f"rep{rep:02d}"
for eval_mode in own.eval_modes:
tower_mode = "ensemble"
# Skip if already fully complete
tm_dir = output_root / rep_label / eval_mode / tower_mode
if (tm_dir / "summary.json").exists():
print(f"[10x5cv] {rep_label} {eval_mode}:{tower_mode} — already done, skipping.")
first_run = False
continue
cli = list(remaining) + [
"--eval-mode", eval_mode,
"--tower-mode", tower_mode,
"--fold-seed", str(fold_seed),
"--run-name", rep_label,
"--output-root", str(output_root),
]
# Reuse crop cache across runs after the first
if not first_run:
cli.append("--persist-img-crop-cache")
print(f"\n[10x5cv] Starting {rep_label} {eval_mode}:{tower_mode} "
f"(fold_seed={fold_seed})")
args = base_parser.parse_args(cli)
V2HyperTower(args).run()
first_run = False
print(f"\n[10x5cv] All done. Results in: {output_root}")
if __name__ == "__main__":
main()
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""
Run single-mode binary + multiclass for each IOP correction method and
collect all results under one output root for easy comparison.
Output layout:
analysis_data/iop_corr_comparison/
ratio/binary/single/ ratio/multiclass/single/
ols/binary/single/ ols/multiclass/single/
lad/binary/single/ lad/multiclass/single/
multi/binary/single/ multi/multiclass/single/
Usage
-----
python scripts/main/v2/run_iop_corr_comparison.py [V2HyperTower args...]
Any extra args (backbone, epochs, img-crop-*, etc.) are forwarded to every run.
"""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.v2_hypertower import V2HyperTower
IOP_METHODS = ["ratio", "ols", "lad", "multi"]
EVAL_MODES = ["binary", "multiclass"]
OUTPUT_ROOT = "analysis_data/iop_corr_comparison"
def main() -> None:
base_parser = V2HyperTower.build_parser()
# Consume only the remaining (forwarded) args — iop-corr-method and
# run-name are set by this script; eval-mode and tower-mode likewise.
_, remaining = base_parser.parse_known_args()
first_run = True
for method in IOP_METHODS:
for eval_mode in EVAL_MODES:
run_name = method # one sub-folder per method
tm_dir = (Path(OUTPUT_ROOT) / run_name / eval_mode / "single")
if (tm_dir / "summary.json").exists():
print(f"[iop_corr] {method}/{eval_mode}/single — already done, skipping.")
first_run = False
continue
cli = list(remaining) + [
"--eval-mode", eval_mode,
"--tower-mode", "single",
"--iop-corr-method", method,
"--output-root", OUTPUT_ROOT,
"--run-name", run_name,
]
if not first_run:
cli.append("--persist-img-crop-cache")
print(f"\n[iop_corr] Starting {method}/{eval_mode}/single ...")
args = base_parser.parse_args(cli)
V2HyperTower(args).run()
first_run = False
# ── summary table ──────────────────────────────────────────────────────
import json
print("\n" + "=" * 60)
print("IOP correction method comparison — single mode")
print("=" * 60)
header = f"{'Method':<8} {'Mode':<12} {'Val AUC':>10} {'Hld AUC':>10}"
print(header)
print("-" * len(header))
for method in IOP_METHODS:
for eval_mode in EVAL_MODES:
p = Path(OUTPUT_ROOT) / method / eval_mode / "single" / "summary.json"
if not p.exists():
print(f"{method:<8} {eval_mode:<12} {'missing':>10} {'missing':>10}")
continue
ms = json.loads(p.read_text()).get("mode_summary", {})
val = ms.get("classic_best_val", {})
hld = ms.get("classic_holdout", {})
val_s = f"{val['auc_mean']:.3f}±{val['auc_std']:.3f}" if val.get("auc_mean") else ""
hld_s = f"{hld['auc_mean']:.3f}±{hld['auc_std']:.3f}" if hld.get("auc_mean") else ""
print(f"{method:<8} {eval_mode:<12} {val_s:>10} {hld_s:>10}")
print("=" * 60)
if __name__ == "__main__":
main()
-484
View File
@@ -1,484 +0,0 @@
#!/usr/bin/env python3
"""
V2-parity metadata-only runner.
Goal:
- Match V2HyperTower single-model metadata-only behavior as closely as possible.
- Avoid image tower/image IO overhead in forward/training.
How parity is achieved:
- Uses PatientFirstSplitManager (same split policy).
- Uses PAPILA profile builders + V2 filters:
- eye_train = filter_eye_samples(...)
- bilat_val/test = filter_bilateral_samples(...)
- Uses V2 training/eval helpers directly:
- train_single_epoch(...)
- collect_probs_single_components(...)
- Uses bridge_mode="metadata_only".
Implementation detail:
- Batch dictionaries still include image slots to satisfy shared V2 helpers,
but these are tiny dummy tensors and are never consumed in metadata-only mode.
Outputs:
analysis_data/{run_name}/{eval_mode}/{tower_mode}/fold{N}/
y_true.npy
probs_classic.npy or probs_ensemble.npy
y_true_holdout.npy
probs_classic_holdout.npy or probs_ensemble_holdout.npy
"""
from __future__ import annotations
import argparse
import copy
import json
import sys
import time
from pathlib import Path
from types import SimpleNamespace
# ensure repo root is on sys.path when run directly
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from classes.v2.bridges import Bridge
from classes.v2.loader_factory import (
build_balanced_sampler,
filter_bilateral_samples,
filter_eye_samples,
)
from classes.v2.metrics import _score_arrays
from classes.v2.models import collect_probs_single_components, train_single_epoch
from classes.v2.papila_builders import build_papila_data
from classes.v2.profiles import build_papila_profile
from classes.v2.split_manager import PatientFirstSplitManager
from classes.v2.towers import MDTower
from classes.v2.utils import choose_device, seed_everything
class MetadataOnlySingleHT(nn.Module):
"""SingleEyeHT-compatible shell without real image tower usage."""
def __init__(
self,
*,
clinical_data,
num_classes: int,
md_hidden_dim: int,
fusion_dim: int,
dropout: float,
use_se: bool,
se_reduction: int,
se_pre_norm: bool,
):
super().__init__()
# Placeholder module to satisfy phase toggling logic.
self.img_tower = nn.Identity()
self.md_tower = MDTower(
clinical_data=clinical_data,
hidden_dim=md_hidden_dim,
dropout=dropout,
use_se=use_se,
se_reduction=se_reduction,
se_pre_norm=se_pre_norm,
)
# img_dim is irrelevant in metadata_only mode, but Bridge defines img head params.
self.bridge = Bridge(
img_dim=1,
meta_dim=self.md_tower.out_dim,
num_classes=num_classes,
fusion_dim=fusion_dim,
mode="metadata_only",
use_se=False,
se_reduction=16,
se_pre_norm=True,
)
class EyeMetaDataset(Dataset):
"""Eye-level dataset for V2 train_single_epoch input contract."""
def __init__(self, samples: list[dict]):
self.samples = samples
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
s = self.samples[idx]
return {
"image_1": torch.zeros(1, dtype=torch.float32),
"matrix_1": torch.as_tensor(s["matrix_1"], dtype=torch.float32),
"label_1": torch.tensor(int(s["label_1"]), dtype=torch.long),
}
class BilatMetaDataset(Dataset):
"""Patient-level bilateral dataset for collect_probs_single_components."""
def __init__(self, samples: list[dict]):
self.samples = samples
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
s = self.samples[idx]
return {
"image_1": torch.zeros(1, dtype=torch.float32),
"image_2": torch.zeros(1, dtype=torch.float32),
"matrix_1": torch.as_tensor(s["matrix_1"], dtype=torch.float32),
"matrix_2": torch.as_tensor(s["matrix_2"], dtype=torch.float32),
"label_1": torch.tensor(int(s["label_1"]), dtype=torch.long),
}
def _phase_for_epoch(epoch_idx: int, warm_tower: int, warm_fused: int, main_epochs: int) -> tuple[str, int]:
if epoch_idx < warm_tower:
return "tower_warmup", 0
if epoch_idx < (warm_tower + warm_fused):
return "fused_warmup", 0
if epoch_idx < (warm_tower + warm_fused + main_epochs):
main_ep = epoch_idx - warm_tower - warm_fused + 1
return "main", main_ep
return "done", main_epochs
def _evaluate_single(
model: nn.Module,
loader: DataLoader,
device: torch.device,
num_classes: int,
aggregate_patient: bool,
) -> tuple[np.ndarray, np.ndarray, float, float]:
y, p_fused, _, p_md = collect_probs_single_components(
model, loader, device, aggregate_patient=aggregate_patient
)
# In metadata_only mode p_fused == p_md; keep md explicitly for clarity.
probs = p_md if p_md.size else p_fused
acc, auc, _ = _score_arrays(y, probs, num_classes)
return y, probs, float(auc), float(acc)
def main() -> None:
ap = argparse.ArgumentParser(description="V2-parity metadata-only runner.")
ap.add_argument("--eval-mode", required=True, choices=["binary", "multiclass"])
ap.add_argument("--tower-mode", default="single", choices=["single", "ensemble"])
ap.add_argument("--run-name", required=True)
ap.add_argument("--epochs", type=int, default=40, help="Main-phase epochs.")
ap.add_argument("--warmup-tower-epochs", type=int, default=None)
ap.add_argument("--warmup-fused-epochs", type=int, default=None)
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--weight-decay", type=float, default=0.0)
ap.add_argument("--bcd-prob", type=float, default=0.5)
ap.add_argument("--md-hidden-dim", type=int, default=128)
ap.add_argument("--fusion-dim", type=int, default=256)
ap.add_argument("--dropout", type=float, default=0.1)
ap.add_argument("--use-se", action="store_true")
ap.add_argument("--se-reduction", type=int, default=16)
ap.add_argument("--se-pre-norm", action="store_true")
ap.add_argument("--n-splits", type=int, default=5)
ap.add_argument("--holdout-per-class", type=int, default=5)
ap.add_argument("--holdout-seed", type=int, default=123)
ap.add_argument("--fold-seed", type=int, default=42)
ap.add_argument("--seed", type=int, default=1234)
ap.add_argument("--balanced-sampling", action=argparse.BooleanOptionalAction, default=False)
ap.add_argument("--analysis-dir", default="analysis_data")
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("--patient-col", default="Patient ID")
ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"])
args = ap.parse_args()
seed_everything(args.seed)
device = choose_device(None)
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)
num_classes = 2 if args.eval_mode == "binary" else 3
df_mode = data.df.copy()
if args.eval_mode == "binary":
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
print(f"[{args.eval_mode}] rows={len(df_mode)}", flush=True)
class _ClinicalShim:
label_col = args.label_col
def __init__(self, df):
self.df = df
split_args = SimpleNamespace(
eval_mode=args.eval_mode,
holdout_per_class=args.holdout_per_class,
holdout_seed=args.holdout_seed,
n_splits=args.n_splits,
fold_seed=args.fold_seed,
)
splitter = PatientFirstSplitManager(patient_col=args.patient_col, label_col=args.label_col)
plans = splitter.build_plans(clinical=_ClinicalShim(df_mode), args=split_args)
profile_eye = build_papila_profile(
patient_col=args.patient_col,
label_col=args.label_col,
sample_mode="eye",
)
profile_patient = build_papila_profile(
patient_col=args.patient_col,
label_col=args.label_col,
sample_mode="patient",
)
warm_tower = int(args.warmup_tower_epochs) if args.warmup_tower_epochs is not None else 2
warm_fused = int(args.warmup_fused_epochs) if args.warmup_fused_epochs is not None else 2
total_epochs = warm_tower + warm_fused + int(args.epochs)
out_root = Path(args.analysis_dir) / args.run_name / args.eval_mode / args.tower_mode
out_root.mkdir(parents=True, exist_ok=True)
fold_metrics = []
aggregate_patient = args.tower_mode == "ensemble"
for fold_idx, split in enumerate(plans[: args.n_splits]):
fold_dir = out_root / f"fold{fold_idx}"
fold_dir.mkdir(parents=True, exist_ok=True)
eye_train = filter_eye_samples(profile_eye.build_samples(df=split.train, clinical=data))
bilat_val = filter_bilateral_samples(profile_patient.build_samples(df=split.val, clinical=data))
holdout_bilat = []
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 not eye_train or not bilat_val:
print(f"[fold {fold_idx+1}] skipped (eye_train={len(eye_train)} bilat_val={len(bilat_val)})", flush=True)
fold_metrics.append(
{
"fold": fold_idx,
"best_epoch": None,
"best_phase": None,
"val_auc": float("nan"),
"val_acc": float("nan"),
"hld_auc": float("nan"),
"hld_acc": float("nan"),
"eye_train_n": len(eye_train),
"bilat_val_n": len(bilat_val),
"bilat_holdout_n": len(holdout_bilat),
}
)
continue
sampler = build_balanced_sampler(eye_train) if args.balanced_sampling else None
train_loader = DataLoader(
EyeMetaDataset(eye_train),
batch_size=args.batch_size,
shuffle=(sampler is None),
sampler=sampler,
)
val_loader = DataLoader(BilatMetaDataset(bilat_val), batch_size=args.batch_size, shuffle=False)
holdout_loader = (
DataLoader(BilatMetaDataset(holdout_bilat), batch_size=args.batch_size, shuffle=False)
if holdout_bilat
else None
)
model = MetadataOnlySingleHT(
clinical_data=data,
num_classes=num_classes,
md_hidden_dim=args.md_hidden_dim,
fusion_dim=args.fusion_dim,
dropout=args.dropout,
use_se=bool(args.use_se),
se_reduction=int(args.se_reduction),
se_pre_norm=bool(args.se_pre_norm),
).to(device)
opt = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
best_auc = -1.0
best_epoch = 0
best_phase = ""
best_state = None
epoch_log_rows = []
print(
f"\n[fold {fold_idx+1}/{args.n_splits}] "
f"eye_train_n={len(eye_train)} bilat_val_n={len(bilat_val)} "
f"holdout_n={len(holdout_bilat)} warmup={warm_tower}+{warm_fused} total={total_epochs}",
flush=True,
)
for ep in range(total_epochs):
phase, main_ep = _phase_for_epoch(ep, warm_tower, warm_fused, int(args.epochs))
tr_loss, tr_acc = train_single_epoch(
model,
train_loader,
opt,
device,
phase=phase,
bcd_prob=float(args.bcd_prob),
)
_, p_val, val_auc, val_acc = _evaluate_single(
model,
val_loader,
device,
num_classes,
aggregate_patient=aggregate_patient,
)
hld_auc_ep = float("nan")
hld_acc_ep = float("nan")
if holdout_loader is not None:
_, _, hld_auc_ep, hld_acc_ep = _evaluate_single(
model, holdout_loader, device, num_classes,
aggregate_patient=aggregate_patient,
)
is_main = phase == "main"
if is_main and (not np.isnan(val_auc)) and val_auc > best_auc:
best_auc = float(val_auc)
best_state = copy.deepcopy(model.state_dict())
best_epoch = ep + 1
best_phase = phase
epoch_log_rows.append({
"epoch": ep + 1,
"phase": phase,
"train_loss": float(tr_loss),
"train_acc": float(tr_acc),
"val_auc": float(val_auc),
"val_acc": float(val_acc),
"hld_auc": float(hld_auc_ep),
"hld_acc": float(hld_acc_ep),
})
if ep == 0 or (ep + 1) % 10 == 0 or (ep + 1) == total_epochs:
print(
f" ep {ep+1:>3}/{total_epochs} [{phase}:{main_ep}/{args.epochs}] "
f"loss={tr_loss:.4f} acc={tr_acc:.4f} "
f"val_auc={val_auc:.4f} val_acc={val_acc:.4f} "
f"best_auc={best_auc:.4f}",
flush=True,
)
import pandas as _pd
_pd.DataFrame(epoch_log_rows).to_csv(fold_dir / "epoch_log.csv", index=False)
if best_state is not None:
model.load_state_dict(best_state)
y_val, p_val, val_auc, val_acc = _evaluate_single(
model,
val_loader,
device,
num_classes,
aggregate_patient=aggregate_patient,
)
if args.tower_mode == "single":
probs_name = "probs_classic.npy"
probs_h_name = "probs_classic_holdout.npy"
else:
probs_name = "probs_ensemble.npy"
probs_h_name = "probs_ensemble_holdout.npy"
np.save(fold_dir / "y_true.npy", y_val)
np.save(fold_dir / probs_name, p_val)
hld_auc = float("nan")
hld_acc = float("nan")
if holdout_loader is not None:
y_h, p_h, hld_auc, hld_acc = _evaluate_single(
model,
holdout_loader,
device,
num_classes,
aggregate_patient=aggregate_patient,
)
np.save(fold_dir / "y_true_holdout.npy", y_h)
np.save(fold_dir / probs_h_name, p_h)
print(
f" [fold {fold_idx+1}] best_epoch={best_epoch} best_auc={best_auc:.4f} "
f"val_auc={val_auc:.4f} val_acc={val_acc:.4f} "
f"hld_auc={hld_auc:.4f} hld_acc={hld_acc:.4f}",
flush=True,
)
fold_metrics.append(
{
"fold": fold_idx,
"best_epoch": best_epoch,
"best_phase": best_phase,
"val_auc": float(val_auc),
"val_acc": float(val_acc),
"hld_auc": float(hld_auc),
"hld_acc": float(hld_acc),
"eye_train_n": len(eye_train),
"bilat_val_n": len(bilat_val),
"bilat_holdout_n": len(holdout_bilat),
}
)
val_aucs = [m["val_auc"] for m in fold_metrics if not np.isnan(m["val_auc"])]
hld_aucs = [m["hld_auc"] for m in fold_metrics if not np.isnan(m["hld_auc"])]
if val_aucs:
print(f"\nMean val AUC: {np.mean(val_aucs):.4f} ± {np.std(val_aucs):.4f}", flush=True)
if hld_aucs:
print(f"Mean hld AUC: {np.mean(hld_aucs):.4f} ± {np.std(hld_aucs):.4f}", flush=True)
summary = {
"run_name": args.run_name,
"eval_mode": args.eval_mode,
"tower_mode": args.tower_mode,
"bridge_mode": "metadata_only",
"model": "MetadataOnlySingleHT",
"epochs": int(args.epochs),
"warmup_tower_epochs": warm_tower,
"warmup_fused_epochs": warm_fused,
"md_hidden_dim": int(args.md_hidden_dim),
"fusion_dim": int(args.fusion_dim),
"dropout": float(args.dropout),
"lr": float(args.lr),
"weight_decay": float(args.weight_decay),
"bcd_prob": float(args.bcd_prob),
"balanced_sampling": bool(args.balanced_sampling),
"feature_dim": int(data.feature_dim),
"timestamp": time.strftime("%Y%m%d_%H%M%S"),
"fold_metrics": fold_metrics,
"val_auc_mean": float(np.mean(val_aucs)) if val_aucs else None,
"val_auc_std": float(np.std(val_aucs)) if val_aucs else None,
"hld_auc_mean": float(np.mean(hld_aucs)) if hld_aucs else None,
"hld_auc_std": float(np.std(hld_aucs)) if hld_aucs else None,
}
(out_root / "summary.json").write_text(json.dumps(summary, indent=2))
print(f"\nOutputs written to: {out_root}", flush=True)
if __name__ == "__main__":
main()
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env python3
"""CLI wrapper for the V2 hypertower pipeline using V2HyperTower directly."""
from __future__ import annotations
from pathlib import Path
import sys
# ensure repo root on path
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.v2_hypertower import V2HyperTower
def run_cli(cli_args=None):
parser = V2HyperTower.build_parser()
args = parser.parse_args(cli_args)
V2HyperTower(args).run()
def main():
run_cli()
if __name__ == "__main__":
main()
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
"""CLI wrapper for the V2 three-mode comparison (classic/ensemble/bilateral)."""
from __future__ import annotations
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.v2_hypertower import V2ModeComparator
def main():
V2ModeComparator.run()
if __name__ == "__main__":
main()
-496
View File
@@ -1,496 +0,0 @@
#!/usr/bin/env python3
"""
Aggregate and visualise results from a 10× repeated 5-fold CV run.
Reads probs / y_true from every rep/fold directory, computes per-fold
metrics, and produces:
outputs/
fold_metrics.csv one row per (rep, fold, eval_mode)
rep_metrics.csv one row per (rep, eval_mode): mean over 5 folds
overall_summary.txt mean ± SD and 95% CI printed to console + file
{eval_mode}_auc_violin.png
{eval_mode}_roc_mean.png mean ± 1 SD OVR ROC (all classes or class 1)
{eval_mode}_holdout_roc_mean.png
Holdout metrics are extracted from the rep-level predictions.npz using the
best_epoch recorded in summary.json, ensemble-averaged over od_fused + os_fused
heads, giving 50 fold-level holdout AUC values (5 folds × 10 reps).
Usage
-----
python scripts/output_analysis/aggregate_10x5cv.py \
--run-root analysis_data/pipeline_10x5 \
--eval-modes binary multiclass \
--out analysis_data/pipeline_10x5/aggregate
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score, accuracy_score, roc_curve, auc as sk_auc
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
_CLASS_NAMES = {
"binary": ["Healthy", "Glaucoma"],
"multiclass": ["Healthy", "Glaucoma", "Suspect"],
}
# Probe files in preference order (first found wins)
# probs_fused = simple OD/OS softmax average (ensemble head — primary metric)
# probs_fused_head = learned logit-level fusion head (worse on average; kept as fallback)
_PROBS_PRIORITY = ["probs_fused.npy", "probs_fused_head.npy"]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_probs(fold_dir: Path) -> Path | None:
for name in _PROBS_PRIORITY:
p = fold_dir / name
if p.exists():
return p
return None
def _load_holdout_summary(mode_dir: Path) -> dict | None:
"""
Read rep-level holdout metrics from summary.json.
Returns dict with keys auc_mean, auc_std, acc_mean (may be None if missing).
"""
summary_path = mode_dir / "summary.json"
if not summary_path.exists():
return None
try:
summary = json.loads(summary_path.read_text())
return summary.get("mode_summary", {}).get("ensemble_holdout")
except Exception:
return None
def _auc_macro(y: np.ndarray, p: np.ndarray, num_classes: int) -> float:
try:
if num_classes == 2:
return float(roc_auc_score(y, p[:, 1]))
return float(roc_auc_score(y, p, multi_class="ovr", average="macro"))
except Exception:
return float("nan")
def _per_class_roc(y: np.ndarray, p: np.ndarray) -> dict[int, dict]:
out: dict[int, dict] = {}
for k in range(p.shape[1]):
yb = (y == k).astype(np.uint8)
if yb.sum() == 0 or yb.sum() == len(yb):
continue
fpr, tpr, _ = roc_curve(yb, p[:, k])
out[k] = {"fpr": fpr, "tpr": tpr, "auc": sk_auc(fpr, tpr)}
return out
def _ci95(values: np.ndarray) -> tuple[float, float]:
"""95% CI via t-distribution (two-sided)."""
from scipy import stats as scipy_stats
if len(values) < 2:
return (float("nan"), float("nan"))
ci = scipy_stats.t.interval(0.95, df=len(values) - 1,
loc=np.mean(values), scale=scipy_stats.sem(values))
return float(ci[0]), float(ci[1])
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_all_folds(run_root: Path, eval_modes: list[str]) -> pd.DataFrame:
rows = []
rep_dirs = sorted(
[d for d in run_root.iterdir() if d.is_dir() and d.name.startswith("rep")],
key=lambda d: d.name,
)
if not rep_dirs:
raise SystemExit(f"No rep* directories found in {run_root}")
for rep_dir in rep_dirs:
for eval_mode in eval_modes:
tower_mode = "ensemble"
mode_dir = rep_dir / eval_mode / tower_mode
if not mode_dir.exists():
print(f" [skip] {mode_dir} not found")
continue
num_classes = 2 if eval_mode == "binary" else 3
fold_dirs = sorted(
[d for d in mode_dir.iterdir() if d.is_dir() and d.name.startswith("fold")],
key=lambda d: int(d.name[4:]),
)
for fold_dir in fold_dirs:
fold_idx = int(fold_dir.name[4:])
y_path = fold_dir / "y_true.npy"
p_path = _find_probs(fold_dir)
if y_path is None or not y_path.exists() or p_path is None:
print(f" [skip] {rep_dir.name}/{eval_mode}/fold{fold_idx}: missing files")
continue
y = np.load(y_path)
p = np.load(p_path)
if eval_mode == "binary":
mask = np.isin(y, [0, 1])
y, p = y[mask], p[mask]
if p.shape[1] > 2:
p = p[:, :2]
auc_macro = _auc_macro(y, p, num_classes)
acc = float(accuracy_score(y, p.argmax(1)))
row = {
"rep": rep_dir.name,
"fold": fold_idx,
"eval_mode": eval_mode,
"probs_file": p_path.name,
"auc_macro": auc_macro,
"acc": acc,
"n": len(y),
}
# Per-class AUC
for k in range(num_classes):
yb = (y == k).astype(np.uint8)
if yb.sum() > 0 and yb.sum() < len(yb):
try:
row[f"auc_class{k}"] = float(roc_auc_score(yb, p[:, k]))
except Exception:
row[f"auc_class{k}"] = float("nan")
else:
row[f"auc_class{k}"] = float("nan")
rows.append(row)
# ---- holdout metrics from rep-level summary.json ----
# Holdout probs are not stored per-fold; only aggregated stats are saved.
# We attach the rep-level mean to each fold row (same value repeated),
# and also add a single rep-level summary row (fold=-1).
hld_summary = _load_holdout_summary(mode_dir)
if hld_summary:
hld_auc = hld_summary.get("auc_mean", float("nan"))
hld_auc_std = hld_summary.get("auc_std", float("nan"))
hld_acc = hld_summary.get("acc_mean", float("nan"))
for row in rows:
if row["rep"] == rep_dir.name and row["eval_mode"] == eval_mode:
row["hld_auc_macro"] = hld_auc
row["hld_acc"] = hld_acc
# Also store a rep-level holdout row (fold=-1) for direct rep-level analysis
rows.append({
"rep": rep_dir.name,
"fold": -1,
"eval_mode": eval_mode,
"probs_file": "summary.json",
"auc_macro": float("nan"),
"acc": float("nan"),
"n": float("nan"),
"hld_auc_macro": hld_auc,
"hld_auc_std_within_rep": hld_auc_std,
"hld_acc": hld_acc,
})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def _violin(fold_df: pd.DataFrame, eval_mode: str, out_dir: Path) -> None:
sub = fold_df[fold_df["eval_mode"] == eval_mode].copy()
num_classes = 2 if eval_mode == "binary" else 3
class_names = _CLASS_NAMES[eval_mode]
auc_cols = ["auc_macro"] + [f"auc_class{k}" for k in range(num_classes)]
labels = ["Macro AUC"] + [f"AUC {class_names[k]}" for k in range(num_classes)]
present = [(c, l) for c, l in zip(auc_cols, labels) if c in sub.columns]
data = [sub[c].dropna().values for c, _ in present]
labels = [l for _, l in present]
fig, ax = plt.subplots(figsize=(max(6, 2 * len(data)), 5))
parts = ax.violinplot(data, showmedians=True, showextrema=True)
for pc in parts["bodies"]:
pc.set_alpha(0.7)
# Overlay individual rep means
rep_means = sub.groupby("rep")[auc_cols[0]].mean().values
ax.scatter(np.ones(len(rep_means)), rep_means, zorder=3,
color="k", s=18, alpha=0.6, label="rep mean")
ax.set_xticks(range(1, len(labels) + 1))
ax.set_xticklabels(labels, rotation=15, ha="right")
ax.set_ylabel("AUC")
ax.set_title(f"AUC distribution — {eval_mode} (10 × 5-fold, n={len(sub)})")
ax.set_ylim(max(0, sub[auc_cols[0]].min() - 0.05), 1.02)
ax.grid(True, axis="y", linewidth=0.4, alpha=0.5)
ax.legend(fontsize=8)
fig.tight_layout()
path = out_dir / f"{eval_mode}_auc_violin.png"
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {path}")
def _mean_roc(fold_df: pd.DataFrame, eval_mode: str,
run_root: Path, out_dir: Path) -> None:
"""Mean ± 1 SD OVR ROC across all 50 folds."""
sub = fold_df[fold_df["eval_mode"] == eval_mode]
num_classes = 2 if eval_mode == "binary" else 3
class_names = _CLASS_NAMES[eval_mode]
# Classes to plot (binary: class 1 only)
plot_classes = [1] if eval_mode == "binary" else list(range(num_classes))
grid = np.linspace(0, 1, 501)
fig, ax = plt.subplots(figsize=(9, 7))
ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey")
for k in plot_classes:
tprs, aucs = [], []
for _, row in sub.iterrows():
rep_dir = run_root / row["rep"]
fold_dir = rep_dir / eval_mode / "ensemble" / f"fold{int(row['fold'])}"
y_path = fold_dir / "y_true.npy"
p_path = _find_probs(fold_dir)
if not y_path.exists() or p_path is None:
continue
y = np.load(y_path)
p = np.load(p_path)
if eval_mode == "binary":
mask = np.isin(y, [0, 1])
y, p = y[mask], p[mask]
if p.shape[1] > 2:
p = p[:, :2]
yb = (y == k).astype(np.uint8)
if yb.sum() == 0 or yb.sum() == len(yb):
continue
fpr, tpr, _ = roc_curve(yb, p[:, k])
tprs.append(np.interp(grid, fpr, tpr))
aucs.append(sk_auc(fpr, tpr))
if not tprs:
continue
arr = np.vstack(tprs)
mean = arr.mean(0)
std = arr.std(0)
cname = class_names[k]
lbl = f"{cname} AUC {np.nanmean(aucs):.3f} ± {np.nanstd(aucs):.3f}"
line, = ax.plot(grid, mean, linewidth=2, label=lbl)
ax.fill_between(grid,
np.clip(mean - std, 0, 1),
np.clip(mean + std, 0, 1),
alpha=0.15, color=line.get_color())
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(f"Mean ± 1 SD OVR ROC — {eval_mode} (10 × 5-fold)")
ax.legend(loc="lower right", fontsize=9)
fig.tight_layout()
path = out_dir / f"{eval_mode}_roc_mean.png"
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {path}")
def _holdout_stability(fold_df: pd.DataFrame, eval_mode: str, out_dir: Path) -> None:
"""Bar chart of per-rep holdout AUC (mean across folds within rep ± within-rep SD)."""
# use the rep-level rows (fold == -1) which have hld_auc_std_within_rep
rep_rows = fold_df[(fold_df["eval_mode"] == eval_mode) & (fold_df["fold"] == -1)].copy()
if rep_rows.empty or "hld_auc_macro" not in rep_rows.columns:
print(f" [skip] no holdout data for {eval_mode}")
return
rep_rows = rep_rows.sort_values("rep")
fig, ax = plt.subplots(figsize=(max(6, len(rep_rows) * 0.9), 4))
x = np.arange(len(rep_rows))
yerr = rep_rows.get("hld_auc_std_within_rep", pd.Series([0]*len(rep_rows))).fillna(0).values
ax.bar(x, rep_rows["hld_auc_macro"].values, yerr=yerr,
capsize=4, color="darkorange", alpha=0.8)
grand_mean = rep_rows["hld_auc_macro"].mean()
ax.axhline(grand_mean, linestyle="--", color="crimson",
linewidth=1.2, label=f"grand mean = {grand_mean:.3f}")
ax.set_xticks(x)
ax.set_xticklabels(rep_rows["rep"].values, rotation=30, ha="right")
ax.set_ylabel("Holdout macro AUC (mean ± within-rep SD)")
ax.set_title(f"Per-rep holdout stability — {eval_mode}")
ymin = max(0, rep_rows["hld_auc_macro"].min() - 0.05)
ax.set_ylim(ymin, 1.02)
ax.legend(fontsize=9)
ax.grid(True, axis="y", linewidth=0.4, alpha=0.5)
fig.tight_layout()
path = out_dir / f"{eval_mode}_holdout_stability.png"
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {path}")
def _rep_stability(fold_df: pd.DataFrame, eval_mode: str, out_dir: Path) -> None:
"""Bar chart of per-rep mean macro AUC with ± 1 SD error bars."""
sub = fold_df[fold_df["eval_mode"] == eval_mode]
rep_stats = sub.groupby("rep")["auc_macro"].agg(["mean", "std"]).reset_index()
rep_stats = rep_stats.sort_values("rep")
fig, ax = plt.subplots(figsize=(max(6, len(rep_stats) * 0.9), 4))
x = np.arange(len(rep_stats))
ax.bar(x, rep_stats["mean"], yerr=rep_stats["std"],
capsize=4, color="steelblue", alpha=0.8)
ax.axhline(rep_stats["mean"].mean(), linestyle="--", color="crimson",
linewidth=1.2, label=f"grand mean = {rep_stats['mean'].mean():.3f}")
ax.set_xticks(x)
ax.set_xticklabels(rep_stats["rep"], rotation=30, ha="right")
ax.set_ylabel("Mean macro AUC (5 folds)")
ax.set_title(f"Per-rep stability — {eval_mode}")
ymin = max(0, rep_stats["mean"].min() - rep_stats["std"].max() - 0.02)
ax.set_ylim(ymin, 1.02)
ax.legend(fontsize=9)
ax.grid(True, axis="y", linewidth=0.4, alpha=0.5)
fig.tight_layout()
path = out_dir / f"{eval_mode}_rep_stability.png"
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.close(fig)
print(f" Saved: {path}")
# ---------------------------------------------------------------------------
# Summary text
# ---------------------------------------------------------------------------
def _print_summary(fold_df: pd.DataFrame, eval_modes: list[str]) -> str:
lines = ["=" * 60, "10 × 5-fold CV — aggregate summary", "=" * 60]
for eval_mode in eval_modes:
sub = fold_df[(fold_df["eval_mode"] == eval_mode) & (fold_df["fold"] >= 0)]
if sub.empty:
continue
num_classes = 2 if eval_mode == "binary" else 3
class_names = _CLASS_NAMES[eval_mode]
lines.append(f"\n--- {eval_mode.upper()} ---")
lines.append(f" n_folds = {len(sub)}")
lines.append(" [Validation]")
for col, label in [("auc_macro", "Macro AUC"), ("acc", "Accuracy")]:
if col not in sub.columns:
continue
vals = sub[col].dropna().values
ci_lo, ci_hi = _ci95(vals)
lines.append(
f" {label:18s}: {vals.mean():.4f} ± {vals.std():.4f}"
f" 95% CI [{ci_lo:.4f}, {ci_hi:.4f}]"
)
for k in range(num_classes):
col = f"auc_class{k}"
if col not in sub.columns:
continue
vals = sub[col].dropna().values
if len(vals) == 0:
continue
ci_lo, ci_hi = _ci95(vals)
lines.append(
f" AUC {class_names[k]:12s}: {vals.mean():.4f} ± {vals.std():.4f}"
f" 95% CI [{ci_lo:.4f}, {ci_hi:.4f}]"
)
# Between-rep variance (val)
rep_means = sub.groupby("rep")["auc_macro"].mean().values
lines.append(
f" Rep-mean AUC (n={len(rep_means)}): "
f"{rep_means.mean():.4f} ± {rep_means.std():.4f}"
f" (between-rep SD = {rep_means.std():.4f})"
)
# Holdout — use rep-level rows (fold == -1)
rep_hld = fold_df[
(fold_df["eval_mode"] == eval_mode) &
(fold_df["fold"] == -1) &
fold_df["hld_auc_macro"].notna()
]["hld_auc_macro"].values if "hld_auc_macro" in fold_df.columns else np.array([])
if len(rep_hld) > 0:
lines.append(" [Holdout] (rep-level means, n_reps={})".format(len(rep_hld)))
ci_lo, ci_hi = _ci95(rep_hld)
lines.append(
f" {'Macro AUC':18s}: {rep_hld.mean():.4f} ± {rep_hld.std():.4f}"
f" 95% CI [{ci_lo:.4f}, {ci_hi:.4f}]"
)
lines.append("=" * 60)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--run-root", default="analysis_data/pipeline_10x5",
help="Root directory containing rep* sub-directories.")
ap.add_argument("--eval-modes", nargs="+",
choices=["binary", "multiclass"],
default=["binary", "multiclass"])
ap.add_argument("--out", default=None,
help="Output directory for plots and CSVs "
"(default: {run-root}/aggregate).")
args = ap.parse_args()
run_root = Path(args.run_root)
out_dir = Path(args.out) if args.out else run_root / "aggregate"
out_dir.mkdir(parents=True, exist_ok=True)
print("Loading fold data...")
fold_df = load_all_folds(run_root, args.eval_modes)
if fold_df.empty:
raise SystemExit("No data loaded — check --run-root.")
fold_df.to_csv(out_dir / "fold_metrics.csv", index=False)
print(f" Saved fold_metrics.csv ({len(fold_df)} rows)")
rep_df = (fold_df.groupby(["rep", "eval_mode"])
[["auc_macro", "acc"] +
[c for c in fold_df.columns if c.startswith("auc_class")]]
.mean()
.reset_index())
rep_df.to_csv(out_dir / "rep_metrics.csv", index=False)
print(f" Saved rep_metrics.csv ({len(rep_df)} rows)")
summary_text = _print_summary(fold_df, args.eval_modes)
print("\n" + summary_text)
(out_dir / "overall_summary.txt").write_text(summary_text + "\n")
print(f"\n Saved overall_summary.txt")
print("\nGenerating plots...")
for eval_mode in args.eval_modes:
if fold_df[fold_df["eval_mode"] == eval_mode].empty:
continue
_violin(fold_df, eval_mode, out_dir)
_mean_roc(fold_df, eval_mode, run_root, out_dir)
_rep_stability(fold_df, eval_mode, out_dir)
_holdout_stability(fold_df, eval_mode, out_dir)
print(f"\nAll outputs written to: {out_dir}")
if __name__ == "__main__":
main()
@@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""
Inspect saved validation/holdout logits for a multifold run.
Prints per-class AUCs and sample counts so we can sanity-check unusually high scores.
Can also print per-fold confusion matrices.
Example:
python scripts/fold_confusion_matrix.py \
--run-dir analysis_data/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_091842 \
--head fused
python scripts/fold_confusion_matrix.py --run-dir ... --head fused --use-holdout --confusion
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from typing import Dict, List
import numpy as np
from sklearn.metrics import roc_auc_score, confusion_matrix
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description="Inspect saved logits for a run and report per-class AUCs.")
ap.add_argument("--run-dir", required=True, type=Path, help="Path to the run directory under analysis_data.")
ap.add_argument("--head", choices=["fused", "image", "metadata"], default="fused",
help="Which prediction head's saved probabilities to load.")
ap.add_argument("--use-holdout", action="store_true",
help="Look for *_holdout.npy dumps instead of validation splits.")
ap.add_argument("--class-names", nargs="*", default=None,
help="Optional override for class labels (order should match numeric labels).")
ap.add_argument("--macro", action="store_true", help="Also print macro-average AUC across classes.")
ap.add_argument("--confusion", action="store_true", help="Print confusion matrix for each fold.")
return ap.parse_args()
def load_cli_args(run_dir: Path) -> Dict:
path = run_dir / "cli_args.json"
if not path.exists():
raise FileNotFoundError(f"Missing cli_args.json in {run_dir}")
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
def find_fold_files(run_dir: Path, suffix: str) -> Dict[int, Dict[str, Path]]:
files: Dict[int, Dict[str, Path]] = {}
for y_file in run_dir.glob(f"fold*_y_true{suffix}.npy"):
fold_str = y_file.stem.split("_")[0].replace("fold", "")
try:
fold_idx = int(fold_str)
except ValueError:
continue
files.setdefault(fold_idx, {})["y_true"] = y_file
for head_key, glob_pat in [
("fused", f"fold*_probs_fused{suffix}.npy"),
("image", f"fold*_probs_img{suffix}.npy"),
("metadata", f"fold*_probs_md{suffix}.npy"),
]:
for p_file in run_dir.glob(glob_pat):
fold_str = p_file.stem.split("_")[0].replace("fold", "")
try:
fold_idx = int(fold_str)
except ValueError:
continue
files.setdefault(fold_idx, {})[head_key] = p_file
return files
def compute_auc(y_true: np.ndarray, probs: np.ndarray, class_names: List[str], macro: bool) -> List[int]:
num_classes = probs.shape[1]
unique = np.unique(y_true)
print(f" classes present: {sorted(unique.tolist())}")
aucs = []
seen_classes: List[int] = []
for cls in range(num_classes):
name = class_names[cls] if cls < len(class_names) else f"class_{cls}"
mask = (y_true == cls)
pos = int(mask.sum())
neg = len(y_true) - pos
if pos == 0 or neg == 0:
print(f" {name:<15} -> insufficient positives/negatives (pos={pos}, neg={neg}); skipping AUC")
continue
try:
auc = roc_auc_score((y_true == cls).astype(int), probs[:, cls])
except ValueError as exc:
print(f" {name:<15} -> AUC error: {exc}")
continue
aucs.append(auc)
seen_classes.append(cls)
print(f" {name:<15} -> AUC={auc:.4f} (pos={pos}, neg={neg})")
if macro and aucs:
mean = float(np.mean(aucs))
std = float(np.std(aucs, ddof=0)) if len(aucs) > 1 else math.nan
print(f" macro AUC across reported classes: {mean:.4f} (std={std:.4f})")
return seen_classes
def print_confusion(y_true: np.ndarray, probs: np.ndarray, class_names: List[str]) -> None:
num_classes = probs.shape[1]
preds = probs.argmax(axis=1)
labels = list(range(num_classes))
cm = confusion_matrix(y_true, preds, labels=labels)
names = [class_names[i] if i < len(class_names) else f"class_{i}" for i in labels]
header = " " * 14 + "".join(f"{name:>12}" for name in names)
print(" Confusion matrix (rows=true, cols=pred):")
print(header)
for idx, row in enumerate(cm):
label = names[idx]
row_str = "".join(f"{int(val):>12}" for val in row)
print(f" {label:<12}{row_str}")
def main() -> None:
args = parse_args()
run_dir = args.run_dir.resolve()
if not run_dir.exists():
raise FileNotFoundError(run_dir)
cli_args = load_cli_args(run_dir)
eval_mode = cli_args.get("eval_mode", "multiclass")
if args.class_names:
class_names = args.class_names
else:
if eval_mode == "binary":
class_names = ["Healthy", "Glaucoma"]
else:
class_names = cli_args.get("class_names") or ["Healthy", "Glaucoma", "Suspect"]
suffix = "_holdout" if args.use_holdout else ""
files = find_fold_files(run_dir, suffix)
if not files:
raise SystemExit(f"No saved probability files matching suffix '{suffix}' found in {run_dir}. "
"Run scripts/rebuild_run_best_plots.py first if needed.")
print(f"[info] Inspecting head='{args.head}' ({'holdout' if args.use_holdout else 'validation'})")
for fold_idx in sorted(files.keys()):
fold = files[fold_idx]
if "y_true" not in fold:
print(f"[warning] Fold {fold_idx}: missing y_true file; skipping.")
continue
head_key = {
"fused": "fused",
"image": "image",
"metadata": "metadata",
}[args.head]
prob_path = fold.get(head_key)
if prob_path is None:
print(f"[warning] Fold {fold_idx}: missing probability file for head '{args.head}'; skipping.")
continue
y_true = np.load(fold["y_true"])
probs = np.load(prob_path)
print(f"\n Fold {fold_idx} -> samples={len(y_true)} file={prob_path.name}")
compute_auc(y_true, probs, class_names, args.macro)
if args.confusion:
print_confusion(y_true, probs, class_names)
if __name__ == "__main__":
main()
@@ -1,241 +0,0 @@
#!/usr/bin/env python3
"""
Aggregate raw GradCAM heatmaps across all folds for a run.
For each combination of (eye, class, correct/incorrect) computes:
- mean heatmap
- std heatmap
- count
Also computes a scalar per patient: fraction of GradCAM attention mass that
falls within the expert-segmented optic disc region (from GT contour files),
using the manifest.csv to locate the contour for each patient/eye.
Outputs
-------
{out_dir}/mean_heatmaps.npz
Keys: {eye}_{class_name}_{correct|incorrect}_{mean|std|count}
e.g. OD_Glaucoma_correct_mean shape (224, 224)
{out_dir}/attention_stats.csv
per-patient scalars: patient_id, fold, eye, true_name, pred_name,
correct, confidence, disc_frac, entropy
Usage
-----
python scripts/output_analysis/explainability/aggregate_gradcam.py \
--run-dir analysis_data/pipeline_nocrop \
--eval-mode binary \
--tower-mode single
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw
def load_disc_mask(contour_path: Path, orig_size: tuple[int, int],
cam_h: int, cam_w: int) -> np.ndarray | None:
"""
Load a PAPILA disc contour TXT file, polygon-fill at original image
dimensions, then resize to (cam_h, cam_w). Returns a bool array or
None if the contour cannot be loaded.
"""
try:
arr = np.loadtxt(str(contour_path), dtype=np.float32)
except Exception:
return None
if arr.ndim == 1:
arr = arr.reshape(-1, 2)
if arr.shape[0] < 3 or arr.shape[1] < 2:
return None
# orig_size is (W, H) as PIL convention
img = Image.new("L", orig_size, 0)
draw = ImageDraw.Draw(img)
draw.polygon([tuple(pt) for pt in arr[:, :2]], fill=1)
mask = np.array(img.resize((cam_w, cam_h), Image.NEAREST), dtype=bool)
return mask
def build_disc_lookup(manifest_path: Path) -> dict[tuple[int, str], tuple[Path, tuple[int, int]]]:
"""
Returns {(patient_id_int, eye): (disc_contour_path, (img_W, img_H))}.
Only PAPILA rows are included.
"""
mf = pd.read_csv(manifest_path)
lookup: dict[tuple[int, str], tuple[Path, tuple[int, int]]] = {}
for _, row in mf.iterrows():
sid = str(row["sample_id"])
if not sid.startswith("papila_RET"):
continue
# sample_id: papila_RET002OD or papila_RET002OS
suffix = sid[len("papila_RET"):] # e.g. "002OD"
eye = suffix[-2:] # "OD" or "OS"
pid = int(suffix[:-2]) # 2
disc_path = Path(str(row["annotation_disc"]))
img_path = Path(str(row["image_path"]))
if not disc_path.exists():
continue
# read original image size once
try:
with Image.open(img_path) as im:
orig_size = im.size # (W, H)
except Exception:
continue
lookup[(pid, eye)] = (disc_path, orig_size)
return lookup
def attention_entropy(cam: np.ndarray) -> float:
flat = cam.flatten().astype(np.float64)
flat = flat / (flat.sum() + 1e-12)
return float(-np.sum(flat * np.log(flat + 1e-12)))
def load_fold(gradcam_dir: Path):
idx_path = gradcam_dir / "gradcam_index.csv"
if not idx_path.exists():
return None
idx = pd.read_csv(idx_path)
records = []
for _, row in idx.iterrows():
pid = row["patient_id"]
for eye in ("OD", "OS"):
npy = gradcam_dir / f"patient_{pid}_{eye}_cam.npy"
if not npy.exists():
continue
cam = np.load(npy)
records.append({
"patient_id": pid,
"eye": eye,
"true_label": int(row["true_label"]),
"true_name": row["true_name"],
"pred_label": int(row["pred_label"]),
"pred_name": row["pred_name"],
"confidence": float(row["confidence"]),
"correct": bool(row["correct"]),
"cam": cam,
})
return records
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run-dir", default="analysis_data/pipeline_nocrop")
ap.add_argument("--eval-mode", default="binary")
ap.add_argument("--tower-mode", default="single")
ap.add_argument("--manifest", default="manifest.csv")
ap.add_argument("--out", default=None)
args = ap.parse_args()
run_dir = Path(args.run_dir)
mode_dir = run_dir / args.eval_mode / args.tower_mode
out_dir = mode_dir / "gradcam_aggregate"
if args.out:
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
# ---- build disc mask lookup ----
manifest_path = Path(args.manifest)
disc_lookup = build_disc_lookup(manifest_path)
print(f"Disc mask lookup: {len(disc_lookup)} entries from {manifest_path}")
# ---- collect all records ----
all_records = []
stat_rows = []
fold_dirs = sorted(
[d for d in mode_dir.iterdir() if d.is_dir() and d.name.startswith("fold")],
key=lambda p: int(p.name.replace("fold", "")),
)
if not fold_dirs:
print(f"No fold dirs found under {mode_dir}")
return
for fd in fold_dirs:
gcam_dir = fd / "explainability" / "gradcam"
records = load_fold(gcam_dir)
if records is None:
print(f" [skip] {fd.name}: no gradcam_index.csv")
continue
print(f" {fd.name}: {len(records)} eye records")
for r in records:
r["fold"] = fd.name
all_records.append(r)
if not all_records:
print("No records found — re-run explain_fold.py first.")
return
print(f"\nTotal eye records: {len(all_records)}")
h, w = all_records[0]["cam"].shape
# ---- per-record stats ----
n_missing = 0
for r in all_records:
cam = r["cam"]
total = cam.sum() + 1e-12
pid = int(r["patient_id"])
eye = r["eye"]
disc_mask = None
key = (pid, eye)
if key in disc_lookup:
disc_path, orig_size = disc_lookup[key]
disc_mask = load_disc_mask(disc_path, orig_size, h, w)
if disc_mask is None:
n_missing += 1
disc_frac = float("nan")
else:
disc_frac = float(cam[disc_mask].sum() / total)
stat_rows.append({
"patient_id": r["patient_id"],
"fold": r["fold"],
"eye": r["eye"],
"true_name": r["true_name"],
"pred_name": r["pred_name"],
"correct": r["correct"],
"confidence": r["confidence"],
"disc_frac": disc_frac,
"entropy": attention_entropy(cam),
})
if n_missing:
print(f" Warning: {n_missing} records had no disc mask (disc_frac=NaN)")
stats_df = pd.DataFrame(stat_rows)
stats_path = out_dir / "attention_stats.csv"
stats_df.to_csv(stats_path, index=False)
print(f"Saved attention stats → {stats_path}")
# ---- mean heatmaps ----
npz_arrays = {}
groups: dict[tuple, list[np.ndarray]] = {}
for r in all_records:
key = (r["eye"], r["true_name"], "correct" if r["correct"] else "incorrect")
groups.setdefault(key, []).append(r["cam"])
for r in all_records:
key = (r["eye"], r["true_name"], "all")
groups.setdefault(key, []).append(r["cam"])
for (eye, cls, split), cams in groups.items():
stack = np.stack(cams, axis=0)
key_base = f"{eye}_{cls}_{split}"
npz_arrays[f"{key_base}_mean"] = stack.mean(axis=0).astype(np.float32)
npz_arrays[f"{key_base}_std"] = stack.std(axis=0).astype(np.float32)
npz_arrays[f"{key_base}_count"] = np.array(len(cams))
print(f" {key_base}: N={len(cams)}")
npz_path = out_dir / "mean_heatmaps.npz"
np.savez_compressed(npz_path, **npz_arrays)
print(f"Saved mean heatmaps → {npz_path}")
if __name__ == "__main__":
main()
@@ -1,933 +0,0 @@
#!/usr/bin/env python3
"""
Post-hoc explainability for a single saved fold.
Phase 1 MD permutation feature importance (bar chart + CSV).
Phase 2 GradCAM overlays on all holdout (or val) patients.
Usage:
python scripts/output_analysis/explainability/explain_fold.py \
--fold-dir analysis_data/.../binary/single/fold0 \
[--checkpoint best_single.pt | best_holdout_single.pt] \
[--split holdout] # falls back to val if no holdout
[--image-dir Papila/FundusImages] \
[--clinical-dir Papila/ClinicalData] \
[--n-permutations 30] \
[--seed 0] \
[--alpha 0.45]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import matplotlib
matplotlib.use("Agg")
import matplotlib.cm as cm
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from sklearn.metrics import roc_auc_score
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.v2.data_bundle import DataBundle
from classes.v2.papila_builders import build_papila_data
from classes.v2.profiles.papila import build_papila_profile
from classes.v2.split_manager import PatientFirstSplitManager
from classes.v2.loader_factory import filter_bilateral_samples, make_loader
from classes.v2.metrics import _score_arrays
from classes.v2.models import SingleEyeHT
from classes.v2.transforms import build_eval_transform
# ---------------------------------------------------------------------------
# Label display helpers
# ---------------------------------------------------------------------------
BINARY_LABELS = {0: "Normal", 1: "Glaucoma"}
MULTICLASS_LABELS = {0: "Normal", 1: "Glaucoma", 2: "Suspect"}
def label_name(label: int, eval_mode: str) -> str:
mapping = BINARY_LABELS if eval_mode == "binary" else MULTICLASS_LABELS
return mapping.get(int(label), str(label))
# ---------------------------------------------------------------------------
# GradCAM
# ---------------------------------------------------------------------------
class GradCAM:
"""Minimal GradCAM using forward/backward hooks. No extra dependencies."""
def __init__(self, target_layer: torch.nn.Module) -> None:
self._acts: torch.Tensor | None = None
self._grads: torch.Tensor | None = None
self._h1 = target_layer.register_forward_hook(self._save_acts)
self._h2 = target_layer.register_full_backward_hook(self._save_grads)
def _save_acts(self, _m, _i, output):
self._acts = output.detach()
def _save_grads(self, _m, _gi, grad_output):
self._grads = grad_output[0].detach()
def compute(
self,
img: torch.Tensor,
meta: torch.Tensor,
model: torch.nn.Module,
target_class: int | None = None,
) -> tuple[np.ndarray, int]:
"""Return (cam [H,W] in [0,1], predicted_class_index)."""
model.eval()
with torch.enable_grad():
out = model(img, meta)
pred = int(out.argmax(1).item())
tc = pred if target_class is None else target_class
model.zero_grad()
out[0, tc].backward()
if self._acts is None or self._grads is None:
raise RuntimeError("GradCAM hooks did not fire — check target_layer.")
weights = self._grads.mean(dim=(2, 3), keepdim=True) # [1,C,1,1]
cam = F.relu((weights * self._acts).sum(dim=1, keepdim=True)) # [1,1,h,w]
cam = F.interpolate(cam, img.shape[-2:], mode="bilinear", align_corners=False)
cam_np = cam.squeeze().cpu().numpy()
lo, hi = cam_np.min(), cam_np.max()
cam_np = (cam_np - lo) / (hi - lo + 1e-8)
return cam_np, pred
def remove(self) -> None:
self._h1.remove()
self._h2.remove()
def get_gradcam_layer(model: SingleEyeHT, backbone: str) -> torch.nn.Module:
"""Return the final spatial feature map layer for GradCAM."""
bb = model.img_tower.backbone
key = backbone.lower()
if key in ("refugelike",) or "resnet" in key:
return bb.layer4[-1]
if "efficientnet" in key or "refuge_efficient" in key:
return bb.features[-1]
if "densenet" in key or key == "refuge_densenet":
return bb.features.denseblock4
if "mobilenet" in key:
return bb.features[-1]
if "vgg" in key:
return bb.features[-1]
raise ValueError(f"Unknown backbone for GradCAM target layer: {backbone!r}")
def overlay_gradcam(
original_pil: Image.Image, cam: np.ndarray, alpha: float = 0.45
) -> Image.Image:
"""Blend a jet-coloured GradCAM map onto the original image."""
cam_u8 = (cam * 255).astype(np.uint8)
cam_resized = (
np.array(Image.fromarray(cam_u8).resize(original_pil.size, Image.BILINEAR))
/ 255.0
)
colored = (cm.jet(cam_resized)[:, :, :3] * 255).astype(np.uint8)
return Image.blend(original_pil.convert("RGB"), Image.fromarray(colored), alpha)
# ---------------------------------------------------------------------------
# Feature index map
# ---------------------------------------------------------------------------
def build_feature_index_map(data: DataBundle) -> dict[str, dict]:
"""
Return a mapping feature_name {"value_dims": [...], "missing_dims": [...]}
that covers every input dimension of the MD tower vector.
Layout (from DataBundle.vectorize_row):
[scalar_0..scalar_n-1 | cat_onehot | scalar_missing_0..scalar_missing_n-1]
"""
n_scalar = len(data.scalar_cols)
cat_expanded = sum(len(m) for m in data.cat_maps.values())
feature_map: dict[str, dict] = {}
idx = 0
# Scalar features: value_dim + corresponding missing flag
for i, col in enumerate(data.scalar_cols):
missing_dim = n_scalar + cat_expanded + i
feature_map[col] = {"value_dims": [i], "missing_dims": [missing_dim]}
idx += 1
# Categorical features: permute the entire one-hot block
cat_offset = n_scalar
for col in data.cat_cols:
n_cats = len(data.cat_maps[col])
dims = list(range(cat_offset, cat_offset + n_cats))
feature_map[col] = {"value_dims": dims, "missing_dims": []}
cat_offset += n_cats
return feature_map
# ---------------------------------------------------------------------------
# Phase 1 — MD permutation importance
# ---------------------------------------------------------------------------
def run_permutation_importance(
model: SingleEyeHT,
loader,
data: DataBundle,
num_classes: int,
device: torch.device,
n_permutations: int,
seed: int,
out_dir: Path,
) -> None:
print("\n[Phase 1] MD permutation importance ...", flush=True)
# ---- cache bilateral image embeddings + metadata tensors + labels ----
img1_feats_list, img2_feats_list = [], []
md1_list, md2_list, label_list = [], [], []
model.eval()
with torch.no_grad():
for batch in loader:
img1 = batch["image_1"].to(device)
img2 = batch["image_2"].to(device)
md1 = batch["matrix_1"].to(device)
md2 = batch["matrix_2"].to(device)
labels = batch["label_1"]
img1_feats_list.append(model.img_tower(img1))
img2_feats_list.append(model.img_tower(img2))
md1_list.append(md1)
md2_list.append(md2)
if isinstance(labels, torch.Tensor):
label_list.append(labels)
else:
label_list.append(torch.tensor(labels, dtype=torch.long))
img1_feats = torch.cat(img1_feats_list) # [N, img_dim]
img2_feats = torch.cat(img2_feats_list) # [N, img_dim]
md1_tensor = torch.cat(md1_list) # [N, feature_dim]
md2_tensor = torch.cat(md2_list) # [N, feature_dim]
y_true = torch.cat(label_list).numpy()
N = len(y_true)
if N == 0:
print(" [Phase 1] No samples — skipping.", flush=True)
return
# ---- baseline AUC (patient-level: average OD/OS fused probabilities) ----
with torch.no_grad():
md1_feats = model.md_tower(md1_tensor)
md2_feats = model.md_tower(md2_tensor)
fused1, _, _ = model.bridge(img1_feats, md1_feats)
fused2, _, _ = model.bridge(img2_feats, md2_feats)
probs_baseline = (
0.5 * (torch.softmax(fused1, dim=1) + torch.softmax(fused2, dim=1))
).cpu().numpy()
_, baseline_auc, _ = _score_arrays(y_true, probs_baseline, num_classes)
print(f" Baseline AUC: {baseline_auc:.4f} (N={N})", flush=True)
# ---- feature index map ----
feat_map = build_feature_index_map(data)
rng = np.random.default_rng(seed)
results = []
for feat_name, dims in feat_map.items():
all_dims = dims["value_dims"] + dims["missing_dims"]
drops = []
for _ in range(n_permutations):
perm1 = md1_tensor.clone()
perm2 = md2_tensor.clone()
perm_idx = torch.from_numpy(rng.permutation(N)).to(device)
# Apply the same donor patient permutation to both eyes to preserve
# within-patient coherence while breaking feature-label association.
perm1[:, all_dims] = perm1[perm_idx][:, all_dims]
perm2[:, all_dims] = perm2[perm_idx][:, all_dims]
with torch.no_grad():
md1_p = model.md_tower(perm1)
md2_p = model.md_tower(perm2)
fused1_p, _, _ = model.bridge(img1_feats, md1_p)
fused2_p, _, _ = model.bridge(img2_feats, md2_p)
probs_p = (
0.5
* (
torch.softmax(fused1_p, dim=1)
+ torch.softmax(fused2_p, dim=1)
)
).cpu().numpy()
_, auc_p, _ = _score_arrays(y_true, probs_p, num_classes)
drops.append(baseline_auc - auc_p)
mean_drop = float(np.mean(drops))
std_drop = float(np.std(drops))
results.append({"feature": feat_name, "importance": mean_drop, "std": std_drop})
print(
f" {feat_name:30s} Δ AUC = {mean_drop:+.4f} ± {std_drop:.4f}", flush=True
)
results.sort(key=lambda r: r["importance"], reverse=True)
# ---- total MD ablation (all features permuted simultaneously) ----
print(" Running total MD ablation ...", flush=True)
total_drops = []
for _ in range(n_permutations):
perm_idx = torch.from_numpy(rng.permutation(N)).to(device)
perm1_all = md1_tensor[perm_idx]
perm2_all = md2_tensor[perm_idx]
with torch.no_grad():
md1_all = model.md_tower(perm1_all)
md2_all = model.md_tower(perm2_all)
f1, _, _ = model.bridge(img1_feats, md1_all)
f2, _, _ = model.bridge(img2_feats, md2_all)
probs_all = (
0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1))
).cpu().numpy()
_, auc_all, _ = _score_arrays(y_true, probs_all, num_classes)
total_drops.append(baseline_auc - auc_all)
total_mean = float(np.mean(total_drops))
total_std = float(np.std(total_drops))
print(
f" Total MD ablation Δ AUC = {total_mean:+.4f} ± {total_std:.4f}", flush=True
)
# ---- Gaussian noise ablation (tests architectural vs informational benefit) ----
print(" Running Gaussian noise ablation ...", flush=True)
noise_drops = []
for _ in range(n_permutations):
noise1 = torch.randn_like(md1_tensor)
noise2 = torch.randn_like(md2_tensor)
with torch.no_grad():
md1_noise = model.md_tower(noise1)
md2_noise = model.md_tower(noise2)
f1, _, _ = model.bridge(img1_feats, md1_noise)
f2, _, _ = model.bridge(img2_feats, md2_noise)
probs_noise = (
0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1))
).cpu().numpy()
_, auc_noise, _ = _score_arrays(y_true, probs_noise, num_classes)
noise_drops.append(baseline_auc - auc_noise)
noise_mean = float(np.mean(noise_drops))
noise_std = float(np.std(noise_drops))
print(
f" Gaussian noise ablation Δ AUC = {noise_mean:+.4f} ± {noise_std:.4f}", flush=True
)
print(
f" [interpretation] permutation Δ={total_mean:+.4f} noise Δ={noise_mean:+.4f} "
f"informational gain = {total_mean - noise_mean:+.4f}",
flush=True,
)
# ---- save CSV ----
import csv
csv_path = out_dir / "md_permutation_importance.csv"
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["feature", "importance", "std"])
writer.writeheader()
writer.writerows(results)
writer.writerow({"feature": "TOTAL_MD_ABLATION", "importance": total_mean, "std": total_std})
writer.writerow({"feature": "GAUSSIAN_NOISE_ABLATION", "importance": noise_mean, "std": noise_std})
# ---- bar chart ----
names = [r["feature"] for r in results]
imps = [r["importance"] for r in results]
stds = [r["std"] for r in results]
colors = ["#e05c5c" if v >= 0 else "#5c9ee0" for v in imps]
fig, ax = plt.subplots(figsize=(9, max(4, (len(names) + 3) * 0.45)))
y_pos = np.arange(len(names))
ax.barh(
y_pos, imps, xerr=stds, color=colors, ecolor="grey", capsize=3, height=0.6
)
ax.axhline(len(names) - 0.25, color="grey", linewidth=0.6, linestyle="--")
# total ablation
ax.barh(
len(names) + 0.5, total_mean, xerr=total_std,
color="#c45ce0" if total_mean >= 0 else "#5c9ee0",
ecolor="grey", capsize=3, height=0.6,
)
# gaussian noise ablation
ax.barh(
len(names) + 1.5, noise_mean, xerr=noise_std,
color="#e08c2a" if noise_mean >= 0 else "#5c9ee0",
ecolor="grey", capsize=3, height=0.6,
)
ax.set_yticks(list(y_pos) + [len(names) + 0.5, len(names) + 1.5])
ax.set_yticklabels(names + ["ALL MD (permute)", "ALL MD (noise)"], fontsize=9)
ax.invert_yaxis()
ax.axvline(0, color="black", linewidth=0.8)
ax.set_xlabel("Mean AUC drop (baseline permuted)", fontsize=10)
ax.set_title(
f"MD Tower — Permutation Feature Importance\n"
f"baseline AUC={baseline_auc:.4f} N={N} repeats={n_permutations}",
fontsize=11,
)
fig.tight_layout()
fig.savefig(out_dir / "md_permutation_importance.png", dpi=150)
plt.close(fig)
print(f" Saved → {out_dir / 'md_permutation_importance.png'}", flush=True)
# ---------------------------------------------------------------------------
# Phase 2 — GradCAM overlays
# ---------------------------------------------------------------------------
def run_gradcam(
model: SingleEyeHT,
loader,
data: DataBundle,
eval_df,
eval_mode: str,
backbone: str,
device: torch.device,
alpha: float,
out_dir: Path,
) -> None:
print("\n[Phase 2] GradCAM overlays ...", flush=True)
gradcam_dir = out_dir / "gradcam"
gradcam_dir.mkdir(exist_ok=True)
target_layer = get_gradcam_layer(model, backbone)
gcam = GradCAM(target_layer)
num_classes = model.bridge.classifier_fused[-1].out_features
overlay_grid_items: list[
tuple[Image.Image | None, Image.Image | None, str, bool]
] = []
index_rows: list[dict] = []
model.eval()
for batch in loader:
img_od = batch["image_1"].to(device) # [1, 3, H, W]
img_os = batch["image_2"].to(device) # [1, 3, H, W]
meta_od = batch["matrix_1"].to(device) # [1, feature_dim]
meta_os = batch["matrix_2"].to(device)
lbl_raw = batch["label_1"][0]
label = int(lbl_raw.item() if isinstance(lbl_raw, torch.Tensor) else lbl_raw)
pid = batch["id_1"][0]
# GradCAM for each eye (OD drives the prediction label)
cam_od, pred = gcam.compute(img_od, meta_od, model)
cam_os, _ = gcam.compute(img_os, meta_os, model)
# Confidence of predicted class
with torch.no_grad():
out_od = model(img_od, meta_od)
conf = float(torch.softmax(out_od, dim=1)[0, pred].item())
# Load original (un-normalised) images from disk
row_od = eval_df[
(eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OD")
]
row_os = eval_df[
(eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OS")
]
orig_od = (
Image.open(data.get_image_path(row_od.iloc[0])).convert("RGB")
if len(row_od)
else None
)
orig_os = (
Image.open(data.get_image_path(row_os.iloc[0])).convert("RGB")
if len(row_os)
else None
)
true_name = label_name(label, eval_mode)
pred_name = label_name(pred, eval_mode)
correct = label == pred
title = (
f"Patient {pid} | True: {true_name} | Pred: {pred_name} "
f"| conf={conf:.2f} {'' if correct else ''}"
)
# ---- per-patient 2×2 figure (OD raw | OD overlay / OS raw | OS overlay) ----
fig, axes = plt.subplots(2, 2, figsize=(10, 9))
fig.suptitle(
title, fontsize=11, fontweight="bold", color="green" if correct else "red"
)
# Row 0: OD
if orig_od is not None:
axes[0, 0].imshow(orig_od)
axes[0, 0].set_title("OD — original", fontsize=9)
axes[0, 1].imshow(overlay_gradcam(orig_od, cam_od, alpha))
axes[0, 1].set_title("OD — GradCAM", fontsize=9)
else:
axes[0, 0].set_title("OD — (missing)", fontsize=9)
axes[0, 0].axis("off")
axes[0, 1].axis("off")
# Row 1: OS
if orig_os is not None:
axes[1, 0].imshow(orig_os)
axes[1, 0].set_title("OS — original", fontsize=9)
axes[1, 1].imshow(overlay_gradcam(orig_os, cam_os, alpha))
axes[1, 1].set_title("OS — GradCAM", fontsize=9)
else:
axes[1, 0].set_title("OS — (missing)", fontsize=9)
axes[1, 0].axis("off")
axes[1, 1].axis("off")
fig.tight_layout()
out_path = gradcam_dir / f"patient_{pid}_OD_OS.png"
fig.savefig(out_path, dpi=120)
plt.close(fig)
print(
f" Patient {pid}: {true_name}{pred_name} ({conf:.2f}) → {out_path.name}",
flush=True,
)
# Save raw CAM arrays
np.save(gradcam_dir / f"patient_{pid}_OD_cam.npy", cam_od)
np.save(gradcam_dir / f"patient_{pid}_OS_cam.npy", cam_os)
index_rows.append({
"patient_id": pid,
"true_label": label,
"true_name": true_name,
"pred_label": pred,
"pred_name": pred_name,
"confidence": conf,
"correct": correct,
})
# Accumulate for summary grid
od_overlay = overlay_gradcam(orig_od, cam_od, alpha) if orig_od else None
os_overlay = overlay_gradcam(orig_os, cam_os, alpha) if orig_os else None
short_lbl = f"P{pid} {true_name[:3]}{pred_name[:3]} {'' if correct else ''}"
overlay_grid_items.append((od_overlay, os_overlay, short_lbl, correct))
gcam.remove()
# ---- save index CSV ----
if index_rows:
import csv
idx_path = gradcam_dir / "gradcam_index.csv"
with idx_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(index_rows[0].keys()))
writer.writeheader()
writer.writerows(index_rows)
print(f" Index CSV → {idx_path}", flush=True)
# ---- summary grid: N_patients rows × 2 cols (OD overlay | OS overlay) ----
n = len(overlay_grid_items)
if n == 0:
print(" [Phase 2] No patients to visualise.", flush=True)
return
fig, axes = plt.subplots(n, 2, figsize=(8, n * 3.2 + 0.8))
if n == 1:
axes = axes[np.newaxis, :]
fig.suptitle("GradCAM Summary Grid — all holdout patients", fontsize=12)
for i, (od_ov, os_ov, lbl, correct) in enumerate(overlay_grid_items):
color = "green" if correct else "red"
for j in range(2):
axes[i, j].axis("off")
if od_ov is not None:
axes[i, 0].imshow(od_ov)
axes[i, 0].set_title(f"{lbl}\nOD", fontsize=7, color=color)
if os_ov is not None:
axes[i, 1].imshow(os_ov)
axes[i, 1].set_title(f"{lbl}\nOS", fontsize=7, color=color)
fig.tight_layout()
grid_path = out_dir / "gradcam_summary_grid.png"
fig.savefig(grid_path, dpi=120)
plt.close(fig)
print(f" Summary grid → {grid_path}", flush=True)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def parse_args():
ap = argparse.ArgumentParser(
description="Post-hoc explainability for a saved fold."
)
ap.add_argument(
"--fold-dir",
type=Path,
required=True,
help="Path to fold directory, e.g. analysis_data/.../binary/single/fold0",
)
ap.add_argument(
"--checkpoint",
default="best_single.pt",
help="Checkpoint filename inside fold_dir (default: best_single.pt; "
"use best_holdout_single.pt for holdout-selected model)",
)
ap.add_argument(
"--split",
choices=["holdout", "val"],
default="holdout",
help="Which patient set to analyse (default: holdout, falls back to val)",
)
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("--fold-seed", type=int, default=42)
ap.add_argument("--holdout-seed", type=int, default=123)
ap.add_argument("--holdout-per-class", type=int, default=5)
ap.add_argument("--n-splits", type=int, default=5)
ap.add_argument(
"--n-permutations",
type=int,
default=30,
help="Repetitions per feature for permutation importance (default: 30)",
)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument(
"--alpha",
type=float,
default=0.45,
help="GradCAM overlay opacity (default: 0.45)",
)
ap.add_argument("--batch-size", type=int, default=1)
ap.add_argument("--no-phase1", action="store_true", help="Skip MD importance")
ap.add_argument("--no-phase2", action="store_true", help="Skip GradCAM")
ap.add_argument("--no-phase3", action="store_true", help="Skip fusion event analysis")
return ap.parse_args()
# ---------------------------------------------------------------------------
# Phase 3 — Fusion event analysis
# ---------------------------------------------------------------------------
def run_fusion_event_analysis(
model: SingleEyeHT,
loader,
device: torch.device,
out_dir: Path,
) -> None:
print("\n[Phase 3] Fusion event analysis ...", flush=True)
from classes.v2.models import collect_probs_single_components
y_true, pf, pi, pm = collect_probs_single_components(
model, loader, device, aggregate_patient=True
)
N = len(y_true)
if N == 0:
print(" [Phase 3] No samples — skipping.", flush=True)
return
pred_f = pf.argmax(axis=1)
pred_i = pi.argmax(axis=1)
pred_m = pm.argmax(axis=1)
# confidence of the predicted class for each head
conf_f = np.take_along_axis(pf, pred_f[:, None], axis=1).squeeze(1)
conf_i = np.take_along_axis(pi, pred_i[:, None], axis=1).squeeze(1)
conf_m = np.take_along_axis(pm, pred_m[:, None], axis=1).squeeze(1)
# how much did fusion shift confidence vs the average of the two towers?
conf_delta = conf_f - 0.5 * (conf_i + conf_m)
f_ok = pred_f == y_true
i_ok = pred_i == y_true
m_ok = pred_m == y_true
# 6 non-trivial bridge-effect event types
full_correction = f_ok & ~i_ok & ~m_ok # both towers wrong → fused right
img_assist = f_ok & ~i_ok & m_ok # img wrong, md right → fused right (md carried it)
md_assist = f_ok & i_ok & ~m_ok # md wrong, img right → fused right (img carried it)
full_error = ~f_ok & i_ok & m_ok # both towers right → fused wrong
img_drag = ~f_ok & ~i_ok & m_ok # img wrong, md right → fused wrong (img dragged it down)
md_drag = ~f_ok & i_ok & ~m_ok # md wrong, img right → fused wrong (md dragged it down)
concordant_ok = f_ok & i_ok & m_ok
concordant_bad = ~f_ok & ~i_ok & ~m_ok
event_labels = [
"full correction\n(both wrong→fused right)",
"img assist\n(img wrong, md right→right)",
"md assist\n(md wrong, img right→right)",
"full error\n(both right→fused wrong)",
"img drag\n(img wrong, md right→wrong)",
"md drag\n(md wrong, img right→wrong)",
]
event_masks = [full_correction, img_assist, md_assist, full_error, img_drag, md_drag]
event_colors = ["#2ca02c", "#98df8a", "#b5d46e", "#d62728", "#ff9896", "#ffbf9b"]
event_keys = ["full_correction", "img_assist", "md_assist",
"full_error", "img_drag", "md_drag"]
counts = [int(m.sum()) for m in event_masks]
print(f" N={N}", flush=True)
for label, count in zip(event_labels, counts):
print(f" {label.replace(chr(10), ' '):55s}: {count}", flush=True)
n_corr, n_err = counts[0], counts[3]
ratio_str = f"{n_corr}/{n_err}" if n_err > 0 else f"{n_corr}/0"
print(f" full correction/error ratio: {ratio_str}", flush=True)
print(f" conf_delta mean={conf_delta.mean():+.4f} median={np.median(conf_delta):+.4f}",
flush=True)
# ---- CSV ----
import csv
event_type = np.where(concordant_ok, "concordant_correct",
np.where(concordant_bad, "concordant_wrong", "other")).astype(object)
for mask, key in zip(event_masks, event_keys):
event_type[mask] = key
rows = []
for idx in range(N):
rows.append({
"patient_idx": idx,
"y_true": int(y_true[idx]),
"pred_fused": int(pred_f[idx]),
"pred_img": int(pred_i[idx]),
"pred_md": int(pred_m[idx]),
"conf_fused": float(conf_f[idx]),
"conf_img": float(conf_i[idx]),
"conf_md": float(conf_m[idx]),
"conf_delta": float(conf_delta[idx]),
"event_type": event_type[idx],
})
csv_path = out_dir / "fusion_events.csv"
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
print(f" Saved → {csv_path}", flush=True)
# ---- plot ----
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# Panel 1: stacked bar — positive events vs negative events
pos_counts = counts[:3]
neg_counts = counts[3:]
pos_colors = event_colors[:3]
neg_colors = event_colors[3:]
for bar_x, bar_counts, bar_colors in ((0, pos_counts, pos_colors),
(1, neg_counts, neg_colors)):
bot = 0
for c, col in zip(bar_counts, bar_colors):
axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5)
if c > 0:
axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center",
fontsize=9, fontweight="bold")
bot += c
axes[0].set_xticks([0, 1])
axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"])
axes[0].set_ylabel("Count")
axes[0].set_title(f"Fusion Events (N={N})")
patches = [mpatches.Patch(color=c, label=l.replace("\n", " "))
for c, l in zip(event_colors, event_labels)]
axes[0].legend(handles=patches, fontsize=6, loc="upper right")
# Panel 2: conf_delta boxplot per event type (only non-empty)
box_data = [conf_delta[m] for m in event_masks if m.sum() > 0]
box_labels = [l.split("\n")[0] for m, l in zip(event_masks, event_labels) if m.sum() > 0]
box_cols = [c for m, c in zip(event_masks, event_colors) if m.sum() > 0]
if box_data:
bp = axes[1].boxplot(box_data, patch_artist=True, widths=0.5)
for patch, color in zip(bp["boxes"], box_cols):
patch.set_facecolor(color)
axes[1].set_xticks(range(1, len(box_labels) + 1))
axes[1].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7)
axes[1].axhline(0, color="black", linewidth=0.8, linestyle="--")
axes[1].set_ylabel("conf_delta\n(fused avg(img, md))")
axes[1].set_title("Confidence delta by event type")
# Panel 3: img vs md confidence space, coloured by event type
for mask, color, label in zip(event_masks, event_colors, event_labels):
if mask.sum() > 0:
axes[2].scatter(conf_i[mask], conf_m[mask], c=color,
label=label.split("\n")[0], alpha=0.85, s=45, edgecolors="none")
if concordant_ok.sum() > 0:
axes[2].scatter(conf_i[concordant_ok], conf_m[concordant_ok],
c="lightgrey", alpha=0.4, s=20, edgecolors="none", label="concordant correct")
if concordant_bad.sum() > 0:
axes[2].scatter(conf_i[concordant_bad], conf_m[concordant_bad],
c="darkgrey", alpha=0.4, s=20, edgecolors="none", label="concordant wrong")
axes[2].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4)
axes[2].set_xlabel("conf_img")
axes[2].set_ylabel("conf_md")
axes[2].set_title("Tower confidence space\ncoloured by fusion event")
axes[2].legend(fontsize=6, loc="lower right")
fig.tight_layout()
fig.savefig(out_dir / "fusion_events.png", dpi=150)
plt.close(fig)
print(f" Saved → {out_dir / 'fusion_events.png'}", flush=True)
def main():
args = parse_args()
fold_dir = args.fold_dir.resolve()
if not fold_dir.is_dir():
sys.exit(f"[ERROR] fold_dir does not exist: {fold_dir}")
ckpt_path = fold_dir / args.checkpoint
if not ckpt_path.exists():
sys.exit(
f"[ERROR] Checkpoint not found: {ckpt_path}\n"
f" Run training with --save-checkpoints (now the default) to produce checkpoints."
)
# ---- read config from summary.json in parent (tower-mode) dir ----
summary_path = fold_dir.parent / "summary.json"
if not summary_path.exists():
sys.exit(f"[ERROR] summary.json not found: {summary_path}")
summary = json.loads(summary_path.read_text())
backbone = summary["backbone"]
eval_mode = summary["eval_mode"]
tower_mode = summary.get("tower_mode", "single")
fold_idx = int(fold_dir.name.replace("fold", ""))
print(
f"[explain_fold] fold={fold_idx} backbone={backbone} eval_mode={eval_mode} tower_mode={tower_mode}"
)
if tower_mode not in ("single", "ensemble"):
sys.exit(
f"[ERROR] explain_fold currently supports single/ensemble tower modes, got: {tower_mode!r}"
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"[explain_fold] device={device} checkpoint={args.checkpoint}")
# ---- build DataBundle ----
print("[explain_fold] Loading clinical data ...", flush=True)
data = build_papila_data(
image_dir=args.image_dir,
clinical_dir=args.clinical_dir,
label_col=args.label_col,
cat_cols=args.cat_cols,
n_splits=args.n_splits,
random_seed=args.fold_seed,
)
df_mode = data.df.copy()
if eval_mode == "binary":
df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True)
num_classes = 2 if eval_mode == "binary" else int(df_mode[args.label_col].nunique())
# ---- reconstruct the exact same split ----
print("[explain_fold] Reconstructing split ...", flush=True)
splitter = PatientFirstSplitManager(
patient_col="Patient ID", label_col=args.label_col
)
split_args = SimpleNamespace(
eval_mode=eval_mode,
holdout_per_class=args.holdout_per_class,
holdout_seed=args.holdout_seed,
n_splits=args.n_splits,
fold_seed=args.fold_seed,
)
clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col)
plans = splitter.build_plans(clinical=clinical_ns, args=split_args, profile=None)
if fold_idx >= len(plans):
sys.exit(f"[ERROR] fold_idx={fold_idx} but only {len(plans)} plans built.")
split = plans[fold_idx]
if (
args.split == "holdout"
and split.holdout is not None
and not split.holdout.empty
):
eval_df = split.holdout
split_name = "holdout"
else:
if args.split == "holdout":
print(" [WARN] No holdout set available; falling back to val.", flush=True)
eval_df = split.val
split_name = "val"
print(
f" Using {split_name} set: {eval_df['Patient ID'].nunique()} patients",
flush=True,
)
# ---- build loader ----
profile_patient = build_papila_profile(
patient_col="Patient ID", label_col=args.label_col, sample_mode="patient"
)
samples = filter_bilateral_samples(
profile_patient.build_samples(df=eval_df, clinical=data)
)
if not samples:
sys.exit("[ERROR] No bilateral samples found in the eval set.")
loader = make_loader(
samples,
profile_patient.slot_descriptors(),
image_transform=build_eval_transform(backbone),
image_preprocessor=None,
batch_size=args.batch_size,
shuffle=False,
num_workers=0,
)
# ---- load model ----
print(f"[explain_fold] Loading model from {ckpt_path} ...", flush=True)
model = SingleEyeHT(
backbone=backbone,
freeze_ratio=0.0,
augment=False,
clinical_data=data,
num_classes=num_classes,
).to(device)
state = torch.load(ckpt_path, map_location=device)
model.load_state_dict(state)
model.eval()
# ---- output directory ----
out_dir = fold_dir / "explainability"
out_dir.mkdir(exist_ok=True)
print(f"[explain_fold] Output → {out_dir}", flush=True)
# ---- Phase 1 ----
if not args.no_phase1:
run_permutation_importance(
model=model,
loader=loader,
data=data,
num_classes=num_classes,
device=device,
n_permutations=args.n_permutations,
seed=args.seed,
out_dir=out_dir,
)
# ---- Phase 2 ----
if not args.no_phase2:
run_gradcam(
model=model,
loader=loader,
data=data,
eval_df=eval_df,
eval_mode=eval_mode,
backbone=backbone,
device=device,
alpha=args.alpha,
out_dir=out_dir,
)
# ---- Phase 3 ----
if not args.no_phase3:
run_fusion_event_analysis(
model=model,
loader=loader,
device=device,
out_dir=out_dir,
)
print("\n[explain_fold] Done.", flush=True)
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More