"""v2 segmentation trainer: bigger, more general, more accurate. Upgrades over src/train_segmentation_torch.py (v1): - Backbone: random 24-filter Attention U-Net -> segmentation_models_pytorch U-Net with ResNet34 ImageNet-pretrained encoder (~24M params). - Loss: Dice + BCE (same) plus Focal Tversky for the LGG dataset's class imbalance. - Augmentation: flip + rot90 -> albumentations pipeline (affine + elastic deform + brightness/contrast + gamma + gaussian noise + gaussian blur + grid distortion + horizontal flip). Validation/test pipelines do only resize + normalize. - Normalization: /255 -> ImageNet per-channel mean/std (matches the ResNet34 pretrained encoder). - Resolution: 192 -> 256 - Precision: FP32 -> mixed precision (FP16) via torch.cuda.amp. ~2x throughput on RTX 4060 + slightly lower power draw. - Optimizer: Adam + ReduceLROnPlateau -> AdamW + cosine annealing with linear warmup over the first 3 epochs. - Epochs: 25 / patience 8 -> 60 / patience 15 - Multi-dataset: train on LGG (real radiologist masks, FLAIR) AND Kaggle (Otsu pseudo-masks, T1c) together. Forces the model to learn a single representation across two different MRI modalities, which is what 'generalize' actually means in practice. - TTA: inference-time augmentation (horizontal + vertical flip averaging) via the existing torch path - hook is in dashboard.py once we want it. - Crash-resilient: per-epoch last.pt + history.json + --resume support (carried over from v1). Output: segmentation_artifacts/attention_unet_v2/{best_model.pt, last.pt, history.json, evaluation_metrics.json, training.log} The saved checkpoint includes encoder_name + base architecture so the dashboard can rebuild the same model when loading. """ from __future__ import annotations import argparse import json import os import sys import time from pathlib import Path import albumentations as A import cv2 import numpy as np import segmentation_models_pytorch as smp import torch import torch.nn.functional as F from albumentations.pytorch import ToTensorV2 from torch.utils.data import ConcatDataset, DataLoader, Dataset _REPO_ROOT = Path(__file__).resolve().parents[1] if str(_REPO_ROOT) not in sys.path: sys.path.append(str(_REPO_ROOT)) IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] def build_train_transform(image_size: int) -> A.Compose: """Light augmentation pipeline. Tuned for medical MRI. Earlier (v3 first run) we used heavy aug: affine+shear+translate+elastic+ grid distortion+noise+blur. Combined with batch=8 + no gradient clipping that caused training divergence at epoch 8 (train_loss jumped 0.227 -> 0.369 over two epochs, val Dice dropped 0.74 -> 0.54). Removed: ElasticTransform, GridDistortion, MedianBlur. Softened: smaller affine ranges, no shear, no translate, lower per-aug probabilities. """ return A.Compose([ A.Resize(image_size, image_size, interpolation=cv2.INTER_AREA), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.3), A.Affine( scale=(0.95, 1.05), rotate=(-10, 10), p=0.5, ), A.OneOf([ A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1), A.RandomGamma(gamma_limit=(90, 110)), ], p=0.4), A.OneOf([ A.GaussNoise(), A.GaussianBlur(blur_limit=3), ], p=0.2), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2(), ]) def build_eval_transform(image_size: int) -> A.Compose: return A.Compose([ A.Resize(image_size, image_size, interpolation=cv2.INTER_AREA), A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), ToTensorV2(), ]) class SegDatasetV2(Dataset): """Reads /images/*.png paired with /masks/*.png and applies an albumentations transform. Pairing is by filename stem.""" def __init__(self, split_dir: Path, transform: A.Compose, name: str = ''): self.split_dir = Path(split_dir) self.transform = transform self.name = name or self.split_dir.parent.name images_dir = self.split_dir / 'images' masks_dir = self.split_dir / 'masks' if not images_dir.exists() or not masks_dir.exists(): raise FileNotFoundError(f'Missing images/ or masks/ under {self.split_dir}') image_paths = sorted([*images_dir.glob('*.png'), *images_dir.glob('*.jpg'), *images_dir.glob('*.jpeg')]) mask_lookup = {p.stem: p for p in masks_dir.glob('*.png')} self.pairs = [(ip, mask_lookup[ip.stem]) for ip in image_paths if ip.stem in mask_lookup] if not self.pairs: raise ValueError(f'No image/mask pairs found under {self.split_dir}') def __len__(self): return len(self.pairs) def __getitem__(self, idx: int): ip, mp = self.pairs[idx] img = cv2.imread(str(ip), cv2.IMREAD_COLOR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(str(mp), cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.float32) out = self.transform(image=img, mask=mask) return out['image'], out['mask'].unsqueeze(0) def dice_score(probs: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: p = probs.contiguous().view(probs.size(0), -1) t = targets.contiguous().view(targets.size(0), -1) inter = (p * t).sum(dim=1) return ((2.0 * inter + smooth) / (p.sum(dim=1) + t.sum(dim=1) + smooth)).mean() def iou_score(probs: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: p = probs.contiguous().view(probs.size(0), -1) t = targets.contiguous().view(targets.size(0), -1) inter = (p * t).sum(dim=1) union = p.sum(dim=1) + t.sum(dim=1) - inter return ((inter + smooth) / (union + smooth)).mean() def dice_loss(logits: torch.Tensor, targets: torch.Tensor, smooth: float = 1e-6) -> torch.Tensor: return 1.0 - dice_score(torch.sigmoid(logits), targets, smooth) def focal_tversky_loss(logits: torch.Tensor, targets: torch.Tensor, alpha: float = 0.7, beta: float = 0.3, gamma: float = 0.75, smooth: float = 1e-6) -> torch.Tensor: """Focal Tversky helps with severe foreground/background imbalance, which LGG has (most pixels are background, tumor area is tiny).""" probs = torch.sigmoid(logits) p = probs.contiguous().view(probs.size(0), -1) t = targets.contiguous().view(targets.size(0), -1) tp = (p * t).sum(dim=1) fn = ((1 - p) * t).sum(dim=1) fp = (p * (1 - t)).sum(dim=1) tversky = (tp + smooth) / (tp + alpha * fn + beta * fp + smooth) return ((1 - tversky) ** gamma).mean() def combined_loss(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: """Dice + BCE only. Focal Tversky was producing NaNs under FP16 mixed precision (the (1 - tversky)**gamma term underflows when tversky -> 1 on confidently-correct batches). Dropped at v3 run #3 after NaN at ep 8.""" bce = F.binary_cross_entropy_with_logits(logits, targets) dl = dice_loss(logits, targets) return 0.5 * bce + 0.5 * dl def evaluate(model: torch.nn.Module, loader: DataLoader, device: torch.device, threshold: float = 0.5, name: str = '') -> dict: model.eval() dice_sum = iou_sum = pix_sum = bce_sum = 0.0 pos_true = pos_pred = inter = union = 0 n = 0 with torch.no_grad(): for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) with torch.amp.autocast('cuda', enabled=device.type == 'cuda'): logits = model(x) probs = torch.sigmoid(logits) binp = (probs >= threshold).float() dice_sum += float(dice_score(binp, y)) iou_sum += float(iou_score(binp, y)) pix_sum += float((binp == y).float().mean()) bce_sum += float(F.binary_cross_entropy_with_logits(logits.float(), y)) pos_true += int(y.sum().item()) pos_pred += int(binp.sum().item()) inter += int((binp * y).sum().item()) union += int(((binp + y) >= 1).float().sum().item()) n += 1 if n == 0: return {} out = { 'split': name or 'eval', 'dice': dice_sum / n, 'iou': iou_sum / n, 'pixel_accuracy': pix_sum / n, 'bce_loss': bce_sum / n, 'positive_voxels_true': pos_true, 'positive_voxels_pred': pos_pred, 'micro_dice': (2 * inter) / max(pos_true + pos_pred, 1), 'micro_iou': inter / max(union, 1), } return out def main(): parser = argparse.ArgumentParser(description='V2 segmentation trainer (SMP UNet + ResNet34 + heavy aug + FP16).') parser.add_argument('--data_dirs', nargs='+', default=['dataset_lgg', 'dataset_real'], help='One or more dataset roots, each with train/val/test/{images,masks}/. ' 'Default trains on LGG (real masks) + Kaggle (pseudo-masks) for cross-modality.') parser.add_argument('--output_dir', default='segmentation_artifacts/attention_unet_v2') parser.add_argument('--encoder', default='resnet34', help='SMP encoder backbone. Options include resnet34, resnet50, efficientnet-b0, mobilenet_v2.') parser.add_argument('--encoder_weights', default='imagenet') parser.add_argument('--architecture', default='Unet', choices=['Unet', 'UnetPlusPlus', 'MAnet', 'Linknet', 'DeepLabV3Plus']) parser.add_argument('--image_size', type=int, default=256) parser.add_argument('--batch_size', type=int, default=8) parser.add_argument('--epochs', type=int, default=60) parser.add_argument('--warmup_epochs', type=int, default=3) parser.add_argument('--learning_rate', type=float, default=3e-4) parser.add_argument('--weight_decay', type=float, default=1e-4) parser.add_argument('--patience', type=int, default=15) parser.add_argument('--num_workers', type=int, default=0) parser.add_argument('--seed', type=int, default=42) parser.add_argument('--device', default='cuda') parser.add_argument('--no_amp', action='store_true', help='Disable mixed-precision (default: enabled on CUDA).') parser.add_argument('--resume', action='store_true') parser.add_argument('--grad_clip_norm', type=float, default=1.0, help='Max grad L2 norm. Prevents the divergence seen at epoch 8 of v3 run #1.') parser.add_argument('--max_gpu_clock_mhz', type=int, default=1500, help='Brownout guard: refuse to start if GPU max clock > this. ' 'Set the cap in admin PS: nvidia-smi --lock-gpu-clocks=210,') parser.add_argument('--skip_gpu_cap_check', action='store_true', help='Bypass the brownout-guard clock-cap check.') args = parser.parse_args() torch.manual_seed(args.seed) np.random.seed(args.seed) if args.device == 'cuda' and not torch.cuda.is_available(): print('[warn] CUDA not available; falling back to CPU.', flush=True) args.device = 'cpu' device = torch.device(args.device) amp_enabled = (device.type == 'cuda') and (not args.no_amp) print(f'[info] device={device} amp={amp_enabled}' + (f' ({torch.cuda.get_device_name(0)})' if device.type == 'cuda' else ''), flush=True) # Live brownout-monitor thread. Polls nvidia-smi every 30s and writes # clocks/temp/power to /gpu_telemetry.csv. If the lock cap # isn't actually in effect, this gives us the data after a crash (the # nvidia-smi --query for clocks.max.graphics returns the silicon ceiling # which is useless for verifying a runtime lock, so we observe behaviour # under load instead). if device.type == 'cuda': import csv import subprocess import threading telemetry_path = Path(args.output_dir) / 'gpu_telemetry.csv' telemetry_path.parent.mkdir(parents=True, exist_ok=True) _stop = {'flag': False} def _telemetry_loop(): with telemetry_path.open('a', newline='', encoding='utf-8') as fh: w = csv.writer(fh) if telemetry_path.stat().st_size == 0: w.writerow(['timestamp', 'gpu_clock_mhz', 'mem_clock_mhz', 'temp_c', 'power_w', 'util_pct', 'mem_used_mb']) while not _stop['flag']: try: out = subprocess.check_output( ['nvidia-smi', '--query-gpu=clocks.gr,clocks.mem,temperature.gpu,power.draw,utilization.gpu,memory.used', '--format=csv,noheader,nounits'], stderr=subprocess.DEVNULL, timeout=5, ).decode().strip() parts = [p.strip() for p in out.split(',')] w.writerow([time.strftime('%H:%M:%S')] + parts) fh.flush() if int(float(parts[0])) > args.max_gpu_clock_mhz: print(f'[telemetry] WARN gpu_clock={parts[0]} MHz exceeds cap ' f'{args.max_gpu_clock_mhz}; check nvidia-smi --lock-gpu-clocks.', flush=True) except Exception: pass time.sleep(30) _t = threading.Thread(target=_telemetry_loop, daemon=True) _t.start() print(f'[info] GPU telemetry logging to {telemetry_path} every 30s', flush=True) train_tf = build_train_transform(args.image_size) eval_tf = build_eval_transform(args.image_size) train_datasets, val_datasets, test_datasets = [], [], [] for d in args.data_dirs: d = Path(d) if not d.exists(): print(f'[warn] data_dir not found, skipping: {d}', flush=True) continue for sub, target, tf in [('train', train_datasets, train_tf), ('val', val_datasets, eval_tf), ('test', test_datasets, eval_tf)]: split = d / sub try: ds = SegDatasetV2(split, tf, name=f'{d.name}/{sub}') target.append(ds) print(f'[info] {d.name}/{sub}: {len(ds)} samples', flush=True) except (FileNotFoundError, ValueError) as exc: print(f'[warn] skip {d.name}/{sub}: {exc}', flush=True) if not train_datasets or not val_datasets: raise RuntimeError('No usable train/val datasets after scanning --data_dirs.') train_ds = ConcatDataset(train_datasets) if len(train_datasets) > 1 else train_datasets[0] val_ds = ConcatDataset(val_datasets) if len(val_datasets) > 1 else val_datasets[0] test_ds = ConcatDataset(test_datasets) if len(test_datasets) > 1 else (test_datasets[0] if test_datasets else None) print(f'[info] total train={len(train_ds)} val={len(val_ds)}' + (f' test={len(test_ds)}' if test_ds else ''), flush=True) common = dict(batch_size=args.batch_size, num_workers=args.num_workers, pin_memory=(device.type == 'cuda')) train_loader = DataLoader(train_ds, shuffle=True, drop_last=False, **common) val_loader = DataLoader(val_ds, shuffle=False, **common) test_loader = DataLoader(test_ds, shuffle=False, **common) if test_ds else None SmpClass = getattr(smp, args.architecture) model = SmpClass( encoder_name=args.encoder, encoder_weights=args.encoder_weights if args.encoder_weights and args.encoder_weights != 'none' else None, in_channels=3, classes=1, ).to(device) n_params = sum(p.numel() for p in model.parameters()) print(f'[info] model: {args.architecture} + {args.encoder} (pretrained={args.encoder_weights}) - {n_params:,} params', flush=True) optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=max(1, args.epochs - args.warmup_epochs), eta_min=1e-6) scaler = torch.amp.GradScaler('cuda', enabled=amp_enabled) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) best_path = output_dir / 'best_model.pt' last_path = output_dir / 'last.pt' history_path = output_dir / 'history.json' history = {'train_loss': [], 'val_dice': [], 'val_iou': [], 'val_loss': [], 'lr': []} best_val_dice = -1.0 epochs_without_improve = 0 start_epoch = 0 if args.resume and last_path.exists(): prev = torch.load(str(last_path), map_location=device, weights_only=False) model.load_state_dict(prev['state_dict']) if 'optimizer_state' in prev: optimizer.load_state_dict(prev['optimizer_state']) if 'scheduler_state' in prev: try: scheduler.load_state_dict(prev['scheduler_state']) except Exception: pass if 'scaler_state' in prev and amp_enabled: try: scaler.load_state_dict(prev['scaler_state']) except Exception: pass history = prev.get('history', history) best_val_dice = float(prev.get('best_val_dice', best_val_dice)) epochs_without_improve = int(prev.get('epochs_without_improve', 0)) start_epoch = int(prev.get('epoch', 0)) print(f'[info] Resumed from {last_path} at epoch {start_epoch} (best_val_dice={best_val_dice:.4f})', flush=True) base_lr = args.learning_rate for epoch in range(start_epoch, args.epochs): # Linear warmup over the first warmup_epochs, then cosine if epoch < args.warmup_epochs: warm_lr = base_lr * (epoch + 1) / max(1, args.warmup_epochs) for pg in optimizer.param_groups: pg['lr'] = warm_lr model.train() t0 = time.time() running_loss = 0.0 n_steps = 0 for x, y in train_loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast('cuda', enabled=amp_enabled): logits = model(x) loss = combined_loss(logits, y) if amp_enabled: scaler.scale(loss).backward() # Unscale BEFORE clipping so the clip threshold is in real (FP32) units. scaler.unscale_(optimizer) grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=args.grad_clip_norm) # NaN guard: scaler.step will internally check infs but doubling # up here makes the skip explicit and avoids any chance of a # poisoned weight update reaching the model. if not torch.isfinite(grad_norm): optimizer.zero_grad(set_to_none=True) scaler.update() print(f'[nan_guard] step skipped (grad_norm not finite)', flush=True) else: scaler.step(optimizer) scaler.update() else: loss.backward() grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=args.grad_clip_norm) if not torch.isfinite(grad_norm): optimizer.zero_grad(set_to_none=True) print(f'[nan_guard] step skipped (grad_norm not finite)', flush=True) else: optimizer.step() running_loss += float(loss) n_steps += 1 if epoch >= args.warmup_epochs: scheduler.step() train_loss = running_loss / max(n_steps, 1) vm = evaluate(model, val_loader, device, name='val') elapsed = time.time() - t0 lr_now = optimizer.param_groups[0]['lr'] history['train_loss'].append(train_loss) history['val_dice'].append(vm['dice']) history['val_iou'].append(vm['iou']) history['val_loss'].append(vm['bce_loss']) history['lr'].append(lr_now) print( f'[epoch {epoch+1:02d}/{args.epochs}] ' f'train_loss={train_loss:.4f} val_dice={vm["dice"]:.4f} val_iou={vm["iou"]:.4f} ' f'val_bce={vm["bce_loss"]:.4f} lr={lr_now:.2e} ({elapsed:.1f}s)', flush=True, ) if vm['dice'] > best_val_dice: best_val_dice = vm['dice'] epochs_without_improve = 0 torch.save({ 'state_dict': model.state_dict(), 'config': vars(args), 'val_metrics': vm, 'epoch': epoch + 1, 'architecture': args.architecture, 'encoder': args.encoder, 'image_size': args.image_size, }, best_path) print(f' -> new best val_dice={best_val_dice:.4f}; weights saved to {best_path}', flush=True) else: epochs_without_improve += 1 torch.save({ 'state_dict': model.state_dict(), 'optimizer_state': optimizer.state_dict(), 'scheduler_state': scheduler.state_dict(), 'scaler_state': scaler.state_dict() if amp_enabled else None, 'config': vars(args), 'val_metrics': vm, 'epoch': epoch + 1, 'history': history, 'best_val_dice': best_val_dice, 'epochs_without_improve': epochs_without_improve, 'architecture': args.architecture, 'encoder': args.encoder, 'image_size': args.image_size, }, last_path) with history_path.open('w', encoding='utf-8') as fh: json.dump(history, fh, indent=2) if epochs_without_improve >= args.patience: print(f'[info] Early stopping: no improvement in {args.patience} epochs.', flush=True) break if best_path.exists(): ckpt = torch.load(best_path, map_location=device, weights_only=False) model.load_state_dict(ckpt['state_dict']) eval_payload = {'val': evaluate(model, val_loader, device, name='val')} if test_loader is not None: eval_payload['test'] = evaluate(model, test_loader, device, name='test') # Also report per-source-dataset test performance so we can see whether # the model generalizes across modalities. if len(test_datasets) > 1: for ds in test_datasets: loader = DataLoader(ds, batch_size=args.batch_size, shuffle=False, num_workers=0, pin_memory=(device.type == 'cuda')) eval_payload[f'test_{ds.name.replace("/", "_")}'] = evaluate(model, loader, device, name=ds.name) with (output_dir / 'evaluation_metrics.json').open('w', encoding='utf-8') as fh: json.dump(eval_payload, fh, indent=2) print('[info] Final evaluation:') print(json.dumps(eval_payload, indent=2)) try: import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt epochs_x = list(range(1, len(history['train_loss']) + 1)) fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(epochs_x, history['train_loss'], label='train loss') axes[0].plot(epochs_x, history['val_loss'], label='val BCE') axes[0].legend(); axes[0].set_xlabel('epoch'); axes[0].set_title('Loss') axes[1].plot(epochs_x, history['val_dice'], label='val dice') axes[1].plot(epochs_x, history['val_iou'], label='val IoU') axes[1].legend(); axes[1].set_xlabel('epoch'); axes[1].set_title('Validation metrics') plt.tight_layout() plt.savefig(output_dir / 'training_curves.png', dpi=120) plt.close() except Exception as exc: print(f'[warn] plot failed: {exc}', flush=True) print(f'[done] Best val Dice = {best_val_dice:.4f}') if __name__ == '__main__': main()