v4 update

This commit is contained in:
rpotter6298
2026-04-20 18:01:31 +02:00
parent 13290575d5
commit 4dea45df78
71 changed files with 8316 additions and 4112 deletions
View File
View File
View File
+217
View File
@@ -0,0 +1,217 @@
"""backbones — backbone registry and builder for v4 image towers."""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List
import torch
from torch import nn
from torchvision import models, transforms
_REPO_ROOT = Path(__file__).resolve().parents[3]
REFUGELIKE_BACKBONE_PATH = _REPO_ROOT / "models/v2/refuge/refugelike_backbone.pt"
REFUGE_DENSENET_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_densenet_backbone.pt"
REFUGE_EFFICIENT_B0_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficient_b0_backbone.pt"
REFUGE_EFFICIENT_B7_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficient_b7_backbone.pt"
@dataclass(frozen=True)
class BackboneSpec:
ctor: Callable
weights_default: object
strip: Callable[[nn.Module], tuple]
blocks: Callable[[nn.Module], List[nn.Module]]
# ---------------------------------------------------------------------------
# Strip helpers
# ---------------------------------------------------------------------------
def _strip_efficientnet(m: models.EfficientNet):
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
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()
m.aux_logits = False
m.AuxLogits = None
return out_dim, m
# ---------------------------------------------------------------------------
# Block splitters for ratio-based freezing
# ---------------------------------------------------------------------------
def _blocks_efficientnet(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):
return [child for name, child in m.named_children()
if name not in ("fc", "AuxLogits")]
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
BACKBONES: Dict[str, BackboneSpec] = {
"efficientnet_b0": BackboneSpec(
ctor=models.efficientnet_b0,
weights_default=models.EfficientNet_B0_Weights.DEFAULT,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"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,
blocks=_blocks_efficientnet,
),
"refuge_efficient_b7": BackboneSpec(
ctor=models.efficientnet_b7,
weights_default=None,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
}
def list_names() -> List[str]:
return list(BACKBONES.keys())
def load_backbone_weights(key: str, model: nn.Module) -> None:
paths = {
"refugelike": REFUGELIKE_BACKBONE_PATH,
"refuge_densenet": REFUGE_DENSENET_PATH,
"refuge_efficient_b0": REFUGE_EFFICIENT_B0_PATH,
"refuge_efficient_b7": REFUGE_EFFICIENT_B7_PATH,
}
path = paths.get(key)
if path is None:
return
if not path.exists():
raise FileNotFoundError(
f"Custom backbone weights not found at {path}. "
"Export them via refuge_build.py --export-backbone first."
)
state = torch.load(path, map_location="cpu")
model.load_state_dict(state, strict=False)
def build_backbone(name: str, freeze_ratio: float = 0.0) -> tuple[nn.Module, int, list]:
"""Instantiate a backbone, strip its classifier head, apply freeze ratio.
Returns (model, out_dim, blocks) where blocks is the ordered list of
freezable units — callers use it to dynamically adjust freeze_ratio later.
"""
key = (name or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unknown backbone '{name}'. Available: {list_names()}")
spec = BACKBONES[key]
if spec.weights_default is not None:
m = spec.ctor(weights=spec.weights_default)
else:
m = spec.ctor(weights=None)
out_dim, m = spec.strip(m)
load_backbone_weights(key, m)
blocks = spec.blocks(m)
fr = max(0.0, min(1.0, float(freeze_ratio)))
n_freeze = int(math.floor(len(blocks) * fr))
for b in blocks[:n_freeze]:
for p in b.parameters():
p.requires_grad = False
return m, out_dim, blocks
+126
View File
@@ -0,0 +1,126 @@
"""se_block — Squeeze-and-Excitation channel gating."""
from __future__ import annotations
import torch
import torch.nn as nn
class SEBlock(nn.Module):
"""SE-style channel gating for vectors, feature maps, and sequences.
Input shapes:
[N, C] (vector) — squeeze is 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:
nn.init.zeros_(self.fc2.weight)
nn.init.zeros_(self.fc2.bias)
def _squeeze(self, x: torch.Tensor) -> torch.Tensor:
if x.dim() == 2:
return x
if x.dim() == 4:
return x.mean(dim=(2, 3))
if x.dim() == 3:
return x.mean(dim=1)
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)
if like.dim() == 4:
return gate.unsqueeze(-1).unsqueeze(-1)
return gate.view_as(like)
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
s = self._squeeze(x)
u = self.fc2(self.act(self.fc1(s)))
gate = (1.0 + torch.tanh(u)) if self.residual else torch.sigmoid(u)
return x * self._broadcast(gate, x), gate
class SEGateLogger:
"""Running stats over SE gate activations across batches."""
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) -> None:
self._n = 0
self._sum = 0.0
self._sum2 = 0.0
self._lt02 = 0
self._gt08 = 0
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) -> None:
if not self.enabled:
return
if gates.dim() == 4:
g = gates.mean(dim=(2, 3))
elif gates.dim() == 3:
g = gates.mean(dim=1)
elif gates.dim() == 2:
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) -> dict | None:
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
+66
View File
@@ -0,0 +1,66 @@
"""transforms — image transform utilities for v4 towers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Tuple
from torchvision import transforms
from v4.classes.accessory.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:
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 += [
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 an ImageTransformConfig using the backbone's default normalisation stats."""
key = (backbone_name or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unknown 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_name: str) -> transforms.Compose:
"""Deterministic eval transform — no augmentation, backbone-matched normalisation."""
return build_backbone_transform(backbone_name, augment=False)
View File
+57
View File
@@ -0,0 +1,57 @@
"""fusion_bridge — FusionBridge: N-input Hadamard-product fusion, embedding output only."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.accessory.se_block import SEBlock
class FusionBridge(nn.Module):
"""Project N input embeddings to a shared dim, fuse via element-wise product.
Pure embedding producer — no classification head. Attach a head stage in
the pipeline config to produce logits.
Parameters
----------
input_dims : ordered list of input embedding dims
fusion_dim : projection / output dimension
use_se : SE gate on the fused vector
se_reduction : SE reduction factor
se_pre_norm : LayerNorm before each projection; else Identity
"""
def __init__(
self,
input_dims: list[int],
fusion_dim: int = 256,
use_se: bool = True,
se_reduction: int = 16,
se_pre_norm: bool = True,
):
super().__init__()
self.out_dim = fusion_dim
self.W = nn.ModuleList([nn.Linear(d, fusion_dim) for d in input_dims])
self.ln = nn.ModuleList(
[nn.LayerNorm(fusion_dim) if se_pre_norm else nn.Identity()
for _ in input_dims]
)
self.se = SEBlock(fusion_dim, reduction=se_reduction, residual=True) if use_se else None
def forward(self, embeddings: list[torch.Tensor]) -> torch.Tensor:
assert len(embeddings) == len(self.W), (
f"FusionBridge expects {len(self.W)} inputs, got {len(embeddings)}"
)
h = self.ln[0](self.W[0](embeddings[0]))
for i in range(1, len(embeddings)):
h = h * self.ln[i](self.W[i](embeddings[i]))
if self.se is not None:
h, _ = self.se(h)
return h
def set_phase(self, phase: str) -> None:
"""Freeze bridge during tower_warmup; trainable otherwise."""
enabled = phase not in ("tower_warmup", "cd_warmup")
for p in self.parameters():
p.requires_grad_(enabled)
+56
View File
@@ -0,0 +1,56 @@
"""hyperbridge — HyperBridge: bilateral fusion over paired embeddings, embedding output only."""
from __future__ import annotations
import torch
import torch.nn as nn
class HyperBridge(nn.Module):
"""Fuse side embeddings (e.g. two z_fused vectors) into a single embedding.
Pure embedding producer — no classification head. Attach a head stage in
the pipeline config to produce logits.
Modes
-----
embedding_mlp (default)
Linear projection of concatenated inputs → hidden_dim embedding.
classic_bridge
Per-side projection → Hadamard product → hidden_dim embedding.
Parameters
----------
input_dims : {side_key: dim} — e.g. {"a": 256, "b": 256}
hidden_dim : output embedding dimension
mode : "embedding_mlp" | "classic_bridge"
"""
def __init__(
self,
input_dims: dict[str, int],
hidden_dim: int = 256,
mode: str = "embedding_mlp",
):
super().__init__()
self.input_names = list(input_dims.keys())
self.mode = mode
self.out_dim = hidden_dim
dims = list(input_dims.values())
if mode == "embedding_mlp":
self.proj = nn.Linear(sum(dims), hidden_dim)
elif mode == "classic_bridge":
self.W = nn.ModuleList([nn.Linear(d, hidden_dim) for d in dims])
self.ln = nn.ModuleList([nn.LayerNorm(hidden_dim) for _ in dims])
else:
raise ValueError(f"Unknown HyperBridge mode: {mode!r}")
def forward(self, inputs: dict[str, torch.Tensor]) -> torch.Tensor:
ordered = [inputs[name] for name in self.input_names]
if self.mode == "embedding_mlp":
return self.proj(torch.cat(ordered, dim=1))
h = self.ln[0](self.W[0](ordered[0]))
for i in range(1, len(ordered)):
h = h * self.ln[i](self.W[i](ordered[i]))
return h
+248
View File
@@ -0,0 +1,248 @@
"""dataset — data packaging for v4: shells, DataBundle, HTDataset.
ShellEntry / LoaderShell
Minimal, data-free structures representing *who* to sample and in what
order. entity_id is opaque to the orchestrator; towers interpret it.
DataBundle
Accumulates per-eye DataFrames and derives scalar/categorical stats used
by ClinicalDataView. No kfold, no vectorization — those live in the
profile and orchestrator respectively.
HTDataset / ht_collate
PyTorch Dataset that delegates sample retrieval to towers via get_sample.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import numpy as np
import pandas as pd
import torch
from torch.utils.data import Dataset
# ---------------------------------------------------------------------------
# Shell
# ---------------------------------------------------------------------------
@dataclass
class ShellEntry:
"""One sample slot in a LoaderShell.
entity_id : opaque — defined by the profile, interpreted by towers.
label : integer class label.
meta : per-entry context a profile wants to pass through.
"""
entity_id: Any
label: int
meta: dict = field(default_factory=dict)
@dataclass
class LoaderShell:
"""An ordered sequence of ShellEntry objects for one split/fold."""
entries: list[ShellEntry]
def __len__(self) -> int:
return len(self.entries)
def __iter__(self):
return iter(self.entries)
# ---------------------------------------------------------------------------
# DataBundle
# ---------------------------------------------------------------------------
class DataBundle:
"""Accumulates per-eye DataFrames and derives feature metadata.
Responsibilities: column type inference, scalar stats (min/max/median),
categorical index maps, and feature dim. Everything else (splits,
vectorization, image paths) lives in the profile that uses this bundle.
"""
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",
) -> 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.filename_template = filename_template
self.clinical_dir = Path(clinical_dir) if clinical_dir else None
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 else []
self.scalar_stats: Dict[str, Dict[str, float]] = {}
self.cat_maps: Dict[str, Dict[object, int]] = {}
self.feature_dim: int = 0
def add_df(
self,
df: pd.DataFrame,
*,
id_column: Optional[str] = None,
exclude_cols: Optional[Iterable[str]] = None,
) -> None:
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()
# ── Internal ─────────────────────────────────────────────────────────────
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; "
f"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}
candidates = [c for c in self.df.columns if c not in excluded]
cats = set(self.cat_cols)
scalars: set[str] = set()
for c in candidates:
if c in cats:
continue
s = self.df[c]
as_num = pd.to_numeric(s, errors="coerce")
n_uniq = s.dropna().nunique()
if as_num.notna().any() and as_num.isna().mean() < 1.0 and n_uniq > self.max_unique_for_cat:
scalars.add(c)
else:
cats.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:
vals = pd.to_numeric(self.df[col], errors="coerce").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: Dict[object, int] = {"<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)
)
# ---------------------------------------------------------------------------
# HTDataset / ht_collate
# ---------------------------------------------------------------------------
class HTDataset(Dataset):
"""PyTorch Dataset backed by a LoaderShell.
Delegates sample retrieval to each tower's get_sample(entry).
Batch keys are tower names plus "label".
"""
def __init__(self, shell: LoaderShell, towers: dict) -> None:
self.entries = shell.entries
self.towers = towers
def __len__(self) -> int:
return len(self.entries)
def __getitem__(self, idx: int) -> dict[str, Any]:
entry = self.entries[idx]
sample = {
"label": torch.tensor(entry.label, dtype=torch.long),
"entity_id": entry.entity_id,
}
for name, tower in self.towers.items():
sample[name] = tower.get_sample(entry)
return sample
def to_label_tensor(labels, device: torch.device) -> torch.Tensor:
"""Normalise a batch of labels (tensor or list) to a long tensor on device."""
if torch.is_tensor(labels):
return labels.to(device=device, dtype=torch.long)
return torch.as_tensor(labels, dtype=torch.long, device=device)
def ht_collate(batch: list[dict[str, Any]]) -> dict[str, Any]:
"""Collate HTDataset samples.
Tensor values are stacked; dict values (side dicts from patient-level
shells) are stacked per inner key; everything else becomes a list.
"""
if not batch:
return {}
result: dict[str, Any] = {}
for key in batch[0]:
vals = [s[key] for s in batch]
first = vals[0]
if isinstance(first, torch.Tensor):
result[key] = torch.stack(vals, dim=0)
elif isinstance(first, dict):
result[key] = {
side: torch.stack([v[side] for v in vals], dim=0)
for side in first
}
else:
result[key] = vals
return result
View File
+24
View File
@@ -0,0 +1,24 @@
"""classifier — ClassificationHead output head."""
from __future__ import annotations
import torch
import torch.nn as nn
class ClassificationHead(nn.Module):
"""Minimal classification head: ReLU → Dropout → Linear(in_dim → num_classes).
Used as the output stage of bridges and any module that needs a reusable,
swappable task head producing class logits.
"""
def __init__(self, in_dim: int, num_classes: int, dropout: float = 0.5):
super().__init__()
self.head = nn.Sequential(
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(in_dim, num_classes),
)
def forward(self, z: torch.Tensor) -> torch.Tensor:
return self.head(z)
View File
+132
View File
@@ -0,0 +1,132 @@
"""image_loader — CachedImageLoader: disk I/O with optional in-memory cache.
A single instance is shared across all towers/datasets for a run so that
images are decoded from disk at most once. The optional preprocessor runs
at cache-fill time (resize, deterministic crop, etc.) so that per-batch
transforms in the image tower only need to apply stochastic augmentations.
Usage
-----
loader = CachedImageLoader(enabled=True, workers=4)
loader.warm(paths, preprocessor=resize_fn) # optional parallel pre-fill
img = loader.load(path, preprocessor=resize_fn) # returns PIL Image
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Callable, Iterable, Optional
import numpy as np
from PIL import Image
class CachedImageLoader:
"""Loads PIL Images from disk with an optional shared in-memory cache.
The cache stores decoded, pre-preprocessed images as uint8 numpy arrays
(RGB, HWC). Storing after the preprocessor runs means the deterministic
resize/crop step executes only once per image across all folds and epochs.
Parameters
----------
enabled : bool
When False the cache is bypassed and every call hits disk.
workers : int
Thread count for ``warm()``. 0 or 1 → single-threaded.
"""
def __init__(self, *, enabled: bool = True, workers: int = 4) -> None:
self._cache: dict[str, np.ndarray] | None = {} if enabled else None
self._workers = workers
@property
def enabled(self) -> bool:
return self._cache is not None
def __len__(self) -> int:
return len(self._cache) if self._cache is not None else 0
def load(
self,
path: str | Path,
preprocessor: Optional[Callable[..., Image.Image]] = None,
) -> Image.Image:
"""Return a PIL Image for *path*, using the cache when enabled."""
key = str(path)
if self._cache is not None:
cached = self._cache.get(key)
if cached is not None:
return Image.fromarray(cached, mode="RGB")
img = Image.open(path).convert("RGB")
if preprocessor is not None:
img = call_preprocessor(preprocessor, img, path)
if self._cache is not None:
self._cache[key] = np.asarray(img, dtype=np.uint8)
return img
def warm(
self,
paths: Iterable[str | Path],
preprocessor: Optional[Callable[..., Image.Image]] = None,
) -> None:
"""Pre-populate the cache for all *paths* (no-op when disabled).
Already-cached paths are skipped, so calling warm() multiple times
(e.g. once per fold) is safe.
"""
if self._cache is None:
return
paths = list(paths)
to_warm = [str(p) for p in paths if str(p) not in self._cache]
if not to_warm:
return
already = len(paths) - len(to_warm)
print(
f"[image_cache] warming {len(to_warm)} images"
+ (f" ({already} already cached)" if already else ""),
flush=True,
)
def _warm_one(path_str: str) -> None:
if path_str in self._cache:
return
img = Image.open(path_str).convert("RGB")
if preprocessor is not None:
img = call_preprocessor(preprocessor, img, Path(path_str))
self._cache[path_str] = np.asarray(img, dtype=np.uint8)
try:
from tqdm import tqdm
except ImportError:
tqdm = None
if self._workers <= 1:
it = tqdm(to_warm, desc="Warm image cache", unit="img") if tqdm else to_warm
for p in it:
_warm_one(p)
else:
with ThreadPoolExecutor(max_workers=self._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 call_preprocessor(
fn: Callable[..., Image.Image],
img: Image.Image,
path: Path | str,
) -> Image.Image:
"""Call preprocessor with (img, path) or just (img) depending on arity."""
try:
return fn(img, path)
except TypeError:
return fn(img)
View File
+394
View File
@@ -0,0 +1,394 @@
"""prediction_store — per-epoch logit and embedding recording across folds and phases.
PredictionStore — records logits for every head, fold, phase, and epoch.
FeatureStore — records embeddings (opt-in); same structure but per-head
tensors since embedding dims vary across heads.
HDF5 layout — PredictionStore
------------------------------
/{phase}/logits float32 (n_folds, n_epochs, n_samples, n_heads, n_classes)
/{phase}/head_names str (n_heads,)
/{phase}/y_true int64 (n_samples,)
/{phase}/entity_id_{k} int64|str (n_samples,) — one dataset per id component
/{phase}/split str (n_folds, n_samples)
/{phase}/loss float32 (n_folds, n_epochs)
HDF5 layout — FeatureStore
---------------------------
/{phase}/{head_name} float32 (n_folds, n_epochs, n_samples, embedding_dim)
/{phase}/y_true int64 (n_samples,)
/{phase}/entity_id_{k} int64|str (n_samples,)
/{phase}/split str (n_folds, n_samples)
"""
from __future__ import annotations
from pathlib import Path
from typing import Sequence
import numpy as np
try:
import h5py
except ImportError as e:
raise ImportError("PredictionStore requires h5py: pip install h5py") from e
_STR_DT = h5py.string_dtype()
# ---------------------------------------------------------------------------
# Internal phase buffer
# ---------------------------------------------------------------------------
class _PhaseBuffer:
def __init__(
self,
entity_ids: list[tuple],
y_true: np.ndarray,
head_names: list[str],
n_epochs: int,
n_folds: int,
n_classes: int,
):
n_s = len(entity_ids)
n_h = len(head_names)
self.entity_ids = list(entity_ids)
self.y_true = np.asarray(y_true, dtype=np.int64)
self.head_names = list(head_names)
self.n_epochs = n_epochs
self.logits = np.full((n_folds, n_epochs, n_s, n_h, n_classes), np.nan, dtype=np.float32)
self.split = np.full((n_folds, n_s), "", dtype=object)
self.loss = np.full((n_folds, n_epochs), np.nan, dtype=np.float32)
self._sid = {str(eid): i for i, eid in enumerate(entity_ids)}
self._hid = {h: i for i, h in enumerate(head_names)}
# ---------------------------------------------------------------------------
# Internal feature buffer (per-head, variable embedding_dim)
# ---------------------------------------------------------------------------
class _FeaturePhaseBuffer:
def __init__(
self,
entity_ids: list[tuple],
y_true: np.ndarray,
n_folds: int,
):
self.entity_ids = list(entity_ids)
self.y_true = np.asarray(y_true, dtype=np.int64)
self.split = np.full((n_folds, len(entity_ids)), "", dtype=object)
self._sid = {str(eid): i for i, eid in enumerate(entity_ids)}
# head_name → (buffer array, n_epochs)
self._heads: dict[str, tuple[np.ndarray, int]] = {}
def register_head(self, head: str, n_epochs: int, embedding_dim: int, n_folds: int) -> None:
n_s = len(self.entity_ids)
self._heads[head] = (
np.full((n_folds, n_epochs, n_s, embedding_dim), np.nan, dtype=np.float32),
n_epochs,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_entity_ids(grp: h5py.Group, entity_ids: list[tuple]) -> None:
if not entity_ids:
return
n_components = max(len(eid) for eid in entity_ids)
for k in range(n_components):
vals = [eid[k] if k < len(eid) else "" for eid in entity_ids]
if all(isinstance(v, (int, np.integer)) for v in vals):
grp.create_dataset(f"entity_id_{k}", data=np.array(vals, dtype=np.int64))
else:
grp.create_dataset(f"entity_id_{k}", data=np.array(vals, dtype=object), dtype=_STR_DT)
def _read_entity_ids(grp: h5py.Group, n_samples: int) -> list[tuple]:
k, components = 0, []
while f"entity_id_{k}" in grp:
arr = grp[f"entity_id_{k}"][:]
if arr.dtype.kind in ("S", "O", "U"):
arr = np.array([v.decode() if isinstance(v, bytes) else str(v) for v in arr])
components.append(arr)
k += 1
if not components:
return [() for _ in range(n_samples)]
return [tuple(c[i] for c in components) for i in range(n_samples)]
def _decode_str_array(arr: np.ndarray) -> list[str]:
return [v.decode() if isinstance(v, bytes) else str(v) for v in arr.flat]
# ---------------------------------------------------------------------------
# PredictionStore
# ---------------------------------------------------------------------------
class PredictionStore:
"""Records per-epoch logits across all folds and phases, saves to HDF5.
The store is generic — it knows nothing about what heads or phases exist.
The orchestrator registers phases and records whatever heads it builds.
"""
def __init__(self, n_folds: int, n_classes: int) -> None:
self.n_folds = n_folds
self.n_classes = n_classes
self._phases: dict[str, _PhaseBuffer] = {}
def register_phase(
self,
phase: str,
entity_ids: list[tuple],
y_true: Sequence[int],
head_names: list[str],
n_epochs: int,
) -> None:
"""Register a training phase before recording begins."""
self._phases[phase] = _PhaseBuffer(
entity_ids=list(entity_ids),
y_true=np.asarray(y_true, dtype=np.int64),
head_names=list(head_names),
n_epochs=n_epochs,
n_folds=self.n_folds,
n_classes=self.n_classes,
)
def record(
self,
phase: str,
fold: int,
epoch: int,
entity_ids: Sequence[tuple],
head: str,
logits: np.ndarray,
) -> None:
"""Record a batch of logits for one head at one epoch."""
buf = self._phases[phase]
hidx = buf._hid.get(head)
if hidx is None:
return
for i, eid in enumerate(entity_ids):
sidx = buf._sid.get(str(eid))
if sidx is not None:
buf.logits[fold, epoch, sidx, hidx, :] = logits[i]
def record_loss(self, phase: str, fold: int, epoch: int, loss: float) -> None:
self._phases[phase].loss[fold, epoch] = float(loss)
def set_split(
self,
phase: str,
fold: int,
entity_ids: Sequence[tuple],
label: str,
) -> None:
"""Mark samples as 'train', 'val', or 'test' for a fold."""
buf = self._phases[phase]
for eid in entity_ids:
sidx = buf._sid.get(str(eid))
if sidx is not None:
buf.split[fold, sidx] = label
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with h5py.File(path, "w") as f:
for phase, buf in self._phases.items():
grp = f.create_group(phase)
grp.create_dataset("logits", data=buf.logits, compression="gzip", compression_opts=4)
grp.create_dataset("y_true", data=buf.y_true)
grp.create_dataset("loss", data=buf.loss)
grp.create_dataset("head_names", data=np.array(buf.head_names, dtype=object), dtype=_STR_DT)
grp.create_dataset("split", data=buf.split.astype(str), dtype=_STR_DT)
_write_entity_ids(grp, buf.entity_ids)
@classmethod
def load(cls, path: str | Path) -> "PredictionStore":
"""Load all phases into memory."""
with h5py.File(path, "r") as f:
first = next(iter(f.values()))
n_folds, _, _, _, n_classes = first["logits"].shape
store = cls(n_folds=n_folds, n_classes=n_classes)
for phase in f:
grp = f[phase]
logits = grp["logits"][:]
n_folds_, n_epochs, n_samples, n_heads, _ = logits.shape
head_names = _decode_str_array(grp["head_names"][:])
entity_ids = _read_entity_ids(grp, n_samples)
buf = _PhaseBuffer(
entity_ids=entity_ids,
y_true=grp["y_true"][:],
head_names=head_names,
n_epochs=n_epochs,
n_folds=n_folds_,
n_classes=n_classes,
)
buf.logits = logits
buf.loss = grp["loss"][:]
split_raw = grp["split"][:]
buf.split = np.array(
[[v.decode() if isinstance(v, bytes) else str(v) for v in row]
for row in split_raw],
dtype=object,
)
store._phases[phase] = buf
return store
# ------------------------------------------------------------------
# Query helpers
# ------------------------------------------------------------------
@property
def phases(self) -> list[str]:
return list(self._phases.keys())
def head_names(self, phase: str) -> list[str]:
return self._phases[phase].head_names
def entity_ids(self, phase: str) -> list[tuple]:
return self._phases[phase].entity_ids
def get_logits(
self,
phase: str,
head: str,
fold: int | None = None,
epoch: int | None = None,
) -> np.ndarray:
"""Slice logits for one head. Unspecified dims return the full axis.
Returns shape (folds, epochs, samples, classes) by default,
with leading dims dropped for each specified index.
"""
buf = self._phases[phase]
hidx = buf._hid[head]
data = buf.logits[:, :, :, hidx, :] # (folds, epochs, samples, classes)
if fold is not None: data = data[fold] # (epochs, samples, classes)
if epoch is not None: data = data[..., epoch, :, :] if fold is None else data[epoch]
return data
def get_split(self, phase: str, fold: int) -> dict[str, list[tuple]]:
"""Return {'train': [...], 'val': [...], 'test': [...]} entity_id lists."""
buf = self._phases[phase]
labels = buf.split[fold]
out: dict[str, list[tuple]] = {}
for eid, lbl in zip(buf.entity_ids, labels):
out.setdefault(lbl, []).append(eid)
return out
# ---------------------------------------------------------------------------
# FeatureStore
# ---------------------------------------------------------------------------
class FeatureStore:
"""Records per-epoch embeddings (variable dim per head), saves to HDF5.
Opt-in companion to PredictionStore. Typically written only on checkpoint
runs where you want to do dimensionality reduction or cluster analysis.
"""
def __init__(self, n_folds: int) -> None:
self.n_folds = n_folds
self._phases: dict[str, _FeaturePhaseBuffer] = {}
def register_phase(
self,
phase: str,
entity_ids: list[tuple],
y_true: Sequence[int],
) -> None:
self._phases[phase] = _FeaturePhaseBuffer(
entity_ids=list(entity_ids),
y_true=np.asarray(y_true, dtype=np.int64),
n_folds=self.n_folds,
)
def register_head(
self,
phase: str,
head: str,
n_epochs: int,
embedding_dim: int,
) -> None:
self._phases[phase].register_head(head, n_epochs, embedding_dim, self.n_folds)
def record(
self,
phase: str,
fold: int,
epoch: int,
entity_ids: Sequence[tuple],
head: str,
embeddings: np.ndarray,
) -> None:
buf = self._phases[phase]
arr, _= buf._heads[head]
for i, eid in enumerate(entity_ids):
sidx = buf._sid.get(str(eid))
if sidx is not None:
arr[fold, epoch, sidx, :] = embeddings[i]
def set_split(
self,
phase: str,
fold: int,
entity_ids: Sequence[tuple],
label: str,
) -> None:
buf = self._phases[phase]
for eid in entity_ids:
sidx = buf._sid.get(str(eid))
if sidx is not None:
buf.split[fold, sidx] = label
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with h5py.File(path, "w") as f:
for phase, buf in self._phases.items():
grp = f.create_group(phase)
grp.create_dataset("y_true", data=buf.y_true)
grp.create_dataset("split", data=buf.split.astype(str), dtype=_STR_DT)
_write_entity_ids(grp, buf.entity_ids)
for head, (arr, _) in buf._heads.items():
grp.create_dataset(head, data=arr, compression="gzip", compression_opts=4)
@classmethod
def load(cls, path: str | Path) -> "FeatureStore":
with h5py.File(path, "r") as f:
n_folds = next(
arr.shape[0]
for grp in f.values()
for k, arr in grp.items()
if k not in ("y_true", "split") and not k.startswith("entity_id_")
)
store = cls(n_folds=n_folds)
_meta = {"y_true", "split"}
for phase in f:
grp = f[phase]
n_samples = grp["y_true"].shape[0]
entity_ids = _read_entity_ids(grp, n_samples)
buf = _FeaturePhaseBuffer(
entity_ids=entity_ids,
y_true=grp["y_true"][:],
n_folds=n_folds,
)
buf.split = np.array(
[[v.decode() if isinstance(v, bytes) else str(v) for v in row]
for row in grp["split"][:]],
dtype=object,
)
for key in grp:
if key in _meta or key.startswith("entity_id_"):
continue
arr = grp[key][:]
buf._heads[key] = (arr, arr.shape[1])
store._phases[phase] = buf
return store
+175
View File
@@ -0,0 +1,175 @@
"""metrics — loss, scoring, calibration, and threshold/bias tuning."""
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:
"""Focal loss; reduces to cross-entropy when gamma=0."""
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)
loss = -(((1.0 - pt).clamp_min(0.0) ** gamma) * logpt)
if weight is not None:
loss = loss * weight.gather(0, targets.view(-1)).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):
"""Return (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 |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
ece += float(mask.sum()) / n * abs(
float(confidences[mask].mean()) - float((predictions[mask] == y_true[mask]).mean())
)
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: kappa = nan
try: mcc = float(matthews_corrcoef(y_true, preds))
except: mcc = nan
try: macro_f1 = float(f1_score(y_true, preds, average="macro", zero_division=0))
except: macro_f1 = nan
try:
pcr = recall_score(
y_true, preds, average=None,
labels=list(range(num_classes)), zero_division=0,
).astype(float)
except:
pcr = np.full(num_classes, nan)
return dict(
kappa=kappa, mcc=mcc, macro_f1=macro_f1,
per_class_recall=pcr, ece=compute_ece(y_true, probs, n_bins=n_bins),
)
# ---------------------------------------------------------------------------
# Threshold / bias tuning
# ---------------------------------------------------------------------------
def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float:
"""Pick threshold via Youden's J (sensitivity + specificity 1).
Class-distribution independent; falls back to 0.5 if fewer than two
classes are present in y_true.
"""
if y_true.size == 0 or len(np.unique(y_true)) < 2:
return 0.5
fpr, tpr, thresholds = roc_curve(y_true, p1)
return float(thresholds[np.argmax(tpr + (1.0 - fpr) - 1.0)])
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)
return float(np.mean([(preds[y_true == c] == c).mean() for c in classes]))
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."""
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
View File
+663
View File
@@ -0,0 +1,663 @@
"""v4papila — self-contained PAPILA data module.
Public contract (v4 orchestrator interface)
-------------------------------------------
bundle = build_data(args: dict) -> PapilaBundle
PapilaBundle exposes:
.df full preprocessed DataFrame (for split building)
.label_col, .patient_col, .feature_dim
.id_names tuple of semantic names for each entity_id slot
e.g. ("patient_id", "eye") — used by orchestrator for logging
.matrix ClinicalDataView (all eyes)
.matrix.od / .matrix.os scoped views (OD or OS only)
.image ImageDataView (all eyes)
.image.od / .image.os scoped views
.build_shells(df, *, level) -> LoaderShell
DataView interface (consumed by towers' get_sample)
----------------------------------------------------
Both views accept positional id slots (*ids) matching entity_id tuple positions.
Semantic names for each position are in view.id_names.
ClinicalDataView:
.feature_dim
.id_names e.g. ("patient_id", "eye")
.vectorize_entity(*ids) -> np.ndarray
.side_map -> dict mapping generic keys {"a", "b"} to id_1 values
.od, .os -> scoped ClinicalDataView
ImageDataView:
.id_names e.g. ("patient_id", "eye")
.get_image_path(*ids) -> Path
.load_image(*ids) -> PIL.Image
.side_map -> dict mapping generic keys {"a", "b"} to id_1 values
.od, .os -> scoped ImageDataView
"""
from __future__ import annotations
import sys
from functools import cached_property
from pathlib import Path
from typing import Callable, Dict, List, Optional
import numpy as np
import pandas as pd
from PIL import Image
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from v4.classes.loaders.image_loader import CachedImageLoader, call_preprocessor
from v4.classes.dataset import DataBundle, LoaderShell, ShellEntry
# ---------------------------------------------------------------------------
# Pachymetry → IOP correction (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:
return int(_PACHY_KEYS[int(np.argmin(np.abs(_PACHY_KEYS - float(x))))])
def _fit_perkins_converter(
frames: List[pd.DataFrame], method: str
) -> Callable[[float, Optional[float]], float]:
combined = pd.concat(frames, ignore_index=True)
paired = combined.dropna(subset=["Pneumatic", "Perkins"])
if len(paired) == 0:
raise ValueError("No paired Pneumatic+Perkins rows; cannot fit converter.")
pneumatic = paired["Pneumatic"].values.astype(float)
perkins = paired["Perkins"].values.astype(float)
if method == "ratio":
ratio = float((pneumatic / perkins).mean())
def _conv(p: float, pachy: Optional[float] = None) -> float:
return p * ratio
return _conv
elif method == "ols":
from scipy import stats as _stats
slope, intercept, *_ = _stats.linregress(perkins, pneumatic)
slope, intercept = float(slope), float(intercept)
def _conv(p: float, pachy: Optional[float] = None) -> float:
return p * slope + intercept
return _conv
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 _conv(p: float, pachy: Optional[float] = None) -> float:
return p * slope + intercept
return _conv
elif method == "multi":
from numpy.linalg import lstsq as _lstsq
pm = combined.dropna(subset=["Pneumatic", "Perkins", "Pachymetry"])
if len(pm) == 0:
raise ValueError("No Pneumatic+Perkins+Pachymetry rows; cannot fit multi.")
pneu = pm["Pneumatic"].values.astype(float)
perk = pm["Perkins"].values.astype(float)
pv = pm["Pachymetry"].values.astype(float)
X = np.column_stack([perk, pv, np.ones(len(perk))])
coeffs, *_ = _lstsq(X, pneu, rcond=None)
slope, pachy_coef, intercept = float(coeffs[0]), float(coeffs[1]), float(coeffs[2])
fallback = float(pv.mean())
def _conv(p: float, pachy: Optional[float] = None) -> float:
pval = pachy if (pachy is not None and not np.isnan(pachy)) else fallback
return p * slope + pachy_coef * pval + intercept
return _conv
else:
raise ValueError(f"Unknown iop_corr_method: {method!r}. Choose ratio/ols/lad/multi.")
def _pick_iop(row: pd.Series, converter: Callable) -> float:
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:
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:
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 = [c for c in ("Pneumatic", "Perkins", "VF_MD") if c in df.columns]
if drop_raw:
drop.append("IOP_raw")
if drop:
df.drop(columns=drop, inplace=True)
return df
def _canonicalize_eye_column(df: pd.DataFrame) -> None:
if "eyeID" in df.columns:
src = "eyeID"
else:
src = next((c for c in df.columns if "eye" in c.lower()), None)
if src is None:
df["eyeID"] = "OS"
return
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 = df[src].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 {sorted(uniq)}")
df["eyeID"] = mapped.fillna("OS")
# ---------------------------------------------------------------------------
# DataView classes
# ---------------------------------------------------------------------------
class ClinicalDataView:
"""Tabular feature view over a (possibly eye-filtered) clinical DataFrame.
Exposes feature_dim and vectorize_entity so towers can retrieve
feature vectors by entity identity without knowing about the DataFrame.
Scoped views (OD or OS only) are accessed via .od and .os properties.
id_names gives semantic labels for each positional slot in an entity_id tuple,
e.g. ("patient_id", "eye"). The orchestrator uses this for logging without
needing to know PAPILA-specific field names itself.
"""
# PAPILA canonical side keys used in ShellEntry entity_ids
SIDE_A = "OD"
SIDE_B = "OS"
# Semantic name for each entity_id position (id_0, id_1, ...)
id_names: tuple[str, ...] = ("patient_id", "eye")
def __init__(
self,
df: pd.DataFrame,
patient_col: str,
scalar_cols: list[str],
cat_cols: list[str],
scalar_stats: dict,
cat_maps: dict,
*,
eye_filter: str | None = None, # "OD", "OS", or None (all eyes)
):
self._df = df
self.patient_col = patient_col
self.scalar_cols = scalar_cols
self.cat_cols = cat_cols
self.scalar_stats = scalar_stats
self.cat_maps = cat_maps
self._eye_filter = eye_filter
# Build a (patient_id, eyeID) → row index for fast lookup
if "eyeID" in df.columns:
self._idx = df.set_index([patient_col, "eyeID"])
else:
self._idx = df.set_index(patient_col)
# ── Public API ──────────────────────────────────────────────────────────
@property
def feature_dim(self) -> int:
n_scalar = len(self.scalar_cols)
n_cat = sum(len(m) for m in self.cat_maps.values())
return n_scalar + n_cat + n_scalar # scalars + one-hots + missing flags
def vectorize_entity(self, *ids) -> np.ndarray:
"""Return the feature vector for an entity identified by positional ids.
Positional slots match entity_id tuple positions (see id_names).
For PAPILA: ids = (id_0, id_1) = (patient_id, eye).
"""
try:
row = self._idx.loc[ids if len(ids) > 1 else ids[0]].copy()
except KeyError:
names = self.id_names[:len(ids)]
raise KeyError(
f"ClinicalDataView: no row found for {dict(zip(names, ids))}"
)
# set_index removes index-level columns from the row; restore any that
# _vectorize_row needs (e.g. eyeID is a cat feature AND an index level)
idx_names = (self._idx.index.names
if hasattr(self._idx.index, 'names')
else [self._idx.index.name])
for name, val in zip(idx_names, ids if len(ids) > 1 else [ids[0]]):
if name not in row.index:
row[name] = val
return self._vectorize_row(row)
# ── Scoped views ─────────────────────────────────────────────────────────
@property
def side_map(self) -> dict[str, str]:
"""Generic side-key → dataset side string. Towers use this for patient-level shells."""
return {"a": self.SIDE_A, "b": self.SIDE_B}
@cached_property
def od(self) -> "ClinicalDataView":
return self._scoped(self.SIDE_A)
@cached_property
def os(self) -> "ClinicalDataView":
return self._scoped(self.SIDE_B)
def _scoped(self, eye: str) -> "ClinicalDataView":
sub = self._df[self._df["eyeID"] == eye].reset_index(drop=True)
return ClinicalDataView(
df=sub,
patient_col=self.patient_col,
scalar_cols=self.scalar_cols,
cat_cols=self.cat_cols,
scalar_stats=self.scalar_stats,
cat_maps=self.cat_maps,
eye_filter=eye,
)
# ── Internal ─────────────────────────────────────────────────────────────
def _vectorize_row(self, row: pd.Series) -> np.ndarray:
feats: list[float] = []
miss: list[float] = []
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)
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
feats.extend(one)
feats.extend(miss)
return np.asarray(feats, dtype=np.float32)
class ImageDataView:
"""Image path and loading view over a (possibly eye-filtered) DataFrame.
Provides get_image_path and load_image keyed by positional id slots.
Scoped views (.od, .os) are available for single-side towers.
The optional image_cache is a shared CachedImageLoader for the run.
id_names gives semantic labels for each positional slot in an entity_id tuple,
e.g. ("patient_id", "eye").
"""
SIDE_A = "OD"
SIDE_B = "OS"
id_names: tuple[str, ...] = ("patient_id", "eye")
def __init__(
self,
df: pd.DataFrame,
patient_col: str,
image_dir: str,
filename_template: str,
preprocessor: Callable | None = None,
image_cache: CachedImageLoader | None = None,
*,
eye_filter: str | None = None,
):
self._df = df
self.patient_col = patient_col
self.image_dir = Path(image_dir)
self.filename_template = filename_template
self.preprocessor = preprocessor
self.image_cache = image_cache
self._eye_filter = eye_filter
if "eyeID" in df.columns:
self._idx = df.set_index([patient_col, "eyeID"])
else:
self._idx = df.set_index(patient_col)
# ── Public API ──────────────────────────────────────────────────────────
def get_image_path(self, *ids) -> Path:
"""Return the image path for an entity identified by positional ids.
For PAPILA: ids = (id_0, id_1) = (patient_id, eye).
"""
id_0, id_1 = ids # PAPILA always uses two slots
try:
row = self._idx.loc[(id_0, id_1)]
except KeyError:
names = self.id_names[:len(ids)]
raise KeyError(
f"ImageDataView: no row for {dict(zip(names, ids))}"
)
pid = int(row[self.patient_col]) if self.patient_col in row.index else int(id_0)
return self.image_dir / self.filename_template.format(pid=pid, eye=id_1)
def load_image(self, *ids) -> Image.Image:
"""Load image for an entity identified by positional ids."""
path = self.get_image_path(*ids)
if self.image_cache is not None:
return self.image_cache.load(path, preprocessor=self.preprocessor)
img = Image.open(path).convert("RGB")
if self.preprocessor is not None:
img = call_preprocessor(self.preprocessor, img, path)
return img
# ── Scoped views ─────────────────────────────────────────────────────────
@cached_property
def od(self) -> "ImageDataView":
return self._scoped(self.SIDE_A)
@cached_property
def os(self) -> "ImageDataView":
return self._scoped(self.SIDE_B)
@property
def side_map(self) -> dict[str, str]:
"""Generic side-key → dataset side string. Towers use this for patient-level shells."""
return {"a": self.SIDE_A, "b": self.SIDE_B}
def _scoped(self, eye: str) -> "ImageDataView":
sub = self._df[self._df["eyeID"] == eye].reset_index(drop=True)
return ImageDataView(
df=sub,
patient_col=self.patient_col,
image_dir=str(self.image_dir),
filename_template=self.filename_template,
preprocessor=self.preprocessor,
image_cache=self.image_cache,
eye_filter=eye,
)
# ---------------------------------------------------------------------------
# PapilaBundle — the v4 DataBundle returned by build_data
# ---------------------------------------------------------------------------
class PapilaBundle:
"""V4 DataBundle for PAPILA.
Wraps the v3 DataBundle for backward compatibility (df, feature_dim,
vectorize_row, get_image_path, patient_col, label_col) while adding
the v4 DataView interface and build_shells().
"""
def __init__(
self,
bundle: DataBundle,
image_dir: str,
preprocessor: Callable | None = None,
image_cache: CachedImageLoader | None = None,
):
self._bundle = bundle
self._image_dir = image_dir
# ── ClinicalDataView (all eyes) ──────────────────────────────────────
self.matrix = ClinicalDataView(
df=bundle.df,
patient_col=bundle.patient_col,
scalar_cols=bundle.scalar_cols,
cat_cols=bundle.cat_cols,
scalar_stats=bundle.scalar_stats,
cat_maps=bundle.cat_maps,
)
# ── ImageDataView (all eyes) ─────────────────────────────────────────
self.image = ImageDataView(
df=bundle.df,
patient_col=bundle.patient_col,
image_dir=image_dir,
filename_template=bundle.filename_template,
preprocessor=preprocessor,
image_cache=image_cache,
)
# ── Entity-id metadata (for orchestrator logging) ────────────────────────
@property
def id_names(self) -> tuple[str, ...]:
"""Semantic names for each entity_id position, e.g. ('patient_id', 'eye').
Orchestrators use this to decode entity_ids for logging without
hardcoding dataset-specific field names.
"""
return self.matrix.id_names # both views share the same structure
# ── Identity column registry (for orchestrator split_identity_level) ────────
@property
def identity_cols(self) -> list[str]:
"""Ordered list of grouping columns, one per identity level.
identity_level=1 → identity_cols[0] → patient column (group by patient)
identity_level=2 → identity_cols[1] → eye column (group by patient+eye)
"""
return [self._bundle.patient_col, "eyeID"]
# ── Backward-compat delegates ────────────────────────────────────────────
@property
def df(self) -> pd.DataFrame:
return self._bundle.df
@property
def label_col(self) -> str:
return self._bundle.label_col
@property
def patient_col(self) -> str:
return self._bundle.patient_col
@property
def feature_dim(self) -> int:
return self._bundle.feature_dim
def vectorize_row(self, row: pd.Series) -> np.ndarray:
return self._bundle.vectorize_row(row)
def get_image_path(self, row: pd.Series):
return self._bundle.get_image_path(row)
# ── Shell building ───────────────────────────────────────────────────────
def build_shells(
self,
df: pd.DataFrame,
*,
level: str = "eye",
label_filter: list[int] | None = None,
) -> LoaderShell:
"""Build a LoaderShell from a split DataFrame.
level="eye" — one ShellEntry per eye row.
entity_id = (patient_id, side_key)
e.g. (42, "OD") or (42, "OS")
level="patient" — one ShellEntry per patient.
entity_id = (patient_id,) — 1-tuple
Towers that need both sides use data_view.side_map
to assemble them in get_sample.
Patients missing either eye are excluded.
"""
pc = self.patient_col
lc = self.label_col
if label_filter is not None:
df = df[df[lc].isin(label_filter)]
entries: list[ShellEntry] = []
if level == "eye":
for _, row in df.iterrows():
pid = int(row[pc])
label = int(row[lc])
side = str(row.get("eyeID", "OD"))
entries.append(ShellEntry(entity_id=(pid, side), label=label))
elif level == "patient":
for pid, grp in df.groupby(pc):
if "eyeID" in grp.columns:
eyes = set(grp["eyeID"].unique())
if "OD" not in eyes or "OS" not in eyes:
continue
label_mode = grp[lc].mode()
label = int(label_mode.iloc[0]) if not label_mode.empty else int(grp[lc].iloc[0])
entries.append(ShellEntry(entity_id=(int(pid),), label=label))
else:
raise ValueError(f"Unknown shell level: {level!r}. Choose 'eye' or 'patient'.")
return LoaderShell(entries=entries)
# ---------------------------------------------------------------------------
# Resolve helper — used by the orchestrator to inject DataView into towers
# ---------------------------------------------------------------------------
def resolve_data_source(bundle: PapilaBundle, path: str):
"""Resolve a dot-path data source string against a PapilaBundle.
Examples
--------
"matrix" → bundle.matrix
"matrix.od" → bundle.matrix.od
"image" → bundle.image
"image.os" → bundle.image.os
"""
obj = bundle
for part in path.split("."):
obj = getattr(obj, part)
return obj
# ---------------------------------------------------------------------------
# Public contract: build_data(args: dict) -> PapilaBundle
# ---------------------------------------------------------------------------
_DEFAULT_CAT_COLS = ["Gender", "Phakic/Pseudophakic"]
def build_data(args: dict) -> PapilaBundle:
"""Build and return a PapilaBundle.
args keys
---------
image_dir (required)
clinical_dir (required)
label_col (default: "Diagnosis")
iop_corr_method (default: "ratio")
iop_drop_raw (default: False)
exclude_cols (default: [])
cat_cols (default: ["Gender", "Phakic/Pseudophakic"])
n_splits (default: 5)
random_seed (default: 42)
in_memory_cache (default: False) — enable shared image cache for the run
"""
image_dir = args["image_dir"]
clinical_dir = args["clinical_dir"]
label_col = args.get("label_col", "Diagnosis")
iop_method = args.get("iop_corr_method", "ratio")
iop_drop_raw = bool(args.get("iop_drop_raw", False))
exclude_cols = list(args.get("exclude_cols", []))
cat_cols = list(args.get("cat_cols", _DEFAULT_CAT_COLS))
n_splits = int(args.get("n_splits", 5))
random_seed = int(args.get("random_seed", 42))
use_cache = bool(args.get("in_memory_cache", False))
effective_cat = [c for c in cat_cols if c not in exclude_cols]
bundle = DataBundle(
image_dir=image_dir,
clinical_dir=clinical_dir,
label_col=label_col,
patient_col="Patient ID",
cat_cols=effective_cat,
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_cols or None)
bundle.add_df(os_, id_column="ID", exclude_cols=exclude_cols or None)
converter = _fit_perkins_converter(bundle.frames, method=iop_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_cols or None)
bundle._infer_or_validate_feature_types(exclude_cols=exclude_cols or None)
bundle._compute_numeric_stats()
bundle._build_cat_maps()
bundle._compute_feature_dim()
image_cache = CachedImageLoader() if use_cache else None
return PapilaBundle(
bundle=bundle,
image_dir=image_dir,
image_cache=image_cache,
)
+177
View File
@@ -0,0 +1,177 @@
"""split_manager — generic stratified k-fold splitter.
SplitManager splits a DataFrame into train/val/test folds using an
outer/inner k-fold scheme. The grouping identity is controlled by
``group_col``:
group_col=None — row-level splits (each row is its own identity)
group_col="Patient ID" — group-level splits (all rows sharing a group
key land in the same fold)
The translation from a conceptual "identity level" to a concrete column
name belongs in the caller (typically the orchestrator), which has access
to the data bundle's column schema.
"""
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
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SplitPlan:
train_ids: set[Any]
val_ids: set[Any]
test_ids: set[Any]
@dataclass
class Split:
"""One fold's train/val/test DataFrames."""
train: pd.DataFrame
val: pd.DataFrame
test: Optional[pd.DataFrame] = None
# ---------------------------------------------------------------------------
# Core splitter
# ---------------------------------------------------------------------------
def _can_stratify(labels: np.ndarray, n_splits: int) -> bool:
if labels.size == 0:
return False
unique, counts = np.unique(labels, return_counts=True)
return len(unique) >= 2 and bool(np.all(counts >= n_splits))
def _build_split_plans(
ids: np.ndarray,
labels: np.ndarray,
n_splits: int,
seed: int,
) -> list[SplitPlan]:
"""Outer/inner k-fold: test=fold k, val=fold (k+1)%n, train=remaining."""
if ids.size == 0:
raise ValueError("No samples available for splitting")
if len(set(ids.tolist())) != ids.size:
raise ValueError("ids must be unique")
if n_splits < 3:
raise ValueError("n_splits must be >= 3 for outer/inner k-fold")
if _can_stratify(labels, n_splits):
splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
folds = list(splitter.split(ids, labels))
else:
splitter = KFold(n_splits=n_splits, shuffle=True, random_state=seed)
folds = list(splitter.split(ids))
fold_sets = [set(ids[test_idx].tolist()) for _, test_idx in folds]
plans = []
for k in range(n_splits):
test_ids = fold_sets[k]
val_ids = fold_sets[(k + 1) % n_splits]
train_ids = set().union(*(fold_sets[j] for j in range(n_splits)
if j != k and j != (k + 1) % n_splits))
plans.append(SplitPlan(train_ids=train_ids, val_ids=val_ids, test_ids=test_ids))
return plans
# ---------------------------------------------------------------------------
# SplitManager
# ---------------------------------------------------------------------------
class SplitManager:
"""Generic stratified k-fold split manager.
Parameters
----------
group_col : str | None
Column whose values define the grouping identity for fold assignment.
``None`` splits on individual rows (no grouping).
label_col : str | None
Column used for stratification. Resolved from the DataFrame at
``build_plans`` time if not provided here.
"""
def __init__(
self,
group_col: Optional[str] = None,
label_col: Optional[str] = None,
) -> None:
self.group_col = group_col
self.label_col = label_col
def build_plans(
self,
df: pd.DataFrame,
*,
n_splits: int = 5,
seed: int = 42,
label_col: Optional[str] = None,
) -> list[Split]:
"""Build n_splits fold plans from df.
Parameters
----------
df : full DataFrame (pre-filtered to the desired eval mode)
n_splits : number of folds (must be >= 3)
seed : random seed for reproducibility
label_col : override for stratification column (falls back to
``self.label_col``, then raises)
"""
lc = label_col or self.label_col
if lc is None:
raise ValueError("label_col must be provided to build_plans or SplitManager")
if lc not in df.columns:
raise ValueError(f"label_col {lc!r} not found in DataFrame")
df = df.copy().reset_index(drop=True)
if self.group_col is None:
# Row-level: each row is its own identity
ids = df.index.to_numpy()
labels = df[lc].to_numpy()
plans = _build_split_plans(ids, labels, n_splits, seed)
return [
Split(
train=df[df.index.isin(p.train_ids)].reset_index(drop=True),
val =df[df.index.isin(p.val_ids) ].reset_index(drop=True),
test =df[df.index.isin(p.test_ids) ].reset_index(drop=True),
)
for p in plans
]
else:
gc = self.group_col
if gc not in df.columns:
raise ValueError(f"group_col {gc!r} not found in DataFrame")
# Group-level: collapse to one row per group, then split
group_table = (
df.groupby(gc, as_index=False)[lc]
.agg(lambda s: s.mode().iloc[0] if not s.mode().empty else s.iloc[0])
.rename(columns={lc: "_label"})
.sort_values(gc)
.reset_index(drop=True)
)
plans = _build_split_plans(
group_table[gc].to_numpy(),
group_table["_label"].to_numpy(),
n_splits,
seed,
)
return [
Split(
train=df[df[gc].isin(p.train_ids)].reset_index(drop=True),
val =df[df[gc].isin(p.val_ids) ].reset_index(drop=True),
test =df[df[gc].isin(p.test_ids) ].reset_index(drop=True),
)
for p in plans
]
View File
+273
View File
@@ -0,0 +1,273 @@
"""stages/fusion — fusion stage runner: trains a bridge + associated head stages."""
from __future__ import annotations
import importlib
from random import choice, random as _random
import numpy as np
import torch
import torch.nn.functional as F
from v4.classes.dataset import LoaderShell, to_label_tensor
from v4.classes.metrics import score_arrays, compute_extended_metrics, tune_binary_threshold
from v4.classes.stages.helpers import (
encode_embedding, get_out_dim, resolve_input_dims, phase_for_epoch,
)
def collect_probs(
bridge,
primary_head,
stage_cfg: dict,
towers: dict,
stage_models: dict,
cfg_stages: list[dict],
loader,
device,
num_classes: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Eval pass for one fusion stage; returns (y_true, softmax_probs)."""
from v4.classes.dataset import to_label_tensor
bridge.eval(); primary_head.eval()
for t in towers.values():
t.eval()
inputs = stage_cfg["inputs"]
is_bilateral = isinstance(inputs, dict)
y_all, p_all = [], []
with torch.no_grad():
for batch in loader:
y = batch.get("label")
if not torch.is_tensor(y):
continue
if is_bilateral:
side_embs = {
side: encode_embedding(src, batch, side, towers, stage_models, cfg_stages, device)
for side, src in inputs.items()
}
z = bridge(side_embs)
else:
embs = [
encode_embedding(n, batch, None, towers, stage_models, cfg_stages, device)
for n in inputs
]
z = bridge(embs)
logits = primary_head(z)
y_all.append(to_label_tensor(y, device).cpu().numpy())
p_all.append(F.softmax(logits, dim=1).cpu().numpy())
if not y_all:
return np.zeros(0, dtype=np.int64), np.zeros((0, num_classes), dtype=np.float32)
return np.concatenate(y_all), np.concatenate(p_all, axis=0)
def run(
stage_cfg: dict,
cfg: dict,
towers: dict,
stage_models: dict,
data,
split,
label_filter,
num_classes: int,
device,
fold: int,
cfg_stages: list[dict],
_make_loader,
) -> tuple[dict, dict]:
"""Train one fusion stage + its associated head stages.
Returns (updated_stage_models, metrics_dict).
"""
nan = float("nan")
name = stage_cfg["name"]
level = stage_cfg["level"]
epochs = stage_cfg["epochs"]
inputs = stage_cfg["inputs"]
is_bilateral = isinstance(inputs, dict)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter)
s_val = data.build_shells(split.val, level=level, label_filter=label_filter)
s_test = (data.build_shells(split.test, level=level, label_filter=label_filter)
if split.test is not None else LoaderShell(entries=[]))
if not s_val.entries:
print(f" fold{fold+1}: no val samples for stage {name!r}, skipping.", flush=True)
return stage_models, {}
bs = cfg["training"]["batch_size"]
train_loader = _make_loader(s_train, towers, batch_size=bs, shuffle=True)
val_loader = _make_loader(s_val, towers, batch_size=bs, shuffle=False)
test_loader = (_make_loader(s_test, towers, batch_size=bs, shuffle=False)
if s_test.entries else None)
# ── Bridge ───────────────────────────────────────────────────────────────
input_dims = resolve_input_dims(inputs, towers, stage_models)
bmod = importlib.import_module(stage_cfg["module"])
bridge = getattr(bmod, stage_cfg["class"])(input_dims, **stage_cfg.get("args", {})).to(device)
# ── Head stages ──────────────────────────────────────────────────────────
head_stage_cfgs = [s for s in cfg_stages if s["type"] == "head"
and s.get("train_with") == name]
head_models: dict[str, torch.nn.Module] = {}
for hs in head_stage_cfgs:
h_dim = get_out_dim(hs["input"], towers, {**stage_models, name: bridge})
h_mod = importlib.import_module(hs.get("module", "v4.classes.heads.classifier"))
h_cls = getattr(h_mod, hs.get("class", "ClassificationHead"))
head_models[hs["name"]] = h_cls(h_dim, num_classes).to(device)
primary_hs_cfg = next((hs for hs in head_stage_cfgs if not hs.get("bcd", False)), None)
bcd_head_cfgs = [hs for hs in head_stage_cfgs if hs.get("bcd", False)]
if primary_hs_cfg is None:
print(f" WARNING: no primary head for stage {name!r}; skipping.", flush=True)
return stage_models, {}
primary_head = head_models[primary_hs_cfg["name"]]
# ── Freeze prior stages ───────────────────────────────────────────────────
for m in stage_models.values():
for p in m.parameters():
p.requires_grad_(False)
m.eval()
# ── Optimizer ────────────────────────────────────────────────────────────
train_towers = stage_cfg.get("train_towers", False)
opt_params = (
([p for t in towers.values() for p in t.parameters()] if train_towers else []) +
list(bridge.parameters()) +
[p for h in head_models.values() for p in h.parameters()]
)
opt = torch.optim.Adam(opt_params, lr=cfg["training"]["lr"])
warmup_cfg = stage_cfg.get("warmup", {})
wt = 0 if is_bilateral else warmup_cfg.get("tower_epochs", 0)
wf = 0 if is_bilateral else warmup_cfg.get("fused_epochs", 0)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
# ── Epoch loop ────────────────────────────────────────────────────────────
for epoch in range(epochs):
bridge.train()
for h in head_models.values():
h.train()
for t in towers.values():
if train_towers:
t.train()
else:
t.eval()
if not is_bilateral:
phase = phase_for_epoch(epoch, wt, wf)
if hasattr(bridge, "set_phase"):
bridge.set_phase(phase)
for t in towers.values():
if hasattr(t, "set_phase"):
t.set_phase(phase)
else:
phase = "fusion"
total_loss = total_correct = total_n = 0
for batch in train_loader:
y = batch.get("label")
if not torch.is_tensor(y):
continue
y_t = to_label_tensor(y, device)
if y_t.numel() == 0:
continue
if is_bilateral:
side_embs = {
side: encode_embedding(src, batch, side, towers, stage_models, cfg_stages, device)
for side, src in inputs.items()
}
local_embs = {name: bridge(side_embs)}
else:
local_embs = {n: towers[n](batch[n].to(device)) for n in inputs
if n in batch and torch.is_tensor(batch[n])}
if len(local_embs) != len(inputs):
continue
local_embs[name] = bridge(list(local_embs[n] for n in inputs))
head_logits = {
hs["name"]: head_models[hs["name"]](local_embs[hs["input"]])
for hs in head_stage_cfgs
if hs["input"] in local_embs
}
if is_bilateral or phase == "fused_warmup":
logits = head_logits.get(primary_hs_cfg["name"])
elif phase == "tower_warmup" and bcd_head_cfgs:
losses = [F.cross_entropy(head_logits[hs["name"]], y_t)
for hs in bcd_head_cfgs if hs["name"] in head_logits]
if not losses:
continue
loss = sum(losses) / len(losses)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
else:
if bcd_head_cfgs and _random() < bcd_prob:
logits = head_logits.get(choice(bcd_head_cfgs)["name"])
else:
logits = head_logits.get(primary_hs_cfg["name"])
if logits is None:
continue
loss = F.cross_entropy(logits, y_t)
opt.zero_grad(); loss.backward(); opt.step()
total_correct += int((logits.argmax(1) == y_t).sum())
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
tr_loss = total_loss / total_n if total_n else nan
tr_acc = total_correct / total_n if total_n else nan
y_v, p_v = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_auc={val_auc:.4f}",
flush=True,
)
# ── Final eval ────────────────────────────────────────────────────────────
y_val, p_val = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
val_threshold = 0.5
if (cfg["training"].get("tune_binary_threshold")
and num_classes == 2 and y_val.size >= 2):
val_threshold = tune_binary_threshold(y_val, p_val[:, 1])
test_auc = test_acc = test_n = nan
if test_loader is not None:
y_te, p_te = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, test_loader, device, num_classes)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
updated = dict(stage_models)
updated[name] = bridge
updated.update(head_models)
metrics = {
f"{name}_val_auc": val_auc,
f"{name}_val_acc": val_acc,
f"{name}_val_n": val_n,
f"{name}_val_kappa": ext.get("kappa", nan),
f"{name}_val_mcc": ext.get("mcc", nan),
f"{name}_val_f1": ext.get("macro_f1", nan),
f"{name}_val_threshold": val_threshold,
f"{name}_test_auc": test_auc,
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
}
return updated, metrics
+66
View File
@@ -0,0 +1,66 @@
"""stages/helpers — shared utilities for stage runners."""
from __future__ import annotations
import torch
def get_out_dim(name: str, towers: dict, stage_models: dict) -> int:
if name in towers:
return towers[name].out_dim
if name in stage_models:
return stage_models[name].out_dim
raise KeyError(f"No out_dim for input {name!r}")
def resolve_input_dims(inputs, towers: dict, stage_models: dict):
"""Return list[int] for list inputs, dict[str, int] for dict inputs."""
if isinstance(inputs, list):
return [get_out_dim(n, towers, stage_models) for n in inputs]
return {k: get_out_dim(v, towers, stage_models) for k, v in inputs.items()}
def encode_embedding(
name: str,
batch: dict,
side: str | None,
towers: dict,
stage_models: dict,
cfg_stages: list[dict],
device,
) -> torch.Tensor:
"""Return the embedding for a named tower or prior frozen fusion stage.
For bilateral batches, *side* selects which side dict entry to use.
Fusion stages are re-encoded recursively from their own inputs.
Runs under torch.no_grad() — only used for frozen passes.
"""
if name in towers:
t = batch[name]
if side is not None and isinstance(t, dict):
t = t[side]
with torch.no_grad():
return towers[name](t.to(device))
s_cfg = next(s for s in cfg_stages if s["name"] == name)
model = stage_models[name]
inputs = s_cfg["inputs"]
if isinstance(inputs, list):
embeddings = [
encode_embedding(n, batch, side, towers, stage_models, cfg_stages, device)
for n in inputs
]
with torch.no_grad():
return model(embeddings)
raise NotImplementedError(
f"Stage {name!r} uses dict inputs and cannot be used as a fusion input."
)
def phase_for_epoch(epoch: int, warmup_tower: int, warmup_fused: int) -> str:
if epoch < warmup_tower:
return "tower_warmup"
if epoch < warmup_tower + warmup_fused:
return "fused_warmup"
return "main"
+73
View File
@@ -0,0 +1,73 @@
"""stages/warm — warm stage runner: pre-trains a single tower with a temporary probe."""
from __future__ import annotations
import torch
import torch.nn.functional as F
from v4.classes.dataset import to_label_tensor
from v4.classes.stages.helpers import phase_for_epoch
def run(
stage_cfg: dict,
towers: dict,
data,
split,
label_filter,
cfg: dict,
num_classes: int,
device,
fold: int,
_make_loader,
_balanced_sampler,
) -> None:
"""Pre-train one tower using a temporary linear probe (probe discarded after)."""
tower_name = stage_cfg["tower"]
n_epochs = stage_cfg.get("epochs", 0)
level = stage_cfg["level"]
if n_epochs == 0:
return
s_train = data.build_shells(split.train, level=level, label_filter=label_filter)
bs = cfg["training"]["batch_size"]
loader = _make_loader(
s_train, {tower_name: towers[tower_name]},
batch_size=bs, shuffle=False,
sampler=_balanced_sampler(s_train),
)
for n, t in towers.items():
for p in t.parameters():
p.requires_grad_(n == tower_name)
probe = torch.nn.Linear(towers[tower_name].out_dim, num_classes).to(device)
opt = torch.optim.Adam(
list(towers[tower_name].parameters()) + list(probe.parameters()),
lr=cfg["training"]["lr"],
)
towers[tower_name].train()
for epoch in range(n_epochs):
total_loss = total_correct = total_n = 0
for batch in loader:
y = batch.get("label")
x = batch.get(tower_name)
if not torch.is_tensor(y) or not torch.is_tensor(x):
continue
y_t = to_label_tensor(y, device)
logits = probe(towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
total_n += len(y_t)
print(
f" fold{fold+1} [warm/{tower_name}] ep{epoch+1:03d}/{n_epochs}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
for t in towers.values():
for p in t.parameters():
p.requires_grad_(True)
+57
View File
@@ -0,0 +1,57 @@
"""towerbase — v4 TowerBase ABC.
All v4 towers inherit from TowerBase. The only required interface is:
out_dim : int property — embedding dimensionality
_side_map : dict property — generic side keys {"a","b"} → dataset-specific ids
_get(*ids) : retrieve and transform one sample by entity_id slots
get_sample handles the eye-level / patient-level dispatch automatically:
len(entity_id) > 1 — full key; calls _get(*entity_id) directly
len(entity_id) == 1 — patient key; builds {"a": _get(...), "b": _get(...)}
using _side_map to expand the missing slot
Towers may optionally implement early_pass(context) for cross-tower
communication before loaders are built.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
import torch
from torch import nn
from v4.classes.dataset import ShellEntry
class TowerBase(nn.Module, ABC):
@property
@abstractmethod
def out_dim(self) -> int: ...
@property
@abstractmethod
def _side_map(self) -> dict[str, str]: ...
@abstractmethod
def _get(self, *ids) -> torch.Tensor: ...
def get_sample(self, entry: ShellEntry) -> torch.Tensor | dict[str, torch.Tensor]:
eid = entry.entity_id
if len(eid) > 1:
return self._get(*eid)
return {key: self._get(eid[0], id_1) for key, id_1 in self._side_map.items()}
def set_phase(self, phase: str) -> None:
"""Called by the orchestrator at the start of each training epoch.
Default: freeze all parameters during fused_warmup, train otherwise.
Override to implement tower-specific phase behaviour.
"""
trainable = phase != "fused_warmup"
for p in self.parameters():
p.requires_grad_(trainable)
def early_pass(self, context) -> None:
pass
View File
+94
View File
@@ -0,0 +1,94 @@
"""clinical_tower — ClinicalEncoder for v4.
Self-contained: no v3 dependencies.
Inherits get_sample dispatch from TowerBase.
"""
from __future__ import annotations
import numpy as np
import torch
from torch import nn
from v4.classes.towerbase import TowerBase
from v4.classes.accessory.se_block import SEBlock
class ClinicalEncoder(TowerBase):
"""MLP over tabular clinical features.
clinical_data : ClinicalDataView — provides feature_dim, vectorize_entity, side_map
hidden_dim : output embedding dimensionality
dropout : applied after the first linear block
use_se : wrap output with SEBlock channel gating
se_reduction : SEBlock bottleneck factor
se_pre_norm : apply LayerNorm before SEBlock
"""
def __init__(
self,
clinical_data,
hidden_dim: int = 128,
dropout: float = 0.1,
use_se: bool = False,
se_reduction: int = 16,
se_pre_norm: bool = True,
):
super().__init__()
self.clinical_data = clinical_data
self._out_dim = hidden_dim
feature_dim = clinical_data.feature_dim
self.block0 = nn.Sequential(
nn.Linear(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
# ── TowerBase interface ──────────────────────────────────────────────────
@property
def out_dim(self) -> int:
return self._out_dim
@property
def _side_map(self) -> dict[str, str]:
return self.clinical_data.side_map
def _get(self, *ids) -> torch.Tensor:
arr = self.clinical_data.vectorize_entity(*ids)
return torch.from_numpy(arr.astype(np.float32, copy=False))
# ── nn.Module forward ────────────────────────────────────────────────────
def forward(self, x) -> torch.Tensor:
if not isinstance(x, torch.Tensor):
x = torch.as_tensor(x, dtype=torch.float32)
h = self.net(x)
if self.tower_se is not None:
h, _ = self.tower_se(self.tower_ln(h))
return h
# ── Utilities ────────────────────────────────────────────────────────────
def set_freeze_ratio(self, ratio: float) -> None:
"""Freeze the earliest MLP block proportionally."""
r = max(0.0, min(1.0, float(ratio)))
for p in self.block0.parameters():
p.requires_grad = True
for p in self.block1.parameters():
p.requires_grad = True
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
+83
View File
@@ -0,0 +1,83 @@
"""image_tower — ImageEncoder for v4.
Self-contained: no v3 dependencies.
Inherits get_sample dispatch from TowerBase.
"""
from __future__ import annotations
import math
import torch
from torch import nn
from v4.classes.towerbase import TowerBase
from v4.classes.accessory.backbones import build_backbone
from v4.classes.accessory.se_block import SEBlock
from v4.classes.accessory.transforms import build_backbone_transform, build_eval_transform
class ImageEncoder(TowerBase):
"""Vision backbone → pooled feature vector.
image_data : ImageDataView — provides load_image(*ids) and side_map
backbone : backbone key (see accessory/backbones.py)
freeze_ratio : fraction of early blocks to freeze in [0, 1]
use_se : apply SE attention over the pooled feature vector
augment : include random flip/rotation/jitter in the train transform
"""
def __init__(
self,
image_data,
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.image_data = image_data
self._name = backbone
self.backbone, self._base_dim, self._blocks = build_backbone(backbone, freeze_ratio)
self.transform = build_backbone_transform(backbone, augment=augment)
self.eval_transform = build_eval_transform(backbone)
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
# ── TowerBase interface ──────────────────────────────────────────────────
@property
def out_dim(self) -> int:
return self._base_dim
@property
def _side_map(self) -> dict[str, str]:
return self.image_data.side_map
def _get(self, *ids) -> torch.Tensor:
img = self.image_data.load_image(*ids)
t = self.transform if self.training else self.eval_transform
return t(img)
# ── nn.Module forward ────────────────────────────────────────────────────
def forward(self, x: torch.Tensor) -> torch.Tensor:
y = self.backbone(x)
if self.tower_se is not None:
y, _ = self.tower_se(self.tower_ln(y))
return y
# ── Utilities ────────────────────────────────────────────────────────────
def set_freeze_ratio(self, ratio: float) -> None:
"""Dynamically freeze the earliest floor(N * ratio) backbone blocks."""
r = max(0.0, min(1.0, float(ratio)))
n_freeze = int(math.floor(len(self._blocks) * r))
for b in self._blocks:
for p in b.parameters():
p.requires_grad = True
for b in self._blocks[:n_freeze]:
for p in b.parameters():
p.requires_grad = False
+59
View File
@@ -0,0 +1,59 @@
"""utils — general-purpose pipeline utilities."""
from __future__ import annotations
import random as pyrandom
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.
Returns (df, changed_rows, still_mixed_pids).
"""
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)
still_mixed = (
out.groupby(patient_col)[label_col]
.nunique(dropna=True)
.pipe(lambda s: s[s > 1].index.tolist())
)
return out.reset_index(drop=True), changed_rows, still_mixed
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python
"""
V4 HyperTower orchestrator — config-driven stage pipeline.
Stage logic lives in v4/classes/stages/:
warm.py — pre-trains a single tower with a temporary linear probe
fusion.py — trains a bridge + associated head stages, freezes for downstream use
helpers.py — encode_embedding, resolve_input_dims, phase_for_epoch, etc.
Usage:
python -m v4.classes.v4_hypertower --config v4/configs/ensemble_fused.json
"""
from __future__ import annotations
import argparse
import importlib
import json
import sys
import time
from pathlib import Path
from typing import Any
import numpy as np
from torch.utils.data import DataLoader
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from v4.classes.dataset import LoaderShell, HTDataset, ht_collate
from v4.classes.utils import seed_everything, choose_device
from v4.classes.split_manager import SplitManager
from v4.classes.stages import warm, fusion
# ---------------------------------------------------------------------------
# Early-pass protocol
# ---------------------------------------------------------------------------
class EarlyPassContext:
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def put(self, key: str, value: Any) -> None:
self._store[key] = value
def get(self, key: str, default: Any = None) -> Any:
return self._store.get(key, default)
def require(self, key: str) -> Any:
if key not in self._store:
raise KeyError(
f"EarlyPassContext: required key '{key}' not present. "
f"Available: {sorted(self._store.keys())}"
)
return self._store[key]
def keys(self) -> set[str]:
return set(self._store.keys())
def _validate_epc_requests(towers_cfg: list[dict], provided_keys: set[str]) -> None:
available = set(provided_keys)
for t in towers_cfg:
for req in t.get("epc_requests", []):
if req not in available:
raise ValueError(
f"Tower '{t['name']}' requests EPC key '{req}' "
f"but no supplier provides it. Available: {sorted(available)}"
)
available.update(t.get("epc_supplies", []))
# ---------------------------------------------------------------------------
# Data + tower helpers
# ---------------------------------------------------------------------------
cfg_ref: dict = {}
def load_data(cfg: dict):
data_cfg = cfg["data"]
args = dict(data_cfg.get("args", {}))
for key in ("image_dir", "clinical_dir"):
if key in args:
p = Path(args[key])
if not p.is_absolute():
args[key] = str(REPO_ROOT / p)
mod = importlib.import_module(data_cfg["module"])
return mod.build_data(args)
def build_towers(towers_cfg: list[dict], data) -> dict:
def _resolve(path: str):
mod_name = cfg_ref.get("data", {}).get("module", "")
if mod_name:
try:
m = importlib.import_module(mod_name)
if hasattr(m, "resolve_data_source"):
return m.resolve_data_source(data, path)
except Exception:
pass
obj = data
for part in path.split("."):
obj = getattr(obj, part)
return obj
towers = {}
for t in towers_cfg:
mod = importlib.import_module(t["module"])
cls = getattr(mod, t["class"])
kwargs = dict(t.get("args", {}))
if "data_source" in t:
towers[t["name"]] = cls(_resolve(t["data_source"]), **kwargs)
elif "data_arg" in t:
kwargs[t["data_arg"]] = data
towers[t["name"]] = cls(**kwargs)
else:
towers[t["name"]] = cls(**kwargs)
return towers
def _make_loader(shell: LoaderShell, towers: dict, *, batch_size: int,
shuffle: bool, sampler=None) -> DataLoader:
dataset = HTDataset(shell, towers)
return DataLoader(
dataset,
batch_size=batch_size,
shuffle=(shuffle and sampler is None),
sampler=sampler,
collate_fn=ht_collate,
num_workers=0,
persistent_workers=False,
)
def _balanced_sampler(shell: LoaderShell):
from torch.utils.data import WeightedRandomSampler
labels = [e.label for e in shell.entries]
counts = {}
for l in labels:
counts[l] = counts.get(l, 0) + 1
weights = [1.0 / counts[l] for l in labels]
return WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)
# ---------------------------------------------------------------------------
# Fold runner
# ---------------------------------------------------------------------------
def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device) -> dict:
seed_everything(cfg["seed"] + fold * 100)
split = splits[fold]
label_filter = cfg.get("label_filter", None)
cfg_stages = cfg["stages"]
towers = build_towers(cfg["towers"], data)
for t in towers.values():
t.to(device)
context = EarlyPassContext()
context.put("device", device)
context.put("data", data)
context.put("split", split)
context.put("label_filter", label_filter)
_validate_epc_requests(cfg["towers"], context.keys())
for tower in towers.values():
if hasattr(tower, "early_pass"):
tower.early_pass(context)
stage_models: dict = {}
fold_result = {"fold": fold}
for stage_cfg in cfg_stages:
stype = stage_cfg["type"]
if stype == "warm":
warm.run(stage_cfg, towers, data, split, label_filter,
cfg, num_classes, device, fold,
_make_loader, _balanced_sampler)
elif stype == "fusion":
stage_models, metrics = fusion.run(
stage_cfg, cfg, towers, stage_models, data, split,
label_filter, num_classes, device, fold, cfg_stages,
_make_loader,
)
fold_result.update(metrics)
# head stages are handled inside fusion.run
return fold_result
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
global cfg_ref
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", required=True)
ap.add_argument("--device", default=None)
args = ap.parse_args()
with open(args.config) as f:
cfg = json.load(f)
cfg_ref = cfg
device = choose_device(args.device or cfg.get("device"))
num_classes = cfg.get("num_classes", 2)
label_filter = cfg.get("label_filter", None)
print(f"Device: {device}", flush=True)
print("Loading data ...", flush=True)
data = load_data(cfg)
print(f" feature_dim={data.feature_dim}", flush=True)
label_col = cfg["data"]["args"].get("label_col", "Diagnosis")
df_mode = data.df.copy()
if label_filter is not None:
df_mode = df_mode[df_mode[label_col].isin(label_filter)].reset_index(drop=True)
identity_level = cfg.get("split_identity_level", 1)
identity_cols = getattr(data, "identity_cols", [])
group_col = identity_cols[identity_level - 1] if identity_level and identity_cols else None
splits = SplitManager(group_col=group_col).build_plans(
df_mode,
label_col=label_col,
n_splits=cfg.get("folds", 5),
seed=cfg.get("fold_seed", 100),
)
out_dir_tags = cfg.get("out_dir_tags", [])
out_dir = REPO_ROOT / cfg.get("output_root", "v4/results") / cfg["run_name"]
for tag in out_dir_tags:
out_dir = out_dir / tag
out_dir.mkdir(parents=True, exist_ok=True)
eval_stage = cfg.get("eval_stage", "hb")
fold_results = []
t0 = time.time()
for fold in range(cfg.get("folds", 5)):
split = splits[fold]
n_train = split.train[group_col].nunique() if group_col else len(split.train)
print(f"\n── fold {fold+1}/{cfg.get('folds', 5)} train_groups={n_train} ──",
flush=True)
result = run_fold(fold, splits, cfg, data, num_classes, device)
fold_results.append(result)
print(
f" fold{fold+1} DONE"
f" val_auc={result.get(f'{eval_stage}_val_auc', float('nan')):.4f}"
f" test_auc={result.get(f'{eval_stage}_test_auc', float('nan')):.4f}",
flush=True,
)
if fold_results:
val_aucs = [r.get(f"{eval_stage}_val_auc", float("nan")) for r in fold_results]
test_aucs = [r.get(f"{eval_stage}_test_auc", float("nan")) for r in fold_results]
val_aucs = [v for v in val_aucs if not np.isnan(v)]
test_aucs = [v for v in test_aucs if not np.isnan(v)]
summary = {
"run_name": cfg["run_name"],
"eval_stage": eval_stage,
"config": cfg,
"mean_val_auc": float(np.mean(val_aucs)) if val_aucs else float("nan"),
"std_val_auc": float(np.std(val_aucs)) if val_aucs else float("nan"),
"mean_test_auc": float(np.mean(test_aucs)) if test_aucs else float("nan"),
"std_test_auc": float(np.std(test_aucs)) if test_aucs else float("nan"),
"elapsed_s": round(time.time() - t0, 1),
"fold_results": fold_results,
}
summary_path = out_dir / "summary.json"
summary_path.write_text(json.dumps(summary, indent=2))
print(f"\n{'='*60}", flush=True)
print(f"Val AUC: {summary['mean_val_auc']:.4f} ± {summary['std_val_auc']:.4f}", flush=True)
print(f"Test AUC: {summary['mean_test_auc']:.4f} ± {summary['std_test_auc']:.4f}", flush=True)
print(f"Saved: {summary_path}", flush=True)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""htbase — HTBase: abstract base for all v4 vehicle classes."""
from __future__ import annotations
from abc import ABC, abstractmethod
import torch
import torch.nn as nn
class HTBase(nn.Module, ABC):
"""Shared interface for all HyperTower vehicles.
Subclasses must implement ``encode`` and ``forward``.
``transform`` walks ``self.towers`` (if present) and returns the transform
from the first tower that exposes one — used by data loaders.
``forward`` contract: returns ``(logits, aux_dict)`` where
``aux_dict`` maps a name or index to per-component logits.
HTMono returns an empty dict to keep the signature uniform.
"""
@abstractmethod
def encode(self, inputs) -> torch.Tensor:
"""Return the pre-classifier embedding."""
@abstractmethod
def forward(self, inputs) -> tuple[torch.Tensor, dict]:
"""Return (logits, aux_dict)."""
@property
def transform(self):
towers = getattr(self, "towers", None) or {}
for t in (towers.values() if hasattr(towers, "values") else []):
if hasattr(t, "transform"):
return t.transform
encoder = getattr(self, "encoder", None)
if encoder is not None:
return getattr(encoder, "transform", None)
return None
@@ -0,0 +1,61 @@
"""htfusion — HTFusion: N named towers fused through a FusionBridge."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.bridges.fusion_bridge import FusionBridge
from v4.classes.vehicles.htbase import HTBase
class HTFusion(HTBase):
"""General N-tower fusion vehicle.
Each named encoder is registered as a submodule; the FusionBridge
projects and Hadamard-fuses their embeddings.
Parameters
----------
towers : ordered dict ``{name: encoder}``. Each encoder must
expose ``.out_dim``.
num_classes : output classes
fusion_dim : bridge projection dimensionality
dropout : bridge dropout
use_se : SE gate on the fused vector
Forward contract
----------------
``forward(embeddings)`` takes a ``dict[str, Tensor]`` of pre-computed
per-tower embeddings and returns ``(logits_fused, aux_dict)`` where
``aux_dict`` maps each tower name to its auxiliary head logits.
"""
def __init__(
self,
towers: dict[str, nn.Module],
num_classes: int,
fusion_dim: int = 256,
dropout: float = 0.5,
use_se: bool = False,
):
super().__init__()
self.towers = nn.ModuleDict(towers)
self.bridge = FusionBridge(
tower_dims=[t.out_dim for t in self.towers.values()],
num_classes=num_classes,
fusion_dim=fusion_dim,
dropout=dropout,
use_se=use_se,
)
def encode(self, embeddings: dict[str, torch.Tensor]) -> torch.Tensor:
"""Return z_fused (pre-classifier) from a dict of per-tower embeddings."""
return self.bridge.encode([embeddings[name] for name in self.towers])
def forward(
self,
embeddings: dict[str, torch.Tensor],
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
ordered = [embeddings[name] for name in self.towers]
logits, aux = self.bridge.fuse(ordered)
return logits, {name: aux[i] for i, name in enumerate(self.towers)}
@@ -0,0 +1,67 @@
"""htlateral — HTLateral: shared encoder over N same-type inputs."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.vehicles.htbase import HTBase
class HTLateral(HTBase):
"""N same-type inputs through a shared encoder, jointly compressed, then classified.
All inputs share the same encoder weights (one forward pass per input).
The joint MLP compresses the concatenated embeddings before classification.
Aux heads provide per-input logits before the joint MLP — useful for
BCD-style training.
Parameters
----------
encoder : shared encoder module with ``.out_dim``
input_names : ordered slot names (e.g. ``["od", "os"]``)
num_classes : output classes
fusion_dim : joint MLP hidden dim
dropout : dropout in MLP and classifier
"""
def __init__(
self,
encoder: nn.Module,
input_names: list[str],
num_classes: int,
fusion_dim: int = 256,
dropout: float = 0.5,
):
super().__init__()
self.encoder = encoder
self.input_names = list(input_names)
n = len(input_names)
in_dim: int = encoder.out_dim # type: ignore[assignment]
self.joint = nn.Sequential(
nn.Linear(n * in_dim, fusion_dim), nn.LayerNorm(fusion_dim),
nn.ReLU(), nn.Dropout(dropout), nn.Linear(fusion_dim, in_dim),
)
self.aux_heads = nn.ModuleList([
nn.Linear(in_dim, num_classes) for _ in range(n)
])
self.head = nn.Sequential(
nn.ReLU(), nn.Dropout(dropout), nn.Linear(in_dim, num_classes),
)
def encode(self, inputs: dict[str, torch.Tensor]) -> torch.Tensor:
"""Return joint embedding (post-MLP, pre-classifier)."""
zs = [self.encoder(inputs[name]) for name in self.input_names]
return self.joint(torch.cat(zs, dim=1))
def forward(
self,
inputs: dict[str, torch.Tensor],
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
zs = [self.encoder(inputs[name]) for name in self.input_names]
z_joint = self.joint(torch.cat(zs, dim=1))
logits = self.head(z_joint)
aux = {name: head(z)
for name, head, z in zip(self.input_names, self.aux_heads, zs)}
return logits, aux
+44
View File
@@ -0,0 +1,44 @@
"""htmono — HTMono: single tower + ClassificationHead, no bridge."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.heads.classifier import ClassificationHead
from v4.classes.vehicles.htbase import HTBase
class HTMono(HTBase):
"""Single-tower vehicle: tower embedding fed directly into a ClassificationHead.
No bridge or projection — the tower's output goes straight to
ReLU → Dropout → Linear. Returns ``(logits, {})`` from ``forward``
to match the HTFusion / HTLateral interface.
Parameters
----------
tower : encoder module with ``.out_dim``
num_classes : output classes
dropout : dropout before the output linear layer
"""
def __init__(
self,
tower: nn.Module,
num_classes: int,
dropout: float = 0.5,
):
super().__init__()
self.tower = tower
self.head = ClassificationHead(tower.out_dim, num_classes, dropout) # type: ignore[arg-type]
def encode(self, inputs) -> torch.Tensor:
"""Return tower embedding (pre-classifier)."""
if isinstance(inputs, dict):
# single-entry dict from HTDataset eye-level pass
(z,) = inputs.values()
return self.tower(z) if torch.is_tensor(z) else self.tower(*z.values())
return self.tower(inputs)
def forward(self, inputs) -> tuple[torch.Tensor, dict]:
return self.head(self.encode(inputs)), {}
+121
View File
@@ -0,0 +1,121 @@
{
"_notes": [
"V4 stage-pipeline: warm → fusion stages with parallel head stages.",
"Bridges are pure embedding producers; heads are separate swappable stages.",
"BCD-eligible heads are sampled during tower_warmup and main phases.",
"eval_stage names the fusion stage whose primary head is used for final metrics."
],
"run_name": "v4/ensemble_fused",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": false,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary", "ntower"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
},
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": {
"hidden_dim": 128
}
}
],
"stages": [
{
"name": "cd_warm",
"type": "warm",
"tower": "cd",
"level": "eye",
"epochs": 40
},
{
"name": "img_aux",
"type": "head",
"input": "img",
"train_with": "nt",
"bcd": true
},
{
"name": "cd_aux",
"type": "head",
"input": "cd",
"train_with": "nt",
"bcd": true
},
{
"name": "nt",
"type": "fusion",
"module": "v4.classes.bridges.fusion_bridge",
"class": "FusionBridge",
"inputs": ["img", "cd"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"warmup": { "tower_epochs": 3, "fused_epochs": 3 },
"args": { "fusion_dim": 256 }
},
{
"name": "nt_head",
"type": "head",
"input": "nt",
"train_with": "nt"
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "nt", "b": "nt" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb"
}
],
"training": {
"lr": 1e-4,
"batch_size": 16,
"bcd_prob": 0.5,
"tune_binary_threshold": true
}
}
@@ -0,0 +1,146 @@
{
"run_name": "v4/ensemble_fused",
"config": {
"_notes": [
"V4 ensemble_fused: img + cd towers, HTFusion Stage 1, HyperBridge Stage 2.",
"Matches phase5/embedding_mlp_head setup for direct comparison.",
"data_source resolves against the PapilaBundle returned by build_data.",
"image_dir / clinical_dir are project-relative; orchestrator resolves against REPO_ROOT."
],
"run_name": "v4/ensemble_fused",
"eval_mode": "binary",
"split_identity_level": 1,
"epochs": 30,
"fusion_epochs": 10,
"folds": 5,
"fold_seed": 100,
"seed": 1234,
"output_root": "v4/results",
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": [
"Axial_Length"
],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
},
"warmup_epochs": 0
},
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": {
"hidden_dim": 128
},
"warmup_epochs": 40,
"warmup_exclude_towers": [
"img"
]
}
],
"bridge": {
"mode": "embedding_mlp",
"fusion_dim": 256,
"hidden_dim": 256
},
"training": {
"lr": 0.0001,
"batch_size": 16,
"bcd_prob": 0.5,
"warmup_tower_epochs": 3,
"warmup_fused_epochs": 3,
"tune_binary_threshold": true
}
},
"mean_val_auc": 0.9036764705882353,
"std_val_auc": 0.02318218054475656,
"mean_test_auc": 0.9007352941176471,
"std_test_auc": 0.04178903599524356,
"elapsed_s": 1563.1,
"fold_results": [
{
"fold": 0,
"val_auc": 0.9044117647058824,
"val_acc": 0.8571428571428571,
"val_n": 42,
"val_kappa": 0.42727272727272725,
"val_mcc": 0.4622975667767223,
"val_f1": 0.7083333333333333,
"val_threshold": 0.1659889668226242,
"test_auc": 0.8566176470588236,
"test_acc": 0.9285714285714286,
"test_n": 42
},
{
"fold": 1,
"val_auc": 0.8970588235294118,
"val_acc": 0.8809523809523809,
"val_n": 42,
"val_kappa": 0.5945945945945946,
"val_mcc": 0.5965587590013045,
"val_f1": 0.7971014492753623,
"val_threshold": 0.017083797603845596,
"test_auc": 0.9044117647058824,
"test_acc": 0.8095238095238095,
"test_n": 42
},
{
"fold": 2,
"val_auc": 0.863970588235294,
"val_acc": 0.8333333333333334,
"val_n": 42,
"val_kappa": 0.43243243243243246,
"val_mcc": 0.4338609156373123,
"val_f1": 0.7159420289855072,
"val_threshold": 0.09843172132968903,
"test_auc": 0.9044117647058824,
"test_acc": 0.8571428571428571,
"test_n": 42
},
{
"fold": 3,
"val_auc": 0.9227941176470588,
"val_acc": 0.8809523809523809,
"val_n": 42,
"val_kappa": 0.631578947368421,
"val_mcc": 0.6333004963811236,
"val_f1": 0.8156277436347674,
"val_threshold": 0.01531070377677679,
"test_auc": 0.8639705882352942,
"test_acc": 0.8571428571428571,
"test_n": 42
},
{
"fold": 4,
"val_auc": 0.9301470588235294,
"val_acc": 0.9047619047619048,
"val_n": 42,
"val_kappa": 0.6181818181818182,
"val_mcc": 0.6688560540599386,
"val_f1": 0.8055555555555556,
"val_threshold": 0.19587548077106476,
"test_auc": 0.9742647058823529,
"test_acc": 0.9285714285714286,
"test_n": 42
}
]
}
@@ -0,0 +1,264 @@
{
"run_name": "v4/ensemble_fused",
"eval_stage": "hb",
"config": {
"_notes": [
"V4 stage-pipeline: warm \u2192 fusion stages with parallel head stages.",
"Bridges are pure embedding producers; heads are separate swappable stages.",
"BCD-eligible heads are sampled during tower_warmup and main phases.",
"eval_stage names the fusion stage whose primary head is used for final metrics."
],
"run_name": "v4/ensemble_fused",
"num_classes": 2,
"label_filter": [
0,
1
],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": false,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": [
"binary",
"ntower"
],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": [
"Axial_Length"
],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
},
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": {
"hidden_dim": 128
}
}
],
"stages": [
{
"name": "cd_warm",
"type": "warm",
"tower": "cd",
"level": "eye",
"epochs": 40
},
{
"name": "img_aux",
"type": "head",
"input": "img",
"train_with": "nt",
"bcd": true
},
{
"name": "cd_aux",
"type": "head",
"input": "cd",
"train_with": "nt",
"bcd": true
},
{
"name": "nt",
"type": "fusion",
"module": "v4.classes.bridges.fusion_bridge",
"class": "FusionBridge",
"inputs": [
"img",
"cd"
],
"level": "eye",
"epochs": 36,
"train_towers": true,
"warmup": {
"tower_epochs": 3,
"fused_epochs": 3
},
"args": {
"fusion_dim": 256
}
},
{
"name": "nt_head",
"type": "head",
"input": "nt",
"train_with": "nt"
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": {
"a": "nt",
"b": "nt"
},
"level": "patient",
"epochs": 10,
"args": {
"hidden_dim": 256,
"mode": "embedding_mlp"
}
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb"
}
],
"training": {
"lr": 0.0001,
"batch_size": 16,
"bcd_prob": 0.5,
"tune_binary_threshold": true
}
},
"mean_val_auc": 0.8926470588235293,
"std_val_auc": 0.06105155516072669,
"mean_test_auc": 0.9022058823529413,
"std_test_auc": 0.035001853633564395,
"elapsed_s": 1525.9,
"fold_results": [
{
"fold": 0,
"nt_val_auc": 0.858647936786655,
"nt_val_acc": 0.8928571428571429,
"nt_val_n": 84,
"nt_val_kappa": 0.6272189349112426,
"nt_val_mcc": 0.6411186083279721,
"nt_val_f1": 0.8124534854874721,
"nt_val_threshold": 0.012987074442207813,
"nt_test_auc": 0.9157155399473222,
"nt_test_acc": 0.9047619047619048,
"nt_test_n": 84,
"hb_val_auc": 0.9080882352941178,
"hb_val_acc": 0.8333333333333334,
"hb_val_n": 42,
"hb_val_kappa": 0.2898550724637682,
"hb_val_mcc": 0.33633639699815626,
"hb_val_f1": 0.6338729763387297,
"hb_val_threshold": 0.011891158297657967,
"hb_test_auc": 0.9522058823529411,
"hb_test_acc": 0.9523809523809523,
"hb_test_n": 42
},
{
"fold": 1,
"nt_val_auc": 0.8282828282828283,
"nt_val_acc": 0.8928571428571429,
"nt_val_n": 84,
"nt_val_kappa": 0.6111111111111112,
"nt_val_mcc": 0.6633249580710799,
"nt_val_f1": 0.801418439716312,
"nt_val_threshold": 0.008082838729023933,
"nt_test_auc": 0.8726953467954346,
"nt_test_acc": 0.8690476190476191,
"nt_test_n": 84,
"hb_val_auc": 0.7904411764705882,
"hb_val_acc": 0.9047619047619048,
"hb_val_n": 42,
"hb_val_kappa": 0.6181818181818182,
"hb_val_mcc": 0.6688560540599386,
"hb_val_f1": 0.8055555555555556,
"hb_val_threshold": 0.013159404508769512,
"hb_test_auc": 0.8970588235294117,
"hb_test_acc": 0.8571428571428571,
"hb_test_n": 42
},
{
"fold": 2,
"nt_val_auc": 0.8906882591093117,
"nt_val_acc": 0.8452380952380952,
"nt_val_n": 84,
"nt_val_kappa": 0.5125,
"nt_val_mcc": 0.5217535056401378,
"nt_val_f1": 0.7548821548821549,
"nt_val_threshold": 0.052708517760038376,
"nt_test_auc": 0.856060606060606,
"nt_test_acc": 0.8333333333333334,
"nt_test_n": 84,
"hb_val_auc": 0.9117647058823529,
"hb_val_acc": 0.8333333333333334,
"hb_val_n": 42,
"hb_val_kappa": 0.36909871244635195,
"hb_val_mcc": 0.3833788364965519,
"hb_val_f1": 0.6814734561213435,
"hb_val_threshold": 0.0401872955262661,
"hb_test_auc": 0.8566176470588236,
"hb_test_acc": 0.8571428571428571,
"hb_test_n": 42
},
{
"fold": 3,
"nt_val_auc": 0.921875,
"nt_val_acc": 0.8928571428571429,
"nt_val_n": 84,
"nt_val_kappa": 0.6758147512864494,
"nt_val_mcc": 0.6797955088067001,
"nt_val_f1": 0.837593984962406,
"nt_val_threshold": 0.708580732345581,
"nt_test_auc": 0.8631578947368421,
"nt_test_acc": 0.8452380952380952,
"nt_test_n": 84,
"hb_val_auc": 0.9779411764705882,
"hb_val_acc": 0.9285714285714286,
"hb_val_n": 42,
"hb_val_kappa": 0.7567567567567568,
"hb_val_mcc": 0.7592566023652966,
"hb_val_f1": 0.8782608695652174,
"hb_val_threshold": 0.04653067886829376,
"hb_test_auc": 0.875,
"hb_test_acc": 0.8809523809523809,
"hb_test_n": 42
},
{
"fold": 4,
"nt_val_auc": 0.8279192273924496,
"nt_val_acc": 0.8571428571428571,
"nt_val_n": 84,
"nt_val_kappa": 0.46325878594249204,
"nt_val_mcc": 0.49610717544581684,
"nt_val_f1": 0.7269772481040087,
"nt_val_threshold": 0.06419441103935242,
"nt_test_auc": 0.8961397058823529,
"nt_test_acc": 0.8928571428571429,
"nt_test_n": 84,
"hb_val_auc": 0.875,
"hb_val_acc": 0.9047619047619048,
"hb_val_n": 42,
"hb_val_kappa": 0.6181818181818182,
"hb_val_mcc": 0.6688560540599386,
"hb_val_f1": 0.8055555555555556,
"hb_val_threshold": 0.23509082198143005,
"hb_test_auc": 0.9301470588235294,
"hb_test_acc": 0.8809523809523809,
"hb_test_n": 42
}
]
}
+65
View File
@@ -0,0 +1,65 @@
"""Compare fold assignments between v3 PatientFirstSplitManager and v4 SplitManager."""
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from v3.classes.split_manager import PatientFirstSplitManager
from v4.classes.split_manager import SplitManager
from v4.classes.profiles.v4papila import build_data
args = {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": True,
"exclude_cols": ["Axial_Length"],
}
# Resolve relative paths
root = Path(__file__).resolve().parents[2]
args["image_dir"] = str(root / args["image_dir"])
args["clinical_dir"] = str(root / args["clinical_dir"])
data = build_data(args)
label_col = data.label_col
patient_col = data.patient_col
df_mode = data.df[data.df[label_col].isin([0, 1])].reset_index(drop=True)
# ── v3 splits ────────────────────────────────────────────────────────────────
split_mgr_v3 = PatientFirstSplitManager(patient_col=patient_col, label_col=label_col)
split_args_v3 = SimpleNamespace(eval_mode="binary", n_splits=5, fold_seed=100)
clinical_ns = SimpleNamespace(df=df_mode, label_col=label_col)
splits_v3 = split_mgr_v3.build_plans(clinical=clinical_ns, args=split_args_v3, profile=None)
# ── v4 splits ────────────────────────────────────────────────────────────────
splits_v4 = SplitManager(group_col=patient_col).build_plans(
df_mode, label_col=label_col, n_splits=5, seed=100,
)
# ── Compare ──────────────────────────────────────────────────────────────────
print(f"{'Fold':<6} {'Set':<6} {'v3 patients':<8} {'v4 patients':<8} {'Match'}")
print("-" * 50)
all_match = True
for fold in range(5):
s3, s4 = splits_v3[fold], splits_v4[fold]
for label, df3, df4 in [
("train", s3.train, s4.train),
("val", s3.val, s4.val),
("test", s3.test, s4.test),
]:
ids3 = set(df3[patient_col].unique()) if df3 is not None else set()
ids4 = set(df4[patient_col].unique()) if df4 is not None else set()
match = ids3 == ids4
if not match:
all_match = False
print(f"{fold+1:<6} {label:<6} {len(ids3):<8} {len(ids4):<8} {'' if match else '✗ DIFF'}")
if not match:
print(f" only in v3: {sorted(ids3 - ids4)[:10]}")
print(f" only in v4: {sorted(ids4 - ids3)[:10]}")
print()
print("All folds match!" if all_match else "SPLITS DIFFER — fold assignments changed.")