Image Classification
LiteRT
LiteRT
ONNX
English
vision
botany
western-australia
dinov3
mixture-of-experts
adaround
fp8
int8
android
biodiversity
flora
Instructions to use thenukegun10x/PLantDetect-WA with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LiteRT
How to use thenukegun10x/PLantDetect-WA with LiteRT:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
Add core src/ package for direct model instantiation
Browse files- src/src/data/__pycache__/plant.cpython-312.pyc +0 -0
- src/src/data/__pycache__/taxonomy.cpython-312.pyc +0 -0
- src/src/data/plant.py +75 -0
- src/src/data/taxonomy.py +150 -0
- src/src/models/__pycache__/moe_swiglu_v2.cpython-312.pyc +0 -0
- src/src/models/__pycache__/moe_vit.cpython-312.pyc +0 -0
- src/src/models/__pycache__/mor_vit.cpython-312.pyc +0 -0
- src/src/models/__pycache__/plant_vit.cpython-312.pyc +0 -0
- src/src/models/__pycache__/plant_vit_v2.cpython-312.pyc +0 -0
- src/src/models/__pycache__/subcenter_arcface.cpython-312.pyc +0 -0
- src/src/models/moe_swiglu_v2.py +136 -0
- src/src/models/moe_vit.py +452 -0
- src/src/models/mor_cnn.py +239 -0
- src/src/models/mor_vit.py +386 -0
- src/src/models/mricore_stem.py +108 -0
- src/src/models/plant_vit.py +137 -0
- src/src/models/plant_vit_v2.py +169 -0
- src/src/models/subcenter_arcface.py +69 -0
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
|