CM-Oculomics / src /train.py
fc28's picture
Upload folder using huggingface_hub
203f76b verified
Raw
History Blame Contribute Delete
4.25 kB
# -*- coding: utf-8 -*-
"""
Fine-tune a vision foundation model for anti-VEGF intolerance (5-fold, patient-level CV).
Primary backbone: DINOv2 (ViT-L/14, Apache-2.0); set `backbone: retfound` in the config
to fine-tune the RETFound comparator instead. Class-weighted cross-entropy handles the
~9:1 imbalance; AMP + cosine schedule; early stopping on validation balanced accuracy.
Supply your own de-identified manifest (see src/dataset.py). NO patient data is included.
"""
import argparse, yaml
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
from sklearn.metrics import roc_auc_score, balanced_accuracy_score
from .model import FundusClassifier, build_dinov2
from .dataset import FundusDataset, build_transforms
from PIL import Image
class _DS(torch.utils.data.Dataset):
def __init__(self, df, train, size=224):
self.df = df.reset_index(drop=True); self.tf = build_transforms(size, train)
def __len__(self): return len(self.df)
def __getitem__(self, i):
r = self.df.iloc[i]
return self.tf(Image.open(r["image_path"]).convert("RGB")), int(r["label"])
def class_weights(labels, device):
c = np.bincount(labels, minlength=2).astype(float)
w = c.sum() / (2 * np.maximum(c, 1))
return torch.tensor(w, dtype=torch.float32, device=device)
def train_one_fold(tr, va, cfg, device):
tr_ds, va_ds = _DS(tr, True), _DS(va, False)
tr_ld = DataLoader(tr_ds, cfg["batch_size"], shuffle=True, num_workers=4, drop_last=True)
va_ld = DataLoader(va_ds, cfg["batch_size"], shuffle=False, num_workers=4)
if cfg.get("backbone", "dinov2") == "retfound":
model = FundusClassifier(2, cfg.get("retfound_weights")).to(device)
else:
model = build_dinov2(2, img_size=cfg.get("image_size", 224)).to(device)
crit = nn.CrossEntropyLoss(weight=class_weights(tr["label"].values, device))
opt = torch.optim.AdamW(model.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=cfg["epochs"])
scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
best, bad = -1, 0
for ep in range(cfg["epochs"]):
model.train()
for x, y in tr_ld:
x, y = x.to(device), y.to(device); opt.zero_grad()
with torch.amp.autocast("cuda", enabled=device == "cuda"):
loss = crit(model(x), y)
scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
sched.step()
ys, ps = _eval(model, va_ld, device)
auc = roc_auc_score(ys, ps); ba = balanced_accuracy_score(ys, (ps >= 0.5).astype(int))
if ba > best: best, bad = ba, 0; torch.save(model.state_dict(), cfg["out_weights"])
else:
bad += 1
if bad >= cfg.get("patience", 10): break
return best
@torch.no_grad()
def _eval(model, ld, device):
model.eval(); ys, ps = [], []
for x, y in ld:
p = torch.softmax(model(x.to(device)), 1)[:, 1].cpu().numpy()
ps.extend(p); ys.extend(y.numpy())
return np.array(ys), np.array(ps)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="configs/default.yaml")
args = ap.parse_args()
cfg = yaml.safe_load(open(args.config, encoding="utf-8"))
device = "cuda" if torch.cuda.is_available() else "cpu"
df = pd.read_csv(cfg["manifest"])
y = df["label"].values
if "patient_id" in df.columns:
splitter = StratifiedGroupKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
folds = splitter.split(df, y, df["patient_id"].values)
else:
splitter = StratifiedKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
folds = splitter.split(df, y)
aucs = []
for k, (tr_idx, va_idx) in enumerate(folds):
cfg["out_weights"] = f"weights/fold{k}.pth"
best = train_one_fold(df.iloc[tr_idx], df.iloc[va_idx], cfg, device)
print(f"fold{k}: best balanced_acc={best:.3f}")
aucs.append(best)
print(f"mean balanced_acc: {np.mean(aucs):.3f} ± {np.std(aucs):.3f}")
if __name__ == "__main__":
main()