Files
hypertower/v4/classes/profiles/v4papila.py
T
rpotter6298 708fbc70ce Add analysis scripts and experiment configurations for bridge attention and sensitivity studies
- Introduced `bridge_attention_ceiling_check.py` for variance decomposition analysis on bridge attention configurations.
- Added `bridge_attention_readout.py` to perform per-tower gate and contribution readouts, including AUC sanity checks.
- Created multiple JSON configuration files for backbone replication experiments, including anonymous CV variants and basic backbones.
- Implemented sensitivity experiments to evaluate the impact of axial length inclusion and EfficientNetV2-M performance at higher resolutions.
- Added a memory probe script to assess GPU memory usage during training with EfficientNetV2-M.
2026-07-03 08:51:44 +02:00

969 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 the raw IOP source columns (Pneumatic, Perkins) since their info
# is already absorbed into IOP_corr. VF_MD is intentionally NOT dropped
# here — it stays available as a target for auxiliary/regression tasks
# (PapilaBundle exposes it via .vf_md_target / .get_vf_md()). It must
# however be added to `exclude_cols` so it is never used as a feature.
drop = [c for c in ("Pneumatic", "Perkins") 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
# ── Optional explainability hooks ────────────────────────────────────────
#
# feature_names + feature_groups describe how the encoded feature_dim
# vector maps back to the original column space. They're consumed by
# v4.classes.accessory.explainability.permutation_importance via the
# caller (F8); profiles without these methods skip per-column importance.
@cached_property
def feature_names(self) -> list[str]:
"""Human-readable name for each original column (used as group label)."""
return list(self.scalar_cols) + list(self.cat_cols)
@cached_property
def feature_groups(self) -> list[list[int]]:
"""Encoded-vector indices grouped by original column.
Each entry is the set of model-input dimensions that encode one
conceptual feature: a scalar groups its value + missing-flag dim,
a categorical groups all of its one-hot dims.
Order matches ``feature_names``.
"""
n_scalar = len(self.scalar_cols)
n_cat_total = sum(len(m) for m in self.cat_maps.values())
groups: list[list[int]] = []
for i in range(n_scalar):
groups.append([i, n_scalar + n_cat_total + i])
offset = n_scalar
for col in self.cat_cols:
k = len(self.cat_maps[col])
groups.append(list(range(offset, offset + k)))
offset += k
return groups
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,
)
# ── Geometry hook ─────────────────────────────────────────────────────────
def _resolve_paths(self, kwargs: dict) -> dict:
"""Resolve any *_dir / *_path kwargs against the repo root."""
repo_root = Path(__file__).resolve().parents[3]
out = {}
for k, v in kwargs.items():
if (k.endswith("_dir") or k.endswith("_path")) and v is not None:
p = Path(v)
out[k] = str(repo_root / p) if not p.is_absolute() else v
else:
out[k] = v
return out
def build_geometry_loader(self, source: str, **kwargs):
"""Return a geometry-vector loader (delegates to fundus_images)."""
from v4.classes.profiles.fundus_images import build_geometry_loader as _build
return _build(source, **self._resolve_paths(kwargs))
def build_seg_map_loader(self, source: str, **kwargs):
"""Return a seg-map loader (delegates to fundus_images)."""
from v4.classes.profiles.fundus_images import build_seg_map_loader as _build
return _build(source, **self._resolve_paths(kwargs))
def build_disc_bbox_loader(self, source: str, **kwargs):
"""Return a disc bounding-box loader for crop-to-disc preprocessing."""
from v4.classes.profiles.fundus_images import build_disc_bbox_loader as _build
kwargs.setdefault("contour_dir", self._DEFAULT_CONTOUR_DIR)
return _build(source, **self._resolve_paths(kwargs))
# ── Optional explainability hooks ────────────────────────────────────────
#
# These methods are consumed by v4.classes.accessory.explainability via
# getattr — they're optional on the data view, so a different profile
# without disc annotations can simply not define them and GradCAM/overlay
# will still work (region-of-interest analysis is skipped).
#
# Eval-crop convention: PAPILA training resizes the short side to 256
# then center-crops 224×224. The methods below mirror that so the disc
# mask and the display image stay aligned with what the image tower sees.
_EVAL_RESIZE = 256
_EVAL_CROP = 224
_DEFAULT_CONTOUR_DIR = "Papila/ExpertsSegmentations/Contours"
def eval_image_pil(self, *ids) -> Image.Image:
"""Load image and apply the 256-resize + 224-center-crop used at eval time."""
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
pil = self.load_image(*ids).convert("RGB")
pil = TF.resize(pil, self._EVAL_RESIZE, interpolation=InterpolationMode.BILINEAR)
return TF.center_crop(pil, [self._EVAL_CROP, self._EVAL_CROP])
def build_roi_mask(
self,
*ids,
target_h: int,
target_w: int,
expert: int = 1,
contour_dir: str | None = None,
) -> np.ndarray | None:
"""Rasterize the expert disc contour for (pid, eye), aligned to the eval crop.
Returns a binary ndarray of shape (target_h, target_w), or None if no
contour file exists for this eye / the file is malformed.
Alignment: the contour polygon is rasterized at the original image
resolution, then resized + center-cropped to match the same eval
transform the image tower uses, then resized to (target_h, target_w).
"""
from PIL import ImageDraw
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
if len(ids) < 2:
raise TypeError(
"build_roi_mask requires (patient_id, eye) — got %r" % (ids,)
)
pid, eye = int(ids[0]), str(ids[1])
repo_root = Path(__file__).resolve().parents[3]
dir_ = Path(contour_dir or self._DEFAULT_CONTOUR_DIR)
if not dir_.is_absolute():
dir_ = repo_root / dir_
contour_path = dir_ / f"RET{pid:03d}{eye}_disc_exp{expert}.txt"
if not contour_path.exists():
return None
try:
arr = np.loadtxt(str(contour_path), dtype=np.float32)
except Exception:
return None
if arr.ndim == 1:
arr = arr.reshape(-1, 2)
if arr.shape[0] < 3:
return None
# Original image size for polygon canvas
try:
with Image.open(self.get_image_path(*ids)) as im:
orig_w, orig_h = im.size
except Exception:
return None
canvas = Image.new("L", (orig_w, orig_h), 0)
ImageDraw.Draw(canvas).polygon([tuple(pt) for pt in arr[:, :2]], fill=1)
# Match the eval transform exactly (resize → center-crop)
canvas = TF.resize(canvas, self._EVAL_RESIZE, interpolation=InterpolationMode.NEAREST)
canvas = TF.center_crop(canvas, [self._EVAL_CROP, self._EVAL_CROP])
if (target_h, target_w) != (self._EVAL_CROP, self._EVAL_CROP):
canvas = canvas.resize((target_w, target_h), Image.NEAREST)
return np.array(canvas, dtype=bool)
def orient_for_display(self, image, side: str):
"""Mirror OS to align its nasaltemporal axis with the OD convention.
Accepts a PIL.Image or a numpy array. OD is passed through unchanged.
"""
if side != self.SIDE_B: # SIDE_A == OD, unchanged
return image
if isinstance(image, Image.Image):
from PIL import ImageOps
return ImageOps.mirror(image)
if isinstance(image, np.ndarray):
return np.ascontiguousarray(np.fliplr(image))
raise TypeError(f"orient_for_display: unsupported type {type(image).__name__}")
# ---------------------------------------------------------------------------
# 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,
vf_md_targets: dict[tuple, float] | 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,
)
# ── VF_MD target table (for regression / auxiliary heads) ────────────
# Provided by build_data — captured from raw frames before the master
# df dropped VF_MD via exclude_cols. Keys are (pid, eyeID), values are
# raw floats (NaN where the measurement was missing).
self._vf_md_raw: dict[tuple, float] = dict(vf_md_targets or {})
# ── Imputation distribution + pre-sampled imputed table ──────────────
# Strategy: in PAPILA, the few healthy patients with measured MD are
# likely biased toward being slightly worse than the typical healthy
# eye (the test is usually administered when there's some suspicion).
# We correct for this by centering the imputation distribution on the
# TOP QUARTILE mean of the measured-healthy MD values (i.e. the
# healthiest of the measured healthies), while using the std of the
# full measured-healthy sample. Missing values are then drawn from
# N(top_q_mean, std²) once at bundle construction, with a fixed seed,
# so the same patient always gets the same imputed value.
self._impute_mean, self._impute_std = self._compute_impute_params()
self._vf_md_imputed: dict[tuple, float] = self._sample_imputations(seed=42)
# ── 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"]
# ── Sample collection (for tower early_pass) ─────────────────────────────
def collect_samples(self, df: pd.DataFrame | None) -> list[tuple]:
"""Build (pid, eye, image_path) tuples from a split DataFrame.
Used by tower early_pass implementations that need per-eye image paths
(UNet inference, contour rasterisation, etc.). Returns [] for an
empty/None df.
"""
if df is None or len(df) == 0:
return []
pc = self._bundle.patient_col
out: list[tuple] = []
for _, row in df.iterrows():
pid = int(row[pc])
eye = str(row.get("eyeID", "OD"))
out.append((pid, eye, self.image.get_image_path(pid, eye)))
return out
# ── VF_MD target accessor (for regression / auxiliary heads) ─────────────
def get_vf_md(self, pid: int, eye: str, *, impute: bool = True) -> float:
"""Return VF_MD for one eye.
impute=True → look up the precomputed imputed value (raw if measured,
distribution sample if missing). This is what training
should use — deterministic, fixed-seed, same value every
call across the run.
impute=False → return the raw value (NaN if unmeasured).
"""
key = (int(pid), str(eye))
if impute:
return self._vf_md_imputed.get(key, self._impute_mean)
return self._vf_md_raw.get(key, float("nan"))
@property
def has_vf_md(self) -> bool:
return bool(self._vf_md_raw)
@property
def impute_params(self) -> dict:
"""Inspection: which mean/std were used to draw imputations."""
return {"mean": self._impute_mean, "std": self._impute_std}
# ── Internal helpers for imputation -------------------------------------
def _compute_impute_params(self) -> tuple[float, float]:
"""Mean = top-quartile mean of measured healthy MDs; std = std of all.
Falls back to (0.0, 0.0) if there are too few measured-healthy samples
to fit a sensible distribution.
"""
# Build (pid, eye) → diagnosis from the underlying bundle df.
diag_lookup: dict[tuple, int] = {}
label_col = self._bundle.label_col
pc = self._bundle.patient_col
if label_col in self._bundle.df.columns:
for _, row in self._bundle.df.iterrows():
key = (int(row[pc]), str(row.get("eyeID", "OD")))
diag_lookup[key] = int(row[label_col])
measured_healthy = [
v for key, v in self._vf_md_raw.items()
if v == v and diag_lookup.get(key, -1) == 0 # not NaN and healthy
]
if len(measured_healthy) < 4:
return 0.0, 0.0
arr = np.asarray(measured_healthy, dtype=np.float64)
q3 = float(np.percentile(arr, 75))
top = arr[arr >= q3]
mean = float(top.mean()) if top.size else float(arr.mean())
std = float(arr.std(ddof=1)) if arr.size > 1 else 0.0
return mean, std
def _sample_imputations(self, *, seed: int) -> dict[tuple, float]:
"""One-shot pre-sample: every (pid, eye) gets a fixed value for the run."""
rng = np.random.default_rng(seed)
out: dict[tuple, float] = {}
for key in sorted(self._vf_md_raw.keys()):
v = self._vf_md_raw[key]
if v == v: # measured
out[key] = float(v)
elif self._impute_std > 0:
out[key] = float(rng.normal(self._impute_mean, self._impute_std))
else:
out[key] = self._impute_mean
return out
# ── 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,
eye_filter: str | 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)]
if eye_filter is not None and "eyeID" in df.columns:
df = df[df["eyeID"] == eye_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"))
meta = {"vf_md": self.get_vf_md(pid, side)} if self.has_vf_md else {}
entries.append(ShellEntry(entity_id=(pid, side), label=label, meta=meta))
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])
meta = {}
if self.has_vf_md:
# Patient-level target: mean of the two eyes' imputed MDs
meta["vf_md"] = 0.5 * (
self.get_vf_md(int(pid), "OD") + self.get_vf_md(int(pid), "OS")
)
entries.append(ShellEntry(entity_id=(int(pid),), label=label, meta=meta))
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))
# Always exclude VF_MD from feature vectorization — it's a target/label
# column (used by regression heads), never an input.
if "VF_MD" not in exclude_cols:
exclude_cols = exclude_cols + ["VF_MD"]
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
)
# Capture VF_MD per (pid, eyeID) from the raw frames BEFORE the master df
# is refreshed (which would drop VF_MD via exclude_cols).
vf_md_targets: dict[tuple, float] = {}
for frame in bundle.frames:
if "VF_MD" not in frame.columns:
continue
for _, row in frame.iterrows():
pid = int(row["Patient ID"])
eye = str(row.get("eyeID", "OD"))
v = row["VF_MD"]
vf_md_targets[(pid, eye)] = (
float(v) if not pd.isna(v) else float("nan")
)
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,
vf_md_targets=vf_md_targets,
)