Gokul-G1
Fix: DRModel attribute names match notebook checkpoint (backbone/head_cls/head_coral/dict return) β weights now load correctly; GradCAM aug_smooth=False; lesion ROI max area cap
33f9765 | """ | |
| model_classes.py β Exact port of DR_Grading_v10_COMPLETE.ipynb cell 25. | |
| CRITICAL: attribute names MUST match the saved checkpoint keys exactly: | |
| self.backbone (NOT self.encoder) | |
| self.head_cls (NOT self.head) | |
| self.head_coral (NOT self.coral_head) | |
| forward() returns Dict {"logits", "coral", "feat"} (NOT a tuple) | |
| """ | |
| import torch, torch.nn as nn, torch.nn.functional as F | |
| import timm, numpy as np | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Optional, Tuple | |
| NUM_CLASSES = 5 | |
| IMAGENET_MEAN = [0.485, 0.456, 0.406] | |
| IMAGENET_STD = [0.229, 0.224, 0.225] | |
| GRADE_MAP = { | |
| 0: "No DR", | |
| 1: "Mild DR", | |
| 2: "Moderate DR", | |
| 3: "Severe DR", | |
| 4: "Proliferative DR (PDR)", | |
| } | |
| GRADE_COLORS = ["#037f0c", "#7d6f00", "#8d4800", "#cc3300", "#d91515"] | |
| GRADE_RISK = [ | |
| "Low", | |
| "Low-Moderate", | |
| "Moderate", | |
| "High", | |
| "Very High", | |
| ] | |
| GRADE_ACTION = [ | |
| "Routine follow-up in 12 months", | |
| "Follow-up in 6β12 months", | |
| "Refer to ophthalmologist within 3β6 months", | |
| "Urgent referral to retina specialist", | |
| "Immediate ophthalmological intervention required", | |
| ] | |
| SEVERITY_ICONS = ["β ", "π‘", "π ", "π΄", "π¨"] | |
| class GeM(nn.Module): | |
| """Generalized mean pooling with a learnable exponent.""" | |
| def __init__(self, p: float = 3.0, eps: float = 1e-6): | |
| super().__init__() | |
| self.p = nn.Parameter(torch.tensor(p)) | |
| self.eps = eps | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return F.adaptive_avg_pool2d( | |
| x.clamp(min=self.eps).pow(self.p), 1 | |
| ).pow(1.0 / self.p) | |
| def __repr__(self) -> str: | |
| return f"GeM(p={self.p.item():.2f})" | |
| class CoralHead(nn.Module): | |
| """CORAL ordinal regression head β K-1 binary predictors.""" | |
| def __init__(self, in_features: int, num_classes: int): | |
| super().__init__() | |
| self.num_classes = num_classes | |
| self.linear = nn.Linear(in_features, 1, bias=False) | |
| self.bias = nn.Parameter(torch.zeros(num_classes - 1)) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| logit = self.linear(x) # (B, 1) | |
| return logit + self.bias # (B, K-1) | |
| class DRHead(nn.Module): | |
| """Softmax classifier head β small MLP on top of pooled features.""" | |
| def __init__(self, in_features: int, num_classes: int = 5, dropout: float = 0.3): | |
| super().__init__() | |
| self.net = nn.Sequential( | |
| nn.Linear(in_features, 512), nn.BatchNorm1d(512), | |
| nn.SiLU(inplace=True), nn.Dropout(dropout), | |
| nn.Linear(512, 256), nn.BatchNorm1d(256), | |
| nn.SiLU(inplace=True), nn.Dropout(dropout / 2), | |
| nn.Linear(256, num_classes), | |
| ) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return self.net(x) | |
| class DRModel(nn.Module): | |
| """Backbone + GeM + {softmax head, CORAL head}. | |
| Attribute names MUST match notebook checkpoint keys exactly. | |
| """ | |
| def __init__(self, backbone: str = "tf_efficientnetv2_m", | |
| num_classes: int = NUM_CLASSES, pretrained: bool = False): | |
| super().__init__() | |
| # Key: "backbone" not "encoder" β matches checkpoint saved by notebook | |
| self.backbone = timm.create_model( | |
| backbone, pretrained=pretrained, num_classes=0, global_pool="" | |
| ) | |
| in_feat = self.backbone.num_features | |
| self.pool = GeM(p=3.0) | |
| # Key: "head_cls" not "head" | |
| self.head_cls = DRHead(in_feat, num_classes) | |
| # Key: "head_coral" not "coral_head" | |
| self.head_coral = CoralHead(in_feat, num_classes) | |
| self._backbone_name = backbone | |
| self.num_classes = num_classes | |
| def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: | |
| feat = self.backbone(x) | |
| feat = self.pool(feat).flatten(1) | |
| return { | |
| "logits": self.head_cls(feat), | |
| "coral": self.head_coral(feat), | |
| "feat": feat, | |
| } | |
| def freeze_backbone(self) -> None: | |
| for p in self.backbone.parameters(): | |
| p.requires_grad_(False) | |
| def unfreeze_all(self) -> None: | |
| for p in self.parameters(): | |
| p.requires_grad_(True) | |
| class FundusValidator(nn.Module): | |
| def __init__(self, pretrained: bool = False): | |
| super().__init__() | |
| self.backbone = timm.create_model( | |
| "convnext_tiny.fb_in22k", pretrained=pretrained, | |
| num_classes=0, global_pool="avg" | |
| ) | |
| self.head = nn.Sequential( | |
| nn.Linear(self.backbone.num_features, 256), | |
| nn.GELU(), nn.Dropout(0.3), nn.Linear(256, 1), | |
| ) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return self.head(self.backbone(x)).squeeze(1) | |
| class CalibrationBundle: | |
| temperature: float | |
| isotonic: list = field(default_factory=list) | |
| num_classes: int = 5 | |
| def apply(self, logits: np.ndarray) -> np.ndarray: | |
| """Temperature scale raw logits β calibrated probabilities. | |
| logits shape: (1, C) or (C,) β returns (1, C) or (C,). | |
| """ | |
| squeeze = logits.ndim == 1 | |
| if squeeze: | |
| logits = logits[np.newaxis] | |
| scaled = logits / max(float(self.temperature), 1e-6) | |
| ex = np.exp(scaled - scaled.max(axis=1, keepdims=True)) | |
| probs = ex / ex.sum(axis=1, keepdims=True) | |
| if self.isotonic: | |
| cal = np.zeros_like(probs) | |
| for k, iso in enumerate(self.isotonic): | |
| cal[:, k] = iso.transform(np.clip(probs[:, k], 1e-6, 1 - 1e-6)) | |
| probs = cal / (cal.sum(axis=1, keepdims=True) + 1e-12) | |
| return probs[0] if squeeze else probs | |
| def coral_rank_from_probs_np(probs: np.ndarray) -> np.ndarray: | |
| return (probs > 0.5).sum(axis=-1).astype(np.int64) |