{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":71549,"databundleVersionId":8561470},{"sourceType":"datasetVersion","sourceId":9245433,"datasetId":5592926,"databundleVersionId":9433882},{"sourceType":"kernelVersion","sourceId":306930178},{"sourceType":"kernelVersion","sourceId":307074433}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ==============================================================================\n# RSNA 2024 – Lumbar Spine Degenerative Classification\n# NOTEBOOK 3 of 4: Model Training (DenseNet121 + ViT Ensemble, T4 x2)\n# ------------------------------------------------------------------------------\n# Inputs  (add as Kaggle dataset, e.g. \"rsna-nb2-out\"):\n#   /kaggle/input/rsna-nb2-out/flat_df_with_crops.pkl\n#   /kaggle/input/rsna-nb2-out/cls_weights.npy\n#   /kaggle/input/rsna-nb2-out/preprocessed_crops.zip\n#\n# Saves to /kaggle/working/:\n#   ensemble_best.pt               (best val QWK checkpoint — unwrapped from DataParallel)\n#   ensemble_epoch{N}_qwk{K}.pt    (periodic checkpoints every 3 epochs, for resume)\n#   history.pkl\n#   outputs/cell_dashboard.png     (if VIS_MODE=True)\n#\n# Upload /kaggle/working/ as Kaggle dataset before running Notebook 4.\n#\n!pip install timm grad-cam albumentations -q\n# ==============================================================================\n\nVIS_MODE = False\n\n# ==============================================================================\n# CELL 1: SETUP & IMPORTS\n# ==============================================================================\nimport os, gc, math, glob, shutil, random, warnings, time, pickle\nwarnings.filterwarnings(\"ignore\")\n\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport matplotlib\nif not VIS_MODE:\n    matplotlib.use('Agg')\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\nimport seaborn as sns\nfrom pathlib import Path\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom torch.amp import GradScaler, autocast\nfrom torchvision.models import densenet121, DenseNet121_Weights\nimport timm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import cohen_kappa_score, confusion_matrix\n\n# ── Paths ──────────────────────────────────────────────────────────────────────\nON_KAGGLE = os.path.exists('/kaggle/input')\n\nif ON_KAGGLE:\n    NB2_DATASET = '/kaggle/input/notebooks/tahuja/notebook2'    # ← UPDATE to your dataset name\n    # Crops are zipped — unzip to /tmp at startup (fast, doesn't cost /kaggle/working space)\n    PREP_DIR    = '/tmp/preprocessed_dataset'\n    OUT_DIR     = '/kaggle/working/outputs'\nelse:\n    LOCAL_DATA_DIR = r'C:\\rsna'\n    NB2_DATASET    = LOCAL_DATA_DIR\n    PREP_DIR       = os.path.join(LOCAL_DATA_DIR, 'preprocessed_dataset')\n    OUT_DIR        = os.path.join(LOCAL_DATA_DIR, 'outputs')\n\nos.makedirs(OUT_DIR, exist_ok=True)\nos.makedirs(PREP_DIR, exist_ok=True)\n\n# ── Hyper-parameters ───────────────────────────────────────────────────────────\nIMG_SIZE     = 224\nLR           = 1e-4\nWEIGHT_DECAY = 1e-4\nLABEL_SMOOTH = 0.1\nSEED         = 42\nN_FOLDS      = 5\nFOLD         = 0\nDEVICE       = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nUSE_AMP      = torch.cuda.is_available()\nN_GPUS       = torch.cuda.device_count()\n\nif ON_KAGGLE:\n    # T4 x2: 32 GB total VRAM — batch 64 per GPU = effective 128\n    BATCH_SIZE  = 64\n    ACCUM       = 1          # no accumulation needed with 32GB\n    CLF_EPOCHS  = 15\n    WARMUP_EP   = 2\n    NUM_WORKERS = 4\n    CKPT_EVERY  = 3\nelse:\n    BATCH_SIZE  = 4\n    ACCUM       = 8\n    CLF_EPOCHS  = 10\n    WARMUP_EP   = 1\n    NUM_WORKERS = 2\n    CKPT_EVERY  = 3\n\nSEVERITY_MAP = {'Normal/Mild': 0, 'Moderate': 1, 'Severe': 2}\nSEV_INV      = {0: 'Normal/Mild', 1: 'Moderate', 2: 'Severe'}\nCONDITIONS   = ['spinal_canal_stenosis',\n                'left_neural_foraminal_narrowing',\n                'right_neural_foraminal_narrowing',\n                'left_subarticular_stenosis',\n                'right_subarticular_stenosis']\nLEVELS       = ['l1_l2', 'l2_l3', 'l3_l4', 'l4_l5', 'l5_s1']\nSEV_COLORS   = {0: '#4CAF50', 1: '#FF9800', 2: '#F44336'}\n\ndef seed_all(s=SEED):\n    random.seed(s); np.random.seed(s)\n    torch.manual_seed(s); torch.cuda.manual_seed_all(s)\n    # deterministic=False + benchmark=True → optimal T4 Tensor Core performance\n    torch.backends.cudnn.deterministic = False\n    torch.backends.cudnn.benchmark     = True\n\nseed_all()\n\nprint(f\"Device      : {DEVICE}\")\nprint(f\"GPU count   : {N_GPUS}\")\nprint(f\"BATCH_SIZE  : {BATCH_SIZE}  (×{N_GPUS} GPUs = effective {BATCH_SIZE*N_GPUS} per step)\")\nprint(f\"AMP         : {USE_AMP}\")\nprint(f\"✅ Cell 1 Complete\")\n\n\n# ==============================================================================\n# CELL 2: UNZIP CROPS + LOAD DATA\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 2 — Unzip crops + load flat_df\")\nprint(\"=\"*65)\n\n# ── Unzip preprocessed crops to /tmp (fast NVMe, not counted in 20GB) ─────────\nzip_src = f'{NB2_DATASET}/preprocessed_crops.zip'\nif os.path.exists(zip_src) and not os.listdir(PREP_DIR):\n    print(f\"Unzipping {zip_src} → {PREP_DIR} ...\")\n    shutil.unpack_archive(zip_src, PREP_DIR)\n    n_files = len(list(Path(PREP_DIR).rglob('*.jpg')))\n    print(f\"Unzipped {n_files} crop files\")\nelse:\n    n_files = len(list(Path(PREP_DIR).rglob('*.jpg')))\n    print(f\"Crops already present: {n_files} files\")\n\nflat_df     = pd.read_pickle(f'{NB2_DATASET}/flat_df_with_crops.pkl')\ncls_weights = np.load(f'{NB2_DATASET}/cls_weights.npy')\n\n# ── Remap crop_path to /tmp location if needed ────────────────────────────────\n# NB2 built crops in /tmp — paths in flat_df point there.\n# If paths are stale (e.g. /kaggle/working from a different session), fix them:\ndef remap_crop_path(p):\n    if pd.isna(p): return p\n    fname = os.path.basename(str(p))\n    new   = os.path.join(PREP_DIR, fname)\n    return new if os.path.exists(new) else p\n\nflat_df['crop_path'] = flat_df['crop_path'].apply(remap_crop_path)\n\nprint(f\"flat_df: {flat_df.shape}\")\nprint(f\"class weights: {dict(zip(SEV_INV.values(), cls_weights.round(3)))}\")\nprint(\"✅ Cell 2 Complete\")\n\n\n# ==============================================================================\n# CELL 3: AUGMENTATIONS + DATASET + DATALOADERS\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 3 — Augmentation + DataLoaders\")\nprint(\"=\"*65)\n\nTRAIN_TF = A.Compose([\n    A.Resize(IMG_SIZE, IMG_SIZE),\n    A.HorizontalFlip(p=0.5),\n    A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=10, p=0.4),\n    A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.3),\n    A.GaussNoise(var_limit=(5, 25), p=0.2),\n    A.CoarseDropout(max_holes=4, max_height=16, max_width=16, min_holes=1, p=0.2),\n    A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n    ToTensorV2(),\n])\nVAL_TF = A.Compose([\n    A.Resize(IMG_SIZE, IMG_SIZE),\n    A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n    ToTensorV2(),\n])\n\ndef unnorm(t):\n    mean = np.array([0.485, 0.456, 0.406])\n    std  = np.array([0.229, 0.224, 0.225])\n    return np.clip(t.permute(1,2,0).numpy() * std + mean, 0, 1)\n\nclass RSNADataset(Dataset):\n    def __init__(self, df: pd.DataFrame, transform=None):\n        self.df = df.dropna(subset=['crop_path']).reset_index(drop=True)\n        self.tf = transform\n\n    def __len__(self): return len(self.df)\n\n    def __getitem__(self, idx):\n        row   = self.df.iloc[idx]\n        label = int(row['severity'])\n        img   = cv2.imread(str(row['crop_path']))\n        if img is None:\n            img = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n        else:\n            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if self.tf:\n            img = self.tf(image=img)['image']\n        return img.float(), torch.tensor(label, dtype=torch.long)\n\n# ── StratifiedGroupKFold ──────────────────────────────────────────────────────\nsgkf   = StratifiedGroupKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)\nstrat  = flat_df['severity'].astype(str) + '_' + flat_df['condition_norm']\ngroups = flat_df['study_id'].values\n\nfor fold_idx, (tri, vli) in enumerate(sgkf.split(flat_df, strat, groups)):\n    if fold_idx == FOLD:\n        trn_df = flat_df.iloc[tri].reset_index(drop=True)\n        val_df = flat_df.iloc[vli].reset_index(drop=True)\n        break\n\ntrain_dataset = RSNADataset(trn_df, TRAIN_TF)\nval_dataset   = RSNADataset(val_df, VAL_TF)\n\ntrn_labels = trn_df['severity'].values.astype(int)\nsample_w   = torch.from_numpy(cls_weights[trn_labels]).float()\nsampler    = WeightedRandomSampler(sample_w, len(trn_labels), replacement=True)\n\n# ── DataParallel doubles effective batch size automatically ──────────────────\n# Each GPU sees BATCH_SIZE images → total per step = BATCH_SIZE × N_GPUS\ntrain_loader = DataLoader(\n    train_dataset, BATCH_SIZE, sampler=sampler,\n    num_workers=NUM_WORKERS, pin_memory=True, drop_last=True,\n    persistent_workers=True, prefetch_factor=2, timeout=60)\nval_loader = DataLoader(\n    val_dataset, BATCH_SIZE * 2, shuffle=False,\n    num_workers=NUM_WORKERS, pin_memory=True,\n    persistent_workers=True, prefetch_factor=2, timeout=60)\n\nprint(f\"Fold {FOLD}  train={len(trn_df)}  val={len(val_df)}\")\nprint(f\"Steps/epoch ≈ {len(train_loader)}\")\nprint(\"✅ Cell 3 Complete\")\n\n\n# ==============================================================================\n# CELL 4: MODEL — DenseNet121 + ViT ENSEMBLE  +  LOSS  +  SCHEDULER\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 4 — Model Architecture\")\nprint(\"=\"*65)\n\nclass DenseNet_ViT_Ensemble(nn.Module):\n    def __init__(self, num_classes=3):\n        super().__init__()\n        dn = densenet121(weights=DenseNet121_Weights.IMAGENET1K_V1)\n        self.dn_dim  = dn.classifier.in_features     # 1024\n        dn.classifier = nn.Identity()\n        self.densenet = dn\n\n        self.vit     = timm.create_model('vit_small_patch16_224',\n                                          pretrained=True, num_classes=0, drop_rate=0.1)\n        self.vit_dim = self.vit.num_features          # 384\n        self.vit.set_grad_checkpointing(True)         # saves ~3 GB VRAM per GPU\n\n        joint = self.dn_dim + self.vit_dim            # 1408\n        self.head = nn.Sequential(\n            nn.LayerNorm(joint),\n            nn.Dropout(0.4),\n            nn.Linear(joint, 512),\n            nn.GELU(),\n            nn.BatchNorm1d(512),\n            nn.Dropout(0.2),\n            nn.Linear(512, num_classes),\n        )\n\n    def forward(self, x):\n        return self.head(torch.cat([self.densenet(x), self.vit(x)], dim=1))\n\n\nclass LabelSmoothCE(nn.Module):\n    def __init__(self, smooth=LABEL_SMOOTH, weight=None):\n        super().__init__()\n        self.smooth = smooth; self.weight = weight\n\n    def forward(self, logits, targets):\n        n  = logits.size(1)\n        lp = F.log_softmax(logits, dim=1)\n        with torch.no_grad():\n            sd = torch.full_like(lp, self.smooth / (n - 1))\n            sd.scatter_(1, targets.unsqueeze(1), 1.0 - self.smooth)\n        loss = -(sd * lp).sum(dim=1)\n        if self.weight is not None:\n            loss = loss * self.weight[targets]\n        return loss.mean()\n\n\n# ── Build model ────────────────────────────────────────────────────────────────\nbase_model = DenseNet_ViT_Ensemble(num_classes=3).to(DEVICE)\n\n# ── DataParallel across both T4s ───────────────────────────────────────────────\nif N_GPUS > 1:\n    print(f\"  Wrapping in nn.DataParallel across {N_GPUS} GPUs\")\n    model = nn.DataParallel(base_model)\nelse:\n    model = base_model\n\n# ── torch.compile (PyTorch 2.0+) — compile AFTER DataParallel wrapping ────────\n# FIX: Compiling the outer (DataParallel) model prevents replica attribute errors.\n# Previously, compiling model.module before DataParallel caused replicas to lose\n# __getattr__ access (e.g. self.head) on device 0, raising AttributeError.\nif ON_KAGGLE and hasattr(torch, 'compile'):\n    model = torch.compile(model, mode='reduce-overhead')\n    print(\"  torch.compile : enabled (reduce-overhead)\")\n\ncls_wt_tensor = torch.tensor(cls_weights, dtype=torch.float32).to(DEVICE)\ncriterion     = LabelSmoothCE(smooth=LABEL_SMOOTH, weight=cls_wt_tensor)\noptimizer     = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscaler        = GradScaler('cuda', enabled=USE_AMP)\n\nsteps_ep    = len(train_loader) // ACCUM\ntotal_steps = steps_ep * CLF_EPOCHS\nwarmup_st   = steps_ep * WARMUP_EP\n\ndef lr_lambda(step):\n    if step < warmup_st:\n        return step / max(1, warmup_st)\n    prog = (step - warmup_st) / max(1, total_steps - warmup_st)\n    return max(1e-6 / LR, 0.5 * (1 + math.cos(math.pi * prog)))\n\nscheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n# ── Unwrap helper: handles torch.compile(_orig_mod) + DataParallel(.module) ───\ndef unwrap_model(m):\n    if hasattr(m, '_orig_mod'):   # torch.compile wrapper\n        m = m._orig_mod\n    if hasattr(m, 'module'):      # DataParallel wrapper\n        m = m.module\n    return m\n\ninner_model = unwrap_model(model)\nn_params    = sum(p.numel() for p in inner_model.parameters()) / 1e6\nprint(f\"  Params: {n_params:.1f}M  |  DN={inner_model.dn_dim}  ViT={inner_model.vit_dim}\")\nprint(\"✅ Cell 4 Complete\")\n\n\n# ==============================================================================\n# CELL 5: RESUME SUPPORT — load checkpoint if NB3 itself gets interrupted\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 5 — Resume check\")\nprint(\"=\"*65)\n\nCKPT_DIR    = '/kaggle/working'\nhistory     = {'train_loss': [], 'val_loss': [],\n               'train_acc':  [], 'val_acc':  [],\n               'val_kappa':  [], 'lr':       []}\nbest_kappa  = -1.0\nbest_preds  = best_true = None\nstart_epoch = 1\n\n# Find the latest periodic checkpoint\nckpt_pattern = os.path.join(CKPT_DIR, 'ensemble_epoch*.pt')\nexisting     = sorted(glob.glob(ckpt_pattern))\n\nif existing:\n    latest = existing[-1]\n    print(f\"  Found checkpoint: {latest}  — resuming...\")\n    ckpt = torch.load(latest, map_location=DEVICE)\n\n    # Load weights — unwrap both torch.compile and DataParallel layers\n    raw_state = ckpt['model_state']\n    unwrap_model(model).load_state_dict(raw_state)\n\n    optimizer.load_state_dict(ckpt['optimizer'])\n    scheduler.load_state_dict(ckpt['scheduler'])\n    scaler.load_state_dict(ckpt['scaler'])\n    start_epoch = ckpt['epoch'] + 1\n    best_kappa  = ckpt.get('best_kappa', -1.0)\n    history     = ckpt.get('history', history)\n    print(f\"  Resumed from epoch {ckpt['epoch']}  |  best QWK so far = {best_kappa:.4f}\")\nelse:\n    print(\"  No checkpoint found — starting from epoch 1\")\n\nprint(\"✅ Cell 5 Complete\")\n\n\n# ==============================================================================\n# CELL 6: TRAIN ENSEMBLE\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 6 — Training\")\nprint(\"=\"*65)\n\ngc.collect()\nif torch.cuda.is_available():\n    for gi in range(N_GPUS):\n        free, total = torch.cuda.mem_get_info(gi)\n        print(f\"  GPU {gi} free={free/1e9:.1f}GB  total={total/1e9:.1f}GB\")\n\nscaler = GradScaler('cuda', enabled=USE_AMP)   # fresh scaler for this run\n\ndef save_checkpoint(epoch, is_best=False):\n    \"\"\"Saves optimizer state + model state (always unwrapped from compile/DataParallel).\"\"\"\n    raw_state = unwrap_model(model).state_dict()\n    payload = {\n        'epoch':       epoch,\n        'model_state': raw_state,\n        'optimizer':   optimizer.state_dict(),\n        'scheduler':   scheduler.state_dict(),\n        'scaler':      scaler.state_dict(),\n        'best_kappa':  best_kappa,\n        'history':     history,\n    }\n    if is_best:\n        torch.save(raw_state, f'{CKPT_DIR}/ensemble_best.pt')\n        print(f\"  ✓ Best model saved  (QWK={best_kappa:.4f})\")\n    if epoch % CKPT_EVERY == 0:\n        path = f'{CKPT_DIR}/ensemble_epoch{epoch:02d}_qwk{best_kappa:.4f}.pt'\n        torch.save(payload, path)\n        print(f\"  [ckpt] Saved → {path}\")\n\n\nfor epoch in range(start_epoch, CLF_EPOCHS + 1):\n    # ── Train ─────────────────────────────────────────────────────────────────\n    model.train()\n    t_loss = t_cor = t_tot = 0\n    grad_step_count = 0\n    optimizer.zero_grad()\n\n    for step, (imgs, labs) in enumerate(tqdm(train_loader,\n                                              desc=f'Ep{epoch:02d} train',\n                                              leave=False)):\n        imgs, labs = imgs.to(DEVICE), labs.to(DEVICE)\n\n        with autocast('cuda', enabled=USE_AMP):\n            out  = model(imgs)\n            loss = criterion(out, labs) / ACCUM\n\n        scaler.scale(loss).backward()\n        grad_step_count += 1\n\n        t_loss += loss.item() * ACCUM * imgs.size(0)\n        t_cor  += (out.argmax(1) == labs).sum().item()\n        t_tot  += imgs.size(0)\n\n        if grad_step_count == ACCUM:\n            scaler.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad()\n            scheduler.step()\n            grad_step_count = 0\n\n    # flush leftover gradients\n    if grad_step_count > 0:\n        scaler.unscale_(optimizer)\n        nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n        scaler.step(optimizer)\n        scaler.update()\n        optimizer.zero_grad()\n        scheduler.step()\n\n    # ── Validation ────────────────────────────────────────────────────────────\n    model.eval()\n    v_loss = v_cor = v_tot = 0\n    all_p = []; all_l = []\n\n    with torch.no_grad():\n        for imgs, labs in tqdm(val_loader, desc=f'Ep{epoch:02d} val', leave=False):\n            imgs, labs = imgs.to(DEVICE), labs.to(DEVICE)\n            with autocast('cuda', enabled=USE_AMP):\n                out  = model(imgs)\n                loss = criterion(out, labs)\n            v_loss += loss.item() * imgs.size(0)\n            v_cor  += (out.argmax(1) == labs).sum().item()\n            v_tot  += imgs.size(0)\n            all_p.extend(out.argmax(1).cpu().numpy())\n            all_l.extend(labs.cpu().numpy())\n\n    tr_l = t_loss / t_tot;   tr_a = 100 * t_cor / t_tot\n    vl_l = v_loss / v_tot;   vl_a = 100 * v_cor / v_tot\n    kappa  = cohen_kappa_score(all_l, all_p, weights='quadratic') if v_tot else 0.0\n    lr_now = scheduler.get_last_lr()[0]\n\n    history['train_loss'].append(tr_l); history['val_loss'].append(vl_l)\n    history['train_acc'].append(tr_a);  history['val_acc'].append(vl_a)\n    history['val_kappa'].append(kappa); history['lr'].append(lr_now)\n\n    print(f\"Ep[{epoch:02d}/{CLF_EPOCHS}]  \"\n          f\"TLoss={tr_l:.4f} TAcc={tr_a:.2f}%  |  \"\n          f\"VLoss={vl_l:.4f} VAcc={vl_a:.2f}% QWK={kappa:.4f}  LR={lr_now:.2e}\")\n\n    is_best = kappa > best_kappa\n    if is_best:\n        best_kappa = kappa\n        best_preds = all_p[:]\n        best_true  = all_l[:]\n\n    save_checkpoint(epoch, is_best=is_best)\n\nprint(f\"\\n✅ Training Complete  |  Best QWK = {best_kappa:.4f}\")\n\n\n# ==============================================================================\n# CELL 7: DASHBOARD + CONFUSION MATRIX (only if VIS_MODE or end of training)\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 7 — Dashboard\")\nprint(\"=\"*65)\n\n# Always save history\nwith open(f'{CKPT_DIR}/history.pkl', 'wb') as f:\n    pickle.dump(history, f)\n\nif VIS_MODE and best_preds:\n    eps_range = range(1, len(history['train_loss']) + 1)\n    sev_labs  = [SEV_INV[i] for i in range(3)]\n\n    fig = plt.figure(figsize=(22, 8))\n    fig.patch.set_facecolor('#0D1117')\n    gs  = gridspec.GridSpec(1, 4, hspace=0.42, wspace=0.35)\n    axes = [fig.add_subplot(gs[0, c]) for c in range(4)]\n    for ax in axes:\n        ax.set_facecolor('#161B22')\n        ax.tick_params(colors='#8B949E')\n        ax.spines[:].set_color('#30363D')\n\n    axes[0].plot(eps_range, history['train_loss'], color='#F44336', lw=2, label='Train')\n    axes[0].plot(eps_range, history['val_loss'],   color='#FF8A80', lw=2, ls='--', label='Val')\n    axes[0].set_title('CE Loss', color='white'); axes[0].legend(framealpha=0, labelcolor='white')\n\n    axes[1].plot(eps_range, history['train_acc'],  color='#2196F3', lw=2, label='Train')\n    axes[1].plot(eps_range, history['val_acc'],    color='#82B1FF', lw=2, ls='--', label='Val')\n    axes[1].set_title('Accuracy (%)', color='white'); axes[1].legend(framealpha=0, labelcolor='white')\n\n    axes[2].plot(eps_range, history['val_kappa'],  color='#4CAF50', lw=2)\n    axes[2].axhline(best_kappa, color='#FF9800', lw=1.5, ls='--', label=f'Best={best_kappa:.4f}')\n    axes[2].set_title('QWK', color='white'); axes[2].legend(framealpha=0, labelcolor='white')\n\n    cm = confusion_matrix(best_true, best_preds)\n    sns.heatmap(cm, ax=axes[3], annot=True, fmt='d', cmap='Blues',\n                xticklabels=['N/M','Mod','Sev'], yticklabels=['N/M','Mod','Sev'],\n                linewidths=0.5, linecolor='#30363D')\n    axes[3].set_title(f'Confusion (QWK={best_kappa:.3f})', color='white', fontsize=10)\n\n    plt.suptitle('CELL 7 — Training Dashboard', color='white', fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.savefig(f'{OUT_DIR}/cell7_dashboard.png', dpi=130, bbox_inches='tight', facecolor='#0D1117')\n    plt.show()\n\nprint(f\"\\n  ✅ Notebook 3 Complete!  Best QWK = {best_kappa:.4f}\")\nprint(\"  Upload /kaggle/working/ as Kaggle dataset before running NB4.\")\nprint(\"  Required files:\")\nprint(\"    ensemble_best.pt\")\nprint(\"    history.pkl\")","metadata":{"_uuid":"210c3942-70ef-4318-959c-d9a7ae956efa","_cell_guid":"87b80977-546b-4018-a2b4-391e9b0aa89b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null}]}