File size: 8,212 Bytes
198a76d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""MoR-CNN: Mixture-of-Recursions applied to a convolutional vision model.

Ports the LLM MoR idea (llm-pipeline) to 2D medical imaging.  The mapping:

    entry block        -> pretrained CNN stem (unique weights)
    shared recursive   -> ``RecursiveConvBlock`` stack reused at every recursion
    core
    depth router       -> per-slice ``DepthRouter`` (expert choice over slices)
    recursion emb      -> learned per-recursion embedding added before the core
    slice freeze       -> a slice that stops routing keeps its current state

At inference the router spends full recursion depth only on the "hard" slices
(those most likely to be abnormal), so FLOPs scale with content — the lever the
competition's Efficiency Track scores.
"""

from __future__ import annotations

import math

import torch
import torch.nn as nn
import torch.nn.functional as F

try:
    import timm

    HAS_TIMM = True
except ImportError:  # pragma: no cover - optional dependency
    HAS_TIMM = False


class DepthScore(nn.Module):
    """Per-slice continuation score: pooled feature -> scalar logit.

    Mirrors ``_DepthScore`` in llm-pipeline's MoR router, but over a global
    pooled per-slice feature instead of a per-token hidden state.
    """

    def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
        super().__init__()
        self.in_proj = nn.Linear(dim, hidden)
        self.out_proj = nn.Linear(hidden, 1)
        with torch.no_grad():
            self.out_proj.bias.fill_(init_bias)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)


class DepthRouter(nn.Module):
    """Expert-choice depth router over slices.

    One ``DepthScore`` head per recursion.  Each recursion also adds a learned
    recursion embedding so the router can condition on how deep a slice
    already is (the vision analogue of ``rec_emb`` in the LLM MoR).
    """

    def __init__(
        self,
        dim: int,
        hidden: int,
        n_recursions: int,
        capacities: list[float],
        init_bias: float = 0.0,
        warmup_steps: int = 0,
    ):
        super().__init__()
        self.n_recursions = n_recursions
        self.capacities = capacities
        self.warmup_steps = warmup_steps
        self.heads = nn.ModuleList(
            [DepthScore(dim, hidden, init_bias) for _ in range(n_recursions)]
        )
        self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))

    def capacity(self, r: int, step: int) -> float:
        """Slice fraction kept at recursion ``r``, ramping from 1.0 during warmup."""
        target = self.capacities[r]
        if self.warmup_steps > 0 and step < self.warmup_steps:
            t = step / self.warmup_steps
            return 1.0 - (1.0 - target) * t
        return target

    def forward(self, x: torch.Tensor, r: int) -> torch.Tensor:
        # x: [S, dim] pooled per-slice features
        return self.heads[r](x + self.rec_emb[r])


class RecursiveConvBlock(nn.Module):
    """A shared inverted-residual conv block reused at every recursion.

    Depthwise-separable (MobileNet-v2 style) so the recursive core stays cheap
    while the stem does the heavy feature extraction.
    """

    def __init__(self, dim: int):
        super().__init__()
        self.norm = nn.LayerNorm(dim)
        self.pw1 = nn.Conv2d(dim, dim * 2, 1)
        self.dw = nn.Conv2d(dim * 2, dim * 2, 3, padding=1, groups=dim * 2)
        self.pw2 = nn.Conv2d(dim * 2, dim, 1)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [N, dim, h, w]
        identity = x
        x = self.norm(x.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)
        x = self.pw2(F.gelu(self.dw(F.gelu(self.pw1(x)))))
        return identity + x


class SliceAttentionPool(nn.Module):
    """Weight slices by learned relevance before aggregating into a study vector."""

    def __init__(self, dim: int):
        super().__init__()
        self.query = nn.Parameter(torch.randn(dim))
        self.scale = dim ** -0.5

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [S, dim]
        w = F.softmax(x @ self.query * self.scale, dim=0)
        return (w.unsqueeze(1) * x).sum(0)


class ChannelLayerNorm(nn.Module):
    """LayerNorm over the channel dim of a [N, C, H, W] feature map."""

    def __init__(self, dim: int):
        super().__init__()
        self.norm = nn.LayerNorm(dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.norm(x.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)


class MoRCNN(nn.Module):
    """Pretrained CNN stem + MoR recursive core + per-slice depth router."""

    def __init__(
        self,
        *,
        stem_name: str = "convnext_tiny",
        pretrained: bool = True,
        n_recursions: int = 3,
        capacities: tuple[float, ...] = (1.0, 2.0 / 3.0, 1.0 / 3.0),
        router_hidden: int = 128,
        core_blocks: int = 2,
        n_classes: int = 12,
        router_warmup_steps: int = 0,
        router_init_bias: float = 0.0,
    ):
        super().__init__()
        if not HAS_TIMM:
            raise ImportError("timm is required for a pretrained stem")
        self.n_recursions = n_recursions
        self.capacities = list(capacities)
        assert len(self.capacities) == n_recursions

        self.stem = timm.create_model(
            stem_name, pretrained=pretrained, features_only=True, num_classes=0
        )
        out_dim = self.stem.feature_info.channels()[-1]

        self.core = nn.Sequential(*[RecursiveConvBlock(out_dim) for _ in range(core_blocks)])
        self.router = DepthRouter(
            out_dim,
            router_hidden,
            n_recursions,
            self.capacities,
            init_bias=router_init_bias,
            warmup_steps=router_warmup_steps,
        )
        self.rec_emb = nn.Parameter(torch.zeros(n_recursions, out_dim))

        self.exit = nn.Sequential(
            ChannelLayerNorm(out_dim),
            nn.Conv2d(out_dim, out_dim, 1),
            nn.GELU(),
        )
        self.pool = SliceAttentionPool(out_dim)

        self.head = nn.Sequential(
            nn.Linear(out_dim, out_dim * 2),
            nn.GELU(),
            nn.Linear(out_dim * 2, n_classes),
        )

    def _run_core(self, feat: torch.Tensor, r: int) -> torch.Tensor:
        emb = self.rec_emb[r].view(1, -1, 1, 1)
        return self.core(feat + emb)

    def _global_pool(self, feat: torch.Tensor) -> torch.Tensor:
        return feat.mean(dim=(2, 3))

    def forward(
        self, x: torch.Tensor, step: int = 0
    ) -> tuple[torch.Tensor, list[float]]:
        """Forward one study.

        Args:
            x: [S, 3, H, W] sampled slices of a single study.
            step: current optimizer step, used for router warmup.

        Returns:
            (logits [n_classes], route_stats) where route_stats is the active
            slice fraction at each recursion.
        """
        S = x.size(0)
        feat = self.stem(x)[-1]  # [S, C, h, w]
        C = feat.size(1)
        pooled = self._global_pool(feat)

        stats: list[float] = []
        for r in range(self.n_recursions):
            probs = torch.sigmoid(self.router(pooled, r))
            cap = self.router.capacity(r, step)
            if r == self.n_recursions - 1:
                active = torch.ones(S, dtype=torch.bool, device=x.device)
            else:
                k = max(1, int(round(S * cap)))
                active = torch.zeros(S, dtype=torch.bool, device=x.device)
                active[torch.topk(probs, k).indices] = True
            stats.append(active.float().mean().item())

            if active.all():
                feat = self._run_core(feat, r)
            else:
                idx = active.nonzero(as_tuple=False).squeeze(1)
                updated = self._run_core(feat[idx], r)
                feat = feat.clone()
                feat[idx] = updated
            pooled = self._global_pool(feat)

        slice_feats = self.exit(feat)  # [S, C, h, w]
        slice_feats = self._global_pool(slice_feats)  # [S, C]
        study = self.pool(slice_feats)  # [C]
        logits = self.head(study)  # [n_classes]
        return logits, stats