Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- src/__init__.py +0 -0
- src/biomarkers.py +81 -0
- src/dataset.py +51 -0
- src/gradcam.py +45 -0
- src/inference.py +32 -0
- src/model.py +97 -0
- src/train.py +101 -0
src/__init__.py
ADDED
|
File without changes
|
src/biomarkers.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Interpretable retinal imaging biomarkers of anti-VEGF intolerance.
|
| 4 |
+
|
| 5 |
+
Computes, from a single color fundus photograph:
|
| 6 |
+
- vascular density : fraction of FOV occupied by the vessel mask
|
| 7 |
+
- vascular skeleton length: normalized length of the vessel skeleton
|
| 8 |
+
- vascular fractal dimension : box-counting fractal dimension of the vessels
|
| 9 |
+
|
| 10 |
+
Vessels are enhanced with CLAHE + morphological black-hat on the green channel.
|
| 11 |
+
PIL is used to read images (supports non-ASCII paths).
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import numpy as np
|
| 15 |
+
import cv2
|
| 16 |
+
from PIL import Image
|
| 17 |
+
from skimage.morphology import skeletonize
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _fov_mask(gray):
|
| 21 |
+
_, m = cv2.threshold(gray, 12, 255, cv2.THRESH_BINARY)
|
| 22 |
+
return m > 0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _vessel_mask(img_bgr, mask):
|
| 26 |
+
g = img_bgr[:, :, 1]
|
| 27 |
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 28 |
+
ge = clahe.apply(g)
|
| 29 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
| 30 |
+
bh = cv2.morphologyEx(ge, cv2.MORPH_BLACKHAT, kernel)
|
| 31 |
+
_, vessel = cv2.threshold(bh, 15, 255, cv2.THRESH_BINARY)
|
| 32 |
+
return (vessel > 0) & mask
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def fractal_dimension(binary):
|
| 36 |
+
"""Box-counting fractal dimension of a binary structure.
|
| 37 |
+
|
| 38 |
+
D = -slope of log N(eps) vs log eps, where N(eps) is the number of boxes of
|
| 39 |
+
side eps that intersect the structure.
|
| 40 |
+
"""
|
| 41 |
+
Z = binary > 0
|
| 42 |
+
if Z.sum() == 0:
|
| 43 |
+
return 0.0
|
| 44 |
+
|
| 45 |
+
def boxcount(Z, k):
|
| 46 |
+
S = np.add.reduceat(
|
| 47 |
+
np.add.reduceat(Z, np.arange(0, Z.shape[0], k), axis=0),
|
| 48 |
+
np.arange(0, Z.shape[1], k), axis=1)
|
| 49 |
+
return len(np.where((S > 0) & (S < k * k))[0])
|
| 50 |
+
|
| 51 |
+
sizes = 2 ** np.arange(1, 7)
|
| 52 |
+
counts = [max(boxcount(Z, int(s)), 1) for s in sizes]
|
| 53 |
+
coeffs = np.polyfit(np.log(sizes), np.log(counts), 1)
|
| 54 |
+
return float(-coeffs[0])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def compute_biomarkers(image_path: str, size: int = 512) -> dict:
|
| 58 |
+
pil = Image.open(image_path).convert("RGB")
|
| 59 |
+
img = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
| 60 |
+
img = cv2.resize(img, (size, size))
|
| 61 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 62 |
+
mask = _fov_mask(gray)
|
| 63 |
+
area = max(int(mask.sum()), 1)
|
| 64 |
+
|
| 65 |
+
vessel = _vessel_mask(img, mask)
|
| 66 |
+
density = float(vessel.sum() / area)
|
| 67 |
+
skeleton_len = float(skeletonize(vessel).sum() / area)
|
| 68 |
+
fd = fractal_dimension(vessel)
|
| 69 |
+
return {
|
| 70 |
+
"vascular_density": round(density, 4),
|
| 71 |
+
"vascular_skeleton_length": round(skeleton_len, 4),
|
| 72 |
+
"vascular_fractal_dimension": round(fd, 4),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
ap = argparse.ArgumentParser(description="Compute retinal vascular biomarkers.")
|
| 78 |
+
ap.add_argument("--image", required=True)
|
| 79 |
+
args = ap.parse_args()
|
| 80 |
+
for k, v in compute_biomarkers(args.image).items():
|
| 81 |
+
print(f"{k}: {v}")
|
src/dataset.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Fundus image dataset and transforms.
|
| 4 |
+
|
| 5 |
+
Expects a manifest CSV with columns:
|
| 6 |
+
image_path,label[,patient_id]
|
| 7 |
+
where label in {0,1} (0 = tolerant / NPDR, 1 = intolerant / PDR).
|
| 8 |
+
patient_id is optional but recommended for patient-level cross-validation splits.
|
| 9 |
+
|
| 10 |
+
NO patient data is distributed with this repository; supply your own
|
| 11 |
+
ethically-approved, de-identified manifest and images.
|
| 12 |
+
"""
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from PIL import Image
|
| 15 |
+
from torch.utils.data import Dataset
|
| 16 |
+
from torchvision import transforms
|
| 17 |
+
|
| 18 |
+
IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
| 19 |
+
IMAGENET_STD = [0.229, 0.224, 0.225]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_transforms(image_size: int = 224, train: bool = False):
|
| 23 |
+
norm = transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD)
|
| 24 |
+
if train:
|
| 25 |
+
return transforms.Compose([
|
| 26 |
+
transforms.RandomResizedCrop(image_size, scale=(0.8, 1.0)),
|
| 27 |
+
transforms.RandomHorizontalFlip(),
|
| 28 |
+
transforms.RandomRotation(15),
|
| 29 |
+
transforms.ColorJitter(0.1, 0.1, 0.1),
|
| 30 |
+
transforms.ToTensor(), norm,
|
| 31 |
+
])
|
| 32 |
+
return transforms.Compose([
|
| 33 |
+
transforms.Resize((image_size, image_size)),
|
| 34 |
+
transforms.ToTensor(), norm,
|
| 35 |
+
])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FundusDataset(Dataset):
|
| 39 |
+
def __init__(self, manifest_csv: str, image_size: int = 224, train: bool = False):
|
| 40 |
+
self.df = pd.read_csv(manifest_csv)
|
| 41 |
+
assert {"image_path", "label"}.issubset(self.df.columns), \
|
| 42 |
+
"manifest must contain columns: image_path,label[,patient_id]"
|
| 43 |
+
self.tf = build_transforms(image_size, train)
|
| 44 |
+
|
| 45 |
+
def __len__(self):
|
| 46 |
+
return len(self.df)
|
| 47 |
+
|
| 48 |
+
def __getitem__(self, i):
|
| 49 |
+
r = self.df.iloc[i]
|
| 50 |
+
x = self.tf(Image.open(r["image_path"]).convert("RGB"))
|
| 51 |
+
return x, int(r["label"])
|
src/gradcam.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Grad-CAM explanation for the DINOv2 (ViT-L/14) intolerance classifier.
|
| 3 |
+
|
| 4 |
+
ViT tokens are reshaped to a 16x16 grid (224/14) for spatial attribution.
|
| 5 |
+
"""
|
| 6 |
+
import argparse
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from PIL import Image
|
| 10 |
+
from pytorch_grad_cam import GradCAM
|
| 11 |
+
from pytorch_grad_cam.utils.image import show_cam_on_image
|
| 12 |
+
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
|
| 13 |
+
from .model import load_dinov2, dinov2_gradcam_target, dinov2_reshape
|
| 14 |
+
from .dataset import build_transforms
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def reshape_transform(tensor, grid=16):
|
| 18 |
+
return dinov2_reshape(tensor, grid)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def gradcam(image_path: str, weights: str, out_path: str = "cam.png",
|
| 22 |
+
device: str = None, target_class: int = 1):
|
| 23 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 24 |
+
model = load_dinov2(weights, device)
|
| 25 |
+
target_layers = dinov2_gradcam_target(model)
|
| 26 |
+
cam = GradCAM(model=model, target_layers=target_layers,
|
| 27 |
+
reshape_transform=reshape_transform)
|
| 28 |
+
|
| 29 |
+
pil = Image.open(image_path).convert("RGB").resize((224, 224))
|
| 30 |
+
rgb = np.array(pil).astype(np.float32) / 255.0
|
| 31 |
+
x = build_transforms(224, train=False)(pil).unsqueeze(0).to(device)
|
| 32 |
+
grayscale = cam(input_tensor=x, targets=[ClassifierOutputTarget(target_class)])[0]
|
| 33 |
+
vis = show_cam_on_image(rgb, grayscale, use_rgb=True)
|
| 34 |
+
Image.fromarray(vis).save(out_path)
|
| 35 |
+
print("saved:", out_path)
|
| 36 |
+
return vis
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
if __name__ == "__main__":
|
| 40 |
+
ap = argparse.ArgumentParser(description="Grad-CAM for the intolerance classifier.")
|
| 41 |
+
ap.add_argument("--image", required=True)
|
| 42 |
+
ap.add_argument("--weights", required=True)
|
| 43 |
+
ap.add_argument("--out", default="cam.png")
|
| 44 |
+
args = ap.parse_args()
|
| 45 |
+
gradcam(args.image, args.weights, args.out)
|
src/inference.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Single-image (or batch) inference: anti-VEGF intolerance risk score."""
|
| 3 |
+
import argparse
|
| 4 |
+
import torch
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from .model import load_dinov2
|
| 7 |
+
from .dataset import build_transforms
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def predict(image_path: str, weights: str, device: str = None, threshold: float = 0.5):
|
| 11 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 12 |
+
model = load_dinov2(weights, device) # primary backbone: DINOv2 (ViT-L/14)
|
| 13 |
+
tf = build_transforms(224, train=False)
|
| 14 |
+
x = tf(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device)
|
| 15 |
+
with torch.no_grad():
|
| 16 |
+
prob = torch.softmax(model(x), 1)[0, 1].item()
|
| 17 |
+
return {
|
| 18 |
+
"intolerance_risk": round(prob, 4),
|
| 19 |
+
"prediction": "intolerant" if prob >= threshold else "tolerant",
|
| 20 |
+
"threshold": threshold,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
ap = argparse.ArgumentParser(description="anti-VEGF intolerance risk from a fundus image.")
|
| 26 |
+
ap.add_argument("--image", required=True)
|
| 27 |
+
ap.add_argument("--weights", required=True)
|
| 28 |
+
ap.add_argument("--threshold", type=float, default=0.5)
|
| 29 |
+
args = ap.parse_args()
|
| 30 |
+
out = predict(args.image, args.weights, threshold=args.threshold)
|
| 31 |
+
for k, v in out.items():
|
| 32 |
+
print(f"{k}: {v}")
|
src/model.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Models for anti-VEGF intolerance prediction.
|
| 4 |
+
|
| 5 |
+
PRIMARY: DINOv2 (ViT-L/14) — a generalist vision foundation model (Meta AI, Apache-2.0),
|
| 6 |
+
fine-tuned for binary classification. Released fine-tuned weights (dino_deploy.pth) are
|
| 7 |
+
Apache-2.0 (free for research and commercial use with attribution). Use `build_dinov2`
|
| 8 |
+
/ `load_dinov2`.
|
| 9 |
+
|
| 10 |
+
COMPARATOR: RETFound (ViT-L/16) — a retinal-domain foundation model (Zhou et al.,
|
| 11 |
+
Nature 2023, CC BY-NC 4.0). Provided via `FundusClassifier` for the head-to-head
|
| 12 |
+
comparison reported in the paper; RETFound weights are NOT redistributed here.
|
| 13 |
+
"""
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
import timm
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FundusClassifier(nn.Module):
|
| 20 |
+
"""ViT-L/16 backbone (RETFound-initialized) + linear head.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
num_classes: 2 (tolerant / intolerant).
|
| 24 |
+
retfound_weights: path to RETFound .pth, or None to start from timm init.
|
| 25 |
+
drop_rate: dropout for the head.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, num_classes: int = 2, retfound_weights: str | None = None,
|
| 29 |
+
backbone: str = "vit_large_patch16_224", drop_rate: float = 0.2):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.backbone = timm.create_model(backbone, pretrained=False,
|
| 32 |
+
num_classes=0, drop_rate=drop_rate)
|
| 33 |
+
if retfound_weights:
|
| 34 |
+
self._load_retfound(retfound_weights)
|
| 35 |
+
d = self.backbone.num_features
|
| 36 |
+
self.head = nn.Sequential(
|
| 37 |
+
nn.LayerNorm(d), nn.Dropout(drop_rate), nn.Linear(d, num_classes)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def _load_retfound(self, path: str):
|
| 41 |
+
sd = torch.load(path, map_location="cpu", weights_only=False)
|
| 42 |
+
sd = sd.get("model", sd)
|
| 43 |
+
own = self.backbone.state_dict()
|
| 44 |
+
matched = {k: v for k, v in sd.items()
|
| 45 |
+
if not k.startswith("decoder") and k != "mask_token"
|
| 46 |
+
and k in own and own[k].shape == v.shape}
|
| 47 |
+
own.update(matched)
|
| 48 |
+
self.backbone.load_state_dict(own, strict=False)
|
| 49 |
+
print(f"[RETFound] loaded {len(matched)}/{len(own)} encoder tensors")
|
| 50 |
+
|
| 51 |
+
def forward(self, x, return_feat: bool = False):
|
| 52 |
+
f = self.backbone(x)
|
| 53 |
+
logits = self.head(f)
|
| 54 |
+
return (logits, f) if return_feat else logits
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_finetuned(weights_path: str, device: str = "cuda"):
|
| 58 |
+
"""Load a fully fine-tuned RETFound (comparator) classifier checkpoint."""
|
| 59 |
+
model = FundusClassifier(num_classes=2, retfound_weights=None)
|
| 60 |
+
state = torch.load(weights_path, map_location="cpu")
|
| 61 |
+
model.load_state_dict(state)
|
| 62 |
+
return model.to(device).eval()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# --------------------------------------------------------------------------- #
|
| 66 |
+
# PRIMARY model: DINOv2 (ViT-L/14) generalist vision foundation model
|
| 67 |
+
# --------------------------------------------------------------------------- #
|
| 68 |
+
def build_dinov2(num_classes: int = 2, img_size: int = 224, drop_rate: float = 0.2):
|
| 69 |
+
"""DINOv2 (ViT-L/14) backbone + LayerNorm/Dropout/Linear head, as an nn.Sequential.
|
| 70 |
+
|
| 71 |
+
The 224-px input yields a 16x16 = 256 patch-token grid. The returned module's
|
| 72 |
+
state_dict matches the released `dino_deploy.pth` (keys '0.*' backbone, '1.*' head).
|
| 73 |
+
"""
|
| 74 |
+
backbone = timm.create_model("vit_large_patch14_dinov2", pretrained=False,
|
| 75 |
+
num_classes=0, img_size=img_size, drop_rate=drop_rate)
|
| 76 |
+
head = nn.Sequential(nn.LayerNorm(backbone.num_features),
|
| 77 |
+
nn.Dropout(drop_rate),
|
| 78 |
+
nn.Linear(backbone.num_features, num_classes))
|
| 79 |
+
return nn.Sequential(backbone, head)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def load_dinov2(weights_path: str, device: str = "cuda", img_size: int = 224):
|
| 83 |
+
"""Load the fine-tuned DINOv2 classifier (dino_deploy.pth) for inference."""
|
| 84 |
+
model = build_dinov2(num_classes=2, img_size=img_size)
|
| 85 |
+
model.load_state_dict(torch.load(weights_path, map_location="cpu"))
|
| 86 |
+
return model.to(device).eval()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def dinov2_gradcam_target(model):
|
| 90 |
+
"""Grad-CAM target layer for the DINOv2 Sequential model."""
|
| 91 |
+
return [model[0].blocks[-1].norm1]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def dinov2_reshape(tensor, grid: int = 16):
|
| 95 |
+
"""reshape_transform for Grad-CAM on DINOv2 (keep the last grid*grid patch tokens)."""
|
| 96 |
+
x = tensor[:, -grid * grid:, :]
|
| 97 |
+
return x.reshape(tensor.size(0), grid, grid, tensor.size(2)).permute(0, 3, 1, 2)
|
src/train.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Fine-tune a vision foundation model for anti-VEGF intolerance (5-fold, patient-level CV).
|
| 4 |
+
|
| 5 |
+
Primary backbone: DINOv2 (ViT-L/14, Apache-2.0); set `backbone: retfound` in the config
|
| 6 |
+
to fine-tune the RETFound comparator instead. Class-weighted cross-entropy handles the
|
| 7 |
+
~9:1 imbalance; AMP + cosine schedule; early stopping on validation balanced accuracy.
|
| 8 |
+
Supply your own de-identified manifest (see src/dataset.py). NO patient data is included.
|
| 9 |
+
"""
|
| 10 |
+
import argparse, yaml
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
from torch.utils.data import DataLoader
|
| 16 |
+
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
|
| 17 |
+
from sklearn.metrics import roc_auc_score, balanced_accuracy_score
|
| 18 |
+
from .model import FundusClassifier, build_dinov2
|
| 19 |
+
from .dataset import FundusDataset, build_transforms
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class _DS(torch.utils.data.Dataset):
|
| 24 |
+
def __init__(self, df, train, size=224):
|
| 25 |
+
self.df = df.reset_index(drop=True); self.tf = build_transforms(size, train)
|
| 26 |
+
def __len__(self): return len(self.df)
|
| 27 |
+
def __getitem__(self, i):
|
| 28 |
+
r = self.df.iloc[i]
|
| 29 |
+
return self.tf(Image.open(r["image_path"]).convert("RGB")), int(r["label"])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def class_weights(labels, device):
|
| 33 |
+
c = np.bincount(labels, minlength=2).astype(float)
|
| 34 |
+
w = c.sum() / (2 * np.maximum(c, 1))
|
| 35 |
+
return torch.tensor(w, dtype=torch.float32, device=device)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def train_one_fold(tr, va, cfg, device):
|
| 39 |
+
tr_ds, va_ds = _DS(tr, True), _DS(va, False)
|
| 40 |
+
tr_ld = DataLoader(tr_ds, cfg["batch_size"], shuffle=True, num_workers=4, drop_last=True)
|
| 41 |
+
va_ld = DataLoader(va_ds, cfg["batch_size"], shuffle=False, num_workers=4)
|
| 42 |
+
if cfg.get("backbone", "dinov2") == "retfound":
|
| 43 |
+
model = FundusClassifier(2, cfg.get("retfound_weights")).to(device)
|
| 44 |
+
else:
|
| 45 |
+
model = build_dinov2(2, img_size=cfg.get("image_size", 224)).to(device)
|
| 46 |
+
crit = nn.CrossEntropyLoss(weight=class_weights(tr["label"].values, device))
|
| 47 |
+
opt = torch.optim.AdamW(model.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
|
| 48 |
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=cfg["epochs"])
|
| 49 |
+
scaler = torch.amp.GradScaler("cuda", enabled=device == "cuda")
|
| 50 |
+
best, bad = -1, 0
|
| 51 |
+
for ep in range(cfg["epochs"]):
|
| 52 |
+
model.train()
|
| 53 |
+
for x, y in tr_ld:
|
| 54 |
+
x, y = x.to(device), y.to(device); opt.zero_grad()
|
| 55 |
+
with torch.amp.autocast("cuda", enabled=device == "cuda"):
|
| 56 |
+
loss = crit(model(x), y)
|
| 57 |
+
scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
|
| 58 |
+
sched.step()
|
| 59 |
+
ys, ps = _eval(model, va_ld, device)
|
| 60 |
+
auc = roc_auc_score(ys, ps); ba = balanced_accuracy_score(ys, (ps >= 0.5).astype(int))
|
| 61 |
+
if ba > best: best, bad = ba, 0; torch.save(model.state_dict(), cfg["out_weights"])
|
| 62 |
+
else:
|
| 63 |
+
bad += 1
|
| 64 |
+
if bad >= cfg.get("patience", 10): break
|
| 65 |
+
return best
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@torch.no_grad()
|
| 69 |
+
def _eval(model, ld, device):
|
| 70 |
+
model.eval(); ys, ps = [], []
|
| 71 |
+
for x, y in ld:
|
| 72 |
+
p = torch.softmax(model(x.to(device)), 1)[:, 1].cpu().numpy()
|
| 73 |
+
ps.extend(p); ys.extend(y.numpy())
|
| 74 |
+
return np.array(ys), np.array(ps)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
ap = argparse.ArgumentParser()
|
| 79 |
+
ap.add_argument("--config", default="configs/default.yaml")
|
| 80 |
+
args = ap.parse_args()
|
| 81 |
+
cfg = yaml.safe_load(open(args.config, encoding="utf-8"))
|
| 82 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 83 |
+
df = pd.read_csv(cfg["manifest"])
|
| 84 |
+
y = df["label"].values
|
| 85 |
+
if "patient_id" in df.columns:
|
| 86 |
+
splitter = StratifiedGroupKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
|
| 87 |
+
folds = splitter.split(df, y, df["patient_id"].values)
|
| 88 |
+
else:
|
| 89 |
+
splitter = StratifiedKFold(cfg["folds"], shuffle=True, random_state=cfg["seed"])
|
| 90 |
+
folds = splitter.split(df, y)
|
| 91 |
+
aucs = []
|
| 92 |
+
for k, (tr_idx, va_idx) in enumerate(folds):
|
| 93 |
+
cfg["out_weights"] = f"weights/fold{k}.pth"
|
| 94 |
+
best = train_one_fold(df.iloc[tr_idx], df.iloc[va_idx], cfg, device)
|
| 95 |
+
print(f"fold{k}: best balanced_acc={best:.3f}")
|
| 96 |
+
aucs.append(best)
|
| 97 |
+
print(f"mean balanced_acc: {np.mean(aucs):.3f} ± {np.std(aucs):.3f}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|