182 lines
7.1 KiB
Python
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
|