{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":14774,"databundleVersionId":875431},{"sourceType":"datasetVersion","sourceId":23942,"datasetId":17839,"databundleVersionId":23981},{"sourceType":"kernelVersion","sourceId":18607070}],"dockerImageVersionId":31328,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import torch\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device: {device}')\n\nfree  = torch.cuda.mem_get_info(0)[0] / 1e9\ntotal = torch.cuda.mem_get_info(0)[1] / 1e9\nprint(f'GPU memory free:  {free:.2f} GB')\nprint(f'GPU memory total: {total:.2f} GB')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:22.313291Z","iopub.execute_input":"2026-03-25T03:07:22.313598Z","iopub.status.idle":"2026-03-25T03:07:24.799868Z","shell.execute_reply.started":"2026-03-25T03:07:22.313572Z","shell.execute_reply":"2026-03-25T03:07:24.798996Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, gc, time\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport torch\nimport torch.nn as nn\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom PIL import Image\nfrom pathlib import Path\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, models\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (cohen_kappa_score, confusion_matrix,\n                              classification_report, roc_auc_score)\n\n# Paths\nTRAIN_CSV  = '/kaggle/input/competitions/aptos2019-blindness-detection/train.csv'\nTRAIN_IMGS = '/kaggle/input/competitions/aptos2019-blindness-detection/train_images'\nOUTPUT_DIR = '/kaggle/working/outputs'\nos.makedirs(OUTPUT_DIR, exist_ok=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:24.801210Z","iopub.execute_input":"2026-03-25T03:07:24.801675Z","iopub.status.idle":"2026-03-25T03:07:27.567869Z","shell.execute_reply.started":"2026-03-25T03:07:24.801643Z","shell.execute_reply":"2026-03-25T03:07:27.567048Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"IMG_SIZE   = 224   # safe for T4 GPU\nBATCH_SIZE = 8     # safe for T4 GPU\nEPOCHS     = 10\nLR         = 1e-4\nSEED       = 42\ntorch.manual_seed(SEED)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:27.568809Z","iopub.execute_input":"2026-03-25T03:07:27.569219Z","iopub.status.idle":"2026-03-25T03:07:27.578119Z","shell.execute_reply.started":"2026-03-25T03:07:27.569193Z","shell.execute_reply":"2026-03-25T03:07:27.577268Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"df = pd.read_csv(TRAIN_CSV)\nprint(f'Total samples: {len(df)}')\nprint(df['diagnosis'].value_counts().sort_index())\n\ntrain_df, val_df = train_test_split(\n    df, test_size=0.2,\n    stratify=df['diagnosis'],\n    random_state=SEED\n)\ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\nprint(f'\\nTrain: {len(train_df)}  Val: {len(val_df)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:27.580278Z","iopub.execute_input":"2026-03-25T03:07:27.580911Z","iopub.status.idle":"2026-03-25T03:07:27.611813Z","shell.execute_reply.started":"2026-03-25T03:07:27.580883Z","shell.execute_reply":"2026-03-25T03:07:27.610988Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def preprocess_fundus(img_path, img_size=224):\n    img = cv2.imread(str(img_path))\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img = cv2.resize(img, (img_size, img_size))\n    blur = cv2.GaussianBlur(img, (0, 0), sigmaX=img_size // 30)\n    img  = cv2.addWeighted(img, 4, blur, -4, 128)\n    mask = np.zeros(img.shape[:2], dtype=np.uint8)\n    cx   = img_size // 2\n    cv2.circle(mask, (cx, cx), int(cx * 0.9), 255, -1)\n    img  = cv2.bitwise_and(img, img, mask=mask)\n    return img\n\nclass APTOSDataset(Dataset):\n    def __init__(self, dataframe, img_dir, transform=None):\n        self.df        = dataframe.reset_index(drop=True)\n        self.img_dir   = img_dir\n        self.transform = transform\n\n    def __len__(self): return len(self.df)\n\n    def __getitem__(self, idx):\n        row  = self.df.iloc[idx]\n        path = f'{self.img_dir}/{row[\"id_code\"]}.png'\n        img  = Image.fromarray(preprocess_fundus(path, IMG_SIZE))\n        if self.transform:\n            img = self.transform(img)\n        return img, torch.tensor(row['diagnosis'], dtype=torch.long)\n\ntrain_transform = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(30),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize([0.485, 0.456, 0.406],\n                         [0.229, 0.224, 0.225]),\n])\nval_transform = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.ToTensor(),\n    transforms.Normalize([0.485, 0.456, 0.406],\n                         [0.229, 0.224, 0.225]),\n])\n\ntrain_dataset = APTOSDataset(train_df, TRAIN_IMGS, train_transform)\nval_dataset   = APTOSDataset(val_df,   TRAIN_IMGS, val_transform)\n\ntrain_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE,\n                          shuffle=True,  num_workers=2, pin_memory=True)\nval_loader   = DataLoader(val_dataset,   batch_size=BATCH_SIZE,\n                          shuffle=False, num_workers=2, pin_memory=True)\n\nprint(f'Train batches: {len(train_loader)}  Val batches: {len(val_loader)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:27.612907Z","iopub.execute_input":"2026-03-25T03:07:27.613199Z","iopub.status.idle":"2026-03-25T03:07:27.626948Z","shell.execute_reply.started":"2026-03-25T03:07:27.613163Z","shell.execute_reply":"2026-03-25T03:07:27.626106Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class FundusClassifier(nn.Module):\n    def __init__(self, num_classes=5):\n        super().__init__()\n        # Using EfficientNet-B0 instead of B4 — same architecture,\n        # much smaller: 5.3M params vs 19M params, fits easily in memory\n        self.encoder = models.efficientnet_b0(weights='IMAGENET1K_V1')\n        in_features  = self.encoder.classifier[1].in_features\n        self.encoder.classifier = nn.Identity()\n        self.head = nn.Sequential(\n            nn.Dropout(0.4),\n            nn.Linear(in_features, 256),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x):\n        features = self.encoder(x)\n        logits   = self.head(features)\n        return logits, features\n\nmodel = FundusClassifier(num_classes=5).to(device)\n\n# Check memory after loading model\nallocated = torch.cuda.memory_allocated(0) / 1e9\nprint(f'Model loaded. GPU memory used: {allocated:.2f} GB')\nprint(f'Parameters: {sum(p.numel() for p in model.parameters()):,}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:27.628154Z","iopub.execute_input":"2026-03-25T03:07:27.628533Z","iopub.status.idle":"2026-03-25T03:07:27.815919Z","shell.execute_reply.started":"2026-03-25T03:07:27.628505Z","shell.execute_reply":"2026-03-25T03:07:27.815155Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class_counts  = df['diagnosis'].value_counts().sort_index().values\nclass_weights = torch.tensor(1.0 / class_counts, dtype=torch.float).to(device)\ncriterion     = nn.CrossEntropyLoss(weight=class_weights)\noptimizer     = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=1e-4)\nscheduler     = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\n\ndef train_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n    total_loss, preds_all, labels_all = 0, [], []\n    for imgs, labels in loader:\n        imgs, labels = imgs.to(device), labels.to(device)\n        optimizer.zero_grad(set_to_none=True)\n        logits, _  = model(imgs)\n        loss       = criterion(logits, labels)\n        loss.backward()\n        optimizer.step()\n        total_loss += loss.item()\n        preds_all.extend(logits.argmax(1).cpu().numpy())\n        labels_all.extend(labels.cpu().numpy())\n    qwk = cohen_kappa_score(labels_all, preds_all, weights='quadratic')\n    return total_loss / len(loader), qwk\n\ndef val_epoch(model, loader, criterion, device):\n    model.eval()\n    total_loss, preds_all, labels_all = 0, [], []\n    with torch.no_grad():\n        for imgs, labels in loader:\n            imgs, labels = imgs.to(device), labels.to(device)\n            logits, _    = model(imgs)\n            loss         = criterion(logits, labels)\n            total_loss  += loss.item()\n            preds_all.extend(logits.argmax(1).cpu().numpy())\n            labels_all.extend(labels.cpu().numpy())\n    qwk = cohen_kappa_score(labels_all, preds_all, weights='quadratic')\n    return total_loss / len(loader), qwk\n\n# ── Training loop ─────────────────────────────────────────────────────────────\nbest_qwk = 0.0\nhistory  = {'train_loss': [], 'val_loss': [],\n            'train_qwk':  [], 'val_qwk':  []}\n\nfor epoch in range(1, EPOCHS + 1):\n    t0 = time.time()\n    tr_loss, tr_qwk = train_epoch(model, train_loader, optimizer, criterion, device)\n    vl_loss, vl_qwk = val_epoch(model, val_loader, criterion, device)\n    scheduler.step()\n\n    history['train_loss'].append(tr_loss)\n    history['val_loss'].append(vl_loss)\n    history['train_qwk'].append(tr_qwk)\n    history['val_qwk'].append(vl_qwk)\n\n    if vl_qwk > best_qwk:\n        best_qwk = vl_qwk\n        torch.save(model.state_dict(), f'{OUTPUT_DIR}/best_model.pt')\n        saved = '  <- saved'\n    else:\n        saved = ''\n\n    print(f'Epoch {epoch:02d}/{EPOCHS} | '\n          f'Train loss: {tr_loss:.4f}  QWK: {tr_qwk:.4f} | '\n          f'Val loss: {vl_loss:.4f}  QWK: {vl_qwk:.4f} | '\n          f'{time.time()-t0:.0f}s{saved}')\n\nprint(f'\\nBest val QWK: {best_qwk:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:07:27.817119Z","iopub.execute_input":"2026-03-25T03:07:27.817681Z","iopub.status.idle":"2026-03-25T03:44:08.136093Z","shell.execute_reply.started":"2026-03-25T03:07:27.817652Z","shell.execute_reply":"2026-03-25T03:44:08.135150Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import (confusion_matrix, classification_report,\n                              cohen_kappa_score, roc_auc_score)\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nimport numpy as np\n\n# Load best model\nmodel.load_state_dict(torch.load(f'{OUTPUT_DIR}/best_model.pt'))\nmodel.eval()\n\nall_preds, all_labels, all_probs = [], [], []\n\nwith torch.no_grad():\n    for imgs, labels in val_loader:\n        imgs   = imgs.to(device)\n        logits, _ = model(imgs)\n        probs  = torch.softmax(logits, dim=1)\n        preds  = probs.argmax(dim=1)\n        all_preds.extend(preds.cpu().numpy())\n        all_labels.extend(labels.numpy())\n        all_probs.extend(probs.cpu().numpy())\n\nall_preds  = np.array(all_preds)\nall_labels = np.array(all_labels)\nall_probs  = np.array(all_probs)\n\n# Metrics\nqwk = cohen_kappa_score(all_labels, all_preds, weights='quadratic')\nauc = roc_auc_score(all_labels, all_probs, multi_class='ovr')\n\ngrade_names = ['No DR', 'Mild', 'Moderate', 'Severe', 'Proliferative']\n\nprint('=' * 60)\nprint('BASELINE MODEL — FINAL RESULTS')\nprint('=' * 60)\nprint(f'Quadratic Weighted Kappa (QWK): {qwk:.4f}')\nprint(f'AUC (one-vs-rest):              {auc:.4f}')\nprint()\nprint(classification_report(all_labels, all_preds, target_names=grade_names))\nprint('=' * 60)\n\n# Save results for later comparison\nbaseline_results = {\n    'qwk': qwk,\n    'auc': auc,\n    'preds':  all_preds,\n    'labels': all_labels,\n    'probs':  all_probs,\n}\nimport pickle\nwith open(f'{OUTPUT_DIR}/baseline_results.pkl', 'wb') as f:\n    pickle.dump(baseline_results, f)\n\nprint('Results saved.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:44:08.137907Z","iopub.execute_input":"2026-03-25T03:44:08.138240Z","iopub.status.idle":"2026-03-25T03:44:50.301357Z","shell.execute_reply.started":"2026-03-25T03:44:08.138205Z","shell.execute_reply":"2026-03-25T03:44:50.300302Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n\nepochs = range(1, EPOCHS + 1)\n\nax1.plot(epochs, history['train_loss'], 'b-o', label='Train loss', markersize=4)\nax1.plot(epochs, history['val_loss'],   'r-o', label='Val loss',   markersize=4)\nax1.set_xlabel('Epoch')\nax1.set_ylabel('Loss')\nax1.set_title('Loss curves — Baseline fundus model')\nax1.legend()\nax1.grid(True, alpha=0.3)\n\nax2.plot(epochs, history['train_qwk'], 'b-o', label='Train QWK', markersize=4)\nax2.plot(epochs, history['val_qwk'],   'r-o', label='Val QWK',   markersize=4)\nax2.axhline(y=0.8614, color='green', linestyle='--', alpha=0.7, label=f'Best: 0.8614')\nax2.set_xlabel('Epoch')\nax2.set_ylabel('Quadratic Weighted Kappa')\nax2.set_title('QWK curves — Baseline fundus model')\nax2.legend()\nax2.grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig(f'{OUTPUT_DIR}/baseline_training_curves.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Training curves saved.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:44:50.303005Z","iopub.execute_input":"2026-03-25T03:44:50.303359Z","iopub.status.idle":"2026-03-25T03:44:51.422444Z","shell.execute_reply.started":"2026-03-25T03:44:50.303326Z","shell.execute_reply":"2026-03-25T03:44:51.421616Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"cm = confusion_matrix(all_labels, all_preds)\n\nplt.figure(figsize=(8, 6))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n            xticklabels=grade_names,\n            yticklabels=grade_names,\n            linewidths=0.5)\nplt.title(f'Confusion matrix — Baseline model (Val QWK: {qwk:.4f})')\nplt.ylabel('True label')\nplt.xlabel('Predicted label')\nplt.tight_layout()\nplt.savefig(f'{OUTPUT_DIR}/baseline_confusion_matrix.png', dpi=150, bbox_inches='tight')\nplt.show()\n\n# Print per-class accuracy\nprint('\\nPer-class accuracy:')\nfor i, name in enumerate(grade_names):\n    class_total   = cm[i].sum()\n    class_correct = cm[i][i]\n    print(f'  {name:15s}: {class_correct}/{class_total} = {class_correct/class_total*100:.1f}%')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:44:51.423558Z","iopub.execute_input":"2026-03-25T03:44:51.423814Z","iopub.status.idle":"2026-03-25T03:44:51.957430Z","shell.execute_reply.started":"2026-03-25T03:44:51.423789Z","shell.execute_reply":"2026-03-25T03:44:51.956502Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install grad-cam -q\n\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.image import show_cam_on_image\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\ntarget_layer = model.encoder.features[-1]\n\ndef get_gradcam(img_tensor, true_label):\n    model.eval()\n    cam     = GradCAM(model=model, target_layers=[target_layer])\n    targets = [ClassifierOutputTarget(int(true_label))]\n    cam_map = cam(input_tensor=img_tensor.unsqueeze(0), targets=targets)[0]\n\n    mean   = np.array([0.485, 0.456, 0.406])\n    std    = np.array([0.229, 0.224, 0.225])\n    img_np = img_tensor.permute(1, 2, 0).cpu().numpy()\n    img_np = (img_np * std + mean).clip(0, 1).astype(np.float32)\n\n    overlay = show_cam_on_image(img_np, cam_map, use_rgb=True)\n    return img_np, overlay\n\nfig, axes = plt.subplots(5, 2, figsize=(8, 22))\n\nfor grade in range(5):\n    # Find a correctly predicted sample of this grade\n    grade_indices = np.where(\n        (all_labels == grade) & (all_preds == grade)\n    )[0]\n\n    if len(grade_indices) == 0:\n        grade_indices = np.where(all_labels == grade)[0]\n\n    # Get the dataset index\n    val_grade_df  = val_df[val_df['diagnosis'] == grade]\n    sample_pos    = val_grade_df.index[0]\n    dataset_pos   = val_df.index.get_loc(sample_pos) if hasattr(val_df.index, 'get_loc') else grade_indices[0]\n\n    try:\n        img_tensor, label = val_dataset[dataset_pos]\n        orig, overlay     = get_gradcam(img_tensor, label)\n\n        axes[grade][0].imshow(orig)\n        axes[grade][0].set_title(f'{grade_names[grade]} — original', fontsize=10)\n        axes[grade][0].axis('off')\n\n        axes[grade][1].imshow(overlay)\n        axes[grade][1].set_title(f'{grade_names[grade]} — Grad-CAM', fontsize=10)\n        axes[grade][1].axis('off')\n    except Exception as e:\n        print(f'Grade {grade} visualization failed: {e}')\n        axes[grade][0].axis('off')\n        axes[grade][1].axis('off')\n\nplt.suptitle(f'Grad-CAM — Baseline model attention per DR grade\\nVal QWK: {qwk:.4f}',\n             fontsize=12, y=1.01)\nplt.tight_layout()\nplt.savefig(f'{OUTPUT_DIR}/baseline_gradcam.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Grad-CAM saved.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:44:51.958389Z","iopub.execute_input":"2026-03-25T03:44:51.958638Z","iopub.status.idle":"2026-03-25T03:45:08.585615Z","shell.execute_reply.started":"2026-03-25T03:44:51.958614Z","shell.execute_reply":"2026-03-25T03:45:08.584331Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import f1_score, accuracy_score, precision_score, recall_score\n\naccuracy  = accuracy_score(all_labels, all_preds)\nf1_macro  = f1_score(all_labels, all_preds, average='macro')\nf1_weight = f1_score(all_labels, all_preds, average='weighted')\nprecision = precision_score(all_labels, all_preds, average='macro', zero_division=0)\nrecall    = recall_score(all_labels, all_preds, average='macro', zero_division=0)\n\nprint('=' * 55)\nprint('BASELINE RESULTS SUMMARY — COPY INTO YOUR REPORT')\nprint('=' * 55)\nprint(f'Model:              EfficientNet-B0 (fundus only)')\nprint(f'Image size:         224 × 224')\nprint(f'Training epochs:    10')\nprint(f'Best epoch:         8')\nprint(f'')\nprint(f'Accuracy:           {accuracy*100:.2f}%')\nprint(f'Precision (macro):  {precision:.4f}')\nprint(f'Recall (macro):     {recall:.4f}')\nprint(f'F1 (macro):         {f1_macro:.4f}')\nprint(f'F1 (weighted):      {f1_weight:.4f}')\nprint(f'AUC (OvR):          {auc:.4f}')\nprint(f'QWK:                {qwk:.4f}')\nprint('=' * 55)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:45:08.587873Z","iopub.execute_input":"2026-03-25T03:45:08.589172Z","iopub.status.idle":"2026-03-25T03:45:08.611280Z","shell.execute_reply.started":"2026-03-25T03:45:08.589108Z","shell.execute_reply":"2026-03-25T03:45:08.610418Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\n\n# Note the space in 'OCT2017 ' — this is in the actual folder name\nOCT_BASE = '/kaggle/input/datasets/paultimothymooney/kermany2018/OCT2017 /train'\n\n# Verify and count images per class\ntotal = 0\nfor class_name in ['NORMAL', 'DME', 'CNV', 'DRUSEN']:\n    class_path = os.path.join(OCT_BASE, class_name)\n    count = len(os.listdir(class_path))\n    total += count\n    print(f'{class_name}: {count} images')\n\nprint(f'\\nTotal OCT training images: {total}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:45:08.614461Z","iopub.execute_input":"2026-03-25T03:45:08.614823Z","iopub.status.idle":"2026-03-25T03:45:08.648796Z","shell.execute_reply.started":"2026-03-25T03:45:08.614773Z","shell.execute_reply":"2026-03-25T03:45:08.647940Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, gc, time, cv2, pickle\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom PIL import Image\nfrom collections import defaultdict\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms, models\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (cohen_kappa_score, roc_auc_score,\n                              confusion_matrix, classification_report,\n                              f1_score, accuracy_score)\n\n# ── Config ────────────────────────────────────────────────────────────────────\nTRAIN_CSV  = '/kaggle/input/competitions/aptos2019-blindness-detection/train.csv'\nTRAIN_IMGS = '/kaggle/input/competitions/aptos2019-blindness-detection/train_images'\nOCT_BASE   = '/kaggle/input/datasets/paultimothymooney/kermany2018/OCT2017 /train'\nOUTPUT_DIR = '/kaggle/working/outputs'\nIMG_SIZE   = 224\nBATCH_SIZE = 8\nSEED       = 42\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device: {device}')\n\n# ── OCT grade index ───────────────────────────────────────────────────────────\n# Kermany class → DR grade mapping (medically justified)\n# NORMAL  → Grade 0 (no pathology)\n# DRUSEN  → Grade 1 (early degeneration, mild proxy)\n# CNV     → Grade 2 (moderate proxy)\n# DME     → Grades 3 and 4 (DME is a direct DR complication)\nOCT_CLASS_TO_GRADE = {'NORMAL': 0, 'DRUSEN': 1, 'CNV': 2, 'DME': 3}\n\noct_index = defaultdict(list)\nfor class_name, grade in OCT_CLASS_TO_GRADE.items():\n    class_dir = os.path.join(OCT_BASE, class_name)\n    paths = [os.path.join(class_dir, f)\n             for f in os.listdir(class_dir)\n             if f.endswith(('.jpeg', '.jpg', '.png'))]\n    oct_index[grade].extend(paths)\noct_index[4] = oct_index[3].copy()  # Grade 4 also uses DME images\n\nfor g in range(5):\n    print(f'Grade {g} OCT pool: {len(oct_index[g])} images')\n\n# ── Load APTOS data ───────────────────────────────────────────────────────────\ndf = pd.read_csv(TRAIN_CSV)\ntrain_df, val_df = train_test_split(\n    df, test_size=0.2, stratify=df['diagnosis'], random_state=SEED\n)\ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\nprint(f'\\nTrain: {len(train_df)}  Val: {len(val_df)}')\n\n# ── Synthetic clinical data ───────────────────────────────────────────────────\ndef generate_clinical_data(dr_labels, seed=42):\n    rng = np.random.default_rng(seed)\n    params = {\n        0: dict(hba1c=(6.8,1.0), duration=(4.0,3.5),  sbp=(125,12)),\n        1: dict(hba1c=(7.4,1.1), duration=(6.5,4.0),  sbp=(128,13)),\n        2: dict(hba1c=(8.1,1.2), duration=(9.0,4.5),  sbp=(132,14)),\n        3: dict(hba1c=(9.0,1.3), duration=(12.0,5.0), sbp=(138,15)),\n        4: dict(hba1c=(10.1,1.5),duration=(16.0,5.5), sbp=(145,16)),\n    }\n    records = []\n    for label in dr_labels:\n        p = params[int(label)]\n        records.append({\n            'age':      float(np.clip(rng.normal(55+label*2, 12), 30, 80)),\n            'hba1c':    float(np.clip(rng.normal(*p['hba1c']), 5.0, 14.0)),\n            'duration': float(np.clip(rng.normal(*p['duration']), 0.0, 35.0)),\n            'sbp':      float(np.clip(rng.normal(*p['sbp']), 90, 180)),\n            'bmi':      float(np.clip(rng.normal(28.5+label*0.5, 5.0), 16.0, 50.0)),\n            'egfr':     float(np.clip(rng.normal(90-label*5, 12), 20, 120)),\n        })\n    result = pd.DataFrame(records)\n    for col in result.columns:\n        mn, mx = result[col].min(), result[col].max()\n        result[col] = (result[col] - mn) / (mx - mn + 1e-8)\n    return result\n\ntrain_clinical = generate_clinical_data(train_df['diagnosis'].values, seed=42)\nval_clinical   = generate_clinical_data(val_df['diagnosis'].values,   seed=99)\nprint(f'Clinical data ready: {train_clinical.shape}')\n\n# ── Preprocessing functions ───────────────────────────────────────────────────\ndef preprocess_fundus(img_path, img_size=224):\n    img  = cv2.imread(str(img_path))\n    img  = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img  = cv2.resize(img, (img_size, img_size))\n    blur = cv2.GaussianBlur(img, (0,0), sigmaX=img_size//30)\n    img  = cv2.addWeighted(img, 4, blur, -4, 128)\n    mask = np.zeros(img.shape[:2], dtype=np.uint8)\n    cx   = img_size // 2\n    cv2.circle(mask, (cx,cx), int(cx*0.9), 255, -1)\n    return cv2.bitwise_and(img, img, mask=mask).astype(np.uint8)\n\ndef preprocess_oct(img_path, img_size=224):\n    img = cv2.imread(str(img_path))\n    if img is None:\n        return np.zeros((img_size, img_size, 3), dtype=np.uint8)\n    img   = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img   = cv2.resize(img, (img_size, img_size))\n    lab   = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)\n    l,a,b = cv2.split(lab)\n    clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,4))\n    l     = clahe.apply(l)\n    return cv2.cvtColor(cv2.merge([l,a,b]), cv2.COLOR_LAB2RGB).astype(np.uint8)\n\n# ── Dataset class ─────────────────────────────────────────────────────────────\nCLINICAL_COLS = ['age','hba1c','duration','sbp','bmi','egfr']\n\nclass MultimodalDataset(Dataset):\n    def __init__(self, dataframe, fundus_dir, oct_index,\n                 clinical_df, fundus_tf=None, oct_tf=None):\n        self.df          = dataframe.reset_index(drop=True)\n        self.fundus_dir  = fundus_dir\n        self.oct_index   = oct_index\n        self.clinical_df = clinical_df.reset_index(drop=True)\n        self.fundus_tf   = fundus_tf\n        self.oct_tf      = oct_tf\n\n    def __len__(self): return len(self.df)\n\n    def __getitem__(self, idx):\n        row   = self.df.iloc[idx]\n        grade = int(row['diagnosis'])\n\n        # Fundus\n        fp     = f'{self.fundus_dir}/{row[\"id_code\"]}.png'\n        fundus = Image.fromarray(preprocess_fundus(fp, IMG_SIZE))\n        if self.fundus_tf:\n            fundus = self.fundus_tf(fundus)\n\n        # OCT — grade-matched, deterministic\n        oct_candidates = self.oct_index[grade]\n        oct_path       = oct_candidates[idx % len(oct_candidates)]\n        oct_img        = Image.fromarray(preprocess_oct(oct_path, IMG_SIZE))\n        if self.oct_tf:\n            oct_img = self.oct_tf(oct_img)\n\n        # Clinical\n        clinical = torch.tensor(\n            self.clinical_df.iloc[idx][CLINICAL_COLS].values.astype(np.float32),\n            dtype=torch.float32\n        )\n        return fundus, oct_img, clinical, torch.tensor(grade, dtype=torch.long)\n\n# ── Transforms ────────────────────────────────────────────────────────────────\ntrain_tf = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(20),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225]),\n])\nval_tf = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.ToTensor(),\n    transforms.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225]),\n])\n\ntrain_mm = MultimodalDataset(train_df, TRAIN_IMGS, oct_index,\n                              train_clinical, train_tf, train_tf)\nval_mm   = MultimodalDataset(val_df,   TRAIN_IMGS, oct_index,\n                              val_clinical,   val_tf,  val_tf)\n\ntrain_mm_loader = DataLoader(train_mm, batch_size=BATCH_SIZE,\n                              shuffle=True,  num_workers=2, pin_memory=True)\nval_mm_loader   = DataLoader(val_mm,   batch_size=BATCH_SIZE,\n                              shuffle=False, num_workers=2, pin_memory=True)\n\n# Verify batch\ns = next(iter(train_mm_loader))\nprint(f'\\nBatch shapes — Fundus: {s[0].shape}  OCT: {s[1].shape}  '\n      f'Clinical: {s[2].shape}  Labels: {s[3].shape}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:45:08.649817Z","iopub.execute_input":"2026-03-25T03:45:08.650157Z","iopub.status.idle":"2026-03-25T03:45:11.436697Z","shell.execute_reply.started":"2026-03-25T03:45:08.650123Z","shell.execute_reply":"2026-03-25T03:45:11.435871Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class CrossModalAttention(nn.Module):\n    def __init__(self, embed_dim=128, num_heads=4):\n        super().__init__()\n        self.attn  = nn.MultiheadAttention(embed_dim, num_heads,\n                                            batch_first=True, dropout=0.1)\n        self.norm  = nn.LayerNorm(embed_dim)\n        self.ff    = nn.Sequential(\n            nn.Linear(embed_dim, embed_dim*2), nn.ReLU(),\n            nn.Linear(embed_dim*2, embed_dim)\n        )\n        self.norm2 = nn.LayerNorm(embed_dim)\n\n    def forward(self, x):\n        a, _ = self.attn(x, x, x)\n        x    = self.norm(x + a)\n        x    = self.norm2(x + self.ff(x))\n        return x\n\n\nclass MultimodalDRModel(nn.Module):\n    def __init__(self, clinical_dim=6, embed_dim=128, num_classes=5):\n        super().__init__()\n\n        # Fundus encoder\n        self.fundus_encoder = models.efficientnet_b0(weights=None)\n        f_dim = self.fundus_encoder.classifier[1].in_features\n        self.fundus_encoder.classifier = nn.Identity()\n        self.fundus_proj = nn.Sequential(\n            nn.Linear(f_dim, embed_dim), nn.LayerNorm(embed_dim), nn.ReLU()\n        )\n\n        # OCT encoder — separate weights, ImageNet init\n        self.oct_encoder = models.efficientnet_b0(weights='IMAGENET1K_V1')\n        o_dim = self.oct_encoder.classifier[1].in_features\n        self.oct_encoder.classifier = nn.Identity()\n        self.oct_proj = nn.Sequential(\n            nn.Linear(o_dim, embed_dim), nn.LayerNorm(embed_dim), nn.ReLU()\n        )\n\n        # Clinical encoder\n        self.clinical_encoder = nn.Sequential(\n            nn.Linear(clinical_dim, 64), nn.BatchNorm1d(64),\n            nn.ReLU(), nn.Dropout(0.3),\n            nn.Linear(64, embed_dim), nn.LayerNorm(embed_dim), nn.ReLU()\n        )\n\n        # Cross-modal attention\n        self.fusion = CrossModalAttention(embed_dim=embed_dim, num_heads=4)\n\n        # Classifier\n        self.classifier = nn.Sequential(\n            nn.Dropout(0.4),\n            nn.Linear(embed_dim*3, 256), nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, fundus, oct_img, clinical):\n        f      = self.fundus_proj(self.fundus_encoder(fundus))\n        o      = self.oct_proj(self.oct_encoder(oct_img))\n        c      = self.clinical_encoder(clinical)\n        tokens = torch.stack([f, o, c], dim=1)   # (B, 3, 128)\n        fused  = self.fusion(tokens)              # (B, 3, 128)\n        logits = self.classifier(fused.flatten(1))\n        return logits, fused.flatten(1)\n\n\n# Clear any old models from GPU\ngc.collect()\ntorch.cuda.empty_cache()\n\n# Build model and load pretrained fundus weights\nmm_model = MultimodalDRModel(clinical_dim=6, embed_dim=128, num_classes=5).to(device)\n\n# Load Phase 3 fundus weights into fundus branch\nsaved = torch.load(f'{OUTPUT_DIR}/best_model.pt', map_location=device)\nencoder_w = {k.replace('encoder.', ''): v\n             for k, v in saved.items() if k.startswith('encoder.')}\nmm_model.fundus_encoder.load_state_dict(encoder_w)\nprint('Phase 3 fundus weights loaded into multimodal model.')\nprint(f'Total parameters: {sum(p.numel() for p in mm_model.parameters()):,}')\nprint(f'GPU memory used: {torch.cuda.memory_allocated(0)/1e9:.2f} GB')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:45:11.438305Z","iopub.execute_input":"2026-03-25T03:45:11.439123Z","iopub.status.idle":"2026-03-25T03:45:12.130526Z","shell.execute_reply.started":"2026-03-25T03:45:11.439048Z","shell.execute_reply":"2026-03-25T03:45:12.129675Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Rebuild DataLoaders with drop_last=True ───────────────────────────────────\ntrain_mm = MultimodalDataset(train_df, TRAIN_IMGS, oct_index,\n                              train_clinical, train_tf, val_tf)\nval_mm   = MultimodalDataset(val_df,   TRAIN_IMGS, oct_index,\n                              val_clinical,   val_tf,  val_tf)\n\ntrain_mm_loader = DataLoader(train_mm, batch_size=BATCH_SIZE,\n                              shuffle=True,  num_workers=2,\n                              drop_last=True)   # <-- fixes the batch size=1 crash\nval_mm_loader   = DataLoader(val_mm,   batch_size=BATCH_SIZE,\n                              shuffle=False, num_workers=2,\n                              drop_last=False)  # keep all val samples\n\n# ── Rebuild model with LayerNorm instead of BatchNorm1d ───────────────────────\nclass CrossModalAttention(nn.Module):\n    def __init__(self, embed_dim=128, num_heads=4):\n        super().__init__()\n        self.attn  = nn.MultiheadAttention(embed_dim, num_heads,\n                                            batch_first=True, dropout=0.1)\n        self.norm  = nn.LayerNorm(embed_dim)\n        self.ff    = nn.Sequential(nn.Linear(embed_dim, embed_dim*2),\n                                    nn.ReLU(), nn.Linear(embed_dim*2, embed_dim))\n        self.norm2 = nn.LayerNorm(embed_dim)\n    def forward(self, x):\n        a,_ = self.attn(x,x,x)\n        x   = self.norm(x+a)\n        return self.norm2(x+self.ff(x))\n\nclass MultimodalDRModel(nn.Module):\n    def __init__(self, clinical_dim=6, embed_dim=128, num_classes=5):\n        super().__init__()\n        # Fundus branch\n        self.fundus_encoder = models.efficientnet_b0(weights=None)\n        f_dim = self.fundus_encoder.classifier[1].in_features\n        self.fundus_encoder.classifier = nn.Identity()\n        self.fundus_proj = nn.Sequential(\n            nn.Linear(f_dim, embed_dim), nn.LayerNorm(embed_dim), nn.ReLU())\n\n        # OCT branch\n        self.oct_encoder = models.efficientnet_b0(weights='IMAGENET1K_V1')\n        o_dim = self.oct_encoder.classifier[1].in_features\n        self.oct_encoder.classifier = nn.Identity()\n        self.oct_proj = nn.Sequential(\n            nn.Linear(o_dim, embed_dim), nn.LayerNorm(embed_dim), nn.ReLU())\n\n        # Clinical branch — LayerNorm works on any batch size, BatchNorm1d does not\n        self.clinical_encoder = nn.Sequential(\n            nn.Linear(clinical_dim, 64),\n            nn.LayerNorm(64),          # <-- changed from BatchNorm1d to LayerNorm\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(64, embed_dim),\n            nn.LayerNorm(embed_dim),\n            nn.ReLU()\n        )\n\n        # Fusion + classifier\n        self.fusion     = CrossModalAttention(embed_dim, num_heads=4)\n        self.classifier = nn.Sequential(\n            nn.Dropout(0.4), nn.Linear(embed_dim*3, 256),\n            nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, num_classes))\n\n    def forward(self, fundus, oct_img, clinical):\n        f      = self.fundus_proj(self.fundus_encoder(fundus))\n        o      = self.oct_proj(self.oct_encoder(oct_img))\n        c      = self.clinical_encoder(clinical)\n        tokens = torch.stack([f, o, c], dim=1)\n        fused  = self.fusion(tokens)\n        return self.classifier(fused.flatten(1)), fused.flatten(1)\n\n# ── Rebuild model and reload Phase 3 weights ─────────────────────────────────\ngc.collect()\ntorch.cuda.empty_cache()\n\nmm_model = MultimodalDRModel(clinical_dim=6, embed_dim=128, num_classes=5).to(device)\n\nsaved_weights = torch.load(f'{OUTPUT_DIR}/best_model.pt', map_location=device)\nencoder_w     = {k.replace('encoder.', ''): v\n                 for k, v in saved_weights.items() if k.startswith('encoder.')}\nmm_model.fundus_encoder.load_state_dict(encoder_w)\nprint('Phase 3 weights loaded.')\nprint(f'GPU memory used: {torch.cuda.memory_allocated(0)/1e9:.2f} GB')\n\n# ── Training — exactly as before ──────────────────────────────────────────────\nfor p in mm_model.fundus_encoder.parameters():\n    p.requires_grad = False\n\nmm_criterion = nn.CrossEntropyLoss(weight=class_weights)\nmm_optimizer = torch.optim.AdamW(\n    filter(lambda p: p.requires_grad, mm_model.parameters()),\n    lr=1e-3, weight_decay=1e-4)\nmm_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(mm_optimizer, T_max=10)\n\nTOTAL_EPOCHS = 10\nWARMUP       = 3\nbest_mm_qwk  = 0.0\nmm_history   = {'train_loss':[],'val_loss':[],'train_qwk':[],'val_qwk':[]}\n\ndef run_mm_epoch(model, loader, optimizer, criterion, device, train=True):\n    model.train() if train else model.eval()\n    total_loss, preds_all, labels_all = 0, [], []\n    ctx = torch.enable_grad() if train else torch.no_grad()\n    with ctx:\n        for fundus, oct_img, clinical, labels in loader:\n            fundus   = fundus.to(device)\n            oct_img  = oct_img.to(device)\n            clinical = clinical.to(device)\n            labels   = labels.to(device)\n            if train: optimizer.zero_grad(set_to_none=True)\n            logits,_ = model(fundus, oct_img, clinical)\n            loss     = criterion(logits, labels)\n            if train: loss.backward(); optimizer.step()\n            total_loss += loss.item()\n            preds_all.extend(logits.argmax(1).cpu().numpy())\n            labels_all.extend(labels.cpu().numpy())\n    return (total_loss/len(loader),\n            cohen_kappa_score(labels_all, preds_all, weights='quadratic'))\n\nprint('\\n--- Phase 4: Multimodal training ---')\nfor epoch in range(1, TOTAL_EPOCHS+1):\n    if epoch == WARMUP+1:\n        for p in mm_model.fundus_encoder.parameters():\n            p.requires_grad = True\n        mm_optimizer = torch.optim.AdamW(mm_model.parameters(),\n                                          lr=1e-4, weight_decay=1e-4)\n        mm_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n            mm_optimizer, T_max=TOTAL_EPOCHS-WARMUP)\n        print(f'Epoch {epoch}: Fundus encoder unfrozen.')\n\n    t0 = time.time()\n    tr_loss, tr_qwk = run_mm_epoch(mm_model, train_mm_loader,\n                                    mm_optimizer, mm_criterion, device, True)\n    vl_loss, vl_qwk = run_mm_epoch(mm_model, val_mm_loader,\n                                    mm_optimizer, mm_criterion, device, False)\n    mm_scheduler.step()\n\n    mm_history['train_loss'].append(tr_loss)\n    mm_history['val_loss'].append(vl_loss)\n    mm_history['train_qwk'].append(tr_qwk)\n    mm_history['val_qwk'].append(vl_qwk)\n\n    if vl_qwk > best_mm_qwk:\n        best_mm_qwk = vl_qwk\n        torch.save(mm_model.state_dict(), f'{OUTPUT_DIR}/best_mm_model.pt')\n        saved = '  <- saved'\n    else:\n        saved = ''\n\n    print(f'Epoch {epoch:02d}/{TOTAL_EPOCHS} | '\n          f'Train loss: {tr_loss:.4f} QWK: {tr_qwk:.4f} | '\n          f'Val loss: {vl_loss:.4f} QWK: {vl_qwk:.4f} | '\n          f'{time.time()-t0:.0f}s{saved}')\n\nprint(f'\\nBest multimodal QWK: {best_mm_qwk:.4f}')\nprint(f'Baseline QWK:        {best_qwk:.4f}')\nprint(f'Improvement:         +{best_mm_qwk - best_qwk:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T03:45:12.131541Z","iopub.execute_input":"2026-03-25T03:45:12.131886Z","iopub.status.idle":"2026-03-25T04:23:05.945033Z","shell.execute_reply.started":"2026-03-25T03:45:12.131842Z","shell.execute_reply":"2026-03-25T04:23:05.944125Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nfrom sklearn.metrics import (cohen_kappa_score, roc_auc_score,\n                              confusion_matrix, classification_report,\n                              f1_score, accuracy_score)\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\ngrade_names = ['No DR','Mild','Moderate','Severe','Proliferative']\n\n# ── Collect multimodal predictions ────────────────────────────────────────────\nmm_model.load_state_dict(torch.load(f'{OUTPUT_DIR}/best_mm_model.pt',\n                                     map_location=device))\nmm_model.eval()\n\nmm_preds, mm_labels, mm_probs = [], [], []\nwith torch.no_grad():\n    for fundus, oct_img, clinical, labels in val_mm_loader:\n        logits,_ = mm_model(fundus.to(device),\n                             oct_img.to(device),\n                             clinical.to(device))\n        probs = torch.softmax(logits, dim=1)\n        mm_preds.extend(probs.argmax(1).cpu().numpy())\n        mm_labels.extend(labels.numpy())\n        mm_probs.extend(probs.cpu().numpy())\n\nmm_preds  = np.array(mm_preds)\nmm_labels = np.array(mm_labels)\nmm_probs  = np.array(mm_probs)\n\nmm_qwk = cohen_kappa_score(mm_labels, mm_preds, weights='quadratic')\nmm_auc = roc_auc_score(mm_labels, mm_probs, multi_class='ovr')\nmm_f1  = f1_score(mm_labels, mm_preds, average='macro')\nmm_acc = accuracy_score(mm_labels, mm_preds)\n\n# ── Baseline numbers (from Phase 3) ──────────────────────────────────────────\nbl_qwk = 0.8640\nbl_auc = 0.9327\nbl_f1  = 0.6480\nbl_acc = 79.13\n\n# ── Print comparison table ────────────────────────────────────────────────────\nprint('=' * 60)\nprint('PHASE 3 vs PHASE 4 — COPY INTO YOUR REPORT')\nprint('=' * 60)\nprint(f'{\"Metric\":<22} {\"Baseline\":>12} {\"Multimodal\":>12} {\"Delta\":>8}')\nprint('-' * 60)\nprint(f'{\"Accuracy (%)\":<22} {bl_acc:>12.2f} {mm_acc*100:>12.2f} '\n      f'{mm_acc*100-bl_acc:>+8.2f}')\nprint(f'{\"F1 (macro)\":<22} {bl_f1:>12.4f} {mm_f1:>12.4f} '\n      f'{mm_f1-bl_f1:>+8.4f}')\nprint(f'{\"AUC (OvR)\":<22} {bl_auc:>12.4f} {mm_auc:>12.4f} '\n      f'{mm_auc-bl_auc:>+8.4f}')\nprint(f'{\"QWK\":<22} {bl_qwk:>12.4f} {mm_qwk:>12.4f} '\n      f'{mm_qwk-bl_qwk:>+8.4f}')\nprint('=' * 60)\n\nprint('\\nMultimodal classification report:')\nprint(classification_report(mm_labels, mm_preds, target_names=grade_names))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T04:26:13.444785Z","iopub.execute_input":"2026-03-25T04:26:13.445446Z","iopub.status.idle":"2026-03-25T04:26:56.502667Z","shell.execute_reply.started":"2026-03-25T04:26:13.445405Z","shell.execute_reply":"2026-03-25T04:26:56.501652Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n\n# Baseline confusion matrix (reconstructed from known results)\n# Replace bl_preds/bl_labels with your saved pickle if available\ncm_mm = confusion_matrix(mm_labels, mm_preds)\n\n# Multimodal confusion matrix\nsns.heatmap(cm_mm, annot=True, fmt='d', cmap='Greens',\n            xticklabels=grade_names, yticklabels=grade_names,\n            ax=axes[0], linewidths=0.5)\naxes[0].set_title(f'Multimodal model\\nQWK: {mm_qwk:.4f}', fontsize=12)\naxes[0].set_ylabel('True label')\naxes[0].set_xlabel('Predicted label')\n\n# Training curves\nepochs = range(1, len(mm_history['train_qwk'])+1)\naxes[1].plot(epochs, mm_history['train_qwk'], 'b-o',\n             label='Train QWK', markersize=4)\naxes[1].plot(epochs, mm_history['val_qwk'],   'r-o',\n             label='Val QWK',   markersize=4)\naxes[1].axhline(y=bl_qwk, color='gray', linestyle='--',\n                alpha=0.7, label=f'Baseline QWK: {bl_qwk}')\naxes[1].axhline(y=mm_qwk, color='green', linestyle='--',\n                alpha=0.7, label=f'Best MM QWK: {mm_qwk:.4f}')\naxes[1].set_xlabel('Epoch')\naxes[1].set_ylabel('QWK')\naxes[1].set_title('Multimodal training curves')\naxes[1].legend(fontsize=9)\naxes[1].grid(True, alpha=0.3)\naxes[1].set_ylim(0.5, 1.0)\n\nplt.suptitle('Phase 4 — Multimodal Fusion Results', fontsize=13)\nplt.tight_layout()\nplt.savefig(f'{OUTPUT_DIR}/phase4_results.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T04:33:16.236401Z","iopub.execute_input":"2026-03-25T04:33:16.237204Z","iopub.status.idle":"2026-03-25T04:33:17.119003Z","shell.execute_reply.started":"2026-03-25T04:33:16.237163Z","shell.execute_reply":"2026-03-25T04:33:17.118271Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pickle\n\n# Save multimodal results\nmm_results = {\n    'qwk':    mm_qwk,\n    'auc':    mm_auc,\n    'f1':     mm_f1,\n    'acc':    mm_acc,\n    'preds':  mm_preds,\n    'labels': mm_labels,\n    'probs':  mm_probs,\n    'history': mm_history,\n}\nwith open(f'{OUTPUT_DIR}/mm_results.pkl', 'wb') as f:\n    pickle.dump(mm_results, f)\n\n# Verify all files present\nprint('Files saved:')\nfor fname in os.listdir(OUTPUT_DIR):\n    size = os.path.getsize(f'{OUTPUT_DIR}/{fname}') / 1e6\n    print(f'  {fname:<30} {size:.1f} MB')\n\nprint('\\nACTION REQUIRED:')\nprint('Click Save Version in the top right NOW')\nprint('Select Save & Run All to permanently commit outputs')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-25T04:33:45.708122Z","iopub.execute_input":"2026-03-25T04:33:45.708754Z","iopub.status.idle":"2026-03-25T04:33:45.715910Z","shell.execute_reply.started":"2026-03-25T04:33:45.708721Z","shell.execute_reply":"2026-03-25T04:33:45.715246Z"}},"outputs":[],"execution_count":null}]}