Files
hypertower/classes/clinical_data.py
T
2026-02-24 10:39:48 +01:00

265 lines
12 KiB
Python
Executable File
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.
# clinical_data.py
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Optional, Dict, List, Tuple
import numpy as np
import pandas as pd
class ClinicalData:
"""
Torch-free container for clinical metadata and file/label bookkeeping.
- Holds one or more dataframes (via add_df) and harmonizes columns
- Canonical IDs: 'Patient ID' must exist (or be specified and will be renamed)
- Canonical eye column: 'eyeID' recoded to 'OS'/'OD' if present; if absent, set to 0
- Feature typing (if cat_cols not provided):
* Categorical if (a) <= max_unique categorical threshold (default 4), or
(b) values cannot be coerced to float; otherwise numeric (scalar)
- Scaling/imputation:
* Numeric: minmax to [0,1], median imputation; + one missing flag per numeric feature
* Categorical: one-hot with '<UNK>' bucket at index 0
- Patient-level K-fold indices stored as dict: folds[k] -> {'train_ids': [...], 'test_ids': [...]}
"""
def __init__(
self,
image_dir: str,
clinical_dir: Optional[str],
label_col: str,
# typing / detection
cat_cols: Optional[Iterable[str]] = None,
max_unique_for_cat: int = 4,
# splitting
n_splits: int = 5,
random_seed: int = 42,
):
self.image_dir = Path(image_dir)
self.clinical_dir = Path(clinical_dir) if clinical_dir else None
self.label_col = label_col
self.max_unique_for_cat = max_unique_for_cat
self.n_splits = n_splits
# Internal state
self.frames: List[pd.DataFrame] = [] # raw frames as added
self.df: pd.DataFrame = pd.DataFrame() # concatenated
self.scalar_cols: List[str] = []
self.cat_cols: List[str] = list(cat_cols) if cat_cols is not None else []
self.scalar_stats: Dict[str, Dict[str, float]] = {}
self.cat_maps: Dict[str, Dict[object, int]] = {}
self.feature_dim: int = 0
self.folds: Dict[int, Dict[str, List[object]]] = {} # fold -> {'train_ids': [], 'test_ids': []}
self.random_seed = int(random_seed)
# ------------------- Public API -------------------
def add_df(
self,
df: pd.DataFrame,
id_column: Optional[str] = None,
eye_column: Optional[str] = None,
exclude_cols: Optional[Iterable[str]] = None,
) -> None:
"""
Add a dataframe and re-run harmonization, typing, stats, and K-fold indices.
QC rules:
- Must have patient ID column; if not provided under that name, specify id_column.
- eyeID, if present, must be binary; recoded to 'OS'/'OD'. If absent, create and set to 0.
"""
df = df.copy()
# --- QC: Patient ID ---
pid_col = self._ensure_patient_id(df, id_column)
# --- QC: eyeID ---
self._canonicalize_eye_column(df, eye_column)
# --- Normalize label presence ---
if self.label_col not in df.columns:
raise ValueError(f"label_col '{self.label_col}' not found in added dataframe")
# append & refresh
self.frames.append(df)
self._refresh_master_df(exclude_cols=exclude_cols)
self._infer_or_validate_feature_types(exclude_cols=exclude_cols)
self._compute_numeric_stats()
self._build_cat_maps()
self._compute_feature_dim()
self._build_kfold_indices()
def get_split_ids(self, fold: int) -> Tuple[List[object], List[object]]:
rec = self.folds.get(fold)
if not rec: raise KeyError(f"Fold {fold} not available. Built folds: {sorted(self.folds.keys())}")
return rec['train_ids'], rec['test_ids']
def get_split_dfs(self, fold: int) -> Tuple[pd.DataFrame, pd.DataFrame]:
train_ids, test_ids = self.get_split_ids(fold)
train_df = self.df[self.df['Patient ID'].isin(train_ids)].reset_index(drop=True)
test_df = self.df[self.df['Patient ID'].isin(test_ids)].reset_index(drop=True)
return train_df, test_df
def vectorize_row(self, row: pd.Series) -> np.ndarray:
"""Return a numpy feature vector (torch-free)."""
feats: List[float] = []
miss: List[float] = []
# numeric
for col in self.scalar_cols:
v = pd.to_numeric(row.get(col), errors='coerce')
if pd.isna(v):
miss.append(1.0)
v = self.scalar_stats[col]['median']
else:
miss.append(0.0)
lo = self.scalar_stats[col]['min']; hi = self.scalar_stats[col]['max']
feats.append((float(v) - lo) / (hi - lo) if hi > lo else 0.0)
# categorical
for col in self.cat_cols:
mapping = self.cat_maps[col]
one = [0.0] * len(mapping)
key = row.get(col)
one[mapping.get(key, 0)] = 1.0 # 0 is <UNK>
feats.extend(one)
# numeric missing flags
feats.extend(miss)
return np.asarray(feats, dtype=np.float32)
def get_image_path(self, row: pd.Series, filename_template: str = "RET{pid:03d}{eye}.jpg") -> Path:
pid = int(row['Patient ID']); eye = row.get('eyeID', 0)
if eye in ("OS", "OD"):
eye_str = eye
else:
eye_str = str(eye)
return self.image_dir / filename_template.format(pid=pid, eye=eye_str)
# ------------------- Internal helpers -------------------
def _ensure_patient_id(self, df: pd.DataFrame, id_column: Optional[str]) -> str:
if 'Patient ID' in df.columns:
return 'Patient ID'
if id_column and id_column in df.columns:
df.rename(columns={id_column: 'Patient ID'}, inplace=True)
return 'Patient ID'
# try auto-detect common variants
candidates = [c for c in df.columns if c.lower().replace(" ", "") in {"patientid","patient","pid"}]
if len(candidates) == 1:
df.rename(columns={candidates[0]: 'Patient ID'}, inplace=True)
return 'Patient ID'
raise ValueError("A 'Patient ID' column is required; provide id_column=... if it has a different name.")
def _canonicalize_eye_column(self, df: pd.DataFrame, eye_column: Optional[str]) -> None:
# Find source
src = None
if 'eyeID' in df.columns: src = 'eyeID'
elif eye_column and eye_column in df.columns: src = eye_column
else:
# try auto detect
for c in df.columns:
if 'eye' in c.lower():
src = c; break
if src is None:
df['eyeID'] = 0
return
# Map to OS/OD
s = df[src]
def norm(v):
if pd.isna(v): return None
x = str(v).strip().upper()
if x in {"OS","L","LEFT","0"}: return "OS"
if x in {"OD","R","RIGHT","1"}: return "OD"
# numbers like 2? fall back by parity
try:
num = int(float(x))
return "OD" if num % 2 == 1 else "OS"
except Exception:
return None
mapped = s.map(norm)
uniq = {u for u in mapped.dropna().unique().tolist()}
if not uniq.issubset({"OS","OD"}):
raise ValueError(f"eyeID must be binary; found values {sorted(uniq)}")
df['eyeID'] = mapped.fillna("OS")
if src != 'eyeID':
# keep original too if you want, but we standardize on 'eyeID'
pass
def _refresh_master_df(self, exclude_cols: Optional[Iterable[str]] = None) -> None:
self.df = pd.concat(self.frames, axis=0, ignore_index=True)
# drop columns explicitly excluded
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, 'Patient ID'}
# we keep canonical 'eyeID' as categorical if present
feature_candidates = [c for c in self.df.columns if c not in excluded]
# If user pre-specified cat_cols in __init__, respect them and infer the rest
cats = set(self.cat_cols) if self.cat_cols else set()
scalars = set()
for c in feature_candidates:
if c == 'eyeID':
cats.add('eyeID'); continue
if c in cats: continue
s = self.df[c]
# try numeric coercion
as_num = pd.to_numeric(s, errors='coerce')
num_missing = as_num.isna().mean()
num_unique = s.dropna().nunique()
if as_num.notna().any() and num_missing < 1.0 and num_unique > self.max_unique_for_cat:
scalars.add(c)
else:
# categorical if few uniques OR non-numeric
if num_unique <= self.max_unique_for_cat or as_num.isna().mean() > 0.0:
cats.add(c)
else:
scalars.add(c)
self.cat_cols = sorted(cats)
self.scalar_cols = sorted(scalars)
def _compute_numeric_stats(self) -> None:
self.scalar_stats.clear()
for col in self.scalar_cols:
s = pd.to_numeric(self.df[col], errors='coerce')
vals = s.dropna().astype(float).values
if vals.size == 0:
lo, hi, med = 0.0, 1.0, 0.0
else:
lo, hi = float(np.min(vals)), float(np.max(vals))
med = float(np.median(vals))
if hi <= lo: hi = lo + 1.0
self.scalar_stats[col] = {"min": lo, "max": hi, "median": med}
def _build_cat_maps(self) -> None:
self.cat_maps.clear()
for col in self.cat_cols:
cats = [v for v in self.df[col].dropna().unique().tolist()]
try: cats = sorted(cats)
except Exception: pass
mapping = {"<UNK>": 0}
for i, v in enumerate(cats, start=1): mapping[v] = i
self.cat_maps[col] = mapping
def _compute_feature_dim(self) -> None:
self.feature_dim = len(self.scalar_cols) + sum(len(m) for m in self.cat_maps.values()) + len(self.scalar_cols)
# ------------------- K-fold on unique patients -------------------
def _build_kfold_indices(self) -> None:
# unique patients and a per-patient label for stratification if possible
pats = self.df['Patient ID'].unique().tolist()
# Derive a patient label as the mode of their rows (fallback to first valid)
labels_by_pat = {}
for pid, grp in self.df.groupby('Patient ID'):
lab = grp[self.label_col].dropna()
if len(lab) == 0:
labels_by_pat[pid] = 0
else:
labels_by_pat[pid] = lab.mode().iloc[0]
y_pat = np.array([labels_by_pat[p] for p in pats])
# Try to use StratifiedGroupKFold if available, else fall back to StratifiedKFold on patient labels
try:
from sklearn.model_selection import StratifiedGroupKFold
sgkf = StratifiedGroupKFold(n_splits=self.n_splits, shuffle=True, random_state=self.random_seed)
split_iter = sgkf.split(X=pats, y=y_pat, groups=pats)
except Exception:
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=self.n_splits, shuffle=True, random_state=self.random_seed)
split_iter = skf.split(X=np.zeros(len(pats)), y=y_pat)
self.folds.clear()
for i, (train_idx, test_idx) in enumerate(split_iter):
train_ids = [pats[j] for j in train_idx]
test_ids = [pats[j] for j in test_idx]
self.folds[i] = {"train_ids": train_ids, "test_ids": test_ids}