thenukegun10x commited on
Commit
2f102b2
·
verified ·
1 Parent(s): f1ffdeb

Add core src/ package for direct model instantiation

Browse files
src/src/data/__pycache__/plant.cpython-312.pyc ADDED
Binary file (4.74 kB). View file
 
src/src/data/__pycache__/taxonomy.cpython-312.pyc ADDED
Binary file (9.18 kB). View file
 
src/src/data/plant.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plant CSV dataset for data/wa_plants/manifest.csv (observation-separated).
2
+ Reuses RSNA train_mor pattern: manifest + ImageNet norm, 336px, Aug.
3
+ """
4
+ from __future__ import annotations
5
+ import csv
6
+ from pathlib import Path
7
+ import random
8
+ from PIL import Image
9
+ import warnings
10
+ # faster decode: raise limit (100Mpx originals) + draft shrink for large JPEGs
11
+ Image.MAX_IMAGE_PIXELS = 300_000_000
12
+ warnings.filterwarnings("ignore", category=Image.DecompressionBombWarning)
13
+ import torch
14
+ from torch.utils.data import Dataset
15
+ import torchvision.transforms as T
16
+
17
+ IMAGENET_MEAN = (0.485, 0.456, 0.406)
18
+ IMAGENET_STD = (0.229, 0.224, 0.225)
19
+
20
+ class PlantDataset(Dataset):
21
+ def __init__(self, manifest: Path, split: str, img_size: int = 336, augment: bool = False):
22
+ self.split = split
23
+ self.img_size = img_size
24
+ rows = []
25
+ # species -> idx map built from manifest train split (500 classes)
26
+ with open(manifest, newline="", encoding="utf-8") as f:
27
+ for r in csv.DictReader(f):
28
+ if r["split"] == split and r["status"] in ("downloaded","skip_exists"):
29
+ rows.append(r)
30
+ # stable class order sorted
31
+ species = sorted({r["species"] for r in rows})
32
+ self.species_to_idx = {s:i for i,s in enumerate(species)}
33
+ self.rows = rows
34
+ # class counts for balanced sampling
35
+ self.augment = augment
36
+ tfms = []
37
+ if augment:
38
+ tfms = [
39
+ T.RandomResizedCrop(img_size, scale=(0.7,1.0)),
40
+ T.RandomHorizontalFlip(),
41
+ T.ColorJitter(0.2,0.2,0.2,0.05),
42
+ T.ToTensor(), T.Normalize(IMAGENET_MEAN, IMAGENET_STD),
43
+ ]
44
+ else:
45
+ tfms = [T.Resize(int(img_size*1.14)), T.CenterCrop(img_size), T.ToTensor(), T.Normalize(IMAGENET_MEAN, IMAGENET_STD)]
46
+ self.tf = T.Compose(tfms)
47
+
48
+ def __len__(self): return len(self.rows)
49
+ def __getitem__(self, i):
50
+ r = self.rows[i]
51
+ p = Path(r["path"])
52
+ # manifest stores absolute win path; if not found try relative to data/wa_plants
53
+ if not p.exists():
54
+ # try finding under train/val subfolders by gbifID
55
+ base = Path(__file__).resolve().parents[2] / "data" / "wa_plants"
56
+ for split in ("train","val"):
57
+ cand = base / split / r["species"].replace(" ","_").replace("/","_")[:120] / f"{r['gbifID']}.jpg"
58
+ if cand.exists():
59
+ p = cand; break
60
+ try:
61
+ im = Image.open(p)
62
+ # fast draft for huge JPEGs (8× shrink before full decode) - no effect on small images
63
+ try:
64
+ if max(im.size) > 1024:
65
+ # draft uses libjpeg shrink 1/2/4/8
66
+ im.draft("RGB", (768, 768))
67
+ except:
68
+ pass
69
+ im = im.convert("RGB")
70
+ except Exception:
71
+ im = Image.new("RGB", (self.img_size, self.img_size))
72
+ # species_to_idx may have been remapped after init (train vs val) - lookup safely
73
+ y = self.species_to_idx.get(r["species"], 0)
74
+ x = self.tf(im)
75
+ return x, y, r["gbifID"]
src/src/data/taxonomy.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Taxonomic Hierarchy Engine for Western Australia Flora.
3
+ Builds and maps Species -> Genus -> Family for hierarchical multi-task classification.
4
+ """
5
+ import pandas as pd
6
+ from pathlib import Path
7
+
8
+ # Common WA Flora Genus -> Family mapping dictionary
9
+ GENUS_TO_FAMILY = {
10
+ # Proteaceae
11
+ "Banksia": "Proteaceae", "Grevillea": "Proteaceae", "Hakea": "Proteaceae",
12
+ "Dryandra": "Proteaceae", "Adenanthos": "Proteaceae", "Isopogon": "Proteaceae",
13
+ "Petrophile": "Proteaceae", "Synaphea": "Proteaceae", "Conospermum": "Proteaceae",
14
+ "Lambertia": "Proteaceae", "Persoonia": "Proteaceae", "Xylomelum": "Proteaceae",
15
+ "Franklandia": "Proteaceae", "Stirlingia": "Proteaceae",
16
+
17
+ # Myrtaceae
18
+ "Eucalyptus": "Myrtaceae", "Corymbia": "Myrtaceae", "Melaleuca": "Myrtaceae",
19
+ "Verticordia": "Myrtaceae", "Calothamnus": "Myrtaceae", "Darwinia": "Myrtaceae",
20
+ "Chamelaucium": "Myrtaceae", "Thryptomene": "Myrtaceae", "Beaufortia": "Myrtaceae",
21
+ "Baeckea": "Myrtaceae", "Regelia": "Myrtaceae", "Calytrix": "Myrtaceae",
22
+ "Eremaea": "Myrtaceae", "Kunzea": "Myrtaceae", "Micromyrtus": "Myrtaceae",
23
+ "Hypocalymma": "Myrtaceae", "Scholtzia": "Myrtaceae", "Phymatocarpus": "Myrtaceae",
24
+ "Leptospermum": "Myrtaceae", "Astartea": "Myrtaceae", "Taxandria": "Myrtaceae",
25
+
26
+ # Fabaceae (Legumes)
27
+ "Acacia": "Fabaceae", "Gastrolobium": "Fabaceae", "Daviesia": "Fabaceae",
28
+ "Jacksonia": "Fabaceae", "Bossiaea": "Fabaceae", "Hovea": "Fabaceae",
29
+ "Kennedia": "Fabaceae", "Hardenbergia": "Fabaceae", "Gompholobium": "Fabaceae",
30
+ "Mirbelia": "Fabaceae", "Chorizema": "Fabaceae", "Pultenaea": "Fabaceae",
31
+ "Isotropis": "Fabaceae", "Swainsona": "Fabaceae", "Senna": "Fabaceae",
32
+ "Indigofera": "Fabaceae", "Templetonia": "Fabaceae", "Latrobea": "Fabaceae",
33
+
34
+ # Orchidaceae (Orchids)
35
+ "Caladenia": "Orchidaceae", "Thelymitra": "Orchidaceae", "Diuris": "Orchidaceae",
36
+ "Pterostylis": "Orchidaceae", "Drakaea": "Orchidaceae", "Microtis": "Orchidaceae",
37
+ "Prasophyllum": "Orchidaceae", "Cyanicula": "Orchidaceae", "Ericksonella": "Orchidaceae",
38
+ "Leporella": "Orchidaceae", "Paracaleana": "Orchidaceae", "Pyrorchis": "Orchidaceae",
39
+ "Cryptostylis": "Orchidaceae", "Elythranthera": "Orchidaceae", "Spiculaea": "Orchidaceae",
40
+
41
+ # Haemodoraceae (Kangaroo Paws)
42
+ "Anigozanthos": "Haemodoraceae", "Macropidia": "Haemodoraceae", "Conostylis": "Haemodoraceae",
43
+ "Tribonanthes": "Haemodoraceae", "Haemodorum": "Haemodoraceae", "Phlebocarya": "Haemodoraceae",
44
+
45
+ # Ericaceae / Epacridaceae (Heaths)
46
+ "Leucopogon": "Ericaceae", "Astroloma": "Ericaceae", "Lysinema": "Ericaceae",
47
+ "Conostephium": "Ericaceae", "Andersonia": "Ericaceae", "Sphenotoma": "Ericaceae",
48
+ "Styphelia": "Ericaceae", "Cosmelia": "Ericaceae",
49
+
50
+ # Goodeniaceae
51
+ "Goodenia": "Goodeniaceae", "Dampiera": "Goodeniaceae", "Scaevola": "Goodeniaceae",
52
+ "Lechenaultia": "Goodeniaceae", "Velleia": "Goodeniaceae", "Anthotium": "Goodeniaceae",
53
+
54
+ # Stylidiaceae (Triggerplants)
55
+ "Stylidium": "Stylidiaceae", "Levenhookia": "Stylidiaceae",
56
+
57
+ # Droseraceae (Sundews / Carnivorous)
58
+ "Drosera": "Droseraceae",
59
+
60
+ # Asteraceae (Daisies)
61
+ "Rhodanthe": "Asteraceae", "Waitzia": "Asteraceae", "Schoenia": "Asteraceae",
62
+ "Podolepis": "Asteraceae", "Olearia": "Asteraceae", "Senecio": "Asteraceae",
63
+ "Brachyscome": "Asteraceae", "Lawrencella": "Asteraceae", "Xerochrysum": "Asteraceae",
64
+
65
+ # Xanthorrhoeaceae / Asphodelaceae
66
+ "Xanthorrhoea": "Asphodelaceae",
67
+
68
+ # Dasypogonaceae
69
+ "Kingia": "Dasypogonaceae", "Dasypogon": "Dasypogonaceae", "Calectasia": "Dasypogonaceae",
70
+
71
+ # Casuarinaceae (Sheoaks)
72
+ "Allocasuarina": "Casuarinaceae", "Casuarina": "Casuarinaceae",
73
+
74
+ # Pittosporaceae
75
+ "Marianthus": "Pittosporaceae", "Billardiera": "Pittosporaceae", "Pittosporum": "Pittosporaceae",
76
+ "Cheiranthera": "Pittosporaceae",
77
+
78
+ # Restionaceae (Rushes / Sedges)
79
+ "Desmocladus": "Restionaceae", "Alexgeorgea": "Restionaceae", "Loxocarya": "Restionaceae",
80
+ "Chordifex": "Restionaceae", "Lyginia": "Restionaceae",
81
+
82
+ # Chenopodiaceae / Amaranthaceae
83
+ "Atriplex": "Amaranthaceae", "Maireana": "Amaranthaceae", "Tecticornia": "Amaranthaceae",
84
+ "Ptilotus": "Amaranthaceae",
85
+
86
+ # Malvaceae
87
+ "Alyogyne": "Malvaceae", "Thomasia": "Malvaceae", "Guichenotia": "Malvaceae",
88
+ "Lasiopetalum": "Malvaceae", "Seringia": "Malvaceae", "Androcalva": "Malvaceae",
89
+
90
+ # Rutaceae (Boronias / Waxflowers)
91
+ "Boronia": "Rutaceae", "Philotheca": "Rutaceae", "Diplolaena": "Rutaceae",
92
+ "Correa": "Rutaceae", "Crowea": "Rutaceae", "Geleznowia": "Rutaceae",
93
+ "Asterolasia": "Rutaceae", "Eriostemon": "Rutaceae",
94
+
95
+ # Loranthaceae (Mistletoes / Christmas Tree)
96
+ "Nuytsia": "Loranthaceae", "Amyema": "Loranthaceae", "Lysiana": "Loranthaceae",
97
+ }
98
+
99
+ class TaxonomyHierarchy:
100
+ """Manages species -> genus -> family indices and mappings."""
101
+ def __init__(self, species_list: list[str]):
102
+ self.species_list = sorted(list(set(s for s in species_list if isinstance(s, str) and s.strip())))
103
+
104
+ # 1. Species Index
105
+ self.species_to_idx = {s: i for i, s in enumerate(self.species_list)}
106
+ self.idx_to_species = {i: s for s, i in self.species_to_idx.items()}
107
+ self.n_species = len(self.species_list)
108
+
109
+ # 2. Genus Index
110
+ self.species_to_genus = {}
111
+ genera_set = set()
112
+ for s in self.species_list:
113
+ genus = s.split()[0] if s else "Unknown"
114
+ self.species_to_genus[s] = genus
115
+ genera_set.add(genus)
116
+
117
+ self.genus_list = sorted(list(genera_set))
118
+ self.genus_to_idx = {g: i for i, g in enumerate(self.genus_list)}
119
+ self.idx_to_genus = {i: g for g, i in self.genus_to_idx.items()}
120
+ self.n_genera = len(self.genus_list)
121
+
122
+ # 3. Family Index
123
+ self.genus_to_family = {}
124
+ families_set = set()
125
+ for g in self.genus_list:
126
+ fam = GENUS_TO_FAMILY.get(g, f"{g}_Family")
127
+ self.genus_to_family[g] = fam
128
+ families_set.add(fam)
129
+
130
+ self.family_list = sorted(list(families_set))
131
+ self.family_to_idx = {f: i for i, f in enumerate(self.family_list)}
132
+ self.idx_to_family = {i: f for f, i in self.family_to_idx.items()}
133
+ self.n_families = len(self.family_list)
134
+
135
+ # Precompute lookup tables
136
+ self.species_idx_to_genus_idx = [
137
+ self.genus_to_idx[self.species_to_genus[self.idx_to_species[i]]]
138
+ for i in range(self.n_species)
139
+ ]
140
+ self.species_idx_to_family_idx = [
141
+ self.family_to_idx[self.genus_to_family[self.species_to_genus[self.idx_to_species[i]]]]
142
+ for i in range(self.n_species)
143
+ ]
144
+
145
+ def get_taxonomy_indices(self, species_name: str) -> tuple[int, int, int]:
146
+ """Returns (species_idx, genus_idx, family_idx)."""
147
+ s_idx = self.species_to_idx.get(species_name, -1)
148
+ if s_idx == -1:
149
+ return -1, -1, -1
150
+ return s_idx, self.species_idx_to_genus_idx[s_idx], self.species_idx_to_family_idx[s_idx]
src/src/models/__pycache__/moe_swiglu_v2.cpython-312.pyc ADDED
Binary file (7.82 kB). View file
 
src/src/models/__pycache__/moe_vit.cpython-312.pyc ADDED
Binary file (27.1 kB). View file
 
src/src/models/__pycache__/mor_vit.cpython-312.pyc ADDED
Binary file (23.4 kB). View file
 
src/src/models/__pycache__/plant_vit.cpython-312.pyc ADDED
Binary file (6.79 kB). View file
 
src/src/models/__pycache__/plant_vit_v2.cpython-312.pyc ADDED
Binary file (7.8 kB). View file
 
src/src/models/__pycache__/subcenter_arcface.cpython-312.pyc ADDED
Binary file (4.13 kB). View file
 
src/src/models/moe_swiglu_v2.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streamlined 16-Expert SwiGLU Mixture-of-Experts (MoE) Layer for Vision Transformers.
3
+ Uses pure SwiGLU FFN experts, Gating Residuals (cross-layer routing memory),
4
+ and Context-Aware Global Projections.
5
+ """
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+
10
+ class SwiGLU(nn.Module):
11
+ """3-Matrix SwiGLU Feed-Forward Network: SiLU(W1 * x) * (W3 * x) @ W2"""
12
+ def __init__(self, d_model: int, d_ff: int):
13
+ super().__init__()
14
+ self.w1 = nn.Linear(d_model, d_ff, bias=False)
15
+ self.w2 = nn.Linear(d_ff, d_model, bias=False)
16
+ self.w3 = nn.Linear(d_model, d_ff, bias=False)
17
+
18
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
19
+ return self.w2(F.silu(self.w1(x)) * self.w3(x))
20
+
21
+ class MoESwiGLUv2(nn.Module):
22
+ """
23
+ Pure SwiGLU MoE FFN layer.
24
+ - num_ffn: Number of SwiGLU experts (default 16)
25
+ - top_k: Active experts per token (default 2)
26
+ - gating_residual: Linear projection of previous-layer gate logits
27
+ - gate_ctx: Global CLS context projection into gate logits
28
+ """
29
+ def __init__(
30
+ self,
31
+ d_model: int,
32
+ d_ff: int = 512,
33
+ num_ffn: int = 16,
34
+ top_k: int = 2,
35
+ gating_residual: bool = True,
36
+ gate_ctx: bool = True,
37
+ ):
38
+ super().__init__()
39
+ self.d_model = d_model
40
+ self.d_ff = d_ff
41
+ self.num_ffn = num_ffn
42
+ self.top_k = min(top_k, num_ffn)
43
+ self.gate_ctx = gate_ctx
44
+
45
+ # Router linear layer: [d_model -> num_ffn]
46
+ self.router = nn.Linear(d_model, num_ffn, bias=False)
47
+ self.experts = nn.ModuleList([SwiGLU(d_model, d_ff) for _ in range(num_ffn)])
48
+
49
+ # Cross-layer gating residual projection
50
+ self.gating_residual = None
51
+ if gating_residual:
52
+ self.gating_residual = nn.Linear(num_ffn, num_ffn, bias=False)
53
+ nn.init.zeros_(self.gating_residual.weight)
54
+
55
+ # Context-aware global projection
56
+ self.ctx_proj = None
57
+ if gate_ctx:
58
+ self.ctx_proj = nn.Linear(d_model, num_ffn, bias=False)
59
+ nn.init.zeros_(self.ctx_proj.weight)
60
+
61
+ def forward(self, x: torch.Tensor, prev_gate: torch.Tensor = None, ctx: torch.Tensor = None):
62
+ """
63
+ x: [B, T, D] Token embeddings
64
+ prev_gate: [B, T, num_ffn] Gate logits from previous layer (optional)
65
+ ctx: [B, D] Global CLS context embedding (optional)
66
+ Returns:
67
+ output: [B, T, D]
68
+ aux_loss: Scalar load balancing loss
69
+ gate_logits: [B, T, num_ffn]
70
+ """
71
+ B, T, D = x.shape
72
+ x_2d = x.reshape(-1, D)
73
+ N = x_2d.size(0)
74
+
75
+ # 1. Compute Gate Logits
76
+ gate_logits = self.router(x_2d) # [N, num_ffn]
77
+
78
+ if self.ctx_proj is not None and ctx is not None:
79
+ # Broadcast global CLS context across all T tokens
80
+ gate_logits = gate_logits + self.ctx_proj(ctx).repeat_interleave(T, dim=0)
81
+
82
+ if self.gating_residual is not None and prev_gate is not None:
83
+ prev_flat = prev_gate.reshape(-1, self.num_ffn)
84
+ if prev_flat.size(0) == N:
85
+ gate_logits = gate_logits + self.gating_residual(prev_flat)
86
+
87
+ # 2. Softmax & Top-K Selection
88
+ gate_weights = F.softmax(gate_logits, dim=-1)
89
+ top_weights, top_indices = gate_weights.topk(self.top_k, dim=-1)
90
+ top_weights = top_weights / top_weights.sum(dim=-1, keepdim=True).clamp(min=1e-9)
91
+ route_weights = top_weights.to(dtype=x_2d.dtype)
92
+
93
+ # 3. Flatten tokens and sort by expert index for batch GEMM efficiency
94
+ flat_expert = top_indices.reshape(-1)
95
+ flat_token = (
96
+ torch.arange(N, device=x.device)
97
+ .unsqueeze(1)
98
+ .expand(-1, self.top_k)
99
+ .reshape(-1)
100
+ )
101
+ flat_weight = route_weights.reshape(-1, 1)
102
+
103
+ order = torch.argsort(flat_expert, stable=True)
104
+ sorted_tokens = flat_token[order]
105
+ sorted_weights = flat_weight[order]
106
+ sorted_x = x_2d.index_select(0, sorted_tokens)
107
+
108
+ counts = torch.bincount(flat_expert, minlength=self.num_ffn).to(torch.int64)
109
+ offsets = torch.cat(
110
+ [torch.zeros(1, dtype=torch.int64, device=x.device),
111
+ torch.cumsum(counts, 0)[:-1]]
112
+ )
113
+
114
+ output = torch.zeros_like(x_2d)
115
+
116
+ # 4. Dispatch each expert
117
+ for expert_idx in range(self.num_ffn):
118
+ lo = int(offsets[expert_idx])
119
+ hi = lo + int(counts[expert_idx])
120
+ if lo == hi:
121
+ continue
122
+ tids = sorted_tokens[lo:hi]
123
+ w = sorted_weights[lo:hi]
124
+ expert_out = self.experts[expert_idx](sorted_x[lo:hi]) * w
125
+ output.index_add_(0, tids, expert_out.to(dtype=output.dtype))
126
+
127
+ # 5. Load-Balancing Auxiliary Loss
128
+ fractions = counts.float() / counts.sum().clamp(min=1e-12).float()
129
+ avg_prob = gate_weights.mean(dim=0)
130
+ aux_loss = self.num_ffn * (fractions.to(dtype=gate_weights.dtype) * avg_prob).sum()
131
+
132
+ return (
133
+ output.view(B, T, D),
134
+ aux_loss,
135
+ gate_logits.view(B, T, self.num_ffn)
136
+ )
src/src/models/moe_vit.py ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MoE-ViT: MoE++ (zero/copy/constant experts) on a pretrained DINOv3 ViT stem.
2
+
3
+ Replaces the MoR recursive core with a plain stacked-transformer core whose
4
+ FFNs are MoE++ layers (arXiv:2410.07348):
5
+
6
+ dense MLP (d_ff=1536) -> 4 FFN experts (d_ff=384) + 1 zero + 1 copy
7
+ + 2 constant experts, top_k=2 per token
8
+
9
+ Routing is per patch token, so easy image regions land on zero/copy/constant
10
+ specialists (near-zero compute) while hard regions route to real FFN experts -
11
+ adaptive compute at the finest granularity, with no recursion machinery.
12
+
13
+ The frozen stem and the study-level pooling/head mirror MoR-ViT so the two
14
+ models share the same ``features()`` / ``forward()`` contract and can be
15
+ compared on identical benchmarks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ try:
25
+ import timm
26
+
27
+ HAS_TIMM = True
28
+ except ImportError: # pragma: no cover - optional dependency
29
+ HAS_TIMM = False
30
+
31
+
32
+ class SwiGLU(nn.Module):
33
+ def __init__(self, d_model: int, d_ff: int):
34
+ super().__init__()
35
+ self.w1 = nn.Linear(d_model, d_ff, bias=False)
36
+ self.w2 = nn.Linear(d_ff, d_model, bias=False)
37
+ self.w3 = nn.Linear(d_model, d_ff, bias=False)
38
+
39
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
40
+ return self.w2(F.silu(self.w1(x)) * self.w3(x))
41
+
42
+
43
+ class ConstantExpert(nn.Module):
44
+ """MoE++ constant expert: alpha1*x + alpha2*v (negligible params)."""
45
+
46
+ def __init__(self, d_model: int):
47
+ super().__init__()
48
+ self.v = nn.Parameter(torch.zeros(d_model))
49
+ self.wc = nn.Linear(d_model, 2, bias=False)
50
+
51
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
52
+ alpha = F.softmax(self.wc(x), dim=-1)
53
+ return (alpha[..., 0:1] * x + alpha[..., 1:2] * self.v.to(dtype=x.dtype)).to(dtype=x.dtype)
54
+
55
+
56
+ class MoEFFN(nn.Module):
57
+ """Sparse MoE FFN with MoE++ zero / copy / constant experts.
58
+
59
+ Index layout: [FFN | zero | copy | constant]. Routing is softmax top-k
60
+ with renormalisation; only tokens selected for a real FFN expert are
61
+ computed (``index_select``), so zero-compute routing is real skipping.
62
+ Returns the load-balance aux loss and pre-softmax gate logits.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ d_model: int,
68
+ d_ff: int,
69
+ num_ffn: int = 4,
70
+ top_k: int = 2,
71
+ n_zero: int = 1,
72
+ n_copy: int = 1,
73
+ n_const: int = 2,
74
+ tau: float = 0.75,
75
+ gating_residual: bool = True,
76
+ gate_ctx: bool = False,
77
+ ):
78
+ super().__init__()
79
+ self.num_ffn = num_ffn
80
+ self.top_k = min(top_k, num_ffn + n_zero + n_copy + n_const)
81
+ self.n_zero = max(0, n_zero)
82
+ self.n_copy = max(0, n_copy)
83
+ self.n_const = max(0, n_const)
84
+ self.n_zc = self.n_zero + self.n_copy + self.n_const
85
+ self.total_experts = num_ffn + self.n_zc
86
+ self.last_counts: torch.Tensor | None = None
87
+ self.tau = float(tau)
88
+ self.gate_ctx = gate_ctx
89
+
90
+ self.copy_start = num_ffn + self.n_zero
91
+ self.const_start = self.copy_start + self.n_copy
92
+
93
+ self.router = nn.Linear(d_model, self.total_experts, bias=False)
94
+ self.experts = nn.ModuleList([SwiGLU(d_model, d_ff) for _ in range(num_ffn)])
95
+ self.const_experts = nn.ModuleList([ConstantExpert(d_model) for _ in range(self.n_const)])
96
+
97
+ self.gating_residual = None
98
+ if gating_residual:
99
+ self.gating_residual = nn.Linear(self.total_experts, self.total_experts, bias=False)
100
+ nn.init.zeros_(self.gating_residual.weight)
101
+
102
+ self.ctx_proj = None
103
+ if gate_ctx:
104
+ self.ctx_proj = nn.Linear(d_model, self.total_experts, bias=False)
105
+ nn.init.zeros_(self.ctx_proj.weight)
106
+
107
+ eta = torch.ones(self.total_experts)
108
+ if self.n_zc > 0:
109
+ eta[num_ffn:] = self.tau
110
+ self.register_buffer("eta", eta, persistent=False)
111
+
112
+ def forward(
113
+ self,
114
+ x: torch.Tensor,
115
+ prev_gate: torch.Tensor | None = None,
116
+ ctx: torch.Tensor | None = None,
117
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
118
+ """Returns (output, aux_loss, gate_logits, gate_weights)."""
119
+ B, T, D = x.shape
120
+ x_2d = x.reshape(-1, D)
121
+ N = x_2d.size(0)
122
+
123
+ gate_logits = self.router(x_2d)
124
+ if self.ctx_proj is not None and ctx is not None:
125
+ gate_logits = gate_logits + self.ctx_proj(ctx).repeat_interleave(T, dim=0)
126
+ if self.gating_residual is not None and prev_gate is not None:
127
+ if prev_gate.dim() == 3:
128
+ prev_gate = prev_gate.reshape(-1, prev_gate.size(-1))
129
+ if prev_gate.size(0) == N and prev_gate.size(-1) == self.total_experts:
130
+ gate_logits = gate_logits + self.gating_residual(prev_gate)
131
+
132
+ gate_weights = F.softmax(gate_logits, dim=-1)
133
+ top_weights, top_indices = gate_weights.topk(self.top_k, dim=-1)
134
+ top_weights = top_weights / top_weights.sum(dim=-1, keepdim=True).clamp(min=1e-9)
135
+ route_weights = top_weights.to(dtype=x_2d.dtype)
136
+
137
+ flat_expert = top_indices.reshape(-1)
138
+ flat_token = (
139
+ torch.arange(N, device=x.device)
140
+ .unsqueeze(1)
141
+ .expand(-1, self.top_k)
142
+ .reshape(-1)
143
+ )
144
+ flat_weight = route_weights.reshape(-1, 1)
145
+
146
+ order = torch.argsort(flat_expert, stable=True)
147
+ sorted_tokens = flat_token[order]
148
+ sorted_weights = flat_weight[order]
149
+ sorted_x = x_2d.index_select(0, sorted_tokens)
150
+ counts = torch.bincount(
151
+ flat_expert, minlength=self.total_experts
152
+ ).to(torch.int64)
153
+ offsets = torch.cat(
154
+ [torch.zeros(1, dtype=torch.int64, device=x.device),
155
+ torch.cumsum(counts, 0)[:-1]]
156
+ )
157
+
158
+ output = torch.zeros_like(x_2d)
159
+
160
+ for expert_idx in range(self.num_ffn):
161
+ lo = int(offsets[expert_idx])
162
+ hi = lo + int(counts[expert_idx])
163
+ if lo == hi:
164
+ continue
165
+ tids = sorted_tokens[lo:hi]
166
+ w = sorted_weights[lo:hi]
167
+ output.index_add_(
168
+ 0, tids,
169
+ (self.experts[expert_idx](sorted_x[lo:hi]) * w).to(dtype=output.dtype),
170
+ )
171
+
172
+ if self.n_zc > 0:
173
+ for i in range(self.n_copy):
174
+ expert_idx = self.copy_start + i
175
+ lo = int(offsets[expert_idx])
176
+ hi = lo + int(counts[expert_idx])
177
+ if lo == hi:
178
+ continue
179
+ tids = sorted_tokens[lo:hi]
180
+ w = sorted_weights[lo:hi]
181
+ output.index_add_(
182
+ 0, tids, (sorted_x[lo:hi] * w).to(dtype=output.dtype)
183
+ )
184
+
185
+ for i in range(self.n_const):
186
+ expert_idx = self.const_start + i
187
+ lo = int(offsets[expert_idx])
188
+ hi = lo + int(counts[expert_idx])
189
+ if lo == hi:
190
+ continue
191
+ tids = sorted_tokens[lo:hi]
192
+ w = sorted_weights[lo:hi]
193
+ output.index_add_(
194
+ 0, tids,
195
+ (self.const_experts[i](sorted_x[lo:hi]) * w).to(dtype=output.dtype),
196
+ )
197
+
198
+ counts = torch.zeros(self.total_experts, device=x.device, dtype=gate_weights.dtype)
199
+ counts.scatter_add_(0, flat_expert, torch.ones_like(flat_expert, dtype=gate_weights.dtype))
200
+ self.last_counts = counts.detach()
201
+ fractions = counts / counts.sum().clamp(min=1e-12)
202
+ avg_prob = gate_weights.mean(dim=0)
203
+ if self.n_zc > 0:
204
+ aux_loss = self.total_experts * (self.eta.to(dtype=gate_weights.dtype) * fractions * avg_prob).sum()
205
+ else:
206
+ aux_loss = self.total_experts * (fractions * avg_prob).sum()
207
+
208
+ return (output.view(B, T, D), aux_loss, gate_logits,
209
+ gate_weights.view(B, T, self.total_experts))
210
+
211
+
212
+ class MoEAttentionBlock(nn.Module):
213
+ """Pre-norm attention + MoE++ FFN. Stores the FFN gate maps for the
214
+ spatial-smoothness (TV) loss."""
215
+
216
+ def __init__(
217
+ self,
218
+ dim: int,
219
+ n_heads: int = 6,
220
+ d_ff: int = 384,
221
+ num_ffn: int = 4,
222
+ top_k: int = 2,
223
+ n_zero: int = 1,
224
+ n_copy: int = 1,
225
+ n_const: int = 2,
226
+ tau: float = 0.75,
227
+ gating_residual: bool = True,
228
+ gate_ctx: bool = False,
229
+ ):
230
+ super().__init__()
231
+ self.n_heads = n_heads
232
+ self.head_dim = dim // n_heads
233
+ self.q = nn.Linear(dim, dim)
234
+ self.k = nn.Linear(dim, dim)
235
+ self.v = nn.Linear(dim, dim)
236
+ self.proj = nn.Linear(dim, dim)
237
+ self.norm1 = nn.LayerNorm(dim)
238
+ self.norm2 = nn.LayerNorm(dim)
239
+ self.moe = MoEFFN(
240
+ dim, d_ff, num_ffn=num_ffn, top_k=top_k,
241
+ n_zero=n_zero, n_copy=n_copy, n_const=n_const,
242
+ tau=tau, gating_residual=gating_residual, gate_ctx=gate_ctx,
243
+ )
244
+
245
+ def forward(
246
+ self, x: torch.Tensor, prev_gate: torch.Tensor | None = None,
247
+ ctx: torch.Tensor | None = None,
248
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
249
+ S, T, D = x.shape
250
+ H = self.n_heads
251
+ xn = self.norm1(x)
252
+ q = self.q(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
253
+ k = self.k(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
254
+ v = self.v(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
255
+ attn = (q @ k.transpose(-1, -2) / (self.head_dim ** 0.5)).softmax(dim=-1)
256
+ out = (attn @ v).transpose(1, 2).reshape(S, T, D)
257
+ x = x + self.proj(out)
258
+
259
+ y, aux, gate_logits, gate_weights = self.moe(
260
+ self.norm2(x), prev_gate=prev_gate, ctx=ctx)
261
+ x = x + y
262
+ return x, aux, gate_logits, gate_weights
263
+
264
+
265
+ class SliceAttentionPool(nn.Module):
266
+ """Weight slices by learned relevance before aggregating into a study vector."""
267
+
268
+ def __init__(self, dim: int):
269
+ super().__init__()
270
+ self.query = nn.Parameter(torch.randn(dim))
271
+ self.scale = dim ** -0.5
272
+
273
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
274
+ w = F.softmax(x @ self.query * self.scale, dim=0)
275
+ return (w.unsqueeze(1) * x).sum(0)
276
+
277
+
278
+ class PerTargetPool(nn.Module):
279
+ """Per-finding attention over slices: each of n_classes findings owns a
280
+ query that weights the slices most relevant to it -> [n_classes, D]."""
281
+
282
+ def __init__(self, dim: int, n_classes: int):
283
+ super().__init__()
284
+ self.queries = nn.Parameter(torch.randn(n_classes, dim) * dim ** -0.5)
285
+ self.scale = dim ** -0.5
286
+
287
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
288
+ # x: [S, D] slice vectors -> [n_classes, S] attention weights
289
+ w = F.softmax(x @ self.queries.T * self.scale, dim=0)
290
+ return (x.unsqueeze(0) * w.T.unsqueeze(-1)).sum(1)
291
+
292
+
293
+ class PerTargetHead(nn.Module):
294
+ """Shared MLP then per-target projection (12 findings, each its own weight)."""
295
+
296
+ def __init__(self, dim: int, n_classes: int, hidden: int = 256):
297
+ super().__init__()
298
+ self.mlp = nn.Sequential(nn.Linear(dim, hidden), nn.GELU())
299
+ self.w = nn.Parameter(torch.randn(n_classes, hidden) * hidden ** -0.5)
300
+ self.b = nn.Parameter(torch.zeros(n_classes))
301
+
302
+ def forward(self, ctx: torch.Tensor) -> torch.Tensor:
303
+ # ctx: [n_classes, D] -> logits [n_classes]
304
+ h = self.mlp(ctx)
305
+ return (h * self.w).sum(-1) + self.b
306
+
307
+
308
+ class MoEViT(nn.Module):
309
+ """Pretrained DINOv3 stem + stacked MoE++ core with per-token routing."""
310
+
311
+ def __init__(
312
+ self,
313
+ *,
314
+ stem_name: str = "vit_small_patch16_dinov3",
315
+ stem: nn.Module | None = None,
316
+ pretrained: bool = True,
317
+ freeze_stem: bool = True,
318
+ core_blocks: int = 2,
319
+ n_heads: int = 6,
320
+ d_ff: int = 384,
321
+ num_ffn: int = 4,
322
+ top_k: int = 2,
323
+ n_zero: int = 1,
324
+ n_copy: int = 1,
325
+ n_const: int = 2,
326
+ tau: float = 0.75,
327
+ gating_residual: bool = True,
328
+ n_classes: int = 12,
329
+ gate_ctx: bool = False,
330
+ per_target: bool = False,
331
+ ):
332
+ super().__init__()
333
+ if stem is None:
334
+ if not HAS_TIMM:
335
+ raise ImportError("timm is required for a pretrained stem")
336
+ stem = timm.create_model(stem_name, pretrained=pretrained, num_classes=0)
337
+ self.num_ffn = num_ffn
338
+ self.top_k = top_k
339
+ self.gate_ctx = gate_ctx
340
+ self.per_target = per_target
341
+
342
+ self.stem = stem
343
+ stem_dim = self.stem.embed_dim
344
+ self.core_dim = 384
345
+ self.input_proj = nn.Linear(stem_dim, self.core_dim) if stem_dim != 384 else None
346
+ dim = self.core_dim
347
+ self.n_prefix = int(getattr(self.stem, "num_prefix_tokens", 1))
348
+
349
+ if freeze_stem:
350
+ for p in self.stem.parameters():
351
+ p.requires_grad_(False)
352
+
353
+ self._ffn_maps: list[torch.Tensor] = []
354
+
355
+ self.blocks = nn.ModuleList(
356
+ [
357
+ MoEAttentionBlock(
358
+ dim, n_heads=n_heads, d_ff=d_ff, num_ffn=num_ffn, top_k=top_k,
359
+ n_zero=n_zero, n_copy=n_copy, n_const=n_const,
360
+ tau=tau, gating_residual=gating_residual, gate_ctx=gate_ctx,
361
+ )
362
+ for _ in range(core_blocks)
363
+ ]
364
+ )
365
+
366
+ self.exit_norm = nn.LayerNorm(dim)
367
+ if per_target:
368
+ self.pool = PerTargetPool(dim, n_classes)
369
+ self.head = PerTargetHead(dim, n_classes)
370
+ else:
371
+ self.pool = SliceAttentionPool(dim)
372
+ self.head = nn.Sequential(
373
+ nn.Linear(dim, dim * 2),
374
+ nn.GELU(),
375
+ nn.Linear(dim * 2, n_classes),
376
+ )
377
+
378
+ def moe_aux_loss(self) -> torch.Tensor:
379
+ total = None
380
+ for b in self.blocks:
381
+ if not hasattr(b, "_aux"):
382
+ continue
383
+ total = b._aux if total is None else total + b._aux
384
+ if total is None:
385
+ return torch.tensor(0.0, device=next(self.parameters()).device)
386
+ return total / len(self.blocks)
387
+
388
+ def routing_tv_loss(self) -> torch.Tensor:
389
+ """Total-variation penalty on the per-patch FFN-mass maps.
390
+
391
+ Knee findings occupy contiguous regions; scattered routing is a bug
392
+ signal. The map is the probability mass routed to real FFN experts,
393
+ reshaped to the patch grid.
394
+ """
395
+ if not self._ffn_maps:
396
+ return torch.tensor(0.0, device=next(self.parameters()).device)
397
+ total = None
398
+ for p in self._ffn_maps:
399
+ P = p.size(1)
400
+ h = int(round(P ** 0.5))
401
+ if h * h != P:
402
+ continue
403
+ g = p.reshape(p.size(0), h, h)
404
+ tv = (g[:, 1:, :] - g[:, :-1, :]).abs().mean() + (
405
+ g[:, :, 1:] - g[:, :, :-1]
406
+ ).abs().mean()
407
+ total = tv if total is None else total + tv
408
+ if total is None:
409
+ return torch.tensor(0.0, device=next(self.parameters()).device)
410
+ return total / len(self._ffn_maps)
411
+
412
+ def features(
413
+ self, x: torch.Tensor, pool: str = "patchmean"
414
+ ) -> torch.Tensor:
415
+ """Per-slice features (no head): [S, D] from [S, 3, H, W] slices."""
416
+ tokens = self.stem.forward_features(x) # [S, T, D]
417
+ return self.features_from_tokens(tokens, pool)
418
+
419
+ def features_from_tokens(
420
+ self, tokens: torch.Tensor, pool: str = "patchmean"
421
+ ) -> torch.Tensor:
422
+ """Features from cached stem tokens (feat cache path): [S, D]."""
423
+ tokens = tokens.float() # core internals are f32; bf16 cache upcasts exactly
424
+ if self.input_proj is not None:
425
+ with torch.autocast("cuda", enabled=False):
426
+ tokens = self.input_proj(tokens).float() # keep f32; autocast would make it bf16
427
+ self._ffn_maps = []
428
+ prev_gate = None
429
+ ctx = tokens.mean(dim=1) if self.gate_ctx else None # [B, D] per-slice context
430
+ for b in self.blocks:
431
+ tokens, aux, gate_logits, gate_weights = b(
432
+ tokens, prev_gate=prev_gate, ctx=ctx)
433
+ b._aux = aux
434
+ self._ffn_maps.append(
435
+ gate_weights[:, self.n_prefix:, : self.num_ffn].sum(dim=-1)
436
+ )
437
+ prev_gate = gate_logits
438
+ tokens = self.exit_norm(tokens)
439
+ if pool == "cls":
440
+ return tokens[:, 0]
441
+ return tokens[:, self.n_prefix:].mean(1)
442
+
443
+ def forward(
444
+ self, x: torch.Tensor
445
+ ) -> tuple[torch.Tensor, list[float]]:
446
+ """Forward one study: [S, 3, H, W] slices -> (logits [n_classes], aux_stats)."""
447
+ slice_feats = self.features(x) # [S, D]
448
+ study = self.pool(slice_feats) # [D] or [n_classes, D]
449
+ logits = self.head(study)
450
+ if logits.dim() == 2:
451
+ logits = logits.squeeze(-1)
452
+ return logits, [self.moe_aux_loss().item()]
src/src/models/mor_cnn.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MoR-CNN: Mixture-of-Recursions applied to a convolutional vision model.
2
+
3
+ Ports the LLM MoR idea (llm-pipeline) to 2D medical imaging. The mapping:
4
+
5
+ entry block -> pretrained CNN stem (unique weights)
6
+ shared recursive -> ``RecursiveConvBlock`` stack reused at every recursion
7
+ core
8
+ depth router -> per-slice ``DepthRouter`` (expert choice over slices)
9
+ recursion emb -> learned per-recursion embedding added before the core
10
+ slice freeze -> a slice that stops routing keeps its current state
11
+
12
+ At inference the router spends full recursion depth only on the "hard" slices
13
+ (those most likely to be abnormal), so FLOPs scale with content — the lever the
14
+ competition's Efficiency Track scores.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+
25
+ try:
26
+ import timm
27
+
28
+ HAS_TIMM = True
29
+ except ImportError: # pragma: no cover - optional dependency
30
+ HAS_TIMM = False
31
+
32
+
33
+ class DepthScore(nn.Module):
34
+ """Per-slice continuation score: pooled feature -> scalar logit.
35
+
36
+ Mirrors ``_DepthScore`` in llm-pipeline's MoR router, but over a global
37
+ pooled per-slice feature instead of a per-token hidden state.
38
+ """
39
+
40
+ def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
41
+ super().__init__()
42
+ self.in_proj = nn.Linear(dim, hidden)
43
+ self.out_proj = nn.Linear(hidden, 1)
44
+ with torch.no_grad():
45
+ self.out_proj.bias.fill_(init_bias)
46
+
47
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
48
+ return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)
49
+
50
+
51
+ class DepthRouter(nn.Module):
52
+ """Expert-choice depth router over slices.
53
+
54
+ One ``DepthScore`` head per recursion. Each recursion also adds a learned
55
+ recursion embedding so the router can condition on how deep a slice
56
+ already is (the vision analogue of ``rec_emb`` in the LLM MoR).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ dim: int,
62
+ hidden: int,
63
+ n_recursions: int,
64
+ capacities: list[float],
65
+ init_bias: float = 0.0,
66
+ warmup_steps: int = 0,
67
+ ):
68
+ super().__init__()
69
+ self.n_recursions = n_recursions
70
+ self.capacities = capacities
71
+ self.warmup_steps = warmup_steps
72
+ self.heads = nn.ModuleList(
73
+ [DepthScore(dim, hidden, init_bias) for _ in range(n_recursions)]
74
+ )
75
+ self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))
76
+
77
+ def capacity(self, r: int, step: int) -> float:
78
+ """Slice fraction kept at recursion ``r``, ramping from 1.0 during warmup."""
79
+ target = self.capacities[r]
80
+ if self.warmup_steps > 0 and step < self.warmup_steps:
81
+ t = step / self.warmup_steps
82
+ return 1.0 - (1.0 - target) * t
83
+ return target
84
+
85
+ def forward(self, x: torch.Tensor, r: int) -> torch.Tensor:
86
+ # x: [S, dim] pooled per-slice features
87
+ return self.heads[r](x + self.rec_emb[r])
88
+
89
+
90
+ class RecursiveConvBlock(nn.Module):
91
+ """A shared inverted-residual conv block reused at every recursion.
92
+
93
+ Depthwise-separable (MobileNet-v2 style) so the recursive core stays cheap
94
+ while the stem does the heavy feature extraction.
95
+ """
96
+
97
+ def __init__(self, dim: int):
98
+ super().__init__()
99
+ self.norm = nn.LayerNorm(dim)
100
+ self.pw1 = nn.Conv2d(dim, dim * 2, 1)
101
+ self.dw = nn.Conv2d(dim * 2, dim * 2, 3, padding=1, groups=dim * 2)
102
+ self.pw2 = nn.Conv2d(dim * 2, dim, 1)
103
+
104
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
105
+ # x: [N, dim, h, w]
106
+ identity = x
107
+ x = self.norm(x.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)
108
+ x = self.pw2(F.gelu(self.dw(F.gelu(self.pw1(x)))))
109
+ return identity + x
110
+
111
+
112
+ class SliceAttentionPool(nn.Module):
113
+ """Weight slices by learned relevance before aggregating into a study vector."""
114
+
115
+ def __init__(self, dim: int):
116
+ super().__init__()
117
+ self.query = nn.Parameter(torch.randn(dim))
118
+ self.scale = dim ** -0.5
119
+
120
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
121
+ # x: [S, dim]
122
+ w = F.softmax(x @ self.query * self.scale, dim=0)
123
+ return (w.unsqueeze(1) * x).sum(0)
124
+
125
+
126
+ class ChannelLayerNorm(nn.Module):
127
+ """LayerNorm over the channel dim of a [N, C, H, W] feature map."""
128
+
129
+ def __init__(self, dim: int):
130
+ super().__init__()
131
+ self.norm = nn.LayerNorm(dim)
132
+
133
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
134
+ return self.norm(x.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)
135
+
136
+
137
+ class MoRCNN(nn.Module):
138
+ """Pretrained CNN stem + MoR recursive core + per-slice depth router."""
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ stem_name: str = "convnext_tiny",
144
+ pretrained: bool = True,
145
+ n_recursions: int = 3,
146
+ capacities: tuple[float, ...] = (1.0, 2.0 / 3.0, 1.0 / 3.0),
147
+ router_hidden: int = 128,
148
+ core_blocks: int = 2,
149
+ n_classes: int = 12,
150
+ router_warmup_steps: int = 0,
151
+ router_init_bias: float = 0.0,
152
+ ):
153
+ super().__init__()
154
+ if not HAS_TIMM:
155
+ raise ImportError("timm is required for a pretrained stem")
156
+ self.n_recursions = n_recursions
157
+ self.capacities = list(capacities)
158
+ assert len(self.capacities) == n_recursions
159
+
160
+ self.stem = timm.create_model(
161
+ stem_name, pretrained=pretrained, features_only=True, num_classes=0
162
+ )
163
+ out_dim = self.stem.feature_info.channels()[-1]
164
+
165
+ self.core = nn.Sequential(*[RecursiveConvBlock(out_dim) for _ in range(core_blocks)])
166
+ self.router = DepthRouter(
167
+ out_dim,
168
+ router_hidden,
169
+ n_recursions,
170
+ self.capacities,
171
+ init_bias=router_init_bias,
172
+ warmup_steps=router_warmup_steps,
173
+ )
174
+ self.rec_emb = nn.Parameter(torch.zeros(n_recursions, out_dim))
175
+
176
+ self.exit = nn.Sequential(
177
+ ChannelLayerNorm(out_dim),
178
+ nn.Conv2d(out_dim, out_dim, 1),
179
+ nn.GELU(),
180
+ )
181
+ self.pool = SliceAttentionPool(out_dim)
182
+
183
+ self.head = nn.Sequential(
184
+ nn.Linear(out_dim, out_dim * 2),
185
+ nn.GELU(),
186
+ nn.Linear(out_dim * 2, n_classes),
187
+ )
188
+
189
+ def _run_core(self, feat: torch.Tensor, r: int) -> torch.Tensor:
190
+ emb = self.rec_emb[r].view(1, -1, 1, 1)
191
+ return self.core(feat + emb)
192
+
193
+ def _global_pool(self, feat: torch.Tensor) -> torch.Tensor:
194
+ return feat.mean(dim=(2, 3))
195
+
196
+ def forward(
197
+ self, x: torch.Tensor, step: int = 0
198
+ ) -> tuple[torch.Tensor, list[float]]:
199
+ """Forward one study.
200
+
201
+ Args:
202
+ x: [S, 3, H, W] sampled slices of a single study.
203
+ step: current optimizer step, used for router warmup.
204
+
205
+ Returns:
206
+ (logits [n_classes], route_stats) where route_stats is the active
207
+ slice fraction at each recursion.
208
+ """
209
+ S = x.size(0)
210
+ feat = self.stem(x)[-1] # [S, C, h, w]
211
+ C = feat.size(1)
212
+ pooled = self._global_pool(feat)
213
+
214
+ stats: list[float] = []
215
+ for r in range(self.n_recursions):
216
+ probs = torch.sigmoid(self.router(pooled, r))
217
+ cap = self.router.capacity(r, step)
218
+ if r == self.n_recursions - 1:
219
+ active = torch.ones(S, dtype=torch.bool, device=x.device)
220
+ else:
221
+ k = max(1, int(round(S * cap)))
222
+ active = torch.zeros(S, dtype=torch.bool, device=x.device)
223
+ active[torch.topk(probs, k).indices] = True
224
+ stats.append(active.float().mean().item())
225
+
226
+ if active.all():
227
+ feat = self._run_core(feat, r)
228
+ else:
229
+ idx = active.nonzero(as_tuple=False).squeeze(1)
230
+ updated = self._run_core(feat[idx], r)
231
+ feat = feat.clone()
232
+ feat[idx] = updated
233
+ pooled = self._global_pool(feat)
234
+
235
+ slice_feats = self.exit(feat) # [S, C, h, w]
236
+ slice_feats = self._global_pool(slice_feats) # [S, C]
237
+ study = self.pool(slice_feats) # [C]
238
+ logits = self.head(study) # [n_classes]
239
+ return logits, stats
src/src/models/mor_vit.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, []
src/src/models/mricore_stem.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MRI-CORE stem: MRI-pretrained ViT-B feature extractor.
2
+
3
+ Loads the MRI-CORE checkpoint (DINOv2-style ViT-B, SAM-init, pretrained on
4
+ 6.1M MRI slices) and exposes a timm-compatible surface so the existing
5
+ MoR/MoE cores can use it as their frozen stem:
6
+
7
+ stem.embed_dim -> 768
8
+ stem.num_prefix_tokens -> 1 (CLS only, no registers)
9
+ stem.pos_embed -> [1, 197, 768]
10
+ stem.forward_features(x)-> [S, 197, 768] (frozen, eval mode)
11
+
12
+ Key layout in the checkpoint: teacher.backbone.{cls_token, pos_embed,
13
+ patch_embed.proj, blocks.<g>.<i>, norm} with blocks nested 4 x 3 = 12.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ _CKPT = "MRI_CORE_vitb.pth"
24
+
25
+
26
+ class Block(nn.Module):
27
+ def __init__(self, dim: int = 768, n_heads: int = 12, mlp_ratio: float = 4.0):
28
+ super().__init__()
29
+ self.norm1 = nn.LayerNorm(dim)
30
+ self.attn = nn.MultiheadAttention(dim, n_heads, batch_first=True)
31
+ self.norm2 = nn.LayerNorm(dim)
32
+ self.mlp = nn.Sequential(
33
+ nn.Linear(dim, int(dim * mlp_ratio)),
34
+ nn.GELU(),
35
+ nn.Linear(int(dim * mlp_ratio), dim),
36
+ )
37
+
38
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
39
+ x = x + self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0]
40
+ x = x + self.mlp(self.norm2(x))
41
+ return x
42
+
43
+
44
+ class MRICoreStem(nn.Module):
45
+ """Frozen MRI-CORE ViT-B feature extractor (embed_dim=768, 197 tokens)."""
46
+
47
+ def __init__(self, ckpt_path: str | None = None, freeze: bool = True):
48
+ super().__init__()
49
+ if ckpt_path is None:
50
+ for base in (
51
+ r"G:\RSNA-Knee\cache",
52
+ os.path.join(os.path.dirname(__file__), "..", "..", "weights"),
53
+ ):
54
+ cand = os.path.join(base, _CKPT)
55
+ if os.path.isfile(cand):
56
+ ckpt_path = cand
57
+ break
58
+ if ckpt_path is None or not os.path.isfile(ckpt_path):
59
+ raise FileNotFoundError(f"MRI-CORE checkpoint not found ({_CKPT})")
60
+ self.embed_dim = 768
61
+ self.num_prefix_tokens = 1
62
+ self.patch_embed = nn.Conv2d(3, 768, kernel_size=16, stride=16, bias=True)
63
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, 768))
64
+ self.pos_embed = nn.Parameter(torch.zeros(1, 197, 768))
65
+ self.mask_token = nn.Parameter(torch.zeros(1, 768))
66
+ self.blocks = nn.ModuleList(
67
+ [nn.ModuleList([Block() for _ in range(3)]) for _ in range(4)]
68
+ )
69
+ self.norm = nn.LayerNorm(768)
70
+ st = torch.load(ckpt_path, map_location="cpu", weights_only=True)
71
+ inner = st["teacher"]
72
+ if isinstance(inner, dict) and "state_dict" in inner:
73
+ inner = inner["state_dict"]
74
+ own = self.state_dict()
75
+ prefix = "backbone."
76
+ missing = []
77
+ for k in list(inner.keys()):
78
+ if not k.startswith(prefix):
79
+ inner.pop(k)
80
+ continue
81
+ name = k[len(prefix):]
82
+ if name not in own:
83
+ continue
84
+ if tuple(inner[k].shape) == tuple(own[name].shape):
85
+ own[name] = inner[k]
86
+ else:
87
+ missing.append((name, tuple(inner[k].shape), tuple(own[name].shape)))
88
+ self.load_state_dict(own)
89
+ if missing:
90
+ print(f"mricore: {len(missing)} shape mismatches skipped "
91
+ f"(e.g. {missing[0]})", flush=True)
92
+ if freeze:
93
+ for p in self.parameters():
94
+ p.requires_grad_(False)
95
+ self.eval()
96
+
97
+ def forward_features(self, x: torch.Tensor) -> torch.Tensor:
98
+ """[S, 3, H, W] -> [S, 197, 768] (CLS + 196 patch tokens)."""
99
+ x = self.patch_embed(x).flatten(2).transpose(1, 2) # [S, 196, 768]
100
+ x = torch.cat([self.cls_token.expand(x.size(0), -1, -1), x], dim=1)
101
+ x = x + self.pos_embed
102
+ for group in self.blocks:
103
+ for blk in group:
104
+ x = blk(x)
105
+ return self.norm(x)
106
+
107
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
108
+ return self.forward_features(x)
src/src/models/plant_vit.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PlantViT - thin adapter over RSNA MoR/MoE for single-image plant ID.
2
+ Reuses moe_vit.MoEViT and mor_vit.MoRViT frozen DINOv3 stem + routing.
3
+ Single image = single study with S=1 slice, so pool reduces to patchmean.
4
+ Adds 512-D embedder head per architecture.md §4.
5
+
6
+ Usage:
7
+ from src.models.plant_vit import PlantViT
8
+ model = PlantViT(stem_name="vit_base_patch16_dinov3", n_classes=500, use_moe=True, embed_dim=512)
9
+ logits, embed, aux = model(x) # x [B,3,336,336] -> logits [B,500], embed [B,512] L2
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+ from .moe_vit import MoEViT
19
+ from .mor_vit import MoRViT
20
+
21
+
22
+ class EmbedHead(nn.Module):
23
+ """Plant-specific projection: 768/384 -> 512 L2 per architecture.md §4."""
24
+ def __init__(self, in_dim: int, embed_dim: int = 512):
25
+ super().__init__()
26
+ self.ln1 = nn.LayerNorm(in_dim)
27
+ self.proj = nn.Linear(in_dim, embed_dim, bias=False)
28
+ self.ln2 = nn.LayerNorm(embed_dim)
29
+
30
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
31
+ # x [B, D]
32
+ x = self.ln1(x)
33
+ x = self.proj(x)
34
+ x = self.ln2(x)
35
+ return F.normalize(x, dim=-1)
36
+
37
+
38
+ class PlantViT(nn.Module):
39
+ """Wrapper that exposes (logits, embed, aux_losses) with interchangeable stem.
40
+
41
+ Args:
42
+ stem_name: timm name, e.g. vit_small_patch16_dinov3, vit_base_patch16_dinov3, vit_large_patch16_dinov3, convnext_tiny
43
+ n_classes: 500 for WA top500
44
+ embed_dim: 512 (test 256/128 later)
45
+ use_moe: True -> MoE++ (MoEViT), False -> MoR (MoRViT), None -> dense
46
+ freeze_stem: keep DINOv3 frozen for CE baseline
47
+ All MoE/MoR kwargs (num_ffn, top_k, d_ff, n_recursions, capacities etc.) passed through.
48
+ """
49
+ def __init__(
50
+ self,
51
+ *,
52
+ stem_name: str = "vit_base_patch16_dinov3",
53
+ n_classes: int = 500,
54
+ embed_dim: int = 512,
55
+ use_moe: bool | None = True,
56
+ freeze_stem: bool = True,
57
+ # MoE knobs
58
+ num_ffn: int = 4,
59
+ top_k: int = 2,
60
+ d_ff: int = 384,
61
+ # MoR knobs
62
+ n_recursions: int = 3,
63
+ capacities: tuple[float, ...] = (1.0, 0.667, 0.333),
64
+ core_blocks: int = 2,
65
+ router_hidden: int = 128,
66
+ pretrained: bool = True,
67
+ **kwargs,
68
+ ):
69
+ super().__init__()
70
+ self.use_moe = use_moe
71
+ # build core - keep RSNA defaults, just swap n_classes and stem
72
+ if use_moe is True:
73
+ self.core = MoEViT(
74
+ stem_name=stem_name, pretrained=pretrained, freeze_stem=freeze_stem,
75
+ core_blocks=core_blocks, d_ff=d_ff, num_ffn=num_ffn, top_k=top_k,
76
+ n_classes=n_classes, **{k: v for k, v in kwargs.items() if k in ["gate_ctx","per_target","tau","n_zero","n_copy","n_const","gating_residual"]},
77
+ )
78
+ feat_dim = self.core.core_dim # 384
79
+ elif use_moe is False:
80
+ self.core = MoRViT(
81
+ stem_name=stem_name, pretrained=pretrained, freeze_stem=freeze_stem,
82
+ n_recursions=n_recursions, capacities=capacities, core_blocks=core_blocks,
83
+ router_hidden=router_hidden, n_classes=n_classes,
84
+ )
85
+ feat_dim = self.core.core_dim
86
+ else:
87
+ # dense baseline - MoR dense=True
88
+ self.core = MoRViT(
89
+ stem_name=stem_name, pretrained=pretrained, freeze_stem=freeze_stem,
90
+ n_recursions=n_recursions, capacities=capacities, core_blocks=core_blocks,
91
+ router_hidden=router_hidden, n_classes=n_classes, dense=True,
92
+ )
93
+ feat_dim = self.core.core_dim
94
+
95
+ # embedder on pooled slice feat (before head pool, use same dim)
96
+ # For MoE/MoR, slice feat dim = core.core_dim (384) ; for base ViT-B 768 -> still 384 via input_proj
97
+ # If stem is vit_base (768) we still route through input_proj -> 384, so embed in 384
98
+ self.embed = EmbedHead(feat_dim, embed_dim)
99
+ self.n_classes = n_classes
100
+ self.embed_dim = embed_dim
101
+ self.stem_name = stem_name
102
+
103
+ def forward(self, x: torch.Tensor, step: int = 0):
104
+ """x [B,3,H,W] single image per plant -> logits [B,C], embed [B,512], aux dict"""
105
+ # RSNA core expects [S,3,H,W] where S is slices per study; for plant S=B single slice per sample
106
+ # Use core.features single-batch path
107
+ feats = self.core.features(x) # [B, D] (MoEViT/MoRViT handle bfloat16 internally)
108
+ logits = self.core.head(self.core.pool(feats.unsqueeze(1).repeat(1,1,1)) if False else feats) # bypass buggy repeat - just pool slice feats
109
+ # Actually MoEViT/MoRViT head expects pooled study vector [D]; we have [B,D] already pooled per image
110
+ # Re-derive logits via head directly for single-slice case:
111
+ # MoEViT/MoRViT: features -> [S,D] -> pool -> [D] -> head -> [C]
112
+ # For B independent images, features is [B,D] already per-image slice feat, so head per row:
113
+ # Use core.head on each row individually via batched linear - head is Sequential Linear/GELU/Linear
114
+ # For per_target head, need [B, n_classes, D] - not used for plant (per_target=False by default)
115
+ # Simpler: call core.head on feats directly (feats is [B,D] -> head expects [D] but Linear broadcasts on last dim)
116
+ # Do batched:
117
+ if isinstance(self.core.head, nn.Sequential):
118
+ logits = self.core.head(feats) # [B,C] broadcasts
119
+ else:
120
+ # PerTarget not used
121
+ logits = self.core.head(feats)
122
+
123
+ embed = self.embed(feats)
124
+ aux = {}
125
+ # surface aux losses if present
126
+ try:
127
+ aux["moe_aux"] = float(self.core.moe_aux_loss().item()) if hasattr(self.core, "moe_aux_loss") else 0.0
128
+ except:
129
+ aux["moe_aux"] = 0.0
130
+ try:
131
+ aux["tv"] = float(self.core.routing_tv_loss().item()) if hasattr(self.core, "routing_tv_loss") else float(self.core.routing_smoothness_loss().item() if hasattr(self.core, "routing_smoothness_loss") else 0.0)
132
+ except:
133
+ aux["tv"] = 0.0
134
+ return logits, embed, aux
135
+
136
+ def features(self, x: torch.Tensor) -> torch.Tensor:
137
+ return self.core.features(x)
src/src/models/plant_vit_v2.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PlantViT v2 Architecture (PlantDetect v2.0):
3
+ 1. DINOv3 ViT-Base Backbone @ 448x448 High-Res (784 patch tokens).
4
+ 2. Multi-Layer Feature Pyramid Fusion (Block 8 Micro-Texture + Block 11 Macro-Semantics).
5
+ 3. Pure 16 SwiGLU Mixture-of-Experts (MoESwiGLUv2 with Gating Residuals & Context Projection).
6
+ 4. Sub-Center ArcFace Metric Head (K=3 Phenology Centers per Class).
7
+ 5. Hierarchical Multi-Task Taxonomic Classification Heads (Species + Genus + Family).
8
+ """
9
+ import torch
10
+ import torch.nn as nn
11
+ import torch.nn.functional as F
12
+ import timm
13
+
14
+ from .moe_swiglu_v2 import MoESwiGLUv2
15
+ from .subcenter_arcface import SubCenterArcFace
16
+
17
+ class MultiLayerFeatureFusion(nn.Module):
18
+ def __init__(self, dim: int = 768, out_dim: int = 512):
19
+ super().__init__()
20
+ self.dim = dim
21
+ self.out_dim = out_dim
22
+
23
+ self.norm_mid = nn.LayerNorm(dim)
24
+ self.norm_deep = nn.LayerNorm(dim)
25
+
26
+ self.fusion_proj = nn.Sequential(
27
+ nn.Linear(dim * 2, out_dim),
28
+ nn.GELU(),
29
+ nn.LayerNorm(out_dim),
30
+ nn.Linear(out_dim, out_dim),
31
+ nn.LayerNorm(out_dim)
32
+ )
33
+
34
+ def forward(self, mid_tokens: torch.Tensor, deep_tokens: torch.Tensor) -> torch.Tensor:
35
+ # Pool spatial tokens over sequence dimension
36
+ mid_pooled = self.norm_mid(mid_tokens.mean(dim=1))
37
+ deep_pooled = self.norm_deep(deep_tokens.mean(dim=1))
38
+
39
+ fused = torch.cat([mid_pooled, deep_pooled], dim=-1)
40
+ emb = self.fusion_proj(fused)
41
+ return F.normalize(emb, dim=-1)
42
+
43
+ class PlantViTv2(nn.Module):
44
+ def __init__(
45
+ self,
46
+ stem_name: str = "vit_base_patch16_dinov3.lvd1689m",
47
+ img_size: int = 448,
48
+ n_species: int = 1000,
49
+ n_genera: int = 358,
50
+ n_families: int = 80,
51
+ embed_dim: int = 512,
52
+ use_moe: bool = True,
53
+ num_ffn: int = 16,
54
+ d_ff: int = 512,
55
+ top_k: int = 2,
56
+ k_subcenters: int = 3,
57
+ pretrained: bool = True,
58
+ freeze_stem: bool = False
59
+ ):
60
+ super().__init__()
61
+ self.stem_name = stem_name
62
+ self.img_size = img_size
63
+ self.n_species = n_species
64
+ self.n_genera = n_genera
65
+ self.n_families = n_families
66
+ self.embed_dim = embed_dim
67
+ self.use_moe = use_moe
68
+
69
+ # 1. DINOv3 Backbone
70
+ stem_model_name = stem_name if stem_name.startswith("vit_") else f"{stem_name}.lvd1689m"
71
+ self.stem = timm.create_model(
72
+ stem_model_name,
73
+ pretrained=pretrained,
74
+ img_size=img_size,
75
+ num_classes=0,
76
+ dynamic_img_size=True
77
+ )
78
+ self.dim = self.stem.embed_dim
79
+
80
+ if freeze_stem:
81
+ for p in self.stem.parameters():
82
+ p.requires_grad = False
83
+
84
+ # 2. Pure 16-SwiGLU MoE Layer
85
+ if self.use_moe:
86
+ self.moe_layer = MoESwiGLUv2(
87
+ d_model=self.dim,
88
+ d_ff=d_ff,
89
+ num_ffn=num_ffn,
90
+ top_k=top_k,
91
+ gating_residual=True,
92
+ gate_ctx=True
93
+ )
94
+ self.moe_norm = nn.LayerNorm(self.dim)
95
+ else:
96
+ self.moe_layer = None
97
+
98
+ # 3. Multi-Layer Feature Fusion (Block 8 + Block 11)
99
+ self.fusion = MultiLayerFeatureFusion(dim=self.dim, out_dim=embed_dim)
100
+
101
+ # 4. Multi-Task Taxonomic Heads
102
+ self.head_species = nn.Linear(embed_dim, n_species, bias=False)
103
+ self.head_genus = nn.Linear(embed_dim, n_genera, bias=False)
104
+ self.head_family = nn.Linear(embed_dim, n_families, bias=False)
105
+
106
+ # 5. Sub-Center ArcFace Metric Head (K=3 Sub-Centers)
107
+ self.subcenter_arcface = SubCenterArcFace(
108
+ embed_dim=embed_dim,
109
+ n_classes=n_species,
110
+ k_subcenters=k_subcenters,
111
+ m=0.5,
112
+ s=30.0
113
+ )
114
+
115
+ def _to_tokens(self, feat: torch.Tensor) -> torch.Tensor:
116
+ """Converts [B, C, H, W] or [B, T, C] to standard [B, T, C]."""
117
+ if feat.dim() == 4:
118
+ return feat.flatten(2).transpose(1, 2)
119
+ return feat
120
+
121
+ def forward_features(self, x: torch.Tensor):
122
+ intermediates, final_feat = self.stem.forward_intermediates(x, indices=[7, 11])
123
+
124
+ if isinstance(intermediates, (list, tuple)):
125
+ mid_raw = intermediates[0]
126
+ deep_raw = intermediates[1] if len(intermediates) > 1 else final_feat
127
+ else:
128
+ mid_raw = intermediates
129
+ deep_raw = final_feat
130
+
131
+ if isinstance(mid_raw, (list, tuple)): mid_raw = mid_raw[0]
132
+ if isinstance(deep_raw, (list, tuple)): deep_raw = deep_raw[0]
133
+
134
+ mid_tokens = self._to_tokens(mid_raw)
135
+ deep_tokens = self._to_tokens(deep_raw)
136
+
137
+ aux_loss = torch.tensor(0.0, device=x.device)
138
+ gate_logits = None
139
+ if self.use_moe and self.moe_layer is not None:
140
+ ctx = deep_tokens[:, 0]
141
+ moe_out, aux_loss, gate_logits = self.moe_layer(self.moe_norm(deep_tokens), ctx=ctx)
142
+ deep_tokens = deep_tokens + moe_out
143
+
144
+ return mid_tokens, deep_tokens, aux_loss, gate_logits
145
+
146
+ def forward(self, x: torch.Tensor, y_species: torch.Tensor = None):
147
+ mid_tokens, deep_tokens, moe_aux, _ = self.forward_features(x)
148
+
149
+ # 1. Multi-Layer Pyramid Fusion (Block 8 Texture + Block 11 Macro)
150
+ emb = self.fusion(mid_tokens, deep_tokens)
151
+
152
+ # 2. Multi-Task Linear Logits
153
+ logits_species = self.head_species(emb)
154
+ logits_genus = self.head_genus(emb)
155
+ logits_family = self.head_family(emb)
156
+
157
+ # 3. Sub-Center ArcFace Margin Logits
158
+ arcface_logits = self.subcenter_arcface(emb, y_species)
159
+
160
+ aux_dict = {"moe_aux": moe_aux}
161
+
162
+ return (
163
+ logits_species,
164
+ logits_genus,
165
+ logits_family,
166
+ arcface_logits,
167
+ emb,
168
+ aux_dict
169
+ )
src/src/models/subcenter_arcface.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sub-Center ArcFace with K Sub-Centers per Class.
3
+ Designed for fine-grained botanical vision to capture multi-modal phenology
4
+ (e.g., Flower vs. Leaf/Foliage vs. Fruit/Seed Pods) without intra-class noise.
5
+ """
6
+ import math
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+
11
+ class SubCenterArcFace(nn.Module):
12
+ """
13
+ Sub-Center ArcFace for 512-D L2 Embeddings.
14
+ Maintains K sub-centers per class (default K=3).
15
+ """
16
+ def __init__(self, embed_dim: int, n_classes: int, k_subcenters: int = 3, m: float = 0.5, s: float = 30.0, easy_margin: bool = False):
17
+ super().__init__()
18
+ self.embed_dim = embed_dim
19
+ self.n_classes = n_classes
20
+ self.k_subcenters = k_subcenters
21
+ self.m = m
22
+ self.s = s
23
+ self.easy_margin = easy_margin
24
+
25
+ # Weight matrix: [n_classes * k_subcenters, embed_dim]
26
+ self.W = nn.Parameter(torch.randn(n_classes * k_subcenters, embed_dim) * 0.01)
27
+ nn.init.xavier_uniform_(self.W)
28
+
29
+ # Precomputed angular constants
30
+ self.cos_m = math.cos(m)
31
+ self.sin_m = math.sin(m)
32
+ self.th = math.cos(math.pi - m)
33
+ self.mm = math.sin(math.pi - m) * m
34
+
35
+ def forward(self, emb: torch.Tensor, y: torch.Tensor = None) -> torch.Tensor:
36
+ """
37
+ emb: [B, D] L2 normalized embedding
38
+ y: [B] Ground-truth class labels (0 .. n_classes-1)
39
+ Returns: [B, n_classes] Logits scaled by s
40
+ """
41
+ # Normalize weights and embeddings
42
+ W_norm = F.normalize(self.W, dim=1) # [C * K, D]
43
+ emb_norm = F.normalize(emb, dim=1) # [B, D]
44
+
45
+ # Compute cosine similarity across all sub-centers: [B, C * K]
46
+ cos_all = F.linear(emb_norm, W_norm)
47
+ B = emb.size(0)
48
+
49
+ # Reshape to [B, C, K] and select the maximum cosine per class
50
+ cos_sub = cos_all.view(B, self.n_classes, self.k_subcenters)
51
+ cos_max, _ = cos_sub.max(dim=-1) # [B, C]
52
+ cos = cos_max.clamp(-1.0 + 1e-7, 1.0 - 1e-7)
53
+
54
+ # Inference mode: return scaled max cosine logits
55
+ if y is None:
56
+ return cos * self.s
57
+
58
+ # Training mode: Apply additive angular margin to ground-truth class
59
+ sin = torch.sqrt((1.0 - cos ** 2).clamp(0.0, 1.0))
60
+ cos_m = cos * self.cos_m - sin * self.sin_m
61
+
62
+ if self.easy_margin:
63
+ cos_m = torch.where(cos > 0, cos_m, cos)
64
+ else:
65
+ cos_m = torch.where(cos > self.th, cos_m, cos - self.mm)
66
+
67
+ one_hot = F.one_hot(y, num_classes=self.n_classes).bool()
68
+ logits = torch.where(one_hot, cos_m, cos)
69
+ return logits * self.s