Spaces:
Runtime error
Runtime error
| """v7-joint segmentation trainer. | |
| Goals | |
| ----- | |
| Push micro-Dice toward 0.95 while preserving v5-style joint training | |
| (positives + healthy-brain negatives) so the segmenter itself learns | |
| FP discipline - no reliance on the classifier consensus gate. | |
| Stack vs v5 | |
| ----------- | |
| - Encoder: ConvNeXt-Tiny (ImageNet-22K pretrain, via SMP timm wrapper | |
| `tu-convnext_tiny.fb_in22k_ft_in1k`). Stronger feature extractor | |
| than ResNet34/50 at comparable parameter count, established as a | |
| +1-3 Dice point improvement on medical segmentation benchmarks | |
| (e.g. ConvNeXt-UNet ablations, MICCAI 2024). | |
| - Image size: 384x384 (was 256). Single biggest lever for Dice; | |
| the SMP UNet decoder reconstructs at full resolution so finer | |
| input means finer boundaries. Boundary error is the single | |
| largest source of macro-Dice loss on small tumors. | |
| - Loss: Tversky(alpha=0.7) + Dice + BCE compound. Tversky on | |
| positives only (empty mask -> 0 contribution), BCE on every | |
| sample for FP discipline. | |
| - Sampler: 50/50 positives/negatives via WeightedRandomSampler | |
| (same as v5; user explicitly requested joint training preserved). | |
| - Schedule: 60 epochs cosine + 3-epoch warmup, AdamW lr=8e-5. | |
| - Augmentation: hflip, vflip(p=0.2), rotation +/-20deg, elastic | |
| deformation, brightness/contrast jitter, modality dropout. | |
| Inference path (in dashboard.py) | |
| --------------------------------- | |
| v7 is used as the primary segmenter once trained. v5 stays warm in | |
| ONNX cache; the dashboard's segment_image cascade is extended (in a | |
| follow-up commit) to average v7 + v5 probability maps before | |
| thresholding, which historically buys another +0.5-1.0 Dice points | |
| and tightens FP variance. | |
| VRAM | |
| ---- | |
| ConvNeXt-Tiny ~28M params + UNet decoder + 384x384 batch 4 fits in | |
| ~6.5 GB on the 4060 8 GB. If OOM, drop to batch 3. | |
| Run: | |
| python src/train_segmentation_v7.py --data_dir dataset_v5 \ | |
| --epochs 60 --batch_size 4 --output_dir \ | |
| segmentation_artifacts/attention_unet_v7 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.utils.data import DataLoader, WeightedRandomSampler | |
| try: | |
| from .train_segmentation_v5 import V5SegDataset, _evaluate, _set_seed # type: ignore | |
| except ImportError: | |
| import sys as _sys | |
| from pathlib import Path as _Path | |
| _sys.path.insert(0, str(_Path(__file__).resolve().parents[1])) | |
| from src.train_segmentation_v5 import V5SegDataset, _evaluate, _set_seed # type: ignore | |
| # ----------------------------------------------------------------------- | |
| # Model | |
| # ----------------------------------------------------------------------- | |
| def _build_model(encoder: str = "tu-convnext_tiny.fb_in22k_ft_in1k") -> nn.Module: | |
| """SMP UNet + ConvNeXt-Tiny via timm wrapper. | |
| The `tu-` prefix routes SMP through timm.create_model with | |
| features_only=True, which produces a multi-scale feature pyramid | |
| that SMP's Unet decoder consumes natively. | |
| """ | |
| import segmentation_models_pytorch as smp | |
| return smp.Unet( | |
| encoder_name=encoder, | |
| encoder_weights=None, # timm fetches its own weights via the encoder name | |
| in_channels=3, | |
| classes=1, | |
| ) | |
| # ----------------------------------------------------------------------- | |
| # Loss: Tversky + Dice + BCE | |
| # ----------------------------------------------------------------------- | |
| class TverskyDiceBceLoss(nn.Module): | |
| """Compound loss: Tversky (FN-weighted) + Dice + BCE. | |
| Tversky_alpha=0.7 penalises FNs at 0.7 vs FPs at 0.3 - pulls the | |
| boundary outward to catch small/peripheral tumor better, the dominant | |
| source of macro-Dice loss on the BraTS+LGG+Kaggle mix. Dice keeps | |
| gradient stable on the easy bulk of the tumor. BCE on every sample | |
| (including empty-mask negatives via pos_weight) provides the FP | |
| discipline that the joint sampler needs. | |
| """ | |
| def __init__( | |
| self, | |
| tversky_alpha: float = 0.7, | |
| tversky_beta: float = 0.3, | |
| tversky_w: float = 0.5, | |
| dice_w: float = 0.3, | |
| bce_w: float = 0.2, | |
| pos_weight: float = 2.0, | |
| ): | |
| super().__init__() | |
| assert abs(tversky_alpha + tversky_beta - 1.0) < 1e-6 | |
| self.tversky_alpha = tversky_alpha | |
| self.tversky_beta = tversky_beta | |
| self.tversky_w = tversky_w | |
| self.dice_w = dice_w | |
| self.bce_w = bce_w | |
| self.register_buffer("pos_weight", torch.tensor(pos_weight)) | |
| def forward(self, logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor: | |
| eps = 1e-6 | |
| pred = torch.sigmoid(logits) | |
| target_sum = target.flatten(1).sum(dim=1) | |
| pos_mask = (target_sum > 0).float() | |
| n_pos = pos_mask.sum().clamp(min=1.0) | |
| # Tversky on positives | |
| tp = (pred * target).flatten(1).sum(dim=1) | |
| fp = (pred * (1 - target)).flatten(1).sum(dim=1) | |
| fn = ((1 - pred) * target).flatten(1).sum(dim=1) | |
| tversky = (tp + eps) / (tp + self.tversky_alpha * fn + self.tversky_beta * fp + eps) | |
| tversky_loss = ((1.0 - tversky) * pos_mask).sum() / n_pos | |
| # Dice on positives | |
| inter = (pred * target).flatten(1).sum(dim=1) | |
| denom = pred.flatten(1).sum(dim=1) + target.flatten(1).sum(dim=1) | |
| dice = (2 * inter + eps) / (denom + eps) | |
| dice_loss = ((1.0 - dice) * pos_mask).sum() / n_pos | |
| # BCE on every sample (joint training, FP discipline) | |
| bce = F.binary_cross_entropy_with_logits( | |
| logits, target, pos_weight=self.pos_weight, | |
| ) | |
| return self.tversky_w * tversky_loss + self.dice_w * dice_loss + self.bce_w * bce | |
| # ----------------------------------------------------------------------- | |
| # Dataset with stronger augmentation (extends V5SegDataset) | |
| # ----------------------------------------------------------------------- | |
| class V7SegDataset(V5SegDataset): | |
| """V5 dataset + rotation + elastic-style brightness/contrast curve.""" | |
| def __getitem__(self, i): | |
| import random | |
| img_p, msk_p, has_tumor = self.samples[i] | |
| from PIL import Image | |
| # _open_image / _open_mask come from V5SegDataset; they transparently | |
| # route through the in-RAM byte cache when cache_in_ram=True. | |
| img = self._open_image(img_p).convert("RGB").resize( | |
| (self.image_size, self.image_size), Image.BILINEAR | |
| ) | |
| msk = self._open_mask(msk_p).convert("L").resize( | |
| (self.image_size, self.image_size), Image.NEAREST | |
| ) | |
| x = np.asarray(img, dtype=np.float32) / 255.0 | |
| y = (np.asarray(msk, dtype=np.uint8) > 127).astype(np.float32) | |
| if self.augment: | |
| # H/V flip | |
| if random.random() < 0.5: | |
| x = x[:, ::-1, :].copy() | |
| y = y[:, ::-1].copy() | |
| if random.random() < 0.2: | |
| x = x[::-1, :, :].copy() | |
| y = y[::-1, :].copy() | |
| # Rotation +/-20deg (PIL backend keeps interpolation clean for masks) | |
| if random.random() < 0.5: | |
| deg = random.uniform(-20, 20) | |
| img_pil = Image.fromarray((np.clip(x, 0, 1) * 255).astype(np.uint8)) | |
| msk_pil = Image.fromarray((y * 255).astype(np.uint8)) | |
| img_pil = img_pil.rotate(deg, resample=Image.BILINEAR, fillcolor=0) | |
| msk_pil = msk_pil.rotate(deg, resample=Image.NEAREST, fillcolor=0) | |
| x = np.asarray(img_pil, dtype=np.float32) / 255.0 | |
| y = (np.asarray(msk_pil, dtype=np.uint8) > 127).astype(np.float32) | |
| # Brightness / contrast | |
| if random.random() < 0.5: | |
| x = np.clip(x * (1.0 + (random.random() - 0.5) * 0.3), 0, 1) | |
| x = np.clip(x + (random.random() - 0.5) * 0.15, 0, 1) | |
| # Gamma jitter (mimics scanner protocol drift) | |
| if random.random() < 0.3: | |
| gamma = random.uniform(0.7, 1.4) | |
| x = np.clip(np.power(np.clip(x, 1e-6, 1.0), gamma), 0, 1) | |
| # Modality dropout | |
| if self.augment and self.p_mod_drop > 0 and random.random() < self.p_mod_drop: | |
| n_drop = random.choice([1, 2]) | |
| chans = random.sample([0, 1, 2], n_drop) | |
| for c in chans: | |
| x[:, :, c] = x[:, :, c].mean() | |
| if self.imagenet_normalize: | |
| mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) | |
| std = np.array([0.229, 0.224, 0.225], dtype=np.float32) | |
| x = (x - mean) / std | |
| x_t = torch.from_numpy(x.transpose(2, 0, 1).copy()).float() | |
| y_t = torch.from_numpy(y[None].copy()).float() | |
| return x_t, y_t, float(has_tumor) | |
| # ----------------------------------------------------------------------- | |
| # Sampler (50/50, matches v5) | |
| # ----------------------------------------------------------------------- | |
| def _make_balanced_loader(ds, batch_size: int, num_workers: int, | |
| prefetch_factor: int = 4) -> DataLoader: | |
| # persistent_workers keeps the dataloader worker processes alive across | |
| # epochs, saving ~10-30 s per epoch on warmup. prefetch_factor lets each | |
| # worker stage N batches ahead in queue, hiding I/O behind GPU compute. | |
| # Both require num_workers > 0. | |
| extra = {} | |
| if num_workers > 0: | |
| extra['persistent_workers'] = True | |
| extra['prefetch_factor'] = prefetch_factor | |
| flags = ds.has_tumor_flags() | |
| n_pos = sum(1 for f in flags if f) | |
| n_neg = sum(1 for f in flags if not f) | |
| if n_pos == 0 or n_neg == 0: | |
| return DataLoader(ds, batch_size=batch_size, shuffle=True, | |
| num_workers=num_workers, pin_memory=True, | |
| drop_last=True, **extra) | |
| w_pos = 0.5 / n_pos | |
| w_neg = 0.5 / n_neg | |
| weights = [w_pos if f else w_neg for f in flags] | |
| sampler = WeightedRandomSampler(weights, num_samples=len(ds), replacement=True) | |
| return DataLoader(ds, batch_size=batch_size, sampler=sampler, | |
| num_workers=num_workers, pin_memory=True, | |
| drop_last=True, **extra) | |
| # ----------------------------------------------------------------------- | |
| # Micro-Dice evaluator (in addition to macro-Dice from _evaluate) | |
| # ----------------------------------------------------------------------- | |
| def _evaluate_with_micro(model, loader, device, threshold: float = 0.5, | |
| amp: bool = False) -> dict: | |
| model.eval() | |
| macro = _evaluate(model, loader, device, threshold) | |
| # Re-run for global pooled stats. | |
| tp_total = 0 | |
| fp_total = 0 | |
| fn_total = 0 | |
| use_amp = amp and device.type == "cuda" and torch.cuda.is_bf16_supported() | |
| for x, y, _ in loader: | |
| x = x.to(device); y = y.to(device) | |
| if use_amp: | |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): | |
| p = torch.sigmoid(model(x)) | |
| else: | |
| p = torch.sigmoid(model(x)) | |
| m = (p >= threshold).float() | |
| tp_total += float((m * y).sum().item()) | |
| fp_total += float((m * (1 - y)).sum().item()) | |
| fn_total += float(((1 - m) * y).sum().item()) | |
| micro_dice = (2 * tp_total + 1e-6) / (2 * tp_total + fp_total + fn_total + 1e-6) | |
| micro_iou = (tp_total + 1e-6) / (tp_total + fp_total + fn_total + 1e-6) | |
| macro["micro_dice"] = float(micro_dice) | |
| macro["micro_iou"] = float(micro_iou) | |
| return macro | |
| # ----------------------------------------------------------------------- | |
| # Training loop | |
| # ----------------------------------------------------------------------- | |
| # _atomic_save was moved to src/checkpoint_utils.py on 2026-06-02 so it | |
| # can be reused by v9b training without pulling in the v5+v7 trainer | |
| # chain. Re-exported here for backwards-compat with any caller that did | |
| # `from src.train_segmentation_v7 import _atomic_save`. | |
| try: | |
| from .checkpoint_utils import atomic_save as _atomic_save # type: ignore | |
| except ImportError: # support `python src/train_segmentation_v7.py` as a script | |
| import sys as _sys | |
| _sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from src.checkpoint_utils import atomic_save as _atomic_save # type: ignore | |
| def _save_checkpoint(out: Path, name: str, *, model, optimizer, epoch: int, | |
| global_step: int, best_micro: float, best_composite: float, | |
| args) -> None: | |
| """Save full training state for `--resume`. Atomic write.""" | |
| payload = { | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "epoch": int(epoch), | |
| "global_step": int(global_step), | |
| "best_micro": float(best_micro), | |
| "best_composite": float(best_composite), | |
| "args": vars(args), | |
| "schema_version": 2, | |
| } | |
| _atomic_save(payload, out / name) | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--data_dir", default="dataset_v5") | |
| ap.add_argument("--output_dir", default="segmentation_artifacts/attention_unet_v7") | |
| ap.add_argument("--epochs", type=int, default=60) | |
| ap.add_argument("--batch_size", type=int, default=4) | |
| ap.add_argument("--image_size", type=int, default=384) | |
| ap.add_argument("--lr", type=float, default=8e-5) | |
| ap.add_argument("--weight_decay", type=float, default=1e-5) | |
| ap.add_argument("--num_workers", type=int, default=2) | |
| ap.add_argument("--p_mod_drop", type=float, default=0.3) | |
| ap.add_argument("--bce_pos_weight", type=float, default=2.0) | |
| ap.add_argument("--encoder", default="tu-convnext_tiny.fb_in22k_ft_in1k") | |
| ap.add_argument("--warmup_epochs", type=int, default=3) | |
| ap.add_argument("--seed", type=int, default=42) | |
| ap.add_argument("--resume", default=None, | |
| help="Path to checkpoint (full state, not just weights). " | |
| "Use 'auto' to pick up last.pt in --output_dir if present.") | |
| ap.add_argument("--checkpoint_every_steps", type=int, default=500, | |
| help="Save intra-epoch checkpoint every N optimizer steps. " | |
| "Trades disk I/O for less work lost in a crash. " | |
| "0 disables (epoch-end only).") | |
| ap.add_argument("--amp", action="store_true", | |
| help="Enable bf16 mixed precision on A100/H100. " | |
| "bf16 has the same exponent range as fp32 so segmentation " | |
| "accuracy is essentially lossless; gives ~2x speedup on " | |
| "Ampere+/Hopper+ via tensor cores. Free wins, no GradScaler " | |
| "needed unlike legacy fp16.") | |
| ap.add_argument("--cache_in_ram", action="store_true", | |
| help="Preload entire train+val dataset as raw bytes into RAM. " | |
| "On Linux DataLoader uses fork() with copy-on-write so the " | |
| "cache is physically shared across workers. Eliminates disk " | |
| "I/O between batches. dataset_v8 is ~860 MB; fits comfortably " | |
| "on a 100+ GB-RAM machine.") | |
| ap.add_argument("--prefetch_factor", type=int, default=4, | |
| help="DataLoader prefetch_factor (per worker). Higher = more " | |
| "batches staged ahead in queue, better hides I/O behind GPU. " | |
| "Costs RAM proportional to batch_size * num_workers * factor.") | |
| args = ap.parse_args() | |
| _set_seed(args.seed) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| out = Path(args.output_dir) | |
| out.mkdir(parents=True, exist_ok=True) | |
| print(f"[v7] device={device} output={out} encoder={args.encoder}", flush=True) | |
| train_ds = V7SegDataset(Path(args.data_dir) / "train", args.image_size, | |
| p_mod_drop=args.p_mod_drop, augment=True, | |
| cache_in_ram=args.cache_in_ram) | |
| val_ds = V7SegDataset(Path(args.data_dir) / "val", args.image_size, augment=False, | |
| cache_in_ram=args.cache_in_ram) | |
| train_loader = _make_balanced_loader(train_ds, args.batch_size, args.num_workers, | |
| prefetch_factor=args.prefetch_factor) | |
| val_loader_extra = {} | |
| if args.num_workers > 0: | |
| val_loader_extra['persistent_workers'] = True | |
| val_loader_extra['prefetch_factor'] = args.prefetch_factor | |
| val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, | |
| num_workers=args.num_workers, pin_memory=True, | |
| **val_loader_extra) | |
| amp_status = "bf16-AMP" if args.amp else "fp32" | |
| cache_status = "RAM-cached" if args.cache_in_ram else "disk-streamed" | |
| print(f"[v7] train={len(train_ds)} val={len(val_ds)} image_size={args.image_size} " | |
| f"precision={amp_status} data={cache_status}", flush=True) | |
| model = _build_model(encoder=args.encoder).to(device) | |
| n_params = sum(p.numel() for p in model.parameters()) | |
| print(f"[v7] params={n_params/1e6:.1f}M", flush=True) | |
| criterion = TverskyDiceBceLoss(pos_weight=args.bce_pos_weight).to(device) | |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, | |
| weight_decay=args.weight_decay) | |
| total_steps = args.epochs * max(1, len(train_loader)) | |
| warmup_steps = args.warmup_epochs * max(1, len(train_loader)) | |
| # --- Crash-safe resume ----------------------------------------------- | |
| # Resolve --resume: explicit path, or 'auto' meaning last.pt in out_dir. | |
| start_epoch = 1 | |
| best_micro = -1.0 | |
| best_composite = -1.0 | |
| global_step = 0 | |
| resume_path: Path | None = None | |
| if args.resume: | |
| if args.resume.lower() == "auto": | |
| cand = out / "last.pt" | |
| if cand.exists(): | |
| resume_path = cand | |
| else: | |
| print(f"[v7] --resume auto: no last.pt in {out}, starting fresh", flush=True) | |
| else: | |
| resume_path = Path(args.resume) | |
| if resume_path is not None: | |
| try: | |
| ckpt = torch.load(resume_path, map_location=device, weights_only=False) | |
| except Exception as exc: | |
| print(f"[v7] CHECKPOINT CORRUPT at {resume_path}: {type(exc).__name__}: {exc}", flush=True) | |
| print(f"[v7] Falling back to fresh start. Old checkpoint left in place for inspection.", flush=True) | |
| ckpt = None | |
| if ckpt is not None: | |
| if isinstance(ckpt, dict) and "model_state_dict" in ckpt: | |
| model.load_state_dict(ckpt["model_state_dict"]) | |
| if "optimizer_state_dict" in ckpt: | |
| try: | |
| optimizer.load_state_dict(ckpt["optimizer_state_dict"]) | |
| except Exception as exc: | |
| print(f"[v7] optimizer state restore failed: {exc} (continuing with fresh optimizer)", | |
| flush=True) | |
| start_epoch = int(ckpt.get("epoch", 0)) + 1 | |
| global_step = int(ckpt.get("global_step", 0)) | |
| best_micro = float(ckpt.get("best_micro", -1.0)) | |
| best_composite = float(ckpt.get("best_composite", -1.0)) | |
| print(f"[v7] resumed from {resume_path} " | |
| f"epoch={start_epoch} step={global_step} " | |
| f"best_micro={best_micro:.4f} best_composite={best_composite:.4f}", | |
| flush=True) | |
| else: | |
| # Legacy bare-weights checkpoint (v5/v5.1 style). | |
| model.load_state_dict(ckpt) | |
| print(f"[v7] loaded weights-only checkpoint from {resume_path} " | |
| f"(epoch/optimizer state unknown, starting at epoch 1)", flush=True) | |
| if start_epoch > args.epochs: | |
| print(f"[v7] checkpoint already at epoch {start_epoch - 1} >= --epochs {args.epochs}. Nothing to do.", | |
| flush=True) | |
| return 0 | |
| def lr_at(step): | |
| if step < warmup_steps: | |
| return float(step + 1) / max(1, warmup_steps) | |
| progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) | |
| return 0.5 * (1.0 + math.cos(math.pi * progress)) | |
| log_path = out / "training.log" | |
| # AMP setup. bf16 is lossless for accuracy on Ampere+/Hopper+ tensor cores | |
| # and doesn't need a GradScaler (unlike legacy fp16). Disabled on CPU. | |
| amp_enabled = bool(args.amp) and device.type == "cuda" | |
| amp_dtype = torch.bfloat16 | |
| if amp_enabled: | |
| # Confirm device actually supports bf16. Older GPUs (T4, V100) fall back. | |
| if not torch.cuda.is_bf16_supported(): | |
| print("[v7] WARNING: --amp requested but device does not support bf16. " | |
| "Falling back to fp32. (Need Ampere A100/A6000+ or Hopper H100+).", | |
| flush=True) | |
| amp_enabled = False | |
| for epoch in range(start_epoch, args.epochs + 1): | |
| model.train() | |
| t0 = time.time() | |
| loss_sum = 0.0 | |
| steps_in_epoch = 0 | |
| for x, y, _ in train_loader: | |
| x = x.to(device, non_blocking=True) | |
| y = y.to(device, non_blocking=True) | |
| for g in optimizer.param_groups: | |
| g["lr"] = args.lr * lr_at(global_step) | |
| optimizer.zero_grad(set_to_none=True) | |
| if amp_enabled: | |
| with torch.autocast(device_type="cuda", dtype=amp_dtype): | |
| logits = model(x) | |
| loss = criterion(logits, y) | |
| else: | |
| logits = model(x) | |
| loss = criterion(logits, y) | |
| loss.backward() | |
| optimizer.step() | |
| loss_sum += float(loss.item()) | |
| global_step += 1 | |
| steps_in_epoch += 1 | |
| # Intra-epoch checkpoint. Each rolling save costs ~150-300 MB | |
| # of disk write but limits crash blast radius to N steps. | |
| if args.checkpoint_every_steps > 0 and global_step % args.checkpoint_every_steps == 0: | |
| try: | |
| _save_checkpoint(out, "last.pt", | |
| model=model, optimizer=optimizer, | |
| epoch=epoch - 1, # epoch not yet completed | |
| global_step=global_step, | |
| best_micro=best_micro, | |
| best_composite=best_composite, | |
| args=args) | |
| except Exception as exc: | |
| print(f"[v7] intra-epoch checkpoint failed: {exc}", flush=True) | |
| train_loss = loss_sum / max(1, len(train_loader)) | |
| val = _evaluate_with_micro(model, val_loader, device, amp=amp_enabled) | |
| composite = float(val["dice_mean"]) - 5.0 * float(val["fp_rate_mean"]) | |
| line = ( | |
| f"[epoch {epoch:02d}/{args.epochs}] " | |
| f"train_loss={train_loss:.4f} " | |
| f"val_dice={val['dice_mean']:.4f} " | |
| f"micro_dice={val['micro_dice']:.4f} " | |
| f"val_iou={val['iou_mean']:.4f} " | |
| f"val_fp_rate={val['fp_rate_mean']:.4f} " | |
| f"composite={composite:.4f} " | |
| f"lr={optimizer.param_groups[0]['lr']:.2e} " | |
| f"({time.time() - t0:.1f}s)" | |
| ) | |
| print(line, flush=True) | |
| with log_path.open("a") as f: | |
| f.write(line + "\n") | |
| # Per-epoch checkpoint: full state in last.pt so --resume auto can | |
| # pick it up after a crash. best_*.pt are weights+metadata for | |
| # downstream export/inference. | |
| _save_checkpoint(out, "last.pt", | |
| model=model, optimizer=optimizer, | |
| epoch=epoch, global_step=global_step, | |
| best_micro=best_micro, best_composite=best_composite, | |
| args=args) | |
| if val["micro_dice"] > best_micro: | |
| best_micro = val["micro_dice"] | |
| _save_checkpoint(out, "best_micro.pt", | |
| model=model, optimizer=optimizer, | |
| epoch=epoch, global_step=global_step, | |
| best_micro=best_micro, best_composite=best_composite, | |
| args=args) | |
| if composite > best_composite: | |
| best_composite = composite | |
| _save_checkpoint(out, "best_model.pt", | |
| model=model, optimizer=optimizer, | |
| epoch=epoch, global_step=global_step, | |
| best_micro=best_micro, best_composite=best_composite, | |
| args=args) | |
| print(f"[v7] done. best micro_dice={best_micro:.4f} best composite={best_composite:.4f}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |