"""Retrain CNN / Transfer / ViT classifiers on dataset_v8. Why this exists (the brutal version): the live classifiers were trained on Kaggle 4-class only and hit 0%-67% recall on OOD tumors (see scripts/eval_ood_classifiers_brutal.py output). dataset_v8 is the same distribution the v8 *segmenter* trains on (BraTS T1c + LGG + Figshare + Kaggle 4-class neg), so retraining the classifiers there closes the gap between segmenter generalisation and classifier generalisation. What's in / not in: - IN (train/val/test): dataset_v8/{train,val,test} ONLY. - NOT IN: samples/ood/* — those are held out for the final production accuracy number, on the user's explicit instruction. Labels derived from masks: a sample is tumor iff its mask has >= 50 tumor pixels (matches MIN_TUMOR_AREA used everywhere else in the repo). Outputs to real_eval_v8_retrained//{best_weights.pt, best_weights.onnx}. The dashboard's _classifier_dir() resolver checks real_eval_v8_retrained/ first when it exists, so a successful run flips production automatically. Run: python scripts/retrain_classifiers_on_v8.py """ from __future__ import annotations import argparse import json import sys import time from pathlib import Path import cv2 import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader, Dataset ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from src.classifier_torch import get_classifier # noqa: E402 MIN_TUMOR_AREA = 50 IMAGE_SIZE = 224 IM_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IM_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) class MaskDerivedClassificationDataset(Dataset): """Reads dataset_v8//images/*.png + masks/*.png. Label: 1 if mask has >= MIN_TUMOR_AREA tumor pixels, else 0. Caches the per-image label once at __init__ so the per-getitem hot path only reads the image. """ def __init__(self, split_dir: Path, image_size: int = IMAGE_SIZE, normalize_imagenet: bool = False, train: bool = True): self.image_size = image_size self.normalize_imagenet = normalize_imagenet self.train = train self.images_dir = Path(split_dir) / 'images' self.masks_dir = Path(split_dir) / 'masks' if not self.images_dir.exists(): raise FileNotFoundError(f'no images dir at {self.images_dir}') if not self.masks_dir.exists(): raise FileNotFoundError(f'no masks dir at {self.masks_dir}') # Pre-compute labels (cheap: a single np.sum per mask). entries = [] n_tum = n_neg = 0 for img_path in sorted(self.images_dir.glob('*.png')): mask_path = self.masks_dir / img_path.name if not mask_path.exists(): continue m = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) # noqa: F811 label = 1.0 if int((m > 127).sum()) >= MIN_TUMOR_AREA else 0.0 entries.append((img_path, label)) if label == 1.0: n_tum += 1 else: n_neg += 1 self.entries = entries self.n_tumor = n_tum self.n_no_tumor = n_neg print(f' [dataset] {split_dir.name:5s}: total={len(entries):5d} ' f'tumor={n_tum:5d} no_tumor={n_neg:5d} ' f'class_balance={n_tum/max(len(entries),1):.1%} positive') def __len__(self): return len(self.entries) def __getitem__(self, idx): path, label = self.entries[idx] img = cv2.imread(str(path)) if img is None: raise RuntimeError(f'failed to read {path}') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) if img.shape[0] != self.image_size or img.shape[1] != self.image_size: img = cv2.resize(img, (self.image_size, self.image_size)) if self.train and np.random.rand() < 0.5: img = np.ascontiguousarray(img[:, ::-1]) img = img.astype(np.float32) / 255.0 if self.normalize_imagenet: img = (img - IM_MEAN) / IM_STD img = img.transpose(2, 0, 1) return torch.from_numpy(img), torch.tensor(label, dtype=torch.float32) def evaluate(model: nn.Module, loader: DataLoader, device, threshold: float = 0.5) -> dict: model.eval() y_true, y_pred_prob, y_pred_bin = [], [], [] bce_total, n = 0.0, 0 with torch.no_grad(): for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) logits = model(x).squeeze(-1) probs = torch.sigmoid(logits) bce_total += F.binary_cross_entropy_with_logits(logits, y, reduction='sum').item() y_true.extend(y.cpu().numpy().tolist()) y_pred_prob.extend(probs.cpu().numpy().tolist()) y_pred_bin.extend((probs >= threshold).float().cpu().numpy().tolist()) n += y.shape[0] y_true = np.asarray(y_true); y_pred_bin = np.asarray(y_pred_bin); y_pred_prob = np.asarray(y_pred_prob) tp = int(((y_true==1)&(y_pred_bin==1)).sum()); fp = int(((y_true==0)&(y_pred_bin==1)).sum()) fn = int(((y_true==1)&(y_pred_bin==0)).sum()); tn = int(((y_true==0)&(y_pred_bin==0)).sum()) accuracy = (tp+tn)/max(n,1); precision = tp/max(tp+fp,1) recall = tp/max(tp+fn,1); f1 = 2*precision*recall/max(precision+recall,1e-9) try: from sklearn.metrics import roc_auc_score roc_auc = float(roc_auc_score(y_true, y_pred_prob)) if len(set(y_true)) > 1 else float('nan') except Exception: roc_auc = float('nan') return {'n': n, 'accuracy': accuracy, 'precision': precision, 'recall': recall, 'f1': f1, 'roc_auc': roc_auc, 'confusion_matrix': {'tn': tn, 'fp': fp, 'fn': fn, 'tp': tp}, 'bce_loss_mean': bce_total/max(n,1)} def export_onnx(model: nn.Module, save_path: Path, device): """Export to ONNX. Redirects the exporter's emoji-heavy stdout/stderr into an in-memory StringIO so it never hits the parent process's cp1252 console (which would crash on the success checkmark).""" import io import contextlib model.eval() dummy = torch.randn(1, 3, IMAGE_SIZE, IMAGE_SIZE, device=device) buf = io.StringIO() ok = False err: Exception | None = None try: with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): torch.onnx.export( model, dummy, str(save_path), input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=17, ) ok = True except Exception as exc: err = exc if ok: print(f' -> exported ONNX: {save_path} ({save_path.stat().st_size/1e6:.1f} MB)') else: print(f' ONNX export failed (continuing): {type(err).__name__}: {err}') def train_one(model_name: str, args) -> dict: print(f'\n========== training {model_name} on dataset_v8 ==========', flush=True) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f'[{model_name}] device={device}' + (f' ({torch.cuda.get_device_name(0)})' if device.type == 'cuda' else ''), flush=True) normalize = (model_name != 'cnn') train_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'train', normalize_imagenet=normalize, train=True) val_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'val', normalize_imagenet=normalize, train=False) test_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'test', normalize_imagenet=normalize, train=False) 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) model = get_classifier(model_name).to(device) n_params = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f'[{model_name}] trainable params: {n_params:,}', flush=True) optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=args.learning_rate) scaler = torch.amp.GradScaler('cuda', enabled=(device.type == 'cuda')) # Compute pos_weight to balance the BCE loss against class imbalance. # pos_weight = n_neg / n_pos. When pos_weight < 1 we downweight the # majority (positives, after OpenNeuro augmentation); when > 1 we # upweight the minority. Without this the previous round learned a # positive bias and false-alarmed on all OOD healthy brains. pw = float(train_ds.n_no_tumor) / max(train_ds.n_tumor, 1) pw_tensor = torch.tensor([pw], device=device, dtype=torch.float32) print(f'[{model_name}] pos_weight = n_neg/n_pos = ' f'{train_ds.n_no_tumor}/{train_ds.n_tumor} = {pw:.4f}', flush=True) out_dir = ROOT / args.output / model_name out_dir.mkdir(parents=True, exist_ok=True) best_path = out_dir / 'best_weights.pt' onnx_path = out_dir / 'best_weights.onnx' best_val_acc = -1.0 epochs_without_improve = 0 for epoch in range(args.epochs): t0 = time.time() model.train() if hasattr(model, 'backbone'): for m in model.backbone.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() running_loss = 0.0; running_correct = 0; running_n = 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=(device.type == 'cuda')): logits = model(x).squeeze(-1) loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=pw_tensor) if device.type == 'cuda': scaler.scale(loss).backward(); scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer); scaler.update() else: loss.backward(); optimizer.step() running_loss += float(loss) * x.size(0) preds = (torch.sigmoid(logits) >= 0.5).float() running_correct += int((preds == y).sum().item()) running_n += x.size(0) train_loss = running_loss/max(running_n,1); train_acc = running_correct/max(running_n,1) val_metrics = evaluate(model, val_loader, device) val_acc = val_metrics['accuracy']; val_loss = val_metrics['bce_loss_mean'] elapsed = time.time() - t0 print(f'[{model_name}][ep {epoch+1:02d}/{args.epochs}] ' f'train_loss={train_loss:.4f} train_acc={train_acc:.4f} ' f'val_loss={val_loss:.4f} val_acc={val_acc:.4f} ' f'val_recall={val_metrics["recall"]:.4f} ({elapsed:.1f}s)', flush=True) if val_acc > best_val_acc: best_val_acc = val_acc epochs_without_improve = 0 torch.save({'state_dict': model.state_dict(), 'model_name': model_name, 'val_metrics': val_metrics, 'epoch': epoch+1, 'normalize_imagenet': normalize}, best_path) print(f' -> new best val_acc={best_val_acc:.4f}; saved {best_path}', flush=True) else: epochs_without_improve += 1 if epochs_without_improve >= args.patience: print(f'[{model_name}] early stopping at ep {epoch+1}', flush=True) break # Final: load best, eval test, export ONNX ckpt = torch.load(str(best_path), map_location=device, weights_only=False) model.load_state_dict(ckpt['state_dict']) test_metrics = evaluate(model, test_loader, device) print(f'[{model_name}] TEST: acc={test_metrics["accuracy"]:.4f} ' f'recall={test_metrics["recall"]:.4f} precision={test_metrics["precision"]:.4f} ' f'f1={test_metrics["f1"]:.4f} roc_auc={test_metrics["roc_auc"]:.4f}', flush=True) final = {'val': evaluate(model, val_loader, device), 'test': test_metrics} (ROOT / args.output / f'{model_name}_evaluation_metrics.json').write_text( json.dumps(final, indent=2), encoding='utf-8') export_onnx(model, onnx_path, device) return final def main(): parser = argparse.ArgumentParser() parser.add_argument('--dataset', default='dataset_v8') parser.add_argument('--output', default='real_eval_v8_retrained') parser.add_argument('--models', nargs='+', default=['cnn', 'transfer', 'vit'], choices=['cnn', 'transfer', 'vit']) parser.add_argument('--epochs', type=int, default=10) parser.add_argument('--batch_size', type=int, default=48) parser.add_argument('--learning_rate', type=float, default=1e-4) parser.add_argument('--num_workers', type=int, default=4) parser.add_argument('--patience', type=int, default=3) parser.add_argument('--seed', type=int, default=42) args = parser.parse_args() torch.manual_seed(args.seed); np.random.seed(args.seed) print(f'[init] cuda={torch.cuda.is_available()} dataset={args.dataset} output={args.output}') print(f'[init] models={args.models} epochs={args.epochs} batch={args.batch_size}') print(f'[init] NOT touching samples/ood/* — those are held out for production accuracy.') results = {} t_total = time.time() for m in args.models: results[m] = train_one(m, args) print(f'\n[done] all classifiers trained in {(time.time()-t_total)/60:.1f} min') print('\nSUMMARY (test split, dataset_v8):') for m, r in results.items(): t = r['test'] print(f' {m:10s} acc={t["accuracy"]:.4f} recall={t["recall"]:.4f} ' f'precision={t["precision"]:.4f} f1={t["f1"]:.4f} auc={t["roc_auc"]:.4f}') if __name__ == '__main__': main()