ARotting commited on
Commit
8822739
·
verified ·
1 Parent(s): 8e53fc0

Publish Top-2 sparse mixture-of-experts and dense control

Browse files
README.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ task_categories:
4
+ - image-classification
5
+ tags:
6
+ - mixture-of-experts
7
+ - sparse-routing
8
+ - load-balancing
9
+ - pytorch
10
+ ---
11
+
12
+ # Pocket MoE
13
+
14
+ Pocket MoE is a compact top-2 mixture-of-experts classifier. A shared image encoder
15
+ feeds four specialist MLPs while a learned router activates two experts for each
16
+ example.
17
+
18
+ The benchmark compares an MoE and a parameter-matched dense MLP on the same split,
19
+ then reports expert utilization, routing entropy, and each digit's dominant expert.
20
+
21
+ ## Reproduce
22
+
23
+ ```powershell
24
+ uv run python projects/tiny-vision-foundry/prepare_data.py
25
+ uv run python projects/pocket-moe/train.py
26
+ ```
27
+
28
+ Sparse routing here demonstrates conditional computation; it does not reduce the
29
+ stored checkpoint size because all four experts remain available.
30
+
31
+ ## Verified results
32
+
33
+ | Model | Stored parameters | Active parameters | Test accuracy | Macro F1 |
34
+ | --- | ---: | ---: | ---: | ---: |
35
+ | Dense control | 5,490 | 5,490 | 96.30% | 0.9618 |
36
+ | Top-2 Pocket MoE | 5,004 | **3,608** | 96.30% | **0.9625** |
37
+
38
+ The MoE activates 34.3% fewer parameters per example than the dense control without
39
+ losing accuracy. Experts specialized strongly: digit `0` routed 86.5% to expert 3,
40
+ digit `2` routed 83.1% to expert 2, and digit `7` routed 90.9% to expert 0.
41
+
42
+ Utilization was not perfectly balanced: expert shares were 44.2%, 12.4%, 19.6%, and
43
+ 23.8%, for a coefficient of variation of 0.473. The stored MoE still includes all four
44
+ experts, so conditional compute should not be confused with checkpoint compression.
dense-control/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:457fabfa0b0762393ca7447de7494ca85019b33beede5c81a94c941f877ad5fe
3
+ size 22432
pocket-moe/evaluation.json ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "Pocket MoE",
3
+ "experts": 4,
4
+ "active_experts_per_example": 2,
5
+ "stored_parameters": 5004,
6
+ "active_parameters_per_example": 3608,
7
+ "dense_control_parameters": 5490,
8
+ "moe_training": {
9
+ "best_epoch": 31,
10
+ "best_validation_accuracy": 0.9555555555555556
11
+ },
12
+ "dense_training": {
13
+ "best_epoch": 58,
14
+ "best_validation_accuracy": 0.9666666666666667
15
+ },
16
+ "moe_test": {
17
+ "accuracy": 0.9629629629629629,
18
+ "macro_f1": 0.9624784837803706,
19
+ "expert_utilization": [
20
+ 0.4422966241836548,
21
+ 0.12372409552335739,
22
+ 0.1955741047859192,
23
+ 0.23840512335300446
24
+ ],
25
+ "utilization_coefficient_of_variation": 0.47337183356285095,
26
+ "mean_router_entropy": 0.897014319896698,
27
+ "maximum_router_entropy": 1.3862943611198906,
28
+ "dominant_expert_by_digit": {
29
+ "0": {
30
+ "expert": 3,
31
+ "routing_share": 0.8653278946876526
32
+ },
33
+ "1": {
34
+ "expert": 0,
35
+ "routing_share": 0.7711800336837769
36
+ },
37
+ "2": {
38
+ "expert": 2,
39
+ "routing_share": 0.8314319849014282
40
+ },
41
+ "3": {
42
+ "expert": 2,
43
+ "routing_share": 0.7494029998779297
44
+ },
45
+ "4": {
46
+ "expert": 3,
47
+ "routing_share": 0.7010031938552856
48
+ },
49
+ "5": {
50
+ "expert": 0,
51
+ "routing_share": 0.6955094933509827
52
+ },
53
+ "6": {
54
+ "expert": 1,
55
+ "routing_share": 0.6472891569137573
56
+ },
57
+ "7": {
58
+ "expert": 0,
59
+ "routing_share": 0.909274697303772
60
+ },
61
+ "8": {
62
+ "expert": 0,
63
+ "routing_share": 0.5719929337501526
64
+ },
65
+ "9": {
66
+ "expert": 0,
67
+ "routing_share": 0.7218619585037231
68
+ }
69
+ }
70
+ },
71
+ "dense_control_test": {
72
+ "accuracy": 0.9629629629629629,
73
+ "macro_f1": 0.9618359470717962
74
+ },
75
+ "accuracy_delta": 0.0
76
+ }
pocket-moe/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f92fa5214a1d4d237c9baf3815f7f0285e14f19813b2ea8fe31224e4e41c120
3
+ size 21592
source/model.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+ from torch.nn import functional as F
6
+
7
+
8
+ class PocketMoE(nn.Module):
9
+ def __init__(self, experts: int = 4, top_k: int = 2) -> None:
10
+ super().__init__()
11
+ self.expert_count = experts
12
+ self.top_k = top_k
13
+ self.encoder = nn.Sequential(
14
+ nn.Linear(64, 32),
15
+ nn.GELU(),
16
+ )
17
+ self.router = nn.Linear(32, experts)
18
+ self.experts = nn.ModuleList(
19
+ [
20
+ nn.Sequential(
21
+ nn.Linear(32, 16),
22
+ nn.GELU(),
23
+ nn.Linear(16, 10),
24
+ )
25
+ for _ in range(experts)
26
+ ]
27
+ )
28
+
29
+ def forward(
30
+ self,
31
+ pixels: torch.Tensor,
32
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
33
+ hidden = self.encoder(pixels)
34
+ router_probabilities = F.softmax(self.router(hidden), dim=1)
35
+ top_probabilities, top_indices = router_probabilities.topk(
36
+ self.top_k,
37
+ dim=1,
38
+ )
39
+ sparse_weights = torch.zeros_like(router_probabilities).scatter(
40
+ 1,
41
+ top_indices,
42
+ top_probabilities,
43
+ )
44
+ sparse_weights = sparse_weights / sparse_weights.sum(dim=1, keepdim=True)
45
+ expert_logits = torch.stack(
46
+ [expert(hidden) for expert in self.experts],
47
+ dim=1,
48
+ )
49
+ logits = (expert_logits * sparse_weights.unsqueeze(-1)).sum(dim=1)
50
+ return logits, router_probabilities, sparse_weights
51
+
52
+
53
+ class DenseControl(nn.Module):
54
+ def __init__(self) -> None:
55
+ super().__init__()
56
+ self.network = nn.Sequential(
57
+ nn.Linear(64, 48),
58
+ nn.GELU(),
59
+ nn.Linear(48, 40),
60
+ nn.GELU(),
61
+ nn.Linear(40, 10),
62
+ )
63
+
64
+ def forward(self, pixels: torch.Tensor) -> torch.Tensor:
65
+ return self.network(pixels)
66
+
67
+
68
+ def parameter_count(model: nn.Module) -> int:
69
+ return sum(parameter.numel() for parameter in model.parameters())
70
+
71
+
72
+ def active_parameter_count(model: PocketMoE) -> int:
73
+ shared = sum(parameter.numel() for parameter in model.encoder.parameters())
74
+ router = sum(parameter.numel() for parameter in model.router.parameters())
75
+ experts = sorted(
76
+ [
77
+ sum(parameter.numel() for parameter in expert.parameters())
78
+ for expert in model.experts
79
+ ],
80
+ reverse=True,
81
+ )
82
+ return shared + router + sum(experts[: model.top_k])
source/train.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+ import trackio
11
+ from model import (
12
+ DenseControl,
13
+ PocketMoE,
14
+ active_parameter_count,
15
+ parameter_count,
16
+ )
17
+ from safetensors.torch import save_file
18
+ from sklearn.metrics import accuracy_score, f1_score
19
+ from torch.nn import functional as F
20
+ from torch.utils.data import DataLoader, TensorDataset
21
+
22
+ PROJECT_DIR = Path(__file__).resolve().parent
23
+ ROOT_DIR = PROJECT_DIR.parents[1]
24
+ DATA_DIR = ROOT_DIR / "projects" / "tiny-vision-foundry" / "data"
25
+ ARTIFACT_DIR = PROJECT_DIR / "artifacts"
26
+
27
+
28
+ def seed_everything(seed: int) -> None:
29
+ random.seed(seed)
30
+ np.random.seed(seed)
31
+ torch.manual_seed(seed)
32
+
33
+
34
+ def load_split(name: str) -> tuple[torch.Tensor, torch.Tensor]:
35
+ frame = pd.read_parquet(DATA_DIR / f"{name}.parquet")
36
+ pixels = np.stack(frame["image"].to_numpy()).astype(np.float32) / 16.0
37
+ labels = frame["label"].to_numpy(dtype=np.int64, copy=True)
38
+ return torch.from_numpy(pixels), torch.from_numpy(labels)
39
+
40
+
41
+ @torch.inference_mode()
42
+ def evaluate_dense(
43
+ model: DenseControl,
44
+ pixels: torch.Tensor,
45
+ labels: torch.Tensor,
46
+ ) -> dict:
47
+ model.eval()
48
+ predictions = model(pixels).argmax(dim=1).numpy()
49
+ return {
50
+ "accuracy": float(accuracy_score(labels.numpy(), predictions)),
51
+ "macro_f1": float(f1_score(labels.numpy(), predictions, average="macro")),
52
+ }
53
+
54
+
55
+ @torch.inference_mode()
56
+ def evaluate_moe(
57
+ model: PocketMoE,
58
+ pixels: torch.Tensor,
59
+ labels: torch.Tensor,
60
+ ) -> dict:
61
+ model.eval()
62
+ logits, router_probabilities, sparse_weights = model(pixels)
63
+ predictions = logits.argmax(dim=1).numpy()
64
+ utilization = sparse_weights.mean(dim=0).numpy()
65
+ dominant_by_class = {}
66
+ for label in range(10):
67
+ selected = labels == label
68
+ class_utilization = sparse_weights[selected].mean(dim=0)
69
+ dominant_by_class[str(label)] = {
70
+ "expert": int(class_utilization.argmax()),
71
+ "routing_share": float(class_utilization.max()),
72
+ }
73
+ entropy = -(
74
+ router_probabilities * torch.log(torch.clamp(router_probabilities, min=1e-9))
75
+ ).sum(dim=1)
76
+ return {
77
+ "accuracy": float(accuracy_score(labels.numpy(), predictions)),
78
+ "macro_f1": float(f1_score(labels.numpy(), predictions, average="macro")),
79
+ "expert_utilization": utilization.tolist(),
80
+ "utilization_coefficient_of_variation": float(
81
+ utilization.std() / utilization.mean()
82
+ ),
83
+ "mean_router_entropy": float(entropy.mean()),
84
+ "maximum_router_entropy": float(np.log(model.expert_count)),
85
+ "dominant_expert_by_digit": dominant_by_class,
86
+ }
87
+
88
+
89
+ def train_dense(
90
+ train_pixels: torch.Tensor,
91
+ train_labels: torch.Tensor,
92
+ validation_pixels: torch.Tensor,
93
+ validation_labels: torch.Tensor,
94
+ ) -> tuple[DenseControl, dict]:
95
+ seed_everything(2042)
96
+ model = DenseControl()
97
+ loader = DataLoader(
98
+ TensorDataset(train_pixels, train_labels),
99
+ batch_size=64,
100
+ shuffle=True,
101
+ generator=torch.Generator().manual_seed(2042),
102
+ )
103
+ optimizer = torch.optim.AdamW(model.parameters(), lr=0.0025, weight_decay=0.002)
104
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=90)
105
+ best_accuracy = -1.0
106
+ best_epoch = 0
107
+ best_state = None
108
+ for epoch in range(1, 91):
109
+ model.train()
110
+ for pixels, labels in loader:
111
+ loss = F.cross_entropy(model(pixels), labels)
112
+ optimizer.zero_grad(set_to_none=True)
113
+ loss.backward()
114
+ optimizer.step()
115
+ scheduler.step()
116
+ validation = evaluate_dense(model, validation_pixels, validation_labels)
117
+ if validation["accuracy"] > best_accuracy:
118
+ best_accuracy = validation["accuracy"]
119
+ best_epoch = epoch
120
+ best_state = {
121
+ key: value.detach().cpu().clone()
122
+ for key, value in model.state_dict().items()
123
+ }
124
+ assert best_state is not None
125
+ model.load_state_dict(best_state)
126
+ return model, {
127
+ "best_epoch": best_epoch,
128
+ "best_validation_accuracy": best_accuracy,
129
+ }
130
+
131
+
132
+ def train_moe(
133
+ train_pixels: torch.Tensor,
134
+ train_labels: torch.Tensor,
135
+ validation_pixels: torch.Tensor,
136
+ validation_labels: torch.Tensor,
137
+ ) -> tuple[PocketMoE, dict]:
138
+ seed_everything(2042)
139
+ model = PocketMoE()
140
+ loader = DataLoader(
141
+ TensorDataset(train_pixels, train_labels),
142
+ batch_size=64,
143
+ shuffle=True,
144
+ generator=torch.Generator().manual_seed(2042),
145
+ )
146
+ optimizer = torch.optim.AdamW(model.parameters(), lr=0.0025, weight_decay=0.002)
147
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=90)
148
+ best_accuracy = -1.0
149
+ best_epoch = 0
150
+ best_state = None
151
+ for epoch in range(1, 91):
152
+ model.train()
153
+ losses = []
154
+ for pixels, labels in loader:
155
+ logits, router_probabilities, _ = model(pixels)
156
+ classification = F.cross_entropy(logits, labels)
157
+ importance = router_probabilities.mean(dim=0)
158
+ balance = ((importance * model.expert_count - 1) ** 2).mean()
159
+ loss = classification + 0.025 * balance
160
+ optimizer.zero_grad(set_to_none=True)
161
+ loss.backward()
162
+ optimizer.step()
163
+ losses.append(loss.item())
164
+ scheduler.step()
165
+ validation = evaluate_moe(model, validation_pixels, validation_labels)
166
+ if validation["accuracy"] > best_accuracy:
167
+ best_accuracy = validation["accuracy"]
168
+ best_epoch = epoch
169
+ best_state = {
170
+ key: value.detach().cpu().clone()
171
+ for key, value in model.state_dict().items()
172
+ }
173
+ if epoch == 1 or epoch % 10 == 0:
174
+ trackio.log(
175
+ {
176
+ "epoch": epoch,
177
+ "moe_train_loss": float(np.mean(losses)),
178
+ "moe_validation_accuracy": validation["accuracy"],
179
+ "moe_utilization_cv": validation[
180
+ "utilization_coefficient_of_variation"
181
+ ],
182
+ "learning_rate": scheduler.get_last_lr()[0],
183
+ }
184
+ )
185
+ assert best_state is not None
186
+ model.load_state_dict(best_state)
187
+ return model, {
188
+ "best_epoch": best_epoch,
189
+ "best_validation_accuracy": best_accuracy,
190
+ }
191
+
192
+
193
+ def main() -> None:
194
+ train_pixels, train_labels = load_split("train")
195
+ validation_pixels, validation_labels = load_split("validation")
196
+ test_pixels, test_labels = load_split("test")
197
+ trackio.init(
198
+ project="pocket-moe",
199
+ name="top2-versus-dense-v1",
200
+ config={
201
+ "experts": 4,
202
+ "active_experts": 2,
203
+ "moe_parameters": parameter_count(PocketMoE()),
204
+ "dense_parameters": parameter_count(DenseControl()),
205
+ },
206
+ )
207
+ dense, dense_training = train_dense(
208
+ train_pixels,
209
+ train_labels,
210
+ validation_pixels,
211
+ validation_labels,
212
+ )
213
+ moe, moe_training = train_moe(
214
+ train_pixels,
215
+ train_labels,
216
+ validation_pixels,
217
+ validation_labels,
218
+ )
219
+ dense_test = evaluate_dense(dense, test_pixels, test_labels)
220
+ moe_test = evaluate_moe(moe, test_pixels, test_labels)
221
+ results = {
222
+ "model": "Pocket MoE",
223
+ "experts": moe.expert_count,
224
+ "active_experts_per_example": moe.top_k,
225
+ "stored_parameters": parameter_count(moe),
226
+ "active_parameters_per_example": active_parameter_count(moe),
227
+ "dense_control_parameters": parameter_count(dense),
228
+ "moe_training": moe_training,
229
+ "dense_training": dense_training,
230
+ "moe_test": moe_test,
231
+ "dense_control_test": dense_test,
232
+ "accuracy_delta": moe_test["accuracy"] - dense_test["accuracy"],
233
+ }
234
+ trackio.log(
235
+ {
236
+ "moe_test_accuracy": moe_test["accuracy"],
237
+ "dense_test_accuracy": dense_test["accuracy"],
238
+ "moe_test_utilization_cv": moe_test["utilization_coefficient_of_variation"],
239
+ }
240
+ )
241
+ trackio.finish()
242
+ moe_dir = ARTIFACT_DIR / "pocket-moe"
243
+ dense_dir = ARTIFACT_DIR / "dense-control"
244
+ moe_dir.mkdir(parents=True, exist_ok=True)
245
+ dense_dir.mkdir(parents=True, exist_ok=True)
246
+ save_file(moe.state_dict(), moe_dir / "model.safetensors")
247
+ save_file(dense.state_dict(), dense_dir / "model.safetensors")
248
+ (moe_dir / "evaluation.json").write_text(
249
+ json.dumps(results, indent=2),
250
+ encoding="utf-8",
251
+ )
252
+ print(json.dumps(results, indent=2))
253
+
254
+
255
+ if __name__ == "__main__":
256
+ main()