callumtilbury commited on
Commit
58f078a
·
verified ·
1 Parent(s): 2439d69

Upload train_mse_distill.py

Browse files
Files changed (1) hide show
  1. train_mse_distill.py +395 -0
train_mse_distill.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fixed distillation training for microbubble segmentation.
3
+
4
+ KEY FIX: Uses MSE distillation on teacher's RAW cell_prob LOGITS instead of
5
+ BCE on binary masks. This solves the class imbalance problem (foreground is
6
+ only ~0.2% of pixels) that causes BCE training to predict all-background.
7
+
8
+ Usage:
9
+ # 1. Generate pseudo-labels (already done for callumtilbury/microbubble-images)
10
+ python generate_pseudolabels.py \
11
+ --image_dir ./images \
12
+ --model_path ./teacher_model/bubble_finetuned \
13
+ --output_dir ./pseudolabels
14
+
15
+ # 2. Train student with MSE distillation
16
+ python train_mse_distill.py \
17
+ --image_dir ./images \
18
+ --label_dir ./pseudolabels \
19
+ --output_dir ./checkpoints_v3 \
20
+ --epochs 400 \
21
+ --batch_size 4 \
22
+ --crop_size 256
23
+
24
+ # 3. Push to hub
25
+ python train_mse_distill.py --push_to_hub callumtilbury/bubble-distill-v3
26
+ """
27
+
28
+ import argparse
29
+ import json
30
+ import time
31
+ from pathlib import Path
32
+
33
+ import numpy as np
34
+ import torch
35
+ import torch.nn as nn
36
+ import torch.nn.functional as F
37
+ from torch.utils.data import DataLoader, random_split
38
+ from skimage import io as skio
39
+ from huggingface_hub import HfApi, create_repo
40
+
41
+
42
+ # ============================================================================
43
+ # TinyBubbleNet (depthwise-separable U-Net, ~389K params)
44
+ # ============================================================================
45
+
46
+ class DepthwiseSeparableConv(nn.Module):
47
+ def __init__(self, in_ch, out_ch, kernel_size=3, padding=1, stride=1):
48
+ super().__init__()
49
+ self.depthwise = nn.Conv2d(in_ch, in_ch, kernel_size, stride=stride, padding=padding, groups=in_ch, bias=False)
50
+ self.pointwise = nn.Conv2d(in_ch, out_ch, 1, bias=False)
51
+ self.bn = nn.BatchNorm2d(out_ch)
52
+ self.relu = nn.ReLU(inplace=True)
53
+
54
+ def forward(self, x):
55
+ return self.relu(self.bn(self.pointwise(self.depthwise(x))))
56
+
57
+
58
+ class ConvBlock(nn.Module):
59
+ def __init__(self, in_ch, out_ch, use_depthwise=True):
60
+ super().__init__()
61
+ if use_depthwise:
62
+ self.block = nn.Sequential(
63
+ DepthwiseSeparableConv(in_ch, out_ch),
64
+ DepthwiseSeparableConv(out_ch, out_ch),
65
+ )
66
+ else:
67
+ self.block = nn.Sequential(
68
+ nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
69
+ nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True),
70
+ nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
71
+ nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True),
72
+ )
73
+
74
+ def forward(self, x):
75
+ return self.block(x)
76
+
77
+
78
+ class TinyBubbleNet(nn.Module):
79
+ def __init__(self, in_channels=1, base_ch=16, out_channels=4, use_depthwise=True):
80
+ super().__init__()
81
+ self.enc1 = ConvBlock(in_channels, base_ch, use_depthwise=False)
82
+ self.enc2 = ConvBlock(base_ch, base_ch * 2, use_depthwise)
83
+ self.enc3 = ConvBlock(base_ch * 2, base_ch * 4, use_depthwise)
84
+ self.enc4 = ConvBlock(base_ch * 4, base_ch * 8, use_depthwise)
85
+ self.pool = nn.MaxPool2d(2)
86
+ self.bottleneck = ConvBlock(base_ch * 8, base_ch * 16, use_depthwise)
87
+ self.up4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, kernel_size=2, stride=2)
88
+ self.dec4 = ConvBlock(base_ch * 16, base_ch * 8, use_depthwise)
89
+ self.up3 = nn.ConvTranspose2d(base_ch * 8, base_ch * 4, kernel_size=2, stride=2)
90
+ self.dec3 = ConvBlock(base_ch * 8, base_ch * 4, use_depthwise)
91
+ self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, kernel_size=2, stride=2)
92
+ self.dec2 = ConvBlock(base_ch * 4, base_ch * 2, use_depthwise)
93
+ self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, kernel_size=2, stride=2)
94
+ self.dec1 = ConvBlock(base_ch * 2, base_ch, use_depthwise)
95
+ self.flow_head = nn.Conv2d(base_ch, 2, 1)
96
+ self.prob_head = nn.Conv2d(base_ch, 1, 1)
97
+ self.dist_head = nn.Conv2d(base_ch, 1, 1)
98
+
99
+ def forward(self, x):
100
+ e1 = self.enc1(x)
101
+ e2 = self.enc2(self.pool(e1))
102
+ e3 = self.enc3(self.pool(e2))
103
+ e4 = self.enc4(self.pool(e3))
104
+ b = self.bottleneck(self.pool(e4))
105
+ d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1))
106
+ d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1))
107
+ d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
108
+ d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
109
+ return torch.cat([self.flow_head(d1), self.prob_head(d1), self.dist_head(d1)], dim=1)
110
+
111
+ def predict(self, x):
112
+ with torch.no_grad():
113
+ out = self.forward(x)
114
+ return {
115
+ "dY": out[:, 0], "dX": out[:, 1],
116
+ "cell_prob": torch.sigmoid(out[:, 2]),
117
+ "dist_transform": torch.relu(out[:, 3]),
118
+ }
119
+
120
+
121
+ # ============================================================================
122
+ # Dataset — uses teacher's RAW cell_prob LOGITS (not binary mask)
123
+ # ============================================================================
124
+
125
+ class RawPseudoLabelDataset(torch.utils.data.Dataset):
126
+ """
127
+ Loads image + pseudo-label pairs where cell_prob is the RAW LOGITS
128
+ from the teacher (range roughly -9 to +5), not a binary mask.
129
+
130
+ This is the KEY FIX: MSE distillation on logits gives gradients on
131
+ ALL pixels, solving the class imbalance (foreground ~0.2%) that
132
+ breaks BCE-based training.
133
+ """
134
+ def __init__(self, image_dir, label_dir, crop_size=None, augment=True, normalize=True):
135
+ self.image_dir = Path(image_dir)
136
+ self.label_dir = Path(label_dir)
137
+ self.crop_size = crop_size
138
+ self.augment = augment
139
+ self.normalize = normalize
140
+ self.samples = []
141
+ for f in self.label_dir.glob("*_flows.npy"):
142
+ stem = f.stem.replace("_flows", "")
143
+ img_path = self.image_dir / f"{stem}.png"
144
+ if not img_path.exists():
145
+ for ext in [".jpg", ".jpeg", ".tif", ".tiff", ".bmp"]:
146
+ alt = self.image_dir / f"{stem}{ext}"
147
+ if alt.exists():
148
+ img_path = alt
149
+ break
150
+ if img_path.exists():
151
+ self.samples.append({
152
+ "image_path": img_path,
153
+ "flows_path": self.label_dir / f"{stem}_flows.npy",
154
+ "dist_path": self.label_dir / f"{stem}_dist.npy",
155
+ })
156
+ print(f"Found {len(self.samples)} samples")
157
+
158
+ def __len__(self):
159
+ return len(self.samples)
160
+
161
+ def __getitem__(self, idx):
162
+ s = self.samples[idx]
163
+ img = skio.imread(str(s["image_path"]))
164
+ if img.ndim == 3 and img.shape[2] >= 3:
165
+ img = np.mean(img[:, :, :3], axis=2)
166
+ img = img.astype(np.float32)
167
+ flows = np.load(s["flows_path"]).astype(np.float32)
168
+ dist = np.load(s["dist_path"]).astype(np.float32)
169
+ if self.normalize:
170
+ p1, p99 = np.percentile(img, [1, 99])
171
+ img = np.clip((img - p1) / (p99 - p1 + 1e-8), 0, 1) if p99 > p1 else img / (img.max() + 1e-8)
172
+ flow_dY, flow_dX, cell_prob_logits = flows[0], flows[1], flows[2]
173
+ dist_max = dist.max()
174
+ dist_norm = dist / (dist_max + 1e-8) if dist_max > 0 else dist
175
+ target = np.stack([flow_dY, flow_dX, cell_prob_logits, dist_norm], axis=0)
176
+ if self.augment:
177
+ img, target = self._augment(img, target)
178
+ if self.crop_size is not None:
179
+ img, target = self._random_crop(img, target, self.crop_size)
180
+ return torch.from_numpy(img[np.newaxis]).float(), torch.from_numpy(target).float()
181
+
182
+ def _augment(self, img, target):
183
+ if np.random.random() < 0.5:
184
+ img = np.flip(img, axis=1).copy()
185
+ target = np.flip(target, axis=2).copy()
186
+ target[1] = -target[1]
187
+ if np.random.random() < 0.5:
188
+ img = np.flip(img, axis=0).copy()
189
+ target = np.flip(target, axis=1).copy()
190
+ target[0] = -target[0]
191
+ k = np.random.randint(4)
192
+ if k > 0:
193
+ img = np.rot90(img, k).copy()
194
+ target = np.rot90(target, k, axes=(1, 2)).copy()
195
+ if k == 1:
196
+ target[0], target[1] = target[1].copy(), -target[0].copy()
197
+ elif k == 2:
198
+ target[0], target[1] = -target[0], -target[1]
199
+ elif k == 3:
200
+ target[0], target[1] = -target[1].copy(), target[0].copy()
201
+ if np.random.random() < 0.5:
202
+ img = np.clip(img + np.random.uniform(-0.1, 0.1), 0, 1)
203
+ if np.random.random() < 0.5:
204
+ m = img.mean()
205
+ img = np.clip((img - m) * np.random.uniform(0.8, 1.2) + m, 0, 1)
206
+ if np.random.random() < 0.3:
207
+ img = np.clip(img + np.random.normal(0, 0.02, img.shape).astype(np.float32), 0, 1)
208
+ return img, target
209
+
210
+ def _random_crop(self, img, target, crop_size):
211
+ h, w = img.shape
212
+ ch, cw = crop_size
213
+ if h <= ch or w <= cw:
214
+ ph, pw = max(ch - h, 0), max(cw - w, 0)
215
+ img = np.pad(img, ((0, ph), (0, pw)), mode="reflect")
216
+ target = np.pad(target, ((0, 0), (0, ph), (0, pw)), mode="reflect")
217
+ h, w = img.shape
218
+ y = np.random.randint(0, h - ch + 1)
219
+ x = np.random.randint(0, w - cw + 1)
220
+ return img[y:y+ch, x:x+cw], target[:, y:y+ch, x:x+cw]
221
+
222
+
223
+ # ============================================================================
224
+ # Loss — pure MSE distillation on all 4 channels (the fix!)
225
+ # ============================================================================
226
+
227
+ class MSEDistillationLoss(nn.Module):
228
+ """
229
+ MSE distillation on teacher logits for ALL channels.
230
+
231
+ Why this works:
232
+ - BCE on binary masks fails because foreground is only ~0.2% of pixels.
233
+ The model can get 99.8% accuracy by predicting ALL background.
234
+ - MSE on teacher logits gives gradients on ALL pixels. Even background
235
+ pixels have informative negative logit values (~ -6) that the student
236
+ must learn to reproduce.
237
+ """
238
+ def __init__(self, flow_weight=1.0, prob_weight=1.0, dist_weight=1.0):
239
+ super().__init__()
240
+ self.flow_weight = flow_weight
241
+ self.prob_weight = prob_weight
242
+ self.dist_weight = dist_weight
243
+
244
+ def forward(self, pred, target):
245
+ flow = F.mse_loss(pred[:, :2], target[:, :2])
246
+ prob = F.mse_loss(pred[:, 2:3], target[:, 2:3])
247
+ dist = F.mse_loss(torch.relu(pred[:, 3:4]), target[:, 3:4])
248
+ total = self.flow_weight * flow + self.prob_weight * prob + self.dist_weight * dist
249
+ return total, {"total": total.item(), "flow": flow.item(), "prob": prob.item(), "dist": dist.item()}
250
+
251
+
252
+ # ============================================================================
253
+ # Training loop
254
+ # ============================================================================
255
+
256
+ def train(args):
257
+ device = torch.device("cuda" if torch.cuda.is_available() and not args.no_gpu else "cpu")
258
+ print(f"Device: {device}")
259
+
260
+ dataset = RawPseudoLabelDataset(
261
+ image_dir=args.image_dir, label_dir=args.label_dir,
262
+ crop_size=(args.crop_size, args.crop_size) if args.crop_size else None,
263
+ augment=True, normalize=True,
264
+ )
265
+
266
+ n_val = max(1, int(len(dataset) * args.val_split))
267
+ n_train = len(dataset) - n_val
268
+ train_set, val_set = random_split(
269
+ dataset, [n_train, n_val], generator=torch.Generator().manual_seed(42))
270
+
271
+ train_loader = DataLoader(train_set, batch_size=args.batch_size, shuffle=True,
272
+ num_workers=0, pin_memory=True, drop_last=True)
273
+ val_loader = DataLoader(val_set, batch_size=args.batch_size, shuffle=False,
274
+ num_workers=0, pin_memory=True)
275
+
276
+ print(f"Train: {n_train}, Val: {n_val}")
277
+
278
+ model = TinyBubbleNet(in_channels=1, base_ch=args.base_ch, out_channels=4,
279
+ use_depthwise=args.use_depthwise).to(device)
280
+ n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
281
+ print(f"Params: {n_params:,}")
282
+
283
+ criterion = MSEDistillationLoss(flow_weight=args.flow_weight,
284
+ prob_weight=args.prob_weight,
285
+ dist_weight=args.dist_weight)
286
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
287
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
288
+ optimizer, T_max=args.epochs, eta_min=args.lr * 0.01)
289
+
290
+ output_dir = Path(args.output_dir)
291
+ output_dir.mkdir(parents=True, exist_ok=True)
292
+
293
+ config = vars(args)
294
+ config["n_params"] = n_params
295
+ with open(output_dir / "config.json", "w") as f:
296
+ json.dump(config, f, indent=2)
297
+
298
+ best_val_loss = float("inf")
299
+ history = []
300
+
301
+ for epoch in range(1, args.epochs + 1):
302
+ t0 = time.time()
303
+ model.train()
304
+ train_losses = {"total": 0.0, "flow": 0.0, "prob": 0.0, "dist": 0.0}
305
+ for images, targets in train_loader:
306
+ images, targets = images.to(device), targets.to(device)
307
+ pred = model(images)
308
+ loss, ld = criterion(pred, targets)
309
+ optimizer.zero_grad()
310
+ loss.backward()
311
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
312
+ optimizer.step()
313
+ for k in train_losses:
314
+ train_losses[k] += ld[k]
315
+ for k in train_losses:
316
+ train_losses[k] /= len(train_loader)
317
+
318
+ model.eval()
319
+ val_losses = {"total": 0.0, "flow": 0.0, "prob": 0.0, "dist": 0.0}
320
+ with torch.no_grad():
321
+ for images, targets in val_loader:
322
+ images, targets = images.to(device), targets.to(device)
323
+ pred = model(images)
324
+ _, ld = criterion(pred, targets)
325
+ for k in val_losses:
326
+ val_losses[k] += ld[k]
327
+ for k in val_losses:
328
+ val_losses[k] /= max(len(val_loader), 1)
329
+
330
+ scheduler.step()
331
+ elapsed = time.time() - t0
332
+ lr = optimizer.param_groups[0]["lr"]
333
+
334
+ print(f"Epoch {epoch:04d}/{args.epochs} | "
335
+ f"Train: {train_losses['total']:.4f} (f={train_losses['flow']:.4f}, "
336
+ f"p={train_losses['prob']:.4f}, d={train_losses['dist']:.4f}) | "
337
+ f"Val: {val_losses['total']:.4f} | LR: {lr:.2e} | {elapsed:.1f}s")
338
+
339
+ if val_losses["total"] < best_val_loss:
340
+ best_val_loss = val_losses["total"]
341
+ torch.save({
342
+ "epoch": epoch,
343
+ "model_state_dict": model.state_dict(),
344
+ "optimizer_state_dict": optimizer.state_dict(),
345
+ "val_loss": best_val_loss,
346
+ "config": config,
347
+ }, output_dir / "best_model.pt")
348
+ print(f" ★ Best val loss: {best_val_loss:.4f}")
349
+
350
+ history.append({"epoch": epoch, "lr": lr, "train": train_losses, "val": val_losses})
351
+ if epoch % 20 == 0:
352
+ with open(output_dir / "history.json", "w") as f:
353
+ json.dump(history, f, indent=2)
354
+
355
+ torch.save({"epoch": args.epochs, "model_state_dict": model.state_dict(), "config": config},
356
+ output_dir / "final_model.pt")
357
+ with open(output_dir / "history.json", "w") as f:
358
+ json.dump(history, f, indent=2)
359
+
360
+ print(f"\nDone! Best val loss: {best_val_loss:.4f}")
361
+
362
+ # Push to Hub
363
+ if args.push_to_hub:
364
+ REPO_ID = args.push_to_hub
365
+ try:
366
+ create_repo(REPO_ID, repo_type="model", private=False, exist_ok=True)
367
+ except Exception:
368
+ pass
369
+ api = HfApi()
370
+ api.upload_file(path_or_fileobj=str(output_dir / "best_model.pt"),
371
+ path_in_repo="best_model.pt", repo_id=REPO_ID, repo_type="model")
372
+ print(f"Model pushed to https://huggingface.co/{REPO_ID}")
373
+
374
+
375
+ if __name__ == "__main__":
376
+ parser = argparse.ArgumentParser()
377
+ parser.add_argument("--image_dir", type=str, default="./images")
378
+ parser.add_argument("--label_dir", type=str, default="./pseudolabels")
379
+ parser.add_argument("--output_dir", type=str, default="./checkpoints_v3")
380
+ parser.add_argument("--base_ch", type=int, default=16)
381
+ parser.add_argument("--use_depthwise", action="store_true", default=True)
382
+ parser.add_argument("--no_depthwise", dest="use_depthwise", action="store_false")
383
+ parser.add_argument("--epochs", type=int, default=400)
384
+ parser.add_argument("--batch_size", type=int, default=4)
385
+ parser.add_argument("--lr", type=float, default=1e-3)
386
+ parser.add_argument("--weight_decay", type=float, default=1e-4)
387
+ parser.add_argument("--crop_size", type=int, default=256)
388
+ parser.add_argument("--val_split", type=float, default=0.15)
389
+ parser.add_argument("--flow_weight", type=float, default=1.0)
390
+ parser.add_argument("--prob_weight", type=float, default=1.0)
391
+ parser.add_argument("--dist_weight", type=float, default=1.0)
392
+ parser.add_argument("--no_gpu", action="store_true")
393
+ parser.add_argument("--push_to_hub", type=str, default=None)
394
+ args = parser.parse_args()
395
+ train(args)