""" train_m5_optimized.py — M5-chip optimized training targeting 98%+ Apple M5 tuning: - Batch size 16 → fits MPS memory comfortably, no throttle - num_workers=2 → M5 efficiency cores handle prefetch without starving GPU - pin_memory=False → MPS doesn't support it (removes warning) - torch.backends.mps.enable_fallback_for_mps_ → avoids unsupported ops - Gradient accumulation x2 → effective batch=32 without memory spike - EfficientNet-B4 stays — M5 handles it fine at batch=16 - Cosine Annealing Warm Restarts — better than step-decay on MPS - No Mixup in Phase B — reduces MPS memory churn - AdamW + weight_decay — cleaner generalization than Adam - Live metrics.json update every epoch for dashboard - Baseline: 95.18% → Target: 98%+ """ import json import os import sys import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts from torch.utils.data import DataLoader from tqdm import tqdm import numpy as np sys.path.insert(0, os.path.dirname(__file__)) from data_loader import ( get_classification_transforms, ClassificationDataset, load_config, set_seed, ) from sklearn.model_selection import train_test_split from torch.utils.data import Subset from model import get_classifier, unfreeze_top_layers # ── Paths ────────────────────────────────────────────────────────────────── BASE = os.path.join(os.path.dirname(__file__), "..") METRICS_JSON = os.path.join(BASE, "stitch_frontend", "metrics.json") CKPT_DIR = os.path.join(BASE, "models", "classifier") BEST_CKPT = os.path.join(CKPT_DIR, "brain_tumor_98plus_best.pth") PREV_CKPT = os.path.join(CKPT_DIR, "brain_tumor_classifier_best.pth") LOG_FILE = os.path.join(BASE, "outputs", "training_m5.log") # ── M5 Tuned Hyperparameters ─────────────────────────────────────────────── M5 = dict( batch_size = 16, # safe for 16GB unified memory at 380px img num_workers = 2, # 2 efficiency cores for prefetch pin_memory = False, # MPS doesn't support pin_memory grad_accum = 2, # effective batch = 32 (no extra memory) phase_a_epochs = 0, # Skipped! User requested jump to Phase B phase_b_epochs = 30, # full unfreeze, very low LR phase_a_lr = 3e-5, # gentle — we're already at 95% phase_b_lr = 8e-6, # ultra-fine polishing weight_decay = 1e-4, label_smoothing = 0.05, # mild smoothing clip_grad = 1.0, patience = 10, ema_decay = 0.999, mixup_alpha = 0.2, # gentle mixup only in Phase A ) # ── Utilities ────────────────────────────────────────────────────────────── class EMA: def __init__(self, model, decay=0.999): self.model = model self.decay = decay self.shadow = {k: v.clone().detach() for k, v in model.state_dict().items()} def update(self): for k, v in self.model.state_dict().items(): self.shadow[k] = self.decay * self.shadow[k] + (1 - self.decay) * v.detach() def apply(self): self._bak = {k: v.clone() for k, v in self.model.state_dict().items()} self.model.load_state_dict(self.shadow) def restore(self): self.model.load_state_dict(self._bak) def mixup(x, y, alpha, device): lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0 idx = torch.randperm(x.size(0)).to(device) return lam * x + (1 - lam) * x[idx], y, y[idx], lam def log(msg): print(msg, flush=True) os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) with open(LOG_FILE, "a") as f: f.write(msg + "\n") def save_metrics(ep, total_ep, phase, t_acc, v_acc, t_loss, v_loss, status="training"): os.makedirs(os.path.dirname(METRICS_JSON), exist_ok=True) with open(METRICS_JSON, "w") as f: json.dump({ "status": status, "phase": phase, "current_epoch": ep, "total_epochs": total_ep, "best_val_acc": round(max(v_acc), 2) if v_acc else 0.0, "target_acc": 98.0, "history": { "epoch": ep, "train_acc": [round(x, 2) for x in t_acc], "val_acc": [round(x, 2) for x in v_acc], "train_loss": [round(x, 4) for x in t_loss], "val_loss": [round(x, 4) for x in v_loss], } }, f, indent=2) def build_loaders(config): """Build loaders with M5-optimised settings.""" cfg = config["classification"] aug = cfg["augmentation"] size = cfg["img_size"] names = cfg["class_names"] data = config["paths"]["data_classification"] train_tf, val_tf = get_classification_transforms(size, aug) full = ClassificationDataset( os.path.join(data, "Training"), names, size, train_tf, "train" ) labels = [s[1] for s in full.samples] tr_idx, va_idx = train_test_split( list(range(len(full))), test_size=cfg.get("val_split", 0.1), random_state=config["seed"], stratify=labels, ) val_ds = ClassificationDataset( os.path.join(data, "Training"), names, size, val_tf, "val" ) val_ds.samples = [full.samples[i] for i in va_idx] kw = dict(num_workers=M5["num_workers"], pin_memory=M5["pin_memory"]) tr_loader = DataLoader(Subset(full, tr_idx), batch_size=M5["batch_size"], shuffle=True, **kw) va_loader = DataLoader(val_ds, batch_size=M5["batch_size"], shuffle=False, **kw) return tr_loader, va_loader # ── Train / Validate ─────────────────────────────────────────────────────── def train_one_epoch(model, loader, criterion, optimizer, device, use_mixup=False, grad_accum=1): model.train() total_loss = correct = total = 0 optimizer.zero_grad() for step, (x, y) in enumerate(tqdm(loader, desc=" Train", leave=False), 1): x, y = x.to(device), y.to(device) if use_mixup: x, ya, yb, lam = mixup(x, y, M5["mixup_alpha"], device) out = model(x) loss = lam * criterion(out, ya) + (1 - lam) * criterion(out, yb) _, pred = torch.max(out, 1) correct += (lam*(pred==ya).float() + (1-lam)*(pred==yb).float()).sum().item() else: out = model(x) loss = criterion(out, y) _, pred = torch.max(out, 1) correct += (pred == y).sum().item() (loss / grad_accum).backward() if step % grad_accum == 0 or step == len(loader): nn.utils.clip_grad_norm_(model.parameters(), M5["clip_grad"]) optimizer.step() optimizer.zero_grad() total_loss += loss.item() * x.size(0) total += x.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device, ema=None): if ema: ema.apply() model.eval() total_loss = correct = total = 0 with torch.no_grad(): for x, y in tqdm(loader, desc=" Val ", leave=False): x, y = x.to(device), y.to(device) out = model(x) loss = criterion(out, y) _, pred = torch.max(out, 1) correct += (pred == y).sum().item() total_loss += loss.item() * x.size(0) total += x.size(0) if ema: ema.restore() return total_loss / total, correct / total # ── Main ─────────────────────────────────────────────────────────────────── def main(): config = load_config("config.yaml") set_seed(config["seed"]) # Apple M5 MPS device device = torch.device("mps") torch.backends.mps.enable_fallback_for_mps_ = True # graceful op fallback log("\n" + "═"*62) log(" 🍎 Apple M5 Optimised Training → Target: 98%+") log(f" Batch={M5['batch_size']} | Workers={M5['num_workers']} | " f"GradAccum={M5['grad_accum']} (eff. batch={M5['batch_size']*M5['grad_accum']})") log("═"*62 + "\n") tr_loader, va_loader = build_loaders(config) criterion = nn.CrossEntropyLoss(label_smoothing=M5["label_smoothing"]) # ── Build model ── model = get_classifier( num_classes=config["classification"]["num_classes"], dropout=config["classification"]["dropout"], freeze_backbone=False, model_name=config["classification"].get("model_name", "efficientnet_b4"), ).to(device) # Load best available checkpoint ckpt_path = BEST_CKPT if os.path.exists(BEST_CKPT) else PREV_CKPT if os.path.exists(ckpt_path): ckpt = torch.load(ckpt_path, map_location=device) model.load_state_dict(ckpt["model_state_dict"]) base_acc = round(float(ckpt.get("metric", 0)) * 100, 2) log(f" ✅ Loaded: {os.path.basename(ckpt_path)} (baseline {base_acc:.2f}%)\n") else: base_acc = 0.0 log(" ⚠️ No checkpoint — starting fresh\n") all_t_acc, all_v_acc, all_t_loss, all_v_loss = [], [], [], [] global_best = base_acc / 100.0 TOTAL = M5["phase_a_epochs"] + M5["phase_b_epochs"] # ════════════════════════════════════════════════════════ # PHASE A — Fine-tune top 30 backbone layers # ════════════════════════════════════════════════════════ log("── Phase A: top-30 fine-tune, Mixup ON ──") unfreeze_top_layers(model, num_layers=30) opt_a = optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=M5["phase_a_lr"], weight_decay=M5["weight_decay"] ) sched_a = CosineAnnealingWarmRestarts(opt_a, T_0=10, T_mult=1, eta_min=1e-8) ema = EMA(model, M5["ema_decay"]) patience_cnt = 0 for ep in range(1, M5["phase_a_epochs"] + 1): tl, ta = train_one_epoch(model, tr_loader, criterion, opt_a, device, use_mixup=True, grad_accum=M5["grad_accum"]) ema.update() vl, va = validate(model, va_loader, criterion, device, ema) sched_a.step() all_t_acc.append(ta*100); all_v_acc.append(va*100) all_t_loss.append(tl); all_v_loss.append(vl) save_metrics(ep, TOTAL, "Phase A – Top-30 Fine-tune", all_t_acc, all_v_acc, all_t_loss, all_v_loss) lr_now = sched_a.get_last_lr()[0] log(f" [A] {ep:02}/{M5['phase_a_epochs']} | " f"Tr {ta*100:.2f}% | Val {va*100:.2f}% | " f"Best {max(all_v_acc):.2f}% | LR {lr_now:.1e}") if va > global_best: global_best = va patience_cnt = 0 os.makedirs(CKPT_DIR, exist_ok=True) torch.save({"epoch": ep, "model_state_dict": model.state_dict(), "metric": va}, BEST_CKPT) log(f" 💾 Saved best ({va*100:.2f}%)") else: patience_cnt += 1 if patience_cnt >= M5["patience"]: log(" ⏹ Early stop Phase A"); break # ════════════════════════════════════════════════════════ # PHASE B — Full unfreeze, ultra-low LR, no Mixup # ════════════════════════════════════════════════════════ log(f"\n── Phase B: full unfreeze, LR={M5['phase_b_lr']:.0e}, Mixup OFF ──") # Reload best from Phase A if os.path.exists(BEST_CKPT): ckpt = torch.load(BEST_CKPT, map_location=device) model.load_state_dict(ckpt["model_state_dict"]) log(f" Reloaded best checkpoint ({max(all_v_acc):.2f}%)") for param in model.parameters(): param.requires_grad = True opt_b = optim.AdamW(model.parameters(), lr=M5["phase_b_lr"], weight_decay=M5["weight_decay"]//2) sched_b = CosineAnnealingWarmRestarts(opt_b, T_0=15, T_mult=1, eta_min=1e-9) ema = EMA(model, decay=0.9995) patience_cnt = 0 phase_b_best = global_best for ep in range(1, M5["phase_b_epochs"] + 1): tl, ta = train_one_epoch(model, tr_loader, criterion, opt_b, device, use_mixup=False, grad_accum=M5["grad_accum"]) ema.update() vl, va = validate(model, va_loader, criterion, device, ema) sched_b.step() all_t_acc.append(ta*100); all_v_acc.append(va*100) all_t_loss.append(tl); all_v_loss.append(vl) global_ep = M5["phase_a_epochs"] + ep save_metrics(global_ep, TOTAL, "Phase B – Full Unfreeze", all_t_acc, all_v_acc, all_t_loss, all_v_loss) lr_now = sched_b.get_last_lr()[0] log(f" [B] {ep:02}/{M5['phase_b_epochs']} | " f"Tr {ta*100:.2f}% | Val {va*100:.2f}% | " f"Best {max(all_v_acc):.2f}% | LR {lr_now:.1e}") if va > phase_b_best: phase_b_best = va global_best = va patience_cnt = 0 torch.save({"epoch": global_ep, "model_state_dict": model.state_dict(), "metric": va}, BEST_CKPT) log(f" 💾 New best ({va*100:.2f}%)") if va >= 0.98: log(f"\n 🎯 98%+ REACHED! Final: {va*100:.2f}% — stopping early.") break else: patience_cnt += 1 if patience_cnt >= M5["patience"]: log(" ⏹ Early stop Phase B"); break # ── Done ── final = max(all_v_acc) reached = final >= 98.0 status = "completed" if reached else "completed_below_target" save_metrics(len(all_t_acc), TOTAL, "Complete", all_t_acc, all_v_acc, all_t_loss, all_v_loss, status) log("\n" + "═"*62) log(f" 🏁 DONE | Best Val Accuracy: {final:.2f}%") log(f" {'✅ GOAL ACHIEVED: 98%+ !' if reached else '🔄 Close — consider one more run'}") log("═"*62 + "\n") if __name__ == "__main__": main()