Files
hypertower/scripts/basic_analysis/cnn_logits_rf_cv.py
T
2026-02-24 10:39:48 +01:00

328 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Train a CNN (resnet50 backbone), extract logits, and train RF on logits+metadata with 5-fold CV."""
from __future__ import annotations
import random
from pathlib import Path
import sys
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes import build_papila_clinical
from classes.backbones import BACKBONES, load_backbone_weights
# ---------------------------
# Config (edit in IDE)
# ---------------------------
IMAGE_DIR = "Papila/FundusImages"
CLINICAL_DIR = "Papila/ClinicalData"
LABEL_COL = "Diagnosis"
CAT_COLS = ["Gender", "Phakic/Pseudophakic"]
EVAL_MODE = "binary" # "binary" or "multiclass"
N_SPLITS = 5
FOLD_SEED = 42
HOLDOUT_SEED = 123
HOLDOUT_PATIENTS_PER_CLASS = 6
BACKBONE_NAME = "resnet50"
BATCH_SIZE = 8
EPOCHS = 40
LR = 1e-4
WEIGHT_DECAY = 1e-5
RF_TREES = 500
RF_MAX_DEPTH = None
RF_MIN_SAMPLES_LEAF = 1
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
OUTPUT_DIR = Path("analysis_data/basic_analysis/cnn_logits_rf_cv")
PRINT_EPOCH_REPORT = True
EPOCH_REPORT_EVERY = 1
class PapilaImageDataset(Dataset):
def __init__(
self, clinical, df: pd.DataFrame, label_col: str, img_transform
) -> None:
self.clinical = clinical
self.df = df.reset_index(drop=True)
self.label_col = label_col
self.img_transform = img_transform
def __len__(self) -> int:
return len(self.df)
def __getitem__(self, idx: int):
row = self.df.iloc[idx]
img_path = self.clinical.get_image_path(row)
image = Image.open(img_path).convert("RGB")
x_img = self.img_transform(image)
y = int(row[self.label_col])
x_md = self.clinical.vectorize_row(row).astype(np.float32)
return x_img, y, x_md
class CNNHead(nn.Module):
def __init__(self, backbone_name: str, num_classes: int) -> None:
super().__init__()
spec = BACKBONES[backbone_name]
backbone = spec.ctor(weights=spec.weights_default)
if backbone_name.startswith("refuge"):
load_backbone_weights(backbone_name, backbone)
out_dim, backbone = spec.strip(backbone)
self.backbone = backbone
self.head = nn.Linear(out_dim, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
feats = self.backbone(x)
return self.head(feats)
def _set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def _auc_score(y_true: np.ndarray, probs: np.ndarray, num_classes: int) -> float:
try:
if num_classes == 2:
return float(roc_auc_score(y_true, probs[:, 1]))
return float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro"))
except Exception:
return float("nan")
def _prepare_clinical() -> Tuple[object, pd.DataFrame]:
clinical = build_papila_clinical(
image_dir=IMAGE_DIR,
clinical_dir=CLINICAL_DIR,
label_col=LABEL_COL,
cat_cols=CAT_COLS,
n_splits=N_SPLITS,
random_seed=FOLD_SEED,
)
df = clinical.df.copy()
if EVAL_MODE == "binary":
df = df[df[LABEL_COL].isin([0, 1])].reset_index(drop=True)
return clinical, df
def _split_holdout_by_patient(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
rng = np.random.default_rng(HOLDOUT_SEED)
patient_label = (
df.groupby("Patient ID")[LABEL_COL]
.agg(lambda s: int(s.mode().iloc[0]))
.reset_index()
)
holdout_patients = []
for lbl, grp in patient_label.groupby(LABEL_COL):
candidates = grp["Patient ID"].to_numpy()
n = min(HOLDOUT_PATIENTS_PER_CLASS, len(candidates))
if n <= 0:
continue
selected = rng.choice(candidates, size=n, replace=False)
holdout_patients.extend(selected.tolist())
holdout_patients = sorted(set(holdout_patients))
holdout_df = df[df["Patient ID"].isin(holdout_patients)].reset_index(drop=True)
train_df = df[~df["Patient ID"].isin(holdout_patients)].reset_index(drop=True)
return train_df, holdout_df
def _rebuild_clinical_from_df(clinical, df: pd.DataFrame) -> object:
clinical.frames = [df.copy()]
clinical.df = df.copy()
clinical._infer_or_validate_feature_types()
clinical._compute_numeric_stats()
clinical._build_cat_maps()
clinical._compute_feature_dim()
clinical._build_kfold_indices()
return clinical
def _train_cnn(
model: nn.Module, loader: DataLoader, num_classes: int, fold: int
) -> None:
model.train()
optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
criterion = nn.CrossEntropyLoss()
for epoch in range(EPOCHS):
running_loss = 0.0
correct = 0
total = 0
for x_img, y, _x_md in loader:
x_img = x_img.to(DEVICE)
y = y.to(DEVICE)
optimizer.zero_grad()
logits = model(x_img)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
running_loss += float(loss.item()) * int(y.size(0))
pred = torch.argmax(logits, dim=1)
correct += int((pred == y).sum().item())
total += int(y.size(0))
if PRINT_EPOCH_REPORT and ((epoch + 1) % EPOCH_REPORT_EVERY == 0):
avg_loss = running_loss / max(total, 1)
train_acc = correct / max(total, 1)
print(
f"[fold {fold + 1}/{N_SPLITS}] epoch {epoch + 1}/{EPOCHS} "
f"train_loss={avg_loss:.4f} train_acc={train_acc:.4f}"
)
def _infer_logits(
model: nn.Module, loader: DataLoader
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
model.eval()
logits_all, probs_all, y_all, md_all = [], [], [], []
with torch.no_grad():
for x_img, y, x_md in loader:
x_img = x_img.to(DEVICE)
logits = model(x_img).cpu().numpy()
probs = torch.softmax(torch.from_numpy(logits), dim=1).numpy()
logits_all.append(logits)
probs_all.append(probs)
y_all.append(y.numpy())
md_all.append(x_md.numpy())
return (
np.concatenate(y_all, axis=0),
np.concatenate(logits_all, axis=0),
np.concatenate(md_all, axis=0),
)
def main() -> None:
_set_seed(FOLD_SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
num_classes = 2 if EVAL_MODE == "binary" else 3
train_tf = transforms.Compose(
[
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
eval_tf = transforms.Compose(
[
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
clinical, df = _prepare_clinical()
train_df, holdout_df = _split_holdout_by_patient(df)
clinical = _rebuild_clinical_from_df(clinical, train_df)
holdout_df.to_csv(OUTPUT_DIR / "holdout_patients.csv", index=False)
rows: List[Dict[str, object]] = []
holdout_rows: List[Dict[str, object]] = []
for fold in range(N_SPLITS):
print(f"\n[info] Starting fold {fold + 1}/{N_SPLITS}")
fold_train_df, fold_val_df = clinical.get_split_dfs(fold)
ds_train = PapilaImageDataset(clinical, fold_train_df, LABEL_COL, train_tf)
ds_val = PapilaImageDataset(clinical, fold_val_df, LABEL_COL, eval_tf)
ds_holdout = PapilaImageDataset(clinical, holdout_df, LABEL_COL, eval_tf)
dl_train = DataLoader(
ds_train, batch_size=BATCH_SIZE, shuffle=True, num_workers=0
)
dl_val = DataLoader(ds_val, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
dl_holdout = DataLoader(
ds_holdout, batch_size=BATCH_SIZE, shuffle=False, num_workers=0
)
model = CNNHead(BACKBONE_NAME, num_classes=num_classes).to(DEVICE)
_train_cnn(model, dl_train, num_classes=num_classes, fold=fold)
y_tr, log_tr, md_tr = _infer_logits(
model,
DataLoader(ds_train, batch_size=BATCH_SIZE, shuffle=False, num_workers=0),
)
y_va, log_va, md_va = _infer_logits(model, dl_val)
y_ho, log_ho, md_ho = _infer_logits(model, dl_holdout)
np.save(OUTPUT_DIR / f"fold{fold}_train_logits.npy", log_tr)
np.save(OUTPUT_DIR / f"fold{fold}_val_logits.npy", log_va)
np.save(OUTPUT_DIR / f"fold{fold}_holdout_logits.npy", log_ho)
X_tr = np.concatenate([log_tr, md_tr], axis=1)
X_va = np.concatenate([log_va, md_va], axis=1)
X_ho = np.concatenate([log_ho, md_ho], axis=1)
rf = RandomForestClassifier(
n_estimators=RF_TREES,
max_depth=RF_MAX_DEPTH,
min_samples_leaf=RF_MIN_SAMPLES_LEAF,
class_weight="balanced",
random_state=FOLD_SEED + fold,
n_jobs=-1,
)
rf.fit(X_tr, y_tr)
p_va = rf.predict_proba(X_va)
p_ho = rf.predict_proba(X_ho)
pred_va = np.argmax(p_va, axis=1)
pred_ho = np.argmax(p_ho, axis=1)
rows.append(
{
"fold": fold,
"val_acc": float(accuracy_score(y_va, pred_va)),
"val_auc": _auc_score(y_va, p_va, num_classes),
"n_val": int(len(y_va)),
}
)
holdout_rows.append(
{
"fold": fold,
"holdout_acc": float(accuracy_score(y_ho, pred_ho)),
"holdout_auc": _auc_score(y_ho, p_ho, num_classes),
"n_holdout": int(len(y_ho)),
}
)
print(
f"[info] Fold {fold + 1} RF: val_acc={rows[-1]['val_acc']:.4f} val_auc={rows[-1]['val_auc']:.4f} "
f"| holdout_acc={holdout_rows[-1]['holdout_acc']:.4f} holdout_auc={holdout_rows[-1]['holdout_auc']:.4f}"
)
fold_df = pd.DataFrame(rows)
holdout_df = pd.DataFrame(holdout_rows)
fold_df.to_csv(OUTPUT_DIR / "rf_val_metrics.csv", index=False)
holdout_df.to_csv(OUTPUT_DIR / "rf_holdout_metrics.csv", index=False)
print("\nRF validation metrics:")
print(fold_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print("\nRF holdout metrics:")
print(holdout_df.to_string(index=False, float_format=lambda x: f"{x:.4f}"))
print(
f"\nMeans: val_acc={fold_df['val_acc'].mean():.4f}, val_auc={fold_df['val_auc'].mean():.4f}, "
f"holdout_acc={holdout_df['holdout_acc'].mean():.4f}, holdout_auc={holdout_df['holdout_auc'].mean():.4f}"
)
print(f"\nSaved outputs to: {OUTPUT_DIR}")
if __name__ == "__main__":
main()