thenukegun10x commited on
Commit
64cd7f6
·
verified ·
1 Parent(s): 0c6cedd

Remove src/src/models/mor_vit.py

Browse files
Files changed (1) hide show
  1. src/src/models/mor_vit.py +0 -386
src/src/models/mor_vit.py DELETED
@@ -1,386 +0,0 @@
1
- """MoR-ViT: Mixture-of-Recursions on a pretrained DINOv3 ViT stem.
2
-
3
- Maps the llm-pipeline MoR recipe to a vision transformer:
4
-
5
- entry block -> pretrained DINOv3 stem (unique weights)
6
- shared core -> ``RecursiveAttentionBlock`` stack reused at every recursion
7
- depth router -> per-TOKEN ``TokenRouter`` (expert choice over patch tokens)
8
- recursion emb -> learned per-recursion embedding added before the core
9
- token freeze -> a patch token that stops routing keeps its current state
10
-
11
- Routing is per patch token (adaptive compute over the image), not per slice:
12
- every study keeps the full recursion budget, but only the "hard" patch tokens
13
- are recursed deeply. Slice features are mean-pooled over patch tokens, then
14
- weighted by a ``SliceAttentionPool`` into a study vector.
15
-
16
- ``features()`` exposes the frozen feature extractor used by linear probing
17
- without running the classification head.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import torch
23
- import torch.nn as nn
24
- import torch.nn.functional as F
25
-
26
- try:
27
- import timm
28
-
29
- HAS_TIMM = True
30
- except ImportError: # pragma: no cover - optional dependency
31
- HAS_TIMM = False
32
-
33
-
34
- class TokenScore(nn.Module):
35
- """Per-token continuation score: token embedding -> scalar logit.
36
-
37
- ``slice_gain`` (zero-initialised) couples the slice-level abnormality
38
- score into the patch decision; the forward is exactly legacy at init.
39
- """
40
-
41
- def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
42
- super().__init__()
43
- self.in_proj = nn.Linear(dim, hidden)
44
- self.out_proj = nn.Linear(hidden, 1)
45
- self.slice_gain = nn.Parameter(torch.zeros(1))
46
- with torch.no_grad():
47
- self.out_proj.bias.fill_(init_bias)
48
-
49
- def forward(self, x: torch.Tensor) -> torch.Tensor:
50
- return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)
51
-
52
-
53
- class SliceScore(nn.Module):
54
- """Per-slice continuation score: pooled patch feature -> scalar logit.
55
-
56
- Gives the patch router a slice-level view, so routing stays consistent
57
- across the slices of a study (a finding shows on several adjacent slices).
58
- """
59
-
60
- def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
61
- super().__init__()
62
- self.in_proj = nn.Linear(dim, hidden)
63
- self.out_proj = nn.Linear(hidden, 1)
64
- with torch.no_grad():
65
- self.out_proj.bias.fill_(init_bias)
66
-
67
- def forward(self, x: torch.Tensor) -> torch.Tensor:
68
- return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)
69
-
70
-
71
- class TokenRouter(nn.Module):
72
- """Expert-choice depth router over patch tokens.
73
-
74
- One ``TokenScore`` head per recursion, plus a learned recursion embedding
75
- so the router can condition on how deep a token already is. The router
76
- input can be spatially conditioned (stem patch position embedding) and
77
- slice-conditioned (``SliceScore`` coupled through a zero-init gain).
78
- Router biases initialise from the per-recursion capacity so the right
79
- fraction of tokens continues from step 0.
80
- """
81
-
82
- def __init__(
83
- self,
84
- dim: int,
85
- hidden: int,
86
- n_recursions: int,
87
- capacities: list[float],
88
- init_bias: float = 0.0,
89
- warmup_steps: int = 0,
90
- init_from_capacity: bool = True,
91
- ):
92
- super().__init__()
93
- self.n_recursions = n_recursions
94
- self.capacities = list(capacities)
95
- self.warmup_steps = warmup_steps
96
- self.heads = nn.ModuleList(
97
- [TokenScore(dim, hidden, init_bias) for _ in range(n_recursions)]
98
- )
99
- self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))
100
- self.slice_score = SliceScore(dim, hidden)
101
- if init_from_capacity:
102
- for r, head in enumerate(self.heads):
103
- cap = max(1e-3, min(1.0 - 1e-3, self.capacities[min(r, len(self.capacities) - 1)]))
104
- with torch.no_grad():
105
- head.out_proj.bias.fill_(float(torch.logit(torch.tensor(cap))))
106
-
107
- def capacity(self, r: int, step: int) -> float:
108
- """Token fraction kept at recursion ``r``, ramping from 1.0 during warmup."""
109
- target = self.capacities[r]
110
- if self.warmup_steps > 0 and step < self.warmup_steps:
111
- t = step / self.warmup_steps
112
- return 1.0 - (1.0 - target) * t
113
- return target
114
-
115
- def forward(
116
- self,
117
- x: torch.Tensor,
118
- r: int,
119
- patch_pos: torch.Tensor | None = None,
120
- slice_feat: torch.Tensor | None = None,
121
- ) -> torch.Tensor:
122
- h = x + self.rec_emb[r]
123
- if patch_pos is not None:
124
- h = h + patch_pos
125
- score = self.heads[r](h)
126
- if slice_feat is not None:
127
- score = score + self.heads[r].slice_gain * self.slice_score(slice_feat).unsqueeze(1)
128
- return score
129
-
130
-
131
- class RecursiveAttentionBlock(nn.Module):
132
- """Shared pre-norm attention block reused at every recursion.
133
-
134
- Operates on the active token group only (the caller gathers/scatters), so
135
- attention is plain unmasked full attention - no -inf masks, no NaN paths.
136
- """
137
-
138
- def __init__(self, dim: int, n_heads: int = 6, mlp_ratio: float = 4.0, dropout: float = 0.0):
139
- super().__init__()
140
- self.n_heads = n_heads
141
- self.head_dim = dim // n_heads
142
- self.q = nn.Linear(dim, dim)
143
- self.k = nn.Linear(dim, dim)
144
- self.v = nn.Linear(dim, dim)
145
- self.proj = nn.Linear(dim, dim)
146
- self.norm1 = nn.LayerNorm(dim)
147
- self.norm2 = nn.LayerNorm(dim)
148
- self.mlp = nn.Sequential(
149
- nn.Linear(dim, int(dim * mlp_ratio)),
150
- nn.GELU(),
151
- nn.Linear(int(dim * mlp_ratio), dim),
152
- )
153
-
154
- def forward(self, x: torch.Tensor) -> torch.Tensor:
155
- S, T, D = x.shape
156
- H = self.n_heads
157
- xn = self.norm1(x)
158
- q = self.q(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
159
- k = self.k(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
160
- v = self.v(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
161
- attn = (q @ k.transpose(-1, -2) / (self.head_dim ** 0.5)).softmax(dim=-1)
162
- out = (attn @ v).transpose(1, 2).reshape(S, T, D)
163
- x = x + self.proj(out)
164
- x = x + self.mlp(self.norm2(x))
165
- return x
166
-
167
-
168
- class SliceAttentionPool(nn.Module):
169
- """Weight slices by learned relevance before aggregating into a study vector."""
170
-
171
- def __init__(self, dim: int):
172
- super().__init__()
173
- self.query = nn.Parameter(torch.randn(dim))
174
- self.scale = dim ** -0.5
175
-
176
- def forward(self, x: torch.Tensor) -> torch.Tensor:
177
- w = F.softmax(x @ self.query * self.scale, dim=0)
178
- return (w.unsqueeze(1) * x).sum(0)
179
-
180
-
181
- class MoRViT(nn.Module):
182
- """Pretrained DINOv3 stem + MoR recursive core with per-token routing."""
183
-
184
- def __init__(
185
- self,
186
- *,
187
- stem_name: str = "vit_small_patch16_dinov3",
188
- stem: nn.Module | None = None,
189
- pretrained: bool = True,
190
- freeze_stem: bool = True,
191
- use_mor: bool = True,
192
- dense: bool = False,
193
- n_recursions: int = 3,
194
- capacities: tuple[float, ...] = (1.0, 2.0 / 3.0, 1.0 / 3.0),
195
- core_blocks: int = 2,
196
- router_hidden: int = 128,
197
- n_heads: int = 6,
198
- n_classes: int = 12,
199
- router_warmup_steps: int = 0,
200
- router_init_bias: float = 0.0,
201
- ):
202
- super().__init__()
203
- if stem is None:
204
- if not HAS_TIMM:
205
- raise ImportError("timm is required for a pretrained stem")
206
- stem = timm.create_model(stem_name, pretrained=pretrained, num_classes=0)
207
- self.n_recursions = n_recursions
208
- self.capacities = list(capacities)
209
- assert len(self.capacities) == n_recursions
210
- self.use_mor = use_mor
211
- self.dense = dense
212
-
213
- self.stem = stem
214
- stem_dim = self.stem.embed_dim
215
- self.core_dim = stem_dim
216
- if stem_dim != 384:
217
- self.input_proj = nn.Linear(stem_dim, 384)
218
- self.core_dim = 384
219
- else:
220
- self.input_proj = None
221
- dim = self.core_dim
222
- self.n_prefix = int(getattr(self.stem, "num_prefix_tokens", 1))
223
-
224
- pe = getattr(self.stem, "pos_embed", None)
225
- if isinstance(pe, torch.Tensor) and pe.dim() == 3 and pe.size(1) > self.n_prefix:
226
- patch_pos = pe[:, self.n_prefix:].detach().clone() # [1, P, stem_dim]
227
- if self.input_proj is not None:
228
- with torch.no_grad():
229
- patch_pos = self.input_proj(patch_pos)
230
- self.register_buffer("patch_pos", patch_pos, persistent=False)
231
- else:
232
- self.patch_pos = None
233
-
234
- if freeze_stem:
235
- for p in self.stem.parameters():
236
- p.requires_grad_(False)
237
-
238
- self._soft_route_maps: list[torch.Tensor] = []
239
-
240
- self.core = nn.ModuleList(
241
- [RecursiveAttentionBlock(dim, n_heads=n_heads) for _ in range(core_blocks)]
242
- )
243
- self.router = (
244
- TokenRouter(
245
- dim,
246
- router_hidden,
247
- n_recursions,
248
- self.capacities,
249
- init_bias=router_init_bias,
250
- warmup_steps=router_warmup_steps,
251
- )
252
- if not dense
253
- else None
254
- )
255
- self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))
256
-
257
- self.exit_norm = nn.LayerNorm(dim)
258
- self.pool = SliceAttentionPool(dim)
259
- self.head = nn.Sequential(
260
- nn.Linear(dim, dim * 2),
261
- nn.GELU(),
262
- nn.Linear(dim * 2, n_classes),
263
- )
264
-
265
- def _run_core(self, tokens: torch.Tensor, r: int) -> torch.Tensor:
266
- x = tokens + self.rec_emb[r]
267
- for block in self.core:
268
- x = block(x)
269
- return x
270
-
271
- def _moR(self, tokens: torch.Tensor, step: int = 0) -> tuple[torch.Tensor, list[float]]:
272
- """Run the recursion loop over a batch of token sequences.
273
-
274
- Dense mode runs the shared core over every token at every recursion
275
- (the same-budget control for adaptive routing). Adaptive mode gathers
276
- the top-k patch tokens (by router score) plus the prefix tokens,
277
- processes them through the core, and scatters them back; non-selected
278
- patch tokens keep their previous state.
279
-
280
- The returned tokens are a soft mixture of the per-recursion states
281
- weighted by the router's continuation probabilities, so the router
282
- receives real gradient (hard top-k alone is non-differentiable). The
283
- soft routing maps are stored for the optional smoothness loss.
284
- """
285
- if self.dense:
286
- for r in range(self.n_recursions):
287
- tokens = self._run_core(tokens, r)
288
- return tokens, [1.0] * self.n_recursions
289
-
290
- S, T, D = tokens.shape
291
- n_pref = self.n_prefix
292
- P = T - n_pref
293
- stats: list[float] = []
294
- self._soft_route_maps = []
295
-
296
- num = tokens.clone()
297
- den = torch.ones(S, T, device=tokens.device)
298
- w_pat = torch.ones(S, P, device=tokens.device)
299
- pref_ones = torch.ones(S, n_pref, device=tokens.device)
300
- pe = (
301
- self.patch_pos
302
- if (self.patch_pos is not None and self.patch_pos.shape[1] == P)
303
- else None
304
- )
305
-
306
- for r in range(self.n_recursions - 1):
307
- cap = self.router.capacity(r, step)
308
- pat = tokens[:, n_pref:]
309
- pat_scores = self.router(pat, r, patch_pos=pe, slice_feat=pat.mean(1))
310
- p = torch.sigmoid(pat_scores)
311
- self._soft_route_maps.append(p)
312
- k = max(1, min(P, int(round(P * cap))))
313
- sel = pat_scores.topk(k, dim=1).indices # [S, k]
314
- pref = torch.arange(n_pref, device=tokens.device).expand(S, n_pref)
315
- idx = torch.cat([pref, sel + n_pref], dim=1) # [S, M]
316
- idx3 = idx.unsqueeze(-1).expand(S, idx.size(1), D)
317
- stats.append((n_pref + k) / T)
318
- gathered = tokens.gather(1, idx3)
319
- updated = self._run_core(gathered, r)
320
- tokens = tokens.scatter(1, idx3, updated)
321
-
322
- w_pat = w_pat * p
323
- w = torch.cat([pref_ones, w_pat], dim=1)
324
- num = num + tokens * w.unsqueeze(-1)
325
- den = den + w
326
-
327
- tokens = self._run_core(tokens, self.n_recursions - 1)
328
- stats.append(1.0)
329
- num = num + tokens * w.unsqueeze(-1)
330
- den = den + w
331
- mixed = num / den.unsqueeze(-1)
332
- return mixed, stats
333
-
334
- def routing_smoothness_loss(self) -> torch.Tensor | float:
335
- """Total-variation penalty on the soft routing maps (spatial coherence).
336
-
337
- Knee findings occupy contiguous regions; a scattered routing map is a
338
- bug signal. Add ``weight * this`` to the training loss.
339
- """
340
- if not self._soft_route_maps:
341
- return 0.0
342
- total = None
343
- for p in self._soft_route_maps: # [S, P]
344
- P = p.size(1)
345
- h = int(round(P ** 0.5))
346
- if h * h != P:
347
- continue
348
- g = p.reshape(p.size(0), h, h)
349
- tv = (g[:, 1:, :] - g[:, :-1, :]).abs().mean() + (
350
- g[:, :, 1:] - g[:, :, :-1]
351
- ).abs().mean()
352
- total = tv if total is None else total + tv
353
- if total is None:
354
- return 0.0
355
- return total / len(self._soft_route_maps)
356
-
357
- def features(
358
- self, x: torch.Tensor, step: int = 0, pool: str = "patchmean"
359
- ) -> torch.Tensor:
360
- """Per-slice features (no head): [S, D] from [S, 3, H, W] slices."""
361
- tokens = self.stem.forward_features(x) # [S, T, D]
362
- return self.features_from_tokens(tokens, step, pool)
363
-
364
- def features_from_tokens(
365
- self, tokens: torch.Tensor, step: int = 0, pool: str = "patchmean"
366
- ) -> torch.Tensor:
367
- """Features from cached stem tokens (feat cache path): [S, D]."""
368
- tokens = tokens.float() # _moR internals are f32; bf16 cache upcasts exactly
369
- if self.input_proj is not None:
370
- with torch.autocast("cuda", enabled=False):
371
- tokens = self.input_proj(tokens).float() # keep f32; autocast would make it bf16
372
- if self.use_mor or self.dense:
373
- tokens, _ = self._moR(tokens, step)
374
- tokens = self.exit_norm(tokens)
375
- if pool == "cls":
376
- return tokens[:, 0]
377
- return tokens[:, self.n_prefix :].mean(1)
378
-
379
- def forward(
380
- self, x: torch.Tensor, step: int = 0
381
- ) -> tuple[torch.Tensor, list[float]]:
382
- """Forward one study: [S, 3, H, W] slices -> (logits [n_classes], route_stats)."""
383
- slice_feats = self.features(x, step) # [S, D]
384
- study = self.pool(slice_feats) # [D]
385
- logits = self.head(study)
386
- return logits, []