multimodalart HF Staff commited on
Commit
8de8f6d
·
verified ·
1 Parent(s): edfe9a2

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
- title: Mopet Medical Classification
3
- emoji: 👁
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MoPET Medical Classification
3
+ emoji: 🩺
4
+ colorFrom: gray
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.22.0
 
8
  app_file: app.py
9
+ short_description: MoPET mixture-of-experts medical image classification
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # MoPET Medical Classification
15
+
16
+ This Space demos **MoPET: Parameter-Efficient Mixture-of-Experts for Unified
17
+ Medical Image Classification** (EMA4MICCAI 2026 Workshop).
18
+
19
+ MoPET adapts a *frozen* DINOv3 ViT-B/16 backbone with a learned sparse top-k
20
+ router over a heterogeneous pool of LoRA + BOFT PEFT experts injected into the
21
+ attention `qkv` projections. A single model consolidates four MedMNIST+
22
+ classification tasks (Blood, Breast, Derma, Path) behind one shared,
23
+ sparsely-routed expert pool.
24
+
25
+ ## Usage
26
+
27
+ 1. Upload a medical image (blood cell microscopy, breast ultrasound, dermoscopy,
28
+ or colon pathology histology).
29
+ 2. Select the matching task head.
30
+ 3. Click **Classify** to get per-class probabilities.
31
+
32
+ > **Disclaimer:** This is a research artifact, not a diagnostic device. Outputs
33
+ > must not be used for clinical diagnosis.
34
+
35
+ ## Links
36
+
37
+ - [Paper (arXiv)](https://arxiv.org/abs/2607.29462)
38
+ - [GitHub](https://github.com/sdoerrich97/mopet)
39
+ - [Pretrained Weights](https://huggingface.co/sdoerrich97/mopet_dinov3_unified_blood_breast_derma_path)
app.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MoPET: Parameter-Efficient Mixture-of-Experts for Unified Medical Image Classification.
2
+
3
+ A Gradio demo that loads the pretrained MoPET ``unified`` checkpoint and lets
4
+ visitors upload a medical image, pick one of four MedMNIST+ tasks, and receive
5
+ a classification from the model's per-dataset head.
6
+ """
7
+
8
+ import os
9
+
10
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
11
+
12
+ import spaces # MUST come before torch / any CUDA-touching import
13
+
14
+ import gradio as gr
15
+ import timm
16
+ import torch
17
+ import torch.nn.functional as F
18
+ from PIL import Image
19
+ from torchvision import transforms
20
+
21
+ from mopet import create_model
22
+ from mopet._factory import BACKBONES, PUBLISHED_MODELS
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Configuration
26
+ # ---------------------------------------------------------------------------
27
+
28
+ VARIANT = "unified"
29
+ PUBLISHED = PUBLISHED_MODELS[VARIANT]
30
+ DATASET_NAMES = list(PUBLISHED.datasets) # head order = dataset id
31
+
32
+ # MedMNIST+ class labels (from the official INFO table)
33
+ CLASS_LABELS: dict[str, list[str]] = {
34
+ "BloodMNIST": [
35
+ "basophil",
36
+ "eosinophil",
37
+ "erythroblast",
38
+ "immature granulocytes",
39
+ "lymphocyte",
40
+ "monocyte",
41
+ "neutrophil",
42
+ "platelet",
43
+ ],
44
+ "BreastMNIST": ["malignant", "normal, benign"],
45
+ "DermaMNIST": [
46
+ "actinic keratoses",
47
+ "basal cell carcinoma",
48
+ "benign keratosis-like lesions",
49
+ "dermatofibroma",
50
+ "melanoma",
51
+ "melanocytic nevi",
52
+ "vascular lesions",
53
+ ],
54
+ "PathMNIST": [
55
+ "adipose",
56
+ "background",
57
+ "debris",
58
+ "lymphocytes",
59
+ "mucus",
60
+ "smooth muscle",
61
+ "normal colon mucosa",
62
+ "cancer-associated stroma",
63
+ "colorectal adenocarcinoma epithelium",
64
+ ],
65
+ }
66
+
67
+ # DINOv3 backbone normalization stats (from timm pretrained config)
68
+ _tim_id = BACKBONES[PUBLISHED.backbone]
69
+ _cfg = timm.get_pretrained_cfg(_tim_id)
70
+ _MEAN = tuple(_cfg.mean)
71
+ _STD = tuple(_cfg.std)
72
+
73
+ # Preprocessing: ToTensor → Normalize → Pad to 224 → Resize to 256
74
+ # (matches the paper's `build_transform` for DINOv3 at resolution 224)
75
+ _padding = max(0, 224 - 224) # 0 when loading at 224; images are resized anyway
76
+ _pad_l = _pad_t = _padding // 2
77
+ _pad_r = _padding - _pad_l
78
+ _pad_b = _padding - _pad_t
79
+
80
+ TRANSFORM = transforms.Compose([
81
+ transforms.Resize((224, 224)),
82
+ transforms.ToTensor(),
83
+ transforms.Normalize(mean=_MEAN, std=_STD),
84
+ transforms.Pad((_pad_l, _pad_t, _pad_r, _pad_b), fill=0, padding_mode="constant"),
85
+ transforms.Resize((256, 256)),
86
+ ])
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Model loading (module scope, eager .to("cuda"))
90
+ # ---------------------------------------------------------------------------
91
+
92
+ print(f"Loading MoPET '{VARIANT}' (backbone={PUBLISHED.backbone}, datasets={DATASET_NAMES}) ...")
93
+ model = create_model(weights=VARIANT, map_location="cpu").eval()
94
+ model = model.to("cuda")
95
+ print("Model loaded and moved to CUDA.")
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Inference
100
+ # ---------------------------------------------------------------------------
101
+
102
+ @spaces.GPU(duration=60)
103
+ def classify(image: Image.Image, dataset_name: str) -> dict:
104
+ """Classify a medical image using the MoPET unified model.
105
+
106
+ Args:
107
+ image: An RGB medical image (blood cell, breast ultrasound, dermoscopy,
108
+ or colon pathology histology).
109
+ dataset_name: Which MedMNIST+ task head to use for classification.
110
+
111
+ Returns:
112
+ A label-probability dictionary for the selected task.
113
+ """
114
+ if image is None:
115
+ return {label: 0.0 for label in CLASS_LABELS[dataset_name]}
116
+
117
+ image = image.convert("RGB")
118
+ tensor = TRANSFORM(image).unsqueeze(0).to("cuda")
119
+
120
+ dataset_id = DATASET_NAMES.index(dataset_name)
121
+ dataset_ids = torch.tensor([dataset_id], device="cuda")
122
+
123
+ with torch.no_grad():
124
+ logits = model(tensor, dataset_ids)
125
+ probs = F.softmax(logits, dim=-1).squeeze(0).cpu()
126
+
127
+ labels = CLASS_LABELS[dataset_name]
128
+ num_classes = len(labels)
129
+ # The model pads to C_max; only the first ``num_classes`` entries are real
130
+ return {labels[i]: float(probs[i]) for i in range(num_classes)}
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Gradio UI
135
+ # ---------------------------------------------------------------------------
136
+
137
+ CSS = """
138
+ #col-container { max-width: 900px; margin: 0 auto; }
139
+ .dark .gradio-container { color: var(--body-text-color); }
140
+ """
141
+
142
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
143
+ gr.Markdown(
144
+ "# MoPET: Parameter-Efficient Mixture-of-Experts for Unified Medical Image Classification\n"
145
+ "Upload a medical image and select a task. The model routes the image through a "
146
+ "frozen DINOv3 backbone with a sparse mixture-of-experts adapter pool and a "
147
+ "per-task classification head.\n\n"
148
+ "[Paper](https://arxiv.org/abs/2607.29462) | "
149
+ "[GitHub](https://github.com/sdoerrich97/mopet) | "
150
+ "[Weights](https://huggingface.co/sdoerrich97/mopet_dinov3_unified_blood_breast_derma_path)"
151
+ )
152
+
153
+ with gr.Column(elem_id="col-container"):
154
+ with gr.Row():
155
+ image_input = gr.Image(type="pil", label="Medical Image", scale=3)
156
+ dataset_selector = gr.Dropdown(
157
+ choices=DATASET_NAMES,
158
+ value=DATASET_NAMES[0],
159
+ label="Task / Dataset Head",
160
+ scale=1,
161
+ )
162
+
163
+ run_btn = gr.Button("Classify", variant="primary")
164
+
165
+ label_output = gr.Label(num_top_classes=5, label="Classification")
166
+
167
+ run_btn.click(
168
+ fn=classify,
169
+ inputs=[image_input, dataset_selector],
170
+ outputs=label_output,
171
+ api_name="classify",
172
+ )
173
+
174
+ gr.Examples(
175
+ examples=[
176
+ ["bloodmnist_sample.png", "BloodMNIST"],
177
+ ["breastmnist_sample.png", "BreastMNIST"],
178
+ ["dermamnist_sample.png", "DermaMNIST"],
179
+ ["pathmnist_sample.png", "PathMNIST"],
180
+ ],
181
+ inputs=[image_input, dataset_selector],
182
+ outputs=label_output,
183
+ fn=classify,
184
+ cache_examples=True,
185
+ cache_mode="lazy",
186
+ )
187
+
188
+ demo.launch(mcp_server=True)
bloodmnist_sample.png ADDED
breastmnist_sample.png ADDED
dermamnist_sample.png ADDED
mopet/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """mopet: parameter-efficient mixture-of-experts for unified medical image classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._factory import create_model, list_pretrained, load_pretrained_weights
6
+ from ._moe import MoEModule, apply_moe_peft
7
+ from .model import MoPET, MultiTaskClassifier
8
+
9
+ __version__ = "0.1.2"
10
+ __all__ = [
11
+ "MoPET",
12
+ "MultiTaskClassifier",
13
+ "MoEModule",
14
+ "apply_moe_peft",
15
+ "create_model",
16
+ "list_pretrained",
17
+ "load_pretrained_weights",
18
+ "__version__",
19
+ ]
mopet/_factory.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Constructors for MoPET models and pretrained-weight loading.
2
+
3
+ ``create_model`` builds a MoPET model on a timm backbone with the paper's default
4
+ expert configuration; ``load_pretrained_weights`` fetches published checkpoints
5
+ from the HuggingFace Hub. The MedMNIST class-count table is kept here so the
6
+ package can size its heads without importing ``medmnist``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from typing import cast
14
+
15
+ import timm
16
+ import torch
17
+ from torch import nn
18
+
19
+ from .model import MoPET
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ #: Friendly backbone name -> timm model id (all ViT-Base/16).
24
+ BACKBONES: dict[str, str] = {
25
+ "dinov3": "vit_base_patch16_dinov3.lvd1689m",
26
+ "dino": "vit_base_patch16_224.dino",
27
+ "clip": "vit_base_patch16_clip_224",
28
+ }
29
+
30
+ #: Number of classes per MedMNIST+ 2D dataset (used to size the per-dataset heads).
31
+ MEDMNIST_NUM_CLASSES: dict[str, int] = {
32
+ "BloodMNIST": 8,
33
+ "BreastMNIST": 2,
34
+ "ChestMNIST": 14,
35
+ "DermaMNIST": 7,
36
+ "OCTMNIST": 4,
37
+ "OrganAMNIST": 11,
38
+ "OrganCMNIST": 11,
39
+ "OrganSMNIST": 11,
40
+ "PathMNIST": 9,
41
+ "PneumoniaMNIST": 2,
42
+ "RetinaMNIST": 5,
43
+ "TissueMNIST": 8,
44
+ }
45
+
46
+ #: Default heterogeneous expert pool (the MoPET configuration from the paper).
47
+ DEFAULT_EXPERT_COUNTS: dict[str, int] = {"LoRA": 20, "BOFT": 12}
48
+ DEFAULT_TOP_K: int = 12
49
+ DEFAULT_EXPERT_KWARGS: dict[str, dict[str, object]] = {
50
+ "LoRA": {"r": 8, "lora_alpha": 8},
51
+ "BOFT": {"boft_block_size": 8, "boft_n_butterfly_factor": 1},
52
+ "FourierFT": {"n_frequency": 1000},
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class PublishedModel:
58
+ """A released MoPET checkpoint: its HuggingFace repo and the exact head layout.
59
+
60
+ ``datasets`` is ordered: index i is the dataset id of the i-th classification head,
61
+ so it must match the order the checkpoint was trained with.
62
+ """
63
+
64
+ repo_id: str
65
+ backbone: str
66
+ datasets: tuple[str, ...]
67
+
68
+
69
+ #: Released MoPET models (the paper's headline checkpoints). ``create_model(weights=<name>)``
70
+ #: and ``load_pretrained_weights(model, <name>)`` resolve these and pull from the Hub.
71
+ PUBLISHED_MODELS: dict[str, PublishedModel] = {
72
+ # 4-dataset unified model (Table 2) — also the Breast-booster (Table 3, same pool).
73
+ "unified": PublishedModel(
74
+ "sdoerrich97/mopet_dinov3_unified_blood_breast_derma_path",
75
+ "dinov3",
76
+ ("BloodMNIST", "BreastMNIST", "DermaMNIST", "PathMNIST"),
77
+ ),
78
+ # Auxiliary-booster models (Table 3): a target co-trained with a hand-picked pool.
79
+ "booster-retina": PublishedModel(
80
+ "sdoerrich97/mopet_dinov3_booster_retina_breast_blood_retina_path_organa",
81
+ "dinov3",
82
+ ("BreastMNIST", "BloodMNIST", "RetinaMNIST", "PathMNIST", "OrganAMNIST"),
83
+ ),
84
+ "booster-derma": PublishedModel(
85
+ "sdoerrich97/mopet_dinov3_booster_derma_derma_blood_oct_organs",
86
+ "dinov3",
87
+ ("DermaMNIST", "BloodMNIST", "OCTMNIST", "OrganSMNIST"),
88
+ ),
89
+ }
90
+
91
+
92
+ def list_pretrained() -> dict[str, dict[str, object]]:
93
+ """List the released MoPET checkpoints and how to load them.
94
+
95
+ Returns:
96
+ A mapping from each variant name (the string passed as ``create_model(weights=...)``)
97
+ to its ``backbone``, ordered ``datasets`` (index i is the dataset id of head i), and
98
+ HuggingFace ``repo_id``. Use it to discover the available weights, e.g.::
99
+
100
+ import mopet
101
+ for name, info in mopet.list_pretrained().items():
102
+ print(name, info["datasets"])
103
+ """
104
+ return {
105
+ name: {
106
+ "backbone": pub.backbone,
107
+ "datasets": list(pub.datasets),
108
+ "repo_id": pub.repo_id,
109
+ }
110
+ for name, pub in PUBLISHED_MODELS.items()
111
+ }
112
+
113
+
114
+ def _resolve_backbone(backbone: str) -> str:
115
+ """Map a friendly backbone name to its timm id (pass-through if already an id)."""
116
+ return BACKBONES.get(backbone, backbone)
117
+
118
+
119
+ def _resolve_num_classes(datasets: list[str] | None, num_classes: list[int] | None) -> list[int]:
120
+ """Resolve the per-dataset class counts from dataset names or an explicit list."""
121
+ if num_classes is not None:
122
+ return num_classes
123
+ if datasets is not None:
124
+ try:
125
+ return [MEDMNIST_NUM_CLASSES[d] for d in datasets]
126
+ except KeyError as exc: # pragma: no cover - defensive
127
+ raise KeyError(f"Unknown MedMNIST dataset: {exc.args[0]!r}") from exc
128
+ raise ValueError("Provide either `datasets` or `num_classes` to size the heads.")
129
+
130
+
131
+ def create_model(
132
+ backbone: str = "dinov3",
133
+ datasets: list[str] | None = None,
134
+ num_classes: list[int] | None = None,
135
+ pretrained_backbone: bool = True,
136
+ weights: str | None = None,
137
+ expert_counts: dict[str, int] | None = None,
138
+ top_k: int = DEFAULT_TOP_K,
139
+ expert_kwargs: dict[str, dict[str, object]] | None = None,
140
+ controller_noise: bool = False,
141
+ map_location: str = "cpu",
142
+ ) -> MoPET:
143
+ """Build a MoPET model.
144
+
145
+ Args:
146
+ backbone: Friendly name (``"dinov3"``/``"dino"``/``"clip"``) or a timm id.
147
+ datasets: MedMNIST dataset names defining the multi-task heads (order matters).
148
+ num_classes: Explicit class counts per head; overrides ``datasets``.
149
+ pretrained_backbone: Load timm pretrained backbone weights.
150
+ weights: Either a published-variant name (a key of ``PUBLISHED_MODELS``, e.g.
151
+ ``"unified"``) to download from the Hub, or a path to a local MoPET
152
+ checkpoint. A variant name also fixes the backbone and head layout.
153
+ expert_counts: Experts per family; defaults to the paper's ``{LoRA:20, BOFT:12}``.
154
+ top_k: Experts activated per token.
155
+ expert_kwargs: Per-family peft keyword arguments; defaults to the paper's.
156
+ controller_noise: Whether routers add exploration noise in training.
157
+ map_location: Device mapping used when loading ``weights``.
158
+
159
+ Returns:
160
+ The constructed :class:`~mopet.model.MoPET`.
161
+ """
162
+ published = PUBLISHED_MODELS.get(weights) if weights is not None else None
163
+ if published is not None:
164
+ # A released variant fixes the backbone and head layout. Published checkpoints carry
165
+ # only the trainable parameters (adapters, router, heads); the frozen backbone is
166
+ # reconstructed from the timm pretrained weights, so keep `pretrained_backbone=True`.
167
+ backbone = published.backbone
168
+ datasets = list(published.datasets)
169
+ num_classes = None
170
+ pretrained_backbone = True
171
+
172
+ timm_id = _resolve_backbone(backbone)
173
+ heads = _resolve_num_classes(datasets, num_classes)
174
+ backbone_module = cast(
175
+ nn.Module, timm.create_model(timm_id, pretrained=pretrained_backbone, num_classes=0)
176
+ )
177
+
178
+ model = MoPET(
179
+ backbone=backbone_module,
180
+ num_classes=heads,
181
+ expert_counts=expert_counts or dict(DEFAULT_EXPERT_COUNTS),
182
+ top_k=top_k,
183
+ expert_kwargs=expert_kwargs or {k: dict(v) for k, v in DEFAULT_EXPERT_KWARGS.items()},
184
+ controller_noise=controller_noise,
185
+ )
186
+ if published is not None:
187
+ _load_into(model, _load_state_dict(_download_variant(published), map_location=map_location))
188
+ elif weights is not None:
189
+ _load_into(model, _load_state_dict(weights, map_location=map_location))
190
+ return model
191
+
192
+
193
+ def load_pretrained_weights(model: MoPET, variant: str, map_location: str = "cpu") -> MoPET:
194
+ """Download and load a published MoPET checkpoint from the HuggingFace Hub.
195
+
196
+ Args:
197
+ model: A MoPET model whose head layout matches ``variant`` (build it with the same
198
+ ``datasets``/``backbone``, e.g. via ``create_model(weights=variant)``).
199
+ variant: A key of ``PUBLISHED_MODELS`` (e.g. ``"unified"``, ``"booster-retina"``).
200
+ map_location: Device mapping for the loaded tensors.
201
+
202
+ Returns:
203
+ ``model`` with the checkpoint loaded in place.
204
+ """
205
+ if variant not in PUBLISHED_MODELS:
206
+ raise KeyError(
207
+ f"No published weights for {variant!r}. Available: {sorted(PUBLISHED_MODELS)}."
208
+ )
209
+ _load_into(model, _load_state_dict(_download_variant(PUBLISHED_MODELS[variant]), map_location))
210
+ return model
211
+
212
+
213
+ def _download_variant(published: PublishedModel) -> str:
214
+ """Download a published variant's weights from the Hub, preferring safetensors."""
215
+ from huggingface_hub import hf_hub_download
216
+
217
+ try:
218
+ return hf_hub_download(published.repo_id, filename="model.safetensors")
219
+ except Exception: # noqa: BLE001 - fall back to a torch checkpoint
220
+ return hf_hub_download(published.repo_id, filename="model.pth")
221
+
222
+
223
+ def _load_state_dict(path: str, map_location: str = "cpu") -> dict[str, torch.Tensor]:
224
+ """Load a state dict from a ``.safetensors`` or torch checkpoint file."""
225
+ if path.endswith(".safetensors"):
226
+ from safetensors.torch import load_file
227
+
228
+ return load_file(path, device=map_location)
229
+ obj = torch.load(path, map_location=map_location, weights_only=False)
230
+ state_dict = obj.get("state_dict", obj) if isinstance(obj, dict) else obj
231
+ return {k.removeprefix("module."): v for k, v in state_dict.items()}
232
+
233
+
234
+ def _load_into(model: MoPET, state_dict: dict[str, torch.Tensor]) -> None:
235
+ """Load trainable parameters into ``model``, tolerating the frozen backbone gap."""
236
+ missing, unexpected = model.load_state_dict(state_dict, strict=False)
237
+ if unexpected:
238
+ logger.warning("Unexpected keys when loading MoPET weights: %s", unexpected[:8])
239
+ logger.info(
240
+ "Loaded MoPET weights (%d missing, %d unexpected keys).", len(missing), len(unexpected)
241
+ )
mopet/_moe.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse mixture-of-experts over parameter-efficient adapters.
2
+
3
+ The core of *MoPET*: a learnable top-k router dispatches each token to a small
4
+ subset of a heterogeneous pool of PEFT experts (LoRA / BOFT / FourierFT) that
5
+ wrap a single frozen projection. Each expert returns the full projection output
6
+ (frozen base plus its low-rank delta); because the router weights are a softmax
7
+ over the selected experts, their sum reduces to the frozen projection plus a
8
+ convex combination of the active experts' deltas.
9
+
10
+ This module is deliberately free of any dataset or configuration framework
11
+ dependency: expert counts, the routing width, and the per-family adapter
12
+ hyperparameters are passed in explicitly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from peft.tuners.boft.layer import Linear as BOFTLinear
22
+ from peft.tuners.fourierft import FourierFTLinear
23
+ from peft.tuners.lora import Linear as LoRALinear
24
+ from torch import nn
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ #: Maps an expert-family name to the peft layer class that implements it. The
29
+ #: internal ``peft`` layer classes are used directly (as in the original thesis
30
+ #: code); this is why ``peft`` is pinned exactly.
31
+ _EXPERT_CLASSES: dict[str, type[nn.Module]] = {
32
+ "LoRA": LoRALinear,
33
+ "BOFT": BOFTLinear,
34
+ "FourierFT": FourierFTLinear,
35
+ }
36
+
37
+
38
+ class LinearTopKGating(nn.Module):
39
+ """Linear router producing per-expert routing logits for each token.
40
+
41
+ Args:
42
+ input_dim: Token feature dimension.
43
+ num_experts: Size of the expert pool to route over.
44
+ noisy: If ``True``, additive standard-normal noise is applied to the
45
+ logits during training only, to encourage exploration.
46
+ """
47
+
48
+ def __init__(self, input_dim: int, num_experts: int, noisy: bool = False) -> None:
49
+ super().__init__()
50
+ self.num_experts = num_experts
51
+ self.noisy = noisy
52
+ # g(x) = W x, no bias.
53
+ self.gate = nn.Linear(input_dim, num_experts, bias=False)
54
+
55
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
56
+ """Return unnormalized routing logits of shape ``(..., num_experts)``."""
57
+ logits = self.gate(x)
58
+ if self.noisy and self.training:
59
+ logits = logits + torch.randn_like(logits)
60
+ return logits
61
+
62
+
63
+ class MoEModule(nn.Module):
64
+ """Replaces a single frozen projection with a routed pool of PEFT experts.
65
+
66
+ Injected in place of an attention ``qkv`` projection. The wrapped frozen
67
+ linear is shared as the base layer of every expert, so each expert output is
68
+ ``base(x) + delta_i(x)`` and the routed, softmax-weighted sum is
69
+ ``base(x) + sum_i w_i * delta_i(x)`` over the active experts.
70
+
71
+ Args:
72
+ base_linear: The frozen projection to adapt (e.g. attention ``qkv``).
73
+ expert_counts: Number of experts per family, e.g. ``{"LoRA": 20, "BOFT": 12}``.
74
+ top_k: Number of experts activated per token.
75
+ expert_kwargs: Per-family keyword arguments forwarded to the peft layer,
76
+ e.g. ``{"LoRA": {"r": 8, "lora_alpha": 8}, "BOFT": {...}}``.
77
+ controller_noise: Whether the router adds exploration noise in training.
78
+ adapter_name: Adapter name handed to the peft layers.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ base_linear: nn.Linear,
84
+ expert_counts: dict[str, int],
85
+ top_k: int,
86
+ expert_kwargs: dict[str, dict[str, object]],
87
+ controller_noise: bool = False,
88
+ adapter_name: str = "default",
89
+ ) -> None:
90
+ super().__init__()
91
+ self.top_k = top_k
92
+ in_dim = base_linear.in_features
93
+ self.out_features = base_linear.out_features
94
+
95
+ self.experts = nn.ModuleList()
96
+ for family, count in expert_counts.items():
97
+ if family not in _EXPERT_CLASSES:
98
+ raise ValueError(f"Unsupported expert family: {family!r}")
99
+ expert_cls = _EXPERT_CLASSES[family]
100
+ kwargs = expert_kwargs.get(family, {})
101
+ for _ in range(count):
102
+ self.experts.append(
103
+ expert_cls(base_layer=base_linear, adapter_name=adapter_name, **kwargs)
104
+ )
105
+
106
+ self.num_experts = len(self.experts)
107
+ self.controller = LinearTopKGating(
108
+ input_dim=in_dim, num_experts=self.num_experts, noisy=controller_noise
109
+ )
110
+ self._aux_loss: torch.Tensor | float = 0.0
111
+
112
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
113
+ """Route tokens through the top-k experts.
114
+
115
+ Args:
116
+ x: Token features of shape ``(B, T, D)``.
117
+
118
+ Returns:
119
+ Adapted projection output of shape ``(B, T, out_features)``.
120
+ """
121
+ b, t, _ = x.shape
122
+ logits = self.controller(x) # (B, T, E)
123
+
124
+ topk_vals, topk_idx = torch.topk(logits, self.top_k, dim=-1) # (B, T, k)
125
+ topk_weights = F.softmax(topk_vals, dim=-1) # (B, T, k)
126
+
127
+ output = torch.zeros(b, t, self.out_features, device=x.device, dtype=x.dtype)
128
+
129
+ for expert_id, expert in enumerate(self.experts):
130
+ mask = topk_idx == expert_id # (B, T, k)
131
+ if not mask.any():
132
+ continue
133
+ b_idx, t_idx, k_idx = mask.nonzero(as_tuple=True)
134
+ routed_x = x[b_idx, t_idx] # (N, D)
135
+ expert_out = expert(routed_x) # (N, out_features)
136
+ weights = topk_weights[b_idx, t_idx, k_idx].unsqueeze(-1) # (N, 1)
137
+ output[b_idx, t_idx] += expert_out * weights
138
+
139
+ self._aux_loss = self.load_balancing_loss(logits, topk_idx)
140
+ return output
141
+
142
+ def load_balancing_loss(self, logits: torch.Tensor, topk_idx: torch.Tensor) -> torch.Tensor:
143
+ """DeepSeekMoE/Switch-style load-balancing loss.
144
+
145
+ Args:
146
+ logits: Router logits of shape ``(B, T, E)``.
147
+ topk_idx: Selected expert indices of shape ``(B, T, k)``.
148
+
149
+ Returns:
150
+ Scalar load-balancing loss ``E * sum_i importance_i * load_i``.
151
+ """
152
+ b, t, num_experts = logits.shape
153
+ k = topk_idx.shape[-1]
154
+
155
+ probs = torch.softmax(logits, dim=-1) # (B, T, E)
156
+ importance = probs.mean(dim=(0, 1)) # (E,)
157
+
158
+ one_hot = F.one_hot(topk_idx, num_classes=num_experts) # (B, T, k, E)
159
+ load = one_hot.sum(dim=(0, 1, 2)).float() / (b * t * k) # (E,)
160
+
161
+ return num_experts * torch.sum(importance * load)
162
+
163
+ def get_aux_loss(self) -> torch.Tensor | float:
164
+ """Return the load-balancing loss from the most recent forward pass."""
165
+ return self._aux_loss
166
+
167
+
168
+ def apply_moe_peft(
169
+ model: nn.Module,
170
+ expert_counts: dict[str, int],
171
+ top_k: int,
172
+ expert_kwargs: dict[str, dict[str, object]],
173
+ controller_noise: bool = False,
174
+ ) -> nn.Module:
175
+ """Replace every attention ``qkv`` projection in ``model`` with a ``MoEModule``.
176
+
177
+ Args:
178
+ model: A timm vision transformer (modified in place).
179
+ expert_counts: Number of experts per family.
180
+ top_k: Number of experts activated per token.
181
+ expert_kwargs: Per-family peft keyword arguments.
182
+ controller_noise: Whether routers add exploration noise in training.
183
+
184
+ Returns:
185
+ The same ``model``, with its ``qkv`` layers swapped for routed experts.
186
+ """
187
+ named_modules = dict(model.named_modules())
188
+ for name, module in list(model.named_modules()):
189
+ if not name.endswith("qkv"):
190
+ continue
191
+ if "." in name:
192
+ parent_name, child_name = name.rsplit(".", 1)
193
+ parent = named_modules[parent_name]
194
+ else:
195
+ parent, child_name = model, name
196
+
197
+ moe_layer = MoEModule(
198
+ base_linear=module,
199
+ expert_counts=expert_counts,
200
+ top_k=top_k,
201
+ expert_kwargs=expert_kwargs,
202
+ controller_noise=controller_noise,
203
+ )
204
+ setattr(parent, child_name, moe_layer)
205
+
206
+ return model
mopet/model.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The MoPET model: a frozen foundation backbone adapted by a routed PEFT expert pool.
2
+
3
+ ``MoPET`` freezes a timm vision-transformer backbone, replaces its attention
4
+ ``qkv`` projections with sparse mixture-of-experts adapters (see :mod:`mopet._moe`),
5
+ and attaches one classification head per dataset so a single network serves many
6
+ heterogeneous medical-image classification tasks at once.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import cast
13
+
14
+ import torch
15
+ from torch import nn
16
+
17
+ from ._moe import MoEModule, apply_moe_peft
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MultiTaskClassifier(nn.Module):
23
+ """One linear classification head per dataset with padded, per-sample routing.
24
+
25
+ Args:
26
+ num_classes: Number of classes for each dataset, in a fixed order; the
27
+ index into this list is the dataset identifier used at forward time.
28
+ input_dim: Dimension of the shared backbone feature.
29
+ """
30
+
31
+ def __init__(self, num_classes: list[int], input_dim: int) -> None:
32
+ super().__init__()
33
+ self.classifiers = nn.ModuleList(
34
+ nn.Linear(in_features=input_dim, out_features=n) for n in num_classes
35
+ )
36
+
37
+ def forward(self, x: torch.Tensor, dataset_ids: torch.Tensor) -> torch.Tensor:
38
+ """Route each sample to its dataset head.
39
+
40
+ Args:
41
+ x: Shared features of shape ``(B, D)``.
42
+ dataset_ids: Dataset index per sample, shape ``(B,)``.
43
+
44
+ Returns:
45
+ Logits of shape ``(B, C_max)`` where ``C_max`` is the largest class
46
+ count present in the batch; unused entries are padded with ``-1e9``.
47
+ """
48
+ b = x.size(0)
49
+ present: list[int] = torch.unique(dataset_ids).tolist()
50
+ heads = [cast(nn.Linear, self.classifiers[task]) for task in present]
51
+ max_classes = max(head.out_features for head in heads)
52
+
53
+ output = torch.full((b, max_classes), fill_value=-1e9, device=x.device, dtype=x.dtype)
54
+ for task, head in zip(present, heads, strict=True):
55
+ idx = (dataset_ids == task).nonzero(as_tuple=True)[0]
56
+ logits = head(x[idx])
57
+ output[idx, : logits.size(1)] = logits
58
+ return output
59
+
60
+
61
+ class MoPET(nn.Module):
62
+ """Frozen backbone + routed PEFT experts + per-dataset heads.
63
+
64
+ Args:
65
+ backbone: A timm vision transformer providing ``(B, D)`` pooled features
66
+ once its own head is removed. Frozen in place.
67
+ num_classes: Class count per dataset (defines the multi-task heads).
68
+ expert_counts: Number of experts per family, e.g. ``{"LoRA": 20, "BOFT": 12}``.
69
+ top_k: Number of experts activated per token.
70
+ expert_kwargs: Per-family peft keyword arguments.
71
+ controller_noise: Whether routers add exploration noise in training.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ backbone: nn.Module,
77
+ num_classes: list[int],
78
+ expert_counts: dict[str, int],
79
+ top_k: int,
80
+ expert_kwargs: dict[str, dict[str, object]],
81
+ controller_noise: bool = False,
82
+ ) -> None:
83
+ super().__init__()
84
+ self.pretrained_cfg = getattr(backbone, "pretrained_cfg", None)
85
+
86
+ backbone.head = nn.Identity()
87
+ for param in backbone.parameters():
88
+ param.requires_grad = False
89
+
90
+ self.backbone = apply_moe_peft(
91
+ backbone,
92
+ expert_counts=expert_counts,
93
+ top_k=top_k,
94
+ expert_kwargs=expert_kwargs,
95
+ controller_noise=controller_noise,
96
+ )
97
+ input_dim = int(cast(int, backbone.num_features))
98
+ self.head = MultiTaskClassifier(num_classes=num_classes, input_dim=input_dim)
99
+
100
+ def forward(self, x: torch.Tensor, dataset_ids: torch.Tensor) -> torch.Tensor:
101
+ """Classify a batch of images tagged with their dataset ids.
102
+
103
+ Args:
104
+ x: Input images of shape ``(B, C, H, W)``.
105
+ dataset_ids: Dataset index per sample, shape ``(B,)``.
106
+
107
+ Returns:
108
+ Padded per-dataset logits of shape ``(B, C_max)``.
109
+ """
110
+ features = self.backbone(x)
111
+ return self.head(features, dataset_ids)
112
+
113
+ def get_aux_loss(self) -> torch.Tensor | float:
114
+ """Sum the load-balancing loss over all routed layers from the last forward."""
115
+ aux_loss: torch.Tensor | float = 0.0
116
+ for module in self.backbone.modules():
117
+ if isinstance(module, MoEModule):
118
+ aux_loss = aux_loss + module.get_aux_loss()
119
+ return aux_loss
pathmnist_sample.png ADDED
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ torchvision
2
+ timm>=1.0.22,<2
3
+ peft>=0.18,<0.19
4
+ safetensors>=0.5
5
+ pillow>=10.0