{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"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":13451,"datasetId":654585,"databundleVersionId":1188070},{"sourceType":"kernelVersion","sourceId":298700524},{"sourceType":"kernelVersion","sourceId":298725060},{"sourceType":"kernelVersion","sourceId":298741841}],"dockerImageVersionId":31287,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Notebook 5 — Grad-CAM Explainability\n**RSNA Intracranial Hemorrhage Detection**\n\nThis notebook generates and validates Grad-CAM visualisations for the trained model.\n\n### Contents\n1. Load trained model (best_model.pth from Notebook 03)\n2. Identify True Positives, False Positives, False Negatives, and True Negatives on validation set\n3. Generate Grad-CAM heatmap overlays for all four categories\n4. **Occlusion sanity check** — verify Grad-CAM highlights causally important regions\n5. Qualitative analysis: what the model attends to vs. clinical expectation\n\n### Required inputs\n- NB02 output: `manifest.csv` + `cache/` NPY arrays\n- NB03 output: `best_model.pth`, `checkpoint.pth`\n\n### Display note\nGrad-CAM overlays are rendered on the **full 3-channel windowed NPY stack**\n(brain / subdural / bone windows mapped to RGB). This means the display image\ncombines all three windows; the heatmap itself reflects features learned across\nall three channels jointly.","metadata":{}},{"cell_type":"code","source":"# ── Config ────────────────────────────────────────────────────────────────\nimport os, gc, random, json as _json\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom pathlib import Path\n\nimport torch\nimport torch.nn as nn\nimport torchvision.models as models\nimport torchvision.transforms as T\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.metrics import roc_auc_score, roc_curve, confusion_matrix\nimport matplotlib.pyplot as plt\nimport matplotlib.cm as cm\nfrom tqdm.auto import tqdm\n\nDEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\n\n# ── Paths — update to match your committed notebook names ────────────────\nCACHE_INPUT_DIR = '/kaggle/input/notebooks/harshitghosh/nb02eda'\nNPY_CACHE_DIR   = f'{CACHE_INPUT_DIR}/cache'\nMANIFEST_PATH   = f'{CACHE_INPUT_DIR}/manifest.csv'\nMODEL_PATH      = '/kaggle/input/notebooks/harshitghosh/03nbeda/best_model.pth'\nCHECKPOINT      = '/kaggle/input/notebooks/harshitghosh/03nbeda/checkpoint.pth'\n\nARCH         = 'efficientnet_b0'   # must match what was trained\nIMG_SIZE     = 256\nBATCH_SIZE   = 32\nNUM_WORKERS  = 4\nSEED         = 42\n\n# ─── Load normalization stats ────────────────────────────────────────────\n_norm_path = os.path.join(CACHE_INPUT_DIR, 'normalization_stats.json')\nif os.path.exists(_norm_path):\n    with open(_norm_path) as f:\n        _norm = _json.load(f)\n    MEAN = _norm['mean']\n    STD  = _norm['std']\n    print(f'Dataset normalization: mean={MEAN}, std={STD}')\nelse:\n    MEAN = [0.485, 0.456, 0.406]\n    STD  = [0.229, 0.224, 0.225]\n    print(f'Using ImageNet defaults: mean={MEAN}, std={STD}')\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\nprint(f'Device: {DEVICE}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T05:58:08.519732Z","iopub.execute_input":"2026-02-20T05:58:08.520426Z","iopub.status.idle":"2026-02-20T05:58:16.562702Z","shell.execute_reply.started":"2026-02-20T05:58:08.520396Z","shell.execute_reply":"2026-02-20T05:58:16.561923Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 1. Load model ─────────────────────────────────────────────────────────\ndef build_model(arch: str) -> nn.Module:\n    if arch == 'efficientnet_b0':\n        m = models.efficientnet_b0(weights=None)\n        m.classifier = nn.Sequential(nn.Dropout(0.3), nn.Linear(m.classifier[1].in_features, 1))\n    elif arch == 'resnet50':\n        m = models.resnet50(weights=None)\n        m.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(m.fc.in_features, 1))\n    else:\n        raise ValueError(arch)\n    return m\n\n\nmodel = build_model(ARCH)\nmodel.load_state_dict(torch.load(MODEL_PATH, map_location=DEVICE))\nmodel = model.to(DEVICE).eval()\n\n# Load optimal threshold from checkpoint\nckpt = torch.load(CHECKPOINT, map_location='cpu')\nTHRESHOLD = ckpt.get('best_thresh', 0.5)\nprint(f'Model loaded. Optimal threshold: {THRESHOLD:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T05:58:22.175513Z","iopub.execute_input":"2026-02-20T05:58:22.176548Z","iopub.status.idle":"2026-02-20T05:58:23.89214Z","shell.execute_reply.started":"2026-02-20T05:58:22.176502Z","shell.execute_reply":"2026-02-20T05:58:23.891308Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 2. Validation dataset ─────────────────────────────────────────────────\nclass ICHDataset(Dataset):\n    def __init__(self, df, npy_root, transform):\n        self.df = df.reset_index(drop=True)\n        self.npy_root = npy_root\n        self.transform = transform\n    def __len__(self): return len(self.df)\n    def __getitem__(self, idx):\n        row  = self.df.iloc[idx]\n        path = os.path.join(self.npy_root, f'{row[\"image_id\"]}.npy')\n        try:\n            img = np.load(path)                        # uint8 H×W×3 [0,255]\n        except Exception:\n            img = np.zeros((IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\n        return self.transform(img), torch.tensor(float(row['any']), dtype=torch.float32), row['image_id']\n\n\nval_transform = T.Compose([\n    T.ToPILImage(), T.ToTensor(), T.Normalize(mean=MEAN, std=STD)\n])\n\nmanifest = pd.read_csv(MANIFEST_PATH)\nval_df   = manifest[manifest['split'] == 'val'].reset_index(drop=True)\nval_ds   = ICHDataset(val_df, NPY_CACHE_DIR, val_transform)\nval_loader = DataLoader(val_ds, BATCH_SIZE, shuffle=False,\n                        num_workers=NUM_WORKERS, pin_memory=True)\nprint(f'Validation set: {len(val_df):,} images')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T05:59:04.532982Z","iopub.execute_input":"2026-02-20T05:59:04.533689Z","iopub.status.idle":"2026-02-20T05:59:04.754922Z","shell.execute_reply.started":"2026-02-20T05:59:04.533652Z","shell.execute_reply":"2026-02-20T05:59:04.754151Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 3. Run inference — collect predictions ────────────────────────────────\n@torch.no_grad()\ndef run_inference(model, loader, threshold):\n    model.eval()\n    records = []\n    for imgs, labels, ids in tqdm(loader, desc='Inference'):\n        with torch.amp.autocast(device_type='cuda'):\n            logits = model(imgs.to(DEVICE)).squeeze(1).cpu().float()\n        probs = torch.sigmoid(logits).numpy()\n        preds = (probs >= threshold).astype(int)\n        for img_id, lbl, prob, pred in zip(ids, labels.numpy(), probs, preds):\n            records.append({\n                'image_id': img_id,\n                'label'   : int(lbl),\n                'prob'    : round(float(prob), 4),\n                'pred'    : int(pred),\n            })\n    return pd.DataFrame(records)\n\n\npred_df = run_inference(model, val_loader, THRESHOLD)\n\n# Categorise each prediction\ndef categorise(row):\n    if row['label'] == 1 and row['pred'] == 1: return 'TP'\n    if row['label'] == 0 and row['pred'] == 0: return 'TN'\n    if row['label'] == 1 and row['pred'] == 0: return 'FN'\n    return 'FP'\n\npred_df['category'] = pred_df.apply(categorise, axis=1)\n\nval_auc = roc_auc_score(pred_df['label'], pred_df['prob'])\nprint(f'Validation AUC: {val_auc:.5f}')\nprint(f'Threshold used: {THRESHOLD:.4f}')\nprint(pred_df['category'].value_counts().to_string())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T05:59:10.183436Z","iopub.execute_input":"2026-02-20T05:59:10.184044Z","iopub.status.idle":"2026-02-20T05:59:55.922531Z","shell.execute_reply.started":"2026-02-20T05:59:10.184015Z","shell.execute_reply":"2026-02-20T05:59:55.921509Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 4. Grad-CAM implementation ────────────────────────────────────────────\nclass GradCAM:\n    \"\"\"\n    Hook-based Grad-CAM.\n    Works with EfficientNet-B0 and ResNet-50.\n    \"\"\"\n\n    def __init__(self, model: nn.Module, arch: str):\n        self.model      = model\n        self.gradients  = None\n        self.activations = None\n\n        # Pick the target layer (last spatial conv block)\n        if arch == 'efficientnet_b0':\n            target = model.features[-1]\n        elif arch == 'resnet50':\n            target = model.layer4[-1]\n        else:\n            raise ValueError(arch)\n\n        self._fwd_hook = target.register_forward_hook(self._fwd_hook_fn)\n        self._bwd_hook = target.register_full_backward_hook(self._bwd_hook_fn)\n\n    def _fwd_hook_fn(self, module, input, output):\n        self.activations = output.detach()\n\n    def _bwd_hook_fn(self, module, grad_input, grad_output):\n        self.gradients = grad_output[0].detach()\n\n    def remove(self):\n        self._fwd_hook.remove()\n        self._bwd_hook.remove()\n\n    def generate(self, img_tensor: torch.Tensor) -> np.ndarray:\n        \"\"\"\n        img_tensor: (1, C, H, W) normalised tensor on DEVICE.\n        Returns: (H, W) heatmap in [0, 1].\n        \"\"\"\n        self.model.zero_grad()\n        img_tensor = img_tensor.clone().requires_grad_(True)\n\n        logit = self.model(img_tensor).squeeze()     # scalar logit\n        logit.backward()                             # differentiate w.r.t. target layer\n\n        grads = self.gradients.squeeze()             # (C, H, W)\n        acts  = self.activations.squeeze()           # (C, H, W)\n\n        weights = grads.mean(dim=(1, 2), keepdim=True)   # global average pool over spatial dims\n        cam     = (weights * acts).sum(dim=0)            # weighted sum over channels\n        cam     = torch.relu(cam)                        # ReLU — keep only positive contributions\n        cam     = cam.cpu().numpy()\n\n        # Normalise to [0, 1]\n        if cam.max() > 0:\n            cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)\n        return cam\n\n\ngrad_cam = GradCAM(model, ARCH)\nprint('Grad-CAM ready.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:05:01.059427Z","iopub.execute_input":"2026-02-20T06:05:01.059775Z","iopub.status.idle":"2026-02-20T06:05:01.070823Z","shell.execute_reply.started":"2026-02-20T06:05:01.059739Z","shell.execute_reply":"2026-02-20T06:05:01.069984Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 5. Overlay utility ────────────────────────────────────────────────────\ndef overlay_cam(original_rgb: np.ndarray, cam: np.ndarray,\n                alpha: float = 0.45) -> np.ndarray:\n    \"\"\"\n    Blend Grad-CAM heatmap over the original image.\n    original_rgb: (H, W, 3) uint8\n    cam         : (h, w) float [0,1]\n    Returns: (H, W, 3) uint8 blended image.\n    \"\"\"\n    H, W = original_rgb.shape[:2]\n    cam_resized = cv2.resize(cam, (W, H), interpolation=cv2.INTER_LINEAR)\n    heatmap     = (cm.jet(cam_resized)[:, :, :3] * 255).astype(np.uint8)   # (H,W,3)\n    blended     = (alpha * heatmap + (1 - alpha) * original_rgb).astype(np.uint8)\n    return blended\n\n\ndef load_npy(image_id: str) -> np.ndarray:\n    \"\"\"Load cached NPY and return as uint8 RGB for display/overlay.\"\"\"\n    path = os.path.join(NPY_CACHE_DIR, f'{image_id}.npy')\n    try:\n        return np.load(path)                           # uint8 [0,255]\n    except Exception:\n        return np.zeros((IMG_SIZE, IMG_SIZE, 3), np.uint8)\n\n\ndef tensor_from_id(image_id: str) -> torch.Tensor:\n    img  = load_npy(image_id)\n    t    = val_transform(img).unsqueeze(0).to(DEVICE)\n    return t\n\n\nprint('Overlay utilities defined.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:05:04.26702Z","iopub.execute_input":"2026-02-20T06:05:04.267783Z","iopub.status.idle":"2026-02-20T06:05:04.277538Z","shell.execute_reply.started":"2026-02-20T06:05:04.267746Z","shell.execute_reply":"2026-02-20T06:05:04.276755Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 6. Generate Grad-CAM for TP / FN / FP / TN cases ─────────────────────\nmodel.train()   # needed so gradients flow in EfficientNet (dropout, BN in eval can prevent grads)\n\nN_EACH = 4      # number of examples per category to show\n\ncategories = [\n    ('TP', 'True Positive  (correct detection)',   '#2ecc71'),\n    ('FN', 'False Negative (missed hemorrhage)',   '#e74c3c'),\n    ('FP', 'False Positive (false alarm)',         '#f39c12'),\n    ('TN', 'True Negative  (correct rejection)',   '#3498db'),\n]\n\nfor cat, cat_label, color in categories:\n    subset = pred_df[pred_df['category'] == cat].sample(\n        min(N_EACH, len(pred_df[pred_df['category'] == cat])), random_state=SEED\n    )\n    if len(subset) == 0:\n        print(f'No {cat} cases found.'); continue\n\n    fig, axes = plt.subplots(len(subset), 2, figsize=(8, len(subset) * 3.5))\n    if len(subset) == 1:\n        axes = axes[np.newaxis, :]   # keep 2D for consistent indexing\n\n    fig.suptitle(f'Grad-CAM: {cat_label}', fontsize=12, color=color)\n\n    for row_i, (_, row) in enumerate(subset.iterrows()):\n        img_id = row['image_id']\n        t      = tensor_from_id(img_id)\n        cam    = grad_cam.generate(t)\n\n        orig   = load_npy(img_id)\n        blended = overlay_cam(orig, cam)\n\n        axes[row_i, 0].imshow(orig)\n        axes[row_i, 0].set_title(\n            f'{img_id[-8:]} | GT={row[\"label\"]} | pred={row[\"pred\"]} '\n            f'| p={row[\"prob\"]:.3f} | thr={THRESHOLD:.3f}', fontsize=7)\n        axes[row_i, 0].axis('off')\n\n        axes[row_i, 1].imshow(blended)\n        axes[row_i, 1].set_title('Grad-CAM overlay', fontsize=8)\n        axes[row_i, 1].axis('off')\n\n    plt.tight_layout()\n    save_path = f'/kaggle/working/gradcam_{cat.lower()}.png'\n    plt.savefig(save_path, bbox_inches='tight')\n    plt.show()\n    print(f'Saved: {save_path}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:05:09.473842Z","iopub.execute_input":"2026-02-20T06:05:09.474422Z","iopub.status.idle":"2026-02-20T06:05:35.361624Z","shell.execute_reply.started":"2026-02-20T06:05:09.474393Z","shell.execute_reply":"2026-02-20T06:05:35.360654Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 7. Occlusion sanity check ─────────────────────────────────────────────\n\"\"\"\nFor each high-confidence TP, we:\n1. Locate the region of maximum Grad-CAM activation\n2. Occlude it with a grey patch (mean pixel value)\n3. Measure the drop in model confidence\n\nIf Grad-CAM is marking causally relevant regions, occluding them should\nreduce the model's hemorrhage confidence noticeably.\n\"\"\"\n\nPATCH_SIZE = 48   # how many pixels to occlude per side\n\ndef occlude_at(img_tensor: torch.Tensor, cam: np.ndarray,\n               patch_size: int = 48) -> torch.Tensor:\n    \"\"\"Replace the peak Grad-CAM region with zeros (grey after normalisation).\"\"\"\n    cam_up = cv2.resize(cam, (IMG_SIZE, IMG_SIZE))\n    flat_idx = cam_up.argmax()\n    cy, cx   = divmod(int(flat_idx), IMG_SIZE)\n\n    y0 = max(cy - patch_size // 2, 0)\n    y1 = min(cy + patch_size // 2, IMG_SIZE)\n    x0 = max(cx - patch_size // 2, 0)\n    x1 = min(cx + patch_size // 2, IMG_SIZE)\n\n    t_occ = img_tensor.clone()\n    t_occ[:, :, y0:y1, x0:x1] = 0.0   # zero = ImageNet mean after normalisation\n    return t_occ, (y0, y1, x0, x1)\n\n\n@torch.no_grad()\ndef get_prob(model, t):\n    return torch.sigmoid(model(t).squeeze()).item()\n\n\nmodel.eval()   # switch back to eval for inference\n\nhigh_conf_tp = pred_df[\n    (pred_df['category'] == 'TP') & (pred_df['prob'] > 0.75)\n].head(8)\n\nocclusion_records = []\nfig, axes = plt.subplots(len(high_conf_tp), 3,\n                         figsize=(12, len(high_conf_tp) * 3))\nif len(high_conf_tp) == 1:\n    axes = axes[np.newaxis, :]\nfig.suptitle('Occlusion Sanity Check — High-confidence True Positives', fontsize=12)\n\n\nfor row_i, (_, row) in enumerate(high_conf_tp.iterrows()):\n    img_id = row['image_id']\n    t      = tensor_from_id(img_id)\n\n    # Generate CAM (needs grad)\n    model.train()\n    cam = grad_cam.generate(t)\n    model.eval()\n\n    # Original probability\n    orig_prob = get_prob(model, t)\n\n    # Occlude and re-evaluate\n    t_occ, bbox = occlude_at(t, cam, PATCH_SIZE)\n    occ_prob    = get_prob(model, t_occ)\n    drop        = orig_prob - occ_prob\n\n    occlusion_records.append({\n        'image_id': img_id,\n        'orig_prob': round(orig_prob, 4),\n        'occ_prob' : round(occ_prob, 4),\n        'prob_drop': round(drop, 4),\n    })\n\n    orig_rgb = load_npy(img_id)\n    cam_overlay = overlay_cam(orig_rgb, cam)\n\n    # Draw occlusion patch on original\n    occ_vis = orig_rgb.copy()\n    y0, y1, x0, x1 = bbox\n    occ_vis[y0:y1, x0:x1] = 127   # grey patch\n\n    axes[row_i, 0].imshow(orig_rgb)\n    axes[row_i, 0].set_title(f'Original  p={orig_prob:.3f}', fontsize=8)\n    axes[row_i, 0].axis('off')\n\n    axes[row_i, 1].imshow(cam_overlay)\n    axes[row_i, 1].set_title('Grad-CAM', fontsize=8)\n    axes[row_i, 1].axis('off')\n\n    axes[row_i, 2].imshow(occ_vis)\n    axes[row_i, 2].set_title(f'Occluded  p={occ_prob:.3f}  Δ={drop:+.3f}',\n                              color='red' if drop > 0.05 else 'grey', fontsize=8)\n    axes[row_i, 2].axis('off')\n\nplt.tight_layout()\nplt.savefig('/kaggle/working/occlusion_sanity_check.png', bbox_inches='tight')\nplt.show()\n\nocc_df = pd.DataFrame(occlusion_records)\nprint(occ_df.to_string(index=False))\nprint(f'\\nMean probability drop when occluding Grad-CAM peak: {occ_df[\"prob_drop\"].mean():.4f}')\nprint('(A positive mean drop confirms Grad-CAM is highlighting causally relevant regions.)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:06:07.273609Z","iopub.execute_input":"2026-02-20T06:06:07.274236Z","iopub.status.idle":"2026-02-20T06:06:10.338364Z","shell.execute_reply.started":"2026-02-20T06:06:07.274203Z","shell.execute_reply":"2026-02-20T06:06:10.33773Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 8. Qualitative failure analysis (FN cases) ────────────────────────────\n\"\"\"\nFor False Negatives (missed hemorrhages), we:\n- Show the Grad-CAM overlay\n- Note what the model attends to\n- Compare with where a haemorrhage would clinically be expected\nThis documents typical failure patterns (small bleeds, artefacts, etc.)\n\"\"\"\nmodel.train()\nfn_subset = pred_df[pred_df['category'] == 'FN'].sample(\n    min(6, len(pred_df[pred_df['category'] == 'FN'])), random_state=SEED\n)\n\nfig, axes = plt.subplots(len(fn_subset), 2,\n                         figsize=(8, len(fn_subset) * 3.5))\nif len(fn_subset) == 1: axes = axes[np.newaxis, :]\nfig.suptitle('False Negatives — Model attention (Grad-CAM)', fontsize=12, color='red')\n\nfn_notes = []\nfor row_i, (_, row) in enumerate(fn_subset.iterrows()):\n    img_id = row['image_id']\n    t = tensor_from_id(img_id)\n    cam = grad_cam.generate(t)\n\n    orig_rgb = load_npy(img_id)\n    blended  = overlay_cam(orig_rgb, cam)\n\n    # Max attention coordinates\n    cam_up  = cv2.resize(cam, (IMG_SIZE, IMG_SIZE))\n    flat_i  = cam_up.argmax()\n    peak_y, peak_x = divmod(int(flat_i), IMG_SIZE)\n    # Normalised peak location (0=left/top, 1=right/bottom)\n    peak_nx = peak_x / IMG_SIZE\n    peak_ny = peak_y / IMG_SIZE\n\n    fn_notes.append({'image_id': img_id, 'prob': row['prob'],\n                     'peak_x_norm': round(peak_nx, 2),\n                     'peak_y_norm': round(peak_ny, 2)})\n\n    axes[row_i, 0].imshow(orig_rgb)\n    axes[row_i, 0].set_title(f'{img_id[-8:]}  p={row[\"prob\"]:.3f} (FN)', fontsize=8)\n    axes[row_i, 0].axis('off')\n\n    axes[row_i, 1].imshow(blended)\n    axes[row_i, 1].set_title(f'Grad-CAM  peak≈({peak_nx:.2f}, {peak_ny:.2f})', fontsize=8)\n    axes[row_i, 1].axis('off')\n\nplt.tight_layout()\nplt.savefig('/kaggle/working/gradcam_fn_analysis.png', bbox_inches='tight')\nplt.show()\n\nmodel.eval()   # restore eval mode\n\nfn_df = pd.DataFrame(fn_notes)\nfn_df.to_csv('/kaggle/working/fn_gradcam_notes.csv', index=False)\nprint(fn_df.to_string(index=False))\nprint()\nprint('Review observations:')\nprint(' - Do FN cases have lower contrast hemorrhage (small/subtle bleeds)?')\nprint(' - Does Grad-CAM highlight artifacts (skull edges, scan markers)?')\nprint(' - Are FN probabilities clustered just below the threshold?')\nprint(' These patterns inform target preprocessing or threshold adjustments.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:06:58.537936Z","iopub.execute_input":"2026-02-20T06:06:58.538406Z","iopub.status.idle":"2026-02-20T06:07:00.635925Z","shell.execute_reply.started":"2026-02-20T06:06:58.538368Z","shell.execute_reply":"2026-02-20T06:07:00.635256Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 9. Skull-edge attention analysis ──────────────────────────────────────\n\"\"\"\nIf Grad-CAM consistently highlights skull edges or image borders rather than\nbrain parenchyma, the model may be using bone artifacts as a shortcut.\nThis cell quantifies how often the peak attention falls near the image border.\n\nBORDER MARGIN: pixels within 15% of the edge are considered 'edge attention'.\n\"\"\"\nBORDER_MARGIN = 0.15   # fraction of image dimension\n\ntp_subset = pred_df[pred_df['category'] == 'TP'].sample(\n    min(50, len(pred_df[pred_df['category'] == 'TP'])), random_state=SEED\n)\n\nmodel.train()\nedge_count = 0\ntotal_checked = 0\n\nfor _, row in tp_subset.iterrows():\n    t = tensor_from_id(row['image_id'])\n    cam = grad_cam.generate(t)\n    cam_up = cv2.resize(cam, (IMG_SIZE, IMG_SIZE))\n\n    flat_idx = cam_up.argmax()\n    peak_y, peak_x = divmod(int(flat_idx), IMG_SIZE)\n    norm_x = peak_x / IMG_SIZE\n    norm_y = peak_y / IMG_SIZE\n\n    # Check if peak is near any edge\n    near_edge = (norm_x < BORDER_MARGIN or norm_x > (1 - BORDER_MARGIN) or\n                 norm_y < BORDER_MARGIN or norm_y > (1 - BORDER_MARGIN))\n    if near_edge:\n        edge_count += 1\n    total_checked += 1\n\nmodel.eval()\n\nedge_pct = edge_count / total_checked * 100 if total_checked > 0 else 0\nprint(f'Skull-edge attention analysis (TP cases):')\nprint(f'  Checked  : {total_checked} images')\nprint(f'  Edge peak: {edge_count} ({edge_pct:.1f}%)')\nprint(f'  Border margin: {BORDER_MARGIN*100:.0f}% of image edge')\nprint()\nif edge_pct > 40:\n    print('  ⚠ WARNING: >40% of TP cases have peak attention near skull/border.')\n    print('  This may indicate the model is using bone artifacts as shortcuts.')\n    print('  Consider: more aggressive cropping augmentation or skull-stripping.')\nelif edge_pct > 20:\n    print('  ⚡ NOTE: ~20-40% edge attention is common with brain CT.')\n    print('  Monitor but not necessarily problematic.')\nelse:\n    print('  ✓ Most attention falls on brain parenchyma — good sign.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:07:06.74468Z","iopub.execute_input":"2026-02-20T06:07:06.745015Z","iopub.status.idle":"2026-02-20T06:07:08.338412Z","shell.execute_reply.started":"2026-02-20T06:07:06.744982Z","shell.execute_reply":"2026-02-20T06:07:08.337686Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── 10. Cleanup hooks ──────────────────────────────────────────────────────\ngrad_cam.remove()\nprint('Grad-CAM hooks removed.')\nprint('\\nSaved outputs:')\nprint(' gradcam_tp.png       — TP overlay examples')\nprint(' gradcam_fn.png       — FN overlay examples')\nprint(' gradcam_fp.png       — FP overlay examples')\nprint(' gradcam_tn.png       — TN overlay examples')\nprint(' occlusion_sanity_check.png  — sanity check results')\nprint(' gradcam_fn_analysis.png     — failure analysis')\nprint(' fn_gradcam_notes.csv        — FN attention coordinates')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:07:36.611147Z","iopub.execute_input":"2026-02-20T06:07:36.61144Z","iopub.status.idle":"2026-02-20T06:07:36.616625Z","shell.execute_reply.started":"2026-02-20T06:07:36.611415Z","shell.execute_reply":"2026-02-20T06:07:36.615844Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── HEALTH CHECK — automated output validation ────────────────────────────\nimport json as _json_hc\n\nerrors = []\n\nexpected_files = [\n    'gradcam_tp.png', 'gradcam_fn.png', 'gradcam_fp.png', 'gradcam_tn.png',\n    'occlusion_sanity_check.png', 'gradcam_fn_analysis.png',\n    'fn_gradcam_notes.csv',\n]\nfor f in expected_files:\n    if not os.path.exists(f'/kaggle/working/{f}'):\n        errors.append(f'Missing: {f}')\n\n# Check occlusion results make sense\nif len(occ_df) > 0:\n    mean_drop = occ_df['prob_drop'].mean()\n    if mean_drop < 0.01:\n        errors.append(f'Occlusion mean drop={mean_drop:.4f} — Grad-CAM may not be highlighting causal regions')\n\nhealth = {\n    'notebook'   : '05_gradcam',\n    'status'     : 'PASS' if not errors else 'FAIL',\n    'errors'     : errors,\n    'val_auc'    : round(float(val_auc), 5),\n    'threshold'  : round(float(THRESHOLD), 4),\n    'n_tp'       : int((pred_df['category'] == 'TP').sum()),\n    'n_tn'       : int((pred_df['category'] == 'TN').sum()),\n    'n_fn'       : int((pred_df['category'] == 'FN').sum()),\n    'n_fp'       : int((pred_df['category'] == 'FP').sum()),\n    'occ_mean_drop'  : round(float(occ_df['prob_drop'].mean()), 4) if len(occ_df) > 0 else None,\n    'edge_attention_pct': round(edge_pct, 1),\n}\n\nwith open('/kaggle/working/health_check_nb05.json', 'w') as f:\n    _json_hc.dump(health, f, indent=2)\n\nif errors:\n    print('❌ HEALTH CHECK FAILED:')\n    for e in errors:\n        print(f'   • {e}')\nelse:\n    print('✅ HEALTH CHECK PASSED')\n    print(f'   Val AUC        : {val_auc:.5f}')\n    print(f'   Threshold      : {THRESHOLD:.4f}')\n    print(f'   TP/TN/FP/FN    : {health[\"n_tp\"]}/{health[\"n_tn\"]}/{health[\"n_fp\"]}/{health[\"n_fn\"]}')\n    print(f'   Occlusion drop : {occ_df[\"prob_drop\"].mean():.4f} (mean)')\n    print(f'   Edge attention  : {edge_pct:.1f}%')\n    print(f'   Plots saved    : {len(expected_files)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-20T06:07:42.764752Z","iopub.execute_input":"2026-02-20T06:07:42.765442Z","iopub.status.idle":"2026-02-20T06:07:42.780426Z","shell.execute_reply.started":"2026-02-20T06:07:42.765411Z","shell.execute_reply":"2026-02-20T06:07:42.779509Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}