File size: 5,795 Bytes
6f91661
33f9765
 
 
 
 
 
 
6f91661
 
8b764f4
 
50a4594
33f9765
6f91661
44259c3
6f91661
 
 
 
 
 
 
 
edc54ba
6f91661
edc54ba
 
 
 
 
 
 
 
 
 
 
6f91661
8b764f4
 
 
 
 
6f91661
 
edc54ba
 
6f91661
 
33f9765
6f91661
 
 
 
edc54ba
33f9765
edc54ba
 
 
6f91661
33f9765
 
 
44259c3
6f91661
33f9765
6f91661
 
33f9765
6f91661
 
edc54ba
33f9765
 
 
6f91661
 
 
33f9765
6f91661
 
 
edc54ba
 
33f9765
edc54ba
6f91661
 
edc54ba
33f9765
edc54ba
6f91661
44259c3
6f91661
33f9765
 
 
 
 
6f91661
33f9765
 
edc54ba
 
33f9765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edc54ba
 
33f9765
edc54ba
 
6f91661
 
 
33f9765
6f91661
edc54ba
 
 
 
6f91661
8b764f4
 
6f91661
edc54ba
33f9765
edc54ba
6f91661
50a4594
 
 
 
edc54ba
 
50a4594
 
33f9765
 
 
 
 
 
44259c3
33f9765
 
50a4594
8b764f4
50a4594
33f9765
 
 
50a4594
 
6f91661
33f9765
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""
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)


@dataclass
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)