skarugu commited on
Commit
b4b7d09
·
verified ·
1 Parent(s): c950571

Delete train_model_v2.py

Browse files
Files changed (1) hide show
  1. train_model_v2.py +0 -552
train_model_v2.py DELETED
@@ -1,552 +0,0 @@
1
- """
2
- Train MyoSeg v2.
3
-
4
- This trains a stronger Attention Residual U-Net for two-channel input:
5
- channel 0 = MyHC/myotube signal, channel 1 = DAPI/nuclei signal.
6
-
7
- Expected data layout:
8
- data/
9
- images/
10
- masks/
11
- Nuclei_m/
12
- Myotubes_m/
13
-
14
- Optional app corrections are also accepted from:
15
- corrections/<sample_id>/image.png
16
- corrections/<sample_id>/nuclei_mask.png
17
- corrections/<sample_id>/myotube_mask.png
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import csv
24
- import json
25
- import math
26
- import os
27
- import random
28
- from dataclasses import asdict, dataclass
29
- from pathlib import Path
30
-
31
- import numpy as np
32
- from PIL import Image
33
-
34
- import torch
35
- import torch.nn as nn
36
- import torch.nn.functional as F
37
- from torch.utils.data import DataLoader, Dataset
38
-
39
- try:
40
- from huggingface_hub import HfApi
41
- except Exception:
42
- HfApi = None
43
-
44
-
45
- ARCH = "attention_resunet_v2"
46
- IMG_EXTS = {".png", ".jpg", ".jpeg", ".tif", ".tiff"}
47
- MASK_EXTS = {".png", ".tif", ".tiff"}
48
-
49
-
50
- def seed_everything(seed: int) -> None:
51
- random.seed(seed)
52
- np.random.seed(seed)
53
- torch.manual_seed(seed)
54
- if torch.cuda.is_available():
55
- torch.cuda.manual_seed_all(seed)
56
-
57
-
58
- def pil_resample(kind: str):
59
- try:
60
- return Image.Resampling.NEAREST if kind == "nearest" else Image.Resampling.BILINEAR
61
- except AttributeError:
62
- return Image.NEAREST if kind == "nearest" else Image.BILINEAR
63
-
64
-
65
- def norm_key(path_or_name: str) -> str:
66
- stem = Path(path_or_name).stem.lower()
67
- keep = [c if c.isalnum() else "_" for c in stem]
68
- return "_".join("".join(keep).split("_"))
69
-
70
-
71
- def find_matching_file(folder: Path, image_path: Path) -> Path | None:
72
- for ext in MASK_EXTS:
73
- p = folder / f"{image_path.stem}{ext}"
74
- if p.exists():
75
- return p
76
-
77
- wanted = norm_key(image_path.name)
78
- for p in folder.iterdir() if folder.exists() else []:
79
- if p.suffix.lower() in MASK_EXTS and norm_key(p.name) == wanted:
80
- return p
81
- return None
82
-
83
-
84
- def mask_has_pixels(path: Path) -> bool:
85
- arr = np.array(Image.open(path).convert("L"))
86
- return bool((arr > 0).any())
87
-
88
-
89
- @dataclass
90
- class Sample:
91
- sample_id: str
92
- image: str
93
- nuclei_mask: str
94
- myotube_mask: str
95
- source: str
96
-
97
-
98
- def collect_samples(data_root: Path, corrections_dir: Path | None, allow_empty_masks: bool) -> list[Sample]:
99
- image_dir = data_root / "images"
100
- nuc_dir = data_root / "masks" / "Nuclei_m"
101
- myo_dir = data_root / "masks" / "Myotubes_m"
102
- if not image_dir.exists():
103
- raise FileNotFoundError(f"Missing image folder: {image_dir}")
104
- if not nuc_dir.exists() or not myo_dir.exists():
105
- raise FileNotFoundError(f"Missing mask folders under: {data_root / 'masks'}")
106
-
107
- samples: list[Sample] = []
108
- skipped: list[str] = []
109
- for image_path in sorted(p for p in image_dir.iterdir() if p.suffix.lower() in IMG_EXTS):
110
- nuc = find_matching_file(nuc_dir, image_path)
111
- myo = find_matching_file(myo_dir, image_path)
112
- if not nuc or not myo:
113
- skipped.append(f"{image_path.name}: missing mask")
114
- continue
115
- if not allow_empty_masks and (not mask_has_pixels(nuc) or not mask_has_pixels(myo)):
116
- skipped.append(f"{image_path.name}: empty mask")
117
- continue
118
- samples.append(Sample(norm_key(image_path.name), str(image_path), str(nuc), str(myo), "base"))
119
-
120
- if corrections_dir and corrections_dir.exists():
121
- for folder in sorted(p for p in corrections_dir.iterdir() if p.is_dir()):
122
- image = folder / "image.png"
123
- nuc = folder / "nuclei_mask.png"
124
- myo = folder / "myotube_mask.png"
125
- if not (image.exists() and nuc.exists() and myo.exists()):
126
- continue
127
- if not allow_empty_masks and (not mask_has_pixels(nuc) or not mask_has_pixels(myo)):
128
- skipped.append(f"{folder.name}: empty correction mask")
129
- continue
130
- samples.append(Sample(norm_key(folder.name), str(image), str(nuc), str(myo), "correction"))
131
-
132
- if skipped:
133
- print(f"Skipped {len(skipped)} unusable sample(s). First few:")
134
- for item in skipped[:12]:
135
- print(f" - {item}")
136
-
137
- if len(samples) < 12:
138
- raise RuntimeError(
139
- f"Only {len(samples)} usable labelled samples found. "
140
- "For v2, add more base labelled images before training."
141
- )
142
- return samples
143
-
144
-
145
- def make_splits(samples: list[Sample], out_dir: Path, seed: int, val_split: float, test_split: float) -> dict:
146
- split_path = out_dir / "splits_v2.json"
147
- if split_path.exists():
148
- return json.loads(split_path.read_text())
149
-
150
- ids = [s.sample_id for s in samples]
151
- random.Random(seed).shuffle(ids)
152
- n = len(ids)
153
- n_test = max(1, int(round(n * test_split))) if n >= 20 and test_split > 0 else 0
154
- n_val = max(2, int(round(n * val_split)))
155
- n_train = n - n_val - n_test
156
- if n_train < 8:
157
- n_test = 0
158
- n_val = max(2, min(n - 8, n_val))
159
- n_train = n - n_val
160
- if n_train < 4:
161
- raise RuntimeError("Not enough data after split. Add more labelled samples.")
162
-
163
- splits = {
164
- "train": ids[:n_train],
165
- "val": ids[n_train:n_train + n_val],
166
- "test": ids[n_train + n_val:],
167
- "seed": seed,
168
- }
169
- split_path.write_text(json.dumps(splits, indent=2))
170
- return splits
171
-
172
-
173
- def load_image_pair(image_path: str, size: int) -> np.ndarray:
174
- rgb = np.array(Image.open(image_path).convert("RGB"), dtype=np.uint8)
175
- red = Image.fromarray(rgb[..., 0], "L").resize((size, size), pil_resample("bilinear"))
176
- blue = Image.fromarray(rgb[..., 2], "L").resize((size, size), pil_resample("bilinear"))
177
- x = np.stack([np.array(red), np.array(blue)], axis=0).astype(np.float32) / 255.0
178
- return x
179
-
180
-
181
- def load_mask_pair(nuc_path: str, myo_path: str, size: int) -> np.ndarray:
182
- nuc = Image.open(nuc_path).convert("L").resize((size, size), pil_resample("nearest"))
183
- myo = Image.open(myo_path).convert("L").resize((size, size), pil_resample("nearest"))
184
- y = np.stack([(np.array(nuc) > 0), (np.array(myo) > 0)], axis=0).astype(np.float32)
185
- return y
186
-
187
-
188
- class MyoSegV2Dataset(Dataset):
189
- def __init__(self, samples: list[Sample], selected_ids: set[str], size: int, augment: bool):
190
- self.samples = [s for s in samples if s.sample_id in selected_ids]
191
- self.size = size
192
- self.augment = augment
193
-
194
- def __len__(self) -> int:
195
- return len(self.samples)
196
-
197
- def __getitem__(self, index: int):
198
- s = self.samples[index]
199
- x = load_image_pair(s.image, self.size)
200
- y = load_mask_pair(s.nuclei_mask, s.myotube_mask, self.size)
201
-
202
- if self.augment:
203
- x, y = augment_xy(x, y)
204
-
205
- return torch.from_numpy(x.copy()), torch.from_numpy(y.copy()), s.sample_id
206
-
207
-
208
- def augment_xy(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
209
- if random.random() < 0.5:
210
- x = x[:, :, ::-1]
211
- y = y[:, :, ::-1]
212
- if random.random() < 0.5:
213
- x = x[:, ::-1, :]
214
- y = y[:, ::-1, :]
215
- k = random.randint(0, 3)
216
- if k:
217
- x = np.rot90(x, k, axes=(1, 2))
218
- y = np.rot90(y, k, axes=(1, 2))
219
-
220
- scale = np.random.uniform(0.85, 1.20, size=(2, 1, 1)).astype(np.float32)
221
- shift = np.random.uniform(-0.05, 0.05, size=(2, 1, 1)).astype(np.float32)
222
- gamma = float(np.random.uniform(0.85, 1.20))
223
- noise = np.random.normal(0.0, 0.015, size=x.shape).astype(np.float32)
224
- x = np.clip((x * scale + shift), 0.0, 1.0)
225
- x = np.clip(x ** gamma + noise, 0.0, 1.0)
226
- return x.astype(np.float32), y.astype(np.float32)
227
-
228
-
229
- def group_count(channels: int) -> int:
230
- for g in (8, 4, 2, 1):
231
- if channels % g == 0:
232
- return g
233
- return 1
234
-
235
-
236
- class ResidualBlock(nn.Module):
237
- def __init__(self, in_ch: int, out_ch: int):
238
- super().__init__()
239
- self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False)
240
- self.norm1 = nn.GroupNorm(group_count(out_ch), out_ch)
241
- self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False)
242
- self.norm2 = nn.GroupNorm(group_count(out_ch), out_ch)
243
- self.skip = nn.Identity() if in_ch == out_ch else nn.Conv2d(in_ch, out_ch, 1, bias=False)
244
-
245
- def forward(self, x):
246
- identity = self.skip(x)
247
- x = F.silu(self.norm1(self.conv1(x)))
248
- x = self.norm2(self.conv2(x))
249
- return F.silu(x + identity)
250
-
251
-
252
- class AttentionGate(nn.Module):
253
- def __init__(self, gate_ch: int, skip_ch: int, inter_ch: int):
254
- super().__init__()
255
- self.gate = nn.Sequential(nn.Conv2d(gate_ch, inter_ch, 1, bias=True), nn.GroupNorm(group_count(inter_ch), inter_ch))
256
- self.skip = nn.Sequential(nn.Conv2d(skip_ch, inter_ch, 1, bias=True), nn.GroupNorm(group_count(inter_ch), inter_ch))
257
- self.psi = nn.Sequential(nn.Conv2d(inter_ch, 1, 1, bias=True), nn.Sigmoid())
258
-
259
- def forward(self, gate, skip):
260
- alpha = self.psi(F.silu(self.gate(gate) + self.skip(skip)))
261
- return skip * alpha
262
-
263
-
264
- def center_pad_like(x, ref):
265
- diff_y = ref.size(2) - x.size(2)
266
- diff_x = ref.size(3) - x.size(3)
267
- if diff_x or diff_y:
268
- x = F.pad(x, [diff_x // 2, diff_x - diff_x // 2, diff_y // 2, diff_y - diff_y // 2])
269
- return x
270
-
271
-
272
- class AttentionResUNetV2(nn.Module):
273
- def __init__(self, in_ch: int = 2, out_ch: int = 2, base: int = 32):
274
- super().__init__()
275
- self.e1 = ResidualBlock(in_ch, base)
276
- self.e2 = ResidualBlock(base, base * 2)
277
- self.e3 = ResidualBlock(base * 2, base * 4)
278
- self.e4 = ResidualBlock(base * 4, base * 8)
279
- self.pool = nn.MaxPool2d(2)
280
- self.bridge = ResidualBlock(base * 8, base * 16)
281
-
282
- self.up4 = nn.ConvTranspose2d(base * 16, base * 8, 2, 2)
283
- self.att4 = AttentionGate(base * 8, base * 8, base * 4)
284
- self.dec4 = ResidualBlock(base * 16, base * 8)
285
- self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, 2)
286
- self.att3 = AttentionGate(base * 4, base * 4, base * 2)
287
- self.dec3 = ResidualBlock(base * 8, base * 4)
288
- self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, 2)
289
- self.att2 = AttentionGate(base * 2, base * 2, base)
290
- self.dec2 = ResidualBlock(base * 4, base * 2)
291
- self.up1 = nn.ConvTranspose2d(base * 2, base, 2, 2)
292
- self.att1 = AttentionGate(base, base, max(base // 2, 1))
293
- self.dec1 = ResidualBlock(base * 2, base)
294
- self.out = nn.Conv2d(base, out_ch, 1)
295
-
296
- def forward(self, x):
297
- e1 = self.e1(x)
298
- e2 = self.e2(self.pool(e1))
299
- e3 = self.e3(self.pool(e2))
300
- e4 = self.e4(self.pool(e3))
301
- b = self.bridge(self.pool(e4))
302
-
303
- x = center_pad_like(self.up4(b), e4)
304
- x = self.dec4(torch.cat([x, self.att4(x, e4)], dim=1))
305
- x = center_pad_like(self.up3(x), e3)
306
- x = self.dec3(torch.cat([x, self.att3(x, e3)], dim=1))
307
- x = center_pad_like(self.up2(x), e2)
308
- x = self.dec2(torch.cat([x, self.att2(x, e2)], dim=1))
309
- x = center_pad_like(self.up1(x), e1)
310
- x = self.dec1(torch.cat([x, self.att1(x, e1)], dim=1))
311
- return self.out(x)
312
-
313
-
314
- class ComboLoss(nn.Module):
315
- def __init__(self, dice_weight: float = 0.45, bce_weight: float = 0.45, focal_weight: float = 0.10):
316
- super().__init__()
317
- self.dice_weight = dice_weight
318
- self.bce_weight = bce_weight
319
- self.focal_weight = focal_weight
320
-
321
- def forward(self, logits, target):
322
- bce_map = F.binary_cross_entropy_with_logits(logits, target, reduction="none")
323
- bce = bce_map.mean()
324
-
325
- prob = torch.sigmoid(logits)
326
- dims = (0, 2, 3)
327
- inter = (prob * target).sum(dim=dims)
328
- union = prob.sum(dim=dims) + target.sum(dim=dims)
329
- dice = 1.0 - ((2.0 * inter + 1.0) / (union + 1.0)).mean()
330
-
331
- pt = prob * target + (1.0 - prob) * (1.0 - target)
332
- focal = ((1.0 - pt).pow(2.0) * bce_map).mean()
333
- return self.bce_weight * bce + self.dice_weight * dice + self.focal_weight * focal
334
-
335
-
336
- @torch.no_grad()
337
- def dice_from_logits(logits, target, thresholds=(0.5, 0.5)):
338
- prob = torch.sigmoid(logits)
339
- pred = torch.zeros_like(prob)
340
- pred[:, 0] = prob[:, 0] > thresholds[0]
341
- pred[:, 1] = prob[:, 1] > thresholds[1]
342
- inter = (pred * target).sum(dim=(0, 2, 3))
343
- union = pred.sum(dim=(0, 2, 3)) + target.sum(dim=(0, 2, 3))
344
- return ((2.0 * inter + 1.0) / (union + 1.0)).cpu().numpy()
345
-
346
-
347
- @torch.no_grad()
348
- def evaluate(model, loader, device, thresholds=(0.5, 0.5)) -> dict:
349
- model.eval()
350
- dices = []
351
- losses = []
352
- loss_fn = ComboLoss()
353
- for x, y, _ in loader:
354
- x = x.to(device)
355
- y = y.to(device)
356
- logits = model(x)
357
- losses.append(float(loss_fn(logits, y).item()))
358
- dices.append(dice_from_logits(logits, y, thresholds=thresholds))
359
- arr = np.array(dices)
360
- d_nuc = float(arr[:, 0].mean())
361
- d_myo = float(arr[:, 1].mean())
362
- return {
363
- "loss": float(np.mean(losses)) if losses else math.nan,
364
- "dice_nuclei": d_nuc,
365
- "dice_myotube": d_myo,
366
- "mean_dice": (d_nuc + d_myo) / 2.0,
367
- }
368
-
369
-
370
- @torch.no_grad()
371
- def tune_thresholds(model, loader, device) -> tuple[tuple[float, float], dict]:
372
- best = ((0.5, 0.5), {"mean_dice": -1.0})
373
- grid = [round(v, 2) for v in np.linspace(0.25, 0.75, 11)]
374
- for tn in grid:
375
- for tm in grid:
376
- metrics = evaluate(model, loader, device, thresholds=(tn, tm))
377
- if metrics["mean_dice"] > best[1]["mean_dice"]:
378
- best = ((tn, tm), metrics)
379
- return best
380
-
381
-
382
- def write_metrics_csv(path: Path, rows: list[dict]) -> None:
383
- if not rows:
384
- return
385
- fields = sorted({k for row in rows for k in row})
386
- with path.open("w", newline="") as f:
387
- writer = csv.DictWriter(f, fieldnames=fields)
388
- writer.writeheader()
389
- writer.writerows(rows)
390
-
391
-
392
- def push_to_hub(model_path: Path, repo_id: str, filename: str, token: str | None) -> None:
393
- if HfApi is None:
394
- raise RuntimeError("huggingface_hub is not installed.")
395
- api = HfApi(token=token)
396
- api.upload_file(
397
- path_or_fileobj=str(model_path),
398
- path_in_repo=filename,
399
- repo_id=repo_id,
400
- repo_type="model",
401
- commit_message=f"Upload MyoSeg v2 model ({filename})",
402
- )
403
-
404
-
405
- def main():
406
- ap = argparse.ArgumentParser()
407
- ap.add_argument("--data_root", default="data")
408
- ap.add_argument("--corrections_dir", default="corrections")
409
- ap.add_argument("--out_dir", default="runs_v2")
410
- ap.add_argument("--epochs", type=int, default=100)
411
- ap.add_argument("--batch_size", type=int, default=4)
412
- ap.add_argument("--image_size", type=int, default=512)
413
- ap.add_argument("--base", type=int, default=32)
414
- ap.add_argument("--lr", type=float, default=3e-4)
415
- ap.add_argument("--weight_decay", type=float, default=1e-4)
416
- ap.add_argument("--val_split", type=float, default=0.15)
417
- ap.add_argument("--test_split", type=float, default=0.15)
418
- ap.add_argument("--patience", type=int, default=18)
419
- ap.add_argument("--seed", type=int, default=42)
420
- ap.add_argument("--allow_empty_masks", action="store_true")
421
- ap.add_argument("--push_to_hub", action="store_true")
422
- ap.add_argument("--hf_repo", default=os.environ.get("HF_REPO_ID", "skarugu/myotube-unet"))
423
- ap.add_argument("--hf_filename", default=os.environ.get("HF_FILENAME", "model_v2.pt"))
424
- ap.add_argument("--hf_token", default=os.environ.get("HF_TOKEN"))
425
- args = ap.parse_args()
426
-
427
- seed_everything(args.seed)
428
- out_dir = Path(args.out_dir)
429
- out_dir.mkdir(parents=True, exist_ok=True)
430
-
431
- samples = collect_samples(
432
- Path(args.data_root),
433
- Path(args.corrections_dir) if args.corrections_dir else None,
434
- allow_empty_masks=args.allow_empty_masks,
435
- )
436
- splits = make_splits(samples, out_dir, args.seed, args.val_split, args.test_split)
437
- sample_by_id = {s.sample_id: s for s in samples}
438
- for split_name in ("train", "val", "test"):
439
- missing = [sid for sid in splits.get(split_name, []) if sid not in sample_by_id]
440
- if missing:
441
- raise RuntimeError(f"Split file references missing sample(s): {missing[:5]}")
442
-
443
- train_ds = MyoSegV2Dataset(samples, set(splits["train"]), args.image_size, augment=True)
444
- val_ds = MyoSegV2Dataset(samples, set(splits["val"]), args.image_size, augment=False)
445
- test_ds = MyoSegV2Dataset(samples, set(splits.get("test", [])), args.image_size, augment=False)
446
-
447
- train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, num_workers=0)
448
- val_loader = DataLoader(val_ds, batch_size=max(1, min(args.batch_size, 4)), shuffle=False, num_workers=0)
449
- test_loader = DataLoader(test_ds, batch_size=max(1, min(args.batch_size, 4)), shuffle=False, num_workers=0) if len(test_ds) else None
450
-
451
- device = "cuda" if torch.cuda.is_available() else "cpu"
452
- model = AttentionResUNetV2(in_ch=2, out_ch=2, base=args.base).to(device)
453
- opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
454
- scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(args.epochs, 1), eta_min=args.lr * 0.05)
455
- loss_fn = ComboLoss()
456
-
457
- best_score = -1.0
458
- best_epoch = 0
459
- history: list[dict] = []
460
- best_path = out_dir / "model_v2_best.pt"
461
- final_path = out_dir / "model_v2.pt"
462
-
463
- print(f"Training {ARCH} on {device}")
464
- print(f"Samples: train={len(train_ds)} val={len(val_ds)} test={len(test_ds)}")
465
-
466
- for epoch in range(1, args.epochs + 1):
467
- model.train()
468
- train_losses = []
469
- for x, y, _ in train_loader:
470
- x = x.to(device)
471
- y = y.to(device)
472
- opt.zero_grad(set_to_none=True)
473
- loss = loss_fn(model(x), y)
474
- loss.backward()
475
- torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
476
- opt.step()
477
- train_losses.append(float(loss.item()))
478
- scheduler.step()
479
-
480
- val_metrics = evaluate(model, val_loader, device)
481
- row = {
482
- "epoch": epoch,
483
- "train_loss": float(np.mean(train_losses)),
484
- **{f"val_{k}": v for k, v in val_metrics.items()},
485
- "lr": scheduler.get_last_lr()[0],
486
- }
487
- history.append(row)
488
- print(
489
- f"epoch {epoch:03d} | loss={row['train_loss']:.4f} | "
490
- f"val_nuc={val_metrics['dice_nuclei']:.4f} | "
491
- f"val_myo={val_metrics['dice_myotube']:.4f} | "
492
- f"mean={val_metrics['mean_dice']:.4f}"
493
- )
494
-
495
- if val_metrics["mean_dice"] > best_score:
496
- best_score = val_metrics["mean_dice"]
497
- best_epoch = epoch
498
- checkpoint = {
499
- "arch": ARCH,
500
- "model": model.state_dict(),
501
- "args": vars(args),
502
- "metrics": {
503
- "best_epoch": best_epoch,
504
- "val": val_metrics,
505
- "n_train": len(train_ds),
506
- "n_val": len(val_ds),
507
- "n_test": len(test_ds),
508
- },
509
- "class_names": ["nuclei", "myotubes"],
510
- "input_channels": ["myhc_red", "dapi_blue"],
511
- }
512
- torch.save(checkpoint, best_path)
513
-
514
- if epoch - best_epoch >= args.patience:
515
- print(f"Early stopping at epoch {epoch}; best epoch was {best_epoch}.")
516
- break
517
-
518
- write_metrics_csv(out_dir / "training_history_v2.csv", history)
519
-
520
- ckpt = torch.load(best_path, map_location=device)
521
- model.load_state_dict(ckpt["model"])
522
- tuned_thresholds, tuned_val = tune_thresholds(model, val_loader, device)
523
- final_metrics = {
524
- "arch": ARCH,
525
- "best_epoch": best_epoch,
526
- "threshold_nuclei": tuned_thresholds[0],
527
- "threshold_myotube": tuned_thresholds[1],
528
- "val": tuned_val,
529
- "n_train": len(train_ds),
530
- "n_val": len(val_ds),
531
- "n_test": len(test_ds),
532
- }
533
- if test_loader is not None:
534
- final_metrics["test"] = evaluate(model, test_loader, device, thresholds=tuned_thresholds)
535
-
536
- ckpt["metrics"] = final_metrics
537
- ckpt["args"]["thr_nuc"] = tuned_thresholds[0]
538
- ckpt["args"]["thr_myo"] = tuned_thresholds[1]
539
- torch.save(ckpt, final_path)
540
- (out_dir / "metrics_v2.json").write_text(json.dumps(final_metrics, indent=2))
541
- (out_dir / "samples_v2.json").write_text(json.dumps([asdict(s) for s in samples], indent=2))
542
-
543
- print(f"Saved best v2 checkpoint: {final_path}")
544
- print(json.dumps(final_metrics, indent=2))
545
-
546
- if args.push_to_hub:
547
- push_to_hub(final_path, args.hf_repo, args.hf_filename, args.hf_token)
548
- print(f"Pushed {final_path} to {args.hf_repo}/{args.hf_filename}")
549
-
550
-
551
- if __name__ == "__main__":
552
- main()