{"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,"isSourceIdPinned":false},{"sourceType":"datasetVersion","sourceId":9245433,"datasetId":5592926,"databundleVersionId":9433882},{"sourceType":"datasetVersion","sourceId":15416927,"datasetId":9862590,"databundleVersionId":16333956},{"sourceType":"kernelVersion","sourceId":306930178,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":307074433,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":307156122,"isSourceIdPinned":false}],"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 datasets):\n#   1. notebook2 dataset  → flat_df_with_crops.pkl, cls_weights.npy, preprocessed_crops.zip\n#   2. rsna-nb3-resume    → ensemble_epoch09_qwk0.5174.pt  (crashed run output)\n#                           ← UPDATE the dataset name below if yours is different\n#\n# Saves to /kaggle/working/:\n#   ensemble_best.pt               (best val QWK weights — for NB4 inference)\n#   ensemble_best_full.pt          (best val QWK full payload — for resume)\n#   ensemble_epoch{N}_qwk{K}.pt    (periodic checkpoints every 3 epochs)\n#   history.pkl\n#   outputs/cell7_dashboard.png    (if VIS_MODE=True)\n#\n# FIXES vs original:\n#   FIX 1 — torch.compile() wraps AFTER nn.DataParallel (prevents AttributeError)\n#   FIX 2 — unwrap_model() peels torch.compile + DataParallel correctly\n#   FIX 3 — save_checkpoint() writes ensemble_best_full.pt on every new best\n#   FIX 4 — Cell 5 picks highest-epoch checkpoint across periodic + best_full\n#   FIX 5 — torch.load(..., weights_only=False) for PyTorch 2.6 compatibility\n#   FIX 6 — RESUME_DATASET path updated to datasets/tahuja/rsna-nb3-resume\n#\n# ⚠️  BEFORE RUNNING: Set Accelerator to GPU T4 x2 in Kaggle Settings\n# ==============================================================================\n\n!pip install timm grad-cam albumentations -q\n\nVIS_MODE = False\n\n# ── ⚠️  UPDATE THIS if your resume dataset has a different name on Kaggle ──────\nRESUME_DATASET = 'datasets/tahuja/rsna-nb3-resume'   # confirmed path from debug scan\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 if needed\n    RESUME_DIR  = f'/kaggle/input/{RESUME_DATASET}'\n    PREP_DIR    = '/tmp/preprocessed_dataset'\n    OUT_DIR     = '/kaggle/working/outputs'\n    CKPT_DIR    = '/kaggle/working'\nelse:\n    LOCAL_DATA_DIR = r'C:\\rsna'\n    NB2_DATASET    = LOCAL_DATA_DIR\n    RESUME_DIR     = 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    CKPT_DIR       = LOCAL_DATA_DIR\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    BATCH_SIZE  = 64\n    ACCUM       = 1\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    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\"Resume dir  : {RESUME_DIR}  (exists={os.path.exists(RESUME_DIR)})\")\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\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\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\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\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\n        self.vit.set_grad_checkpointing(True)\n\n        joint = self.dn_dim + self.vit_dim\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 FIRST, torch.compile AFTER — FIX 1 ──────────────────────────\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\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# ── FIX 2: unwrap helper ──────────────────────────────────────────────────────\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\n# Searches BOTH crashed-run input dataset AND /kaggle/working/\n# FIX 5: weights_only=False for PyTorch 2.6 compatibility\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 5 — Resume check\")\nprint(\"=\"*65)\n\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\ndef _epoch_of(path):\n    \"\"\"Safely read epoch field — weights_only=False needed for PyTorch 2.6.\"\"\"\n    try:\n        return torch.load(\n            path, map_location='cpu', weights_only=False\n        ).get('epoch', 0)\n    except Exception:\n        return 0\n\n# Gather candidates from crashed-run dataset AND current working dir\ncandidates = []\nfor search_dir in [RESUME_DIR, CKPT_DIR]:\n    if os.path.exists(search_dir):\n        candidates += glob.glob(os.path.join(search_dir, 'ensemble_epoch*.pt'))\n        best_full = os.path.join(search_dir, 'ensemble_best_full.pt')\n        if os.path.exists(best_full):\n            candidates.append(best_full)\n\ncandidates = list(set(candidates))\n\nif candidates:\n    latest = max(candidates, key=_epoch_of)\n    ep     = _epoch_of(latest)\n    print(f\"  Candidates found : {len(candidates)}\")\n    print(f\"  Resuming from    : {latest}  (epoch {ep})\")\n\n    # FIX 5: weights_only=False — checkpoint contains numpy scalars in history dict\n    ckpt = torch.load(latest, map_location=DEVICE, weights_only=False)\n    unwrap_model(model).load_state_dict(ckpt['model_state'])\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}\")\n    print(f\"  Training will continue from epoch {start_epoch} → {CLF_EPOCHS}\")\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)\n\ndef save_checkpoint(epoch, is_best=False):\n    \"\"\"\n    FIX 2: always unwraps torch.compile + DataParallel before saving.\n    FIX 3: writes ensemble_best_full.pt on every new best for safe resume.\n    \"\"\"\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        torch.save(payload,   f'{CKPT_DIR}/ensemble_best_full.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    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\n# ==============================================================================\nprint(\"\\n\" + \"=\"*65)\nprint(\"  CELL 7 — Dashboard\")\nprint(\"=\"*65)\n\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\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        ← weights for inference\")\nprint(\"    ensemble_best_full.pt   ← full payload if you need to resume again\")\nprint(\"    history.pkl\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null}]}