Files
hypertower/v3/scripts/output_analysis/explainability/fold_patient_ids.py
T
2026-04-14 19:42:16 +02:00

182 lines
7.1 KiB
Python

"""
Derives test-set patient IDs for any (rep, fold) without re-running training.
The split is fully deterministic: fold_seed = rep_seed_start + rep * rep_seed_step.
build_samples() groups by patient_id with sort=True (pandas default), and the
test DataLoader uses shuffle=False — so rows in predictions_test.csv are always
in ascending Patient ID order within each test fold.
Usage:
from v3.scripts.output_analysis.explainability.fold_patient_ids import get_test_patient_ids
pids = get_test_patient_ids(rep=0, fold=2) # list of int patient IDs, sorted
# Attach to a pooled predictions DataFrame:
df = attach_patient_ids(df, clinical_dir=...)
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
REPO_ROOT = Path(__file__).resolve().parents[4]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# These match the defaults in run_cv.py
_REP_SEED_START = 100
_REP_SEED_STEP = 100
_N_SPLITS = 5
_EVAL_MODE = "binary"
_LABEL_COL = "Diagnosis"
_PATIENT_COL = "Patient ID"
@lru_cache(maxsize=4)
def _load_clinical(clinical_dir: Path) -> pd.DataFrame:
"""
Load OD/OS Excel sheets, extract Patient ID + Diagnosis, binary-filter.
Returns a DataFrame with one row per eye (OD+OS stacked), columns:
[Patient ID, Diagnosis, eyeID, VF_MD].
"""
od = pd.read_excel(clinical_dir / "patient_data_od.xlsx", header=1)
os_ = pd.read_excel(clinical_dir / "patient_data_os.xlsx", header=1)
od["eyeID"] = "OD"
os_["eyeID"] = "OS"
df = pd.concat([od, os_], ignore_index=True)
# Raw column is "ID" (e.g. "#002"); canonicalize to "Patient ID"
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
# PAPILA encoding: 0=Normal, 1=Glaucoma, 2=Suspect
# Binary mode keeps 0 and 1, excludes Suspect (2)
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df.reset_index(drop=True)
def _build_splits(clinical_dir: Path, fold_seed: int) -> list[Any]:
"""Return list of PatientSplit for a given fold seed."""
import sys
sys.path.insert(0, str(REPO_ROOT))
from v3.classes.split_manager import PatientFirstSplitManager, build_patient_split_plans
df = _load_clinical(clinical_dir)
# Patient-level label table (mode label per patient)
patient_table = (
df.groupby(_PATIENT_COL)[_LABEL_COL]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
plans_raw = build_patient_split_plans(
patient_ids=patient_table[_PATIENT_COL].to_numpy(),
patient_labels=patient_table[_LABEL_COL].to_numpy(),
n_splits=_N_SPLITS,
seed=fold_seed,
)
# Wrap into PatientSplit-like objects with .test DataFrame
class _Split:
def __init__(self, test_ids):
self.test = df[df[_PATIENT_COL].isin(test_ids)].reset_index(drop=True)
return [_Split(p.test_patient_ids) for p in plans_raw]
def get_test_patient_ids(rep: int, fold: int,
clinical_dir: Path = CLINICAL_DIR,
rep_seed_start: int = _REP_SEED_START,
rep_seed_step: int = _REP_SEED_STEP) -> list[int]:
"""
Return sorted list of Patient IDs in the test set for (rep, fold).
Matches the row order of predictions_test.csv for that fold.
"""
fold_seed = rep_seed_start + rep * rep_seed_step
plans = _build_splits(clinical_dir, fold_seed)
test_df = plans[fold].test
# groupby sorts by default → same order as build_samples / test loader
return sorted(test_df[_PATIENT_COL].unique().tolist())
def _row_to_patient_pos(row_idx: int, n_patients: int, batch_size: int) -> int:
"""
Map a single-mode row index to its patient position in the sorted patient list.
collect_probs_single_components (aggregate_patient=False) emits predictions
in batch-interleaved order: for each batch of B patients, OD rows come first
then OS rows. The last batch may be smaller than batch_size.
Batch i (B patients): rows [i*2B .. i*2B+B-1] = OD
[i*2B+B .. i*2B+2B-1] = OS
Patient position = i*B + (row_in_batch % B)
"""
full = n_patients // batch_size
last_b = n_patients % batch_size
for bi in range(full):
s = bi * 2 * batch_size
if s <= row_idx < s + 2 * batch_size:
return bi * batch_size + (row_idx - s) % batch_size
if last_b > 0:
s = full * 2 * batch_size
return full * batch_size + (row_idx - s) % last_b
raise IndexError(f"row_idx {row_idx} out of range for n_patients={n_patients}")
def attach_patient_ids(df: pd.DataFrame,
clinical_dir: Path = CLINICAL_DIR,
rep_seed_start: int = _REP_SEED_START,
rep_seed_step: int = _REP_SEED_STEP) -> pd.DataFrame:
"""
Add a 'patient_id' column to a pooled predictions DataFrame.
Requires 'rep' and 'fold' columns (added by load_all_predictions).
The 'idx' column is the row index within each fold's test set.
For patient-level modes (ensemble): idx == patient position directly.
"""
df = df.copy()
pid_col = []
for _, row in df.iterrows():
rep_idx = int(row["rep"].replace("rep", ""))
fold_idx = int(row["fold"].replace("fold", ""))
idx = int(row["idx"])
pids = get_test_patient_ids(rep_idx, fold_idx,
clinical_dir=clinical_dir,
rep_seed_start=rep_seed_start,
rep_seed_step=rep_seed_step)
pid_col.append(pids[idx] if idx < len(pids) else None)
df["patient_id"] = pid_col
return df
def attach_patient_ids_single(df: pd.DataFrame,
clinical_dir: Path = CLINICAL_DIR,
batch_size: int = 8,
rep_seed_start: int = _REP_SEED_START,
rep_seed_step: int = _REP_SEED_STEP) -> pd.DataFrame:
"""
Like attach_patient_ids but for single (eye-level) mode.
Single mode emits predictions in batch-interleaved order (see _row_to_patient_pos).
batch_size must match the --batch-size used during training (default 8).
"""
df = df.copy()
pid_col = []
for _, row in df.iterrows():
rep_idx = int(row["rep"].replace("rep", ""))
fold_idx = int(row["fold"].replace("fold", ""))
idx = int(row["idx"])
pids = get_test_patient_ids(rep_idx, fold_idx,
clinical_dir=clinical_dir,
rep_seed_start=rep_seed_start,
rep_seed_step=rep_seed_step)
patient_pos = _row_to_patient_pos(idx, len(pids), batch_size)
pid_col.append(pids[patient_pos])
df["patient_id"] = pid_col
return df