{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nprint(os.listdir(\"/kaggle/input/datasets/anushakirand/\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-22T09:46:23.233646Z","iopub.execute_input":"2026-07-22T09:46:23.233978Z","iopub.status.idle":"2026-07-22T09:46:23.239417Z","shell.execute_reply.started":"2026-07-22T09:46:23.233952Z","shell.execute_reply":"2026-07-22T09:46:23.238599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport torch\nfrom torch.utils.data import DataLoader\nfrom sklearn.metrics import (\n    roc_auc_score, average_precision_score, precision_recall_curve,\n    confusion_matrix, f1_score,\n)\n\nCHECKPOINT_PATH = f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\"\n\n# Reuse from Cell 1 if present, else rebuild\nif \"val_df_g\" in globals() and \"test_df_g\" in globals() and \"id_to_path_g\" in globals():\n    print(\"Reusing splits from training cell.\")\n    val_df, test_df, id_to_path = val_df_g, test_df_g, id_to_path_g\n    eval_transform = transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                              std=[0.229, 0.224, 0.225]),\n    ])\n    val_ds = ICHDataset(val_df, id_to_path, transform=eval_transform)\n    test_ds = ICHDataset(test_df, id_to_path, transform=eval_transform)\nelse:\n    print(\"Splits not found in memory — rebuilding (same random_state=42).\")\n    id_to_path = scan_input_dirs(INPUT_DIRS, MIN_FILE_SIZE_BYTES)\n    df = load_labels(TRAIN_CSV)\n    df = df[df.index.isin(id_to_path.keys())]\n    _, val_df, test_df = make_splits(df)\n    eval_transform = transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                              std=[0.229, 0.224, 0.225]),\n    ])\n    val_ds = ICHDataset(val_df, id_to_path, transform=eval_transform)\n    test_ds = ICHDataset(test_df, id_to_path, transform=eval_transform)\n\nval_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2, pin_memory=True)\ntest_loader = DataLoader(test_ds, batch_size=32, shuffle=False, num_workers=2, pin_memory=True)\n\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(CHECKPOINT_PATH, map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")\n\n\n@torch.no_grad()\ndef run_inference(loader):\n    all_probs, all_labels = [], []\n    for imgs, labels in loader:\n        imgs = imgs.to(DEVICE)\n        with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n            outputs = model(imgs)\n        probs = torch.sigmoid(outputs.float()).cpu().numpy()\n        all_probs.append(probs)\n        all_labels.append(labels.numpy())\n    return np.concatenate(all_probs), np.concatenate(all_labels)\n\n\nprint(\"Running inference on val split (threshold selection only)...\")\nval_probs, val_labels = run_inference(val_loader)\nprint(\"Running inference on test split (reported numbers)...\")\ntest_probs, test_labels = run_inference(test_loader)\nprint(f\"Done: {len(val_probs)} val images, {len(test_probs)} test images.\")\n\n\ndef best_f1_threshold(y_true, y_prob):\n    precisions, recalls, thresholds = precision_recall_curve(y_true, y_prob)\n    f1s = 2 * precisions * recalls / (precisions + recalls + 1e-12)\n    best_idx = np.nanargmax(f1s[:-1])\n    return thresholds[best_idx] if len(thresholds) > 0 else 0.5\n\n\nrows = []\nconfusion_matrices = {}\n\nfor i, name in enumerate(LABEL_COLS):\n    y_true_val = val_labels[:, i]\n    y_true_test = test_labels[:, i]\n    y_prob_test = test_probs[:, i]\n\n    if len(np.unique(y_true_val)) < 2 or len(np.unique(y_true_test)) < 2:\n        continue\n\n    thresh = best_f1_threshold(y_true_val, val_probs[:, i])\n    y_pred_test = (y_prob_test >= thresh).astype(int)\n\n    roc_auc = roc_auc_score(y_true_test, y_prob_test)\n    pr_auc = average_precision_score(y_true_test, y_prob_test)\n\n    tn, fp, fn, tp = confusion_matrix(y_true_test, y_pred_test, labels=[0, 1]).ravel()\n    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0\n    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0\n    specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0\n    f1 = f1_score(y_true_test, y_pred_test, zero_division=0)\n\n    confusion_matrices[name] = np.array([[tn, fp], [fn, tp]])\n\n    rows.append({\n        \"class\": name, \"roc_auc\": roc_auc, \"pr_auc\": pr_auc,\n        \"threshold_from_val\": thresh, \"precision\": precision,\n        \"recall_sensitivity\": recall, \"specificity\": specificity, \"f1\": f1,\n        \"n_positive\": int(y_true_test.sum()), \"n_total\": len(y_true_test),\n    })\n\nresults_df = pd.DataFrame(rows).set_index(\"class\")\nmacro_auc = roc_auc_score(test_labels, test_probs, average=\"macro\")\nmicro_auc = roc_auc_score(test_labels, test_probs, average=\"micro\")\n\nprint(\"\\n\" + \"=\" * 90)\nprint(f\"PER-CLASS METRICS (test split) — {MODEL_NAME}, checkpoint epoch {checkpoint['epoch']}\")\nprint(\"=\" * 90)\nprint(results_df.round(4).to_string())\nprint(f\"\\nMacro-average ROC-AUC: {macro_auc:.4f}\")\nprint(f\"Micro-average ROC-AUC: {micro_auc:.4f}\")\n\nprint(\"\\n\" + \"=\" * 90)\nprint(\"CONFUSION MATRICES (test split; rows=true, cols=predicted, order=[neg, pos])\")\nprint(\"=\" * 90)\nfor name, cm in confusion_matrices.items():\n    print(f\"\\n{name}:\")\n    print(f\"           pred_neg  pred_pos\")\n    print(f\"true_neg   {cm[0,0]:>8}  {cm[0,1]:>8}\")\n    print(f\"true_pos   {cm[1,0]:>8}  {cm[1,1]:>8}\")\n\nout_path = f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\"\nresults_df.to_csv(out_path)\nprint(f\"\\nFull metrics table saved to {out_path}\")\n\nglobal val_probs_g, val_labels_g, test_probs_g, test_labels_g\nval_probs_g, val_labels_g, test_probs_g, test_labels_g = val_probs, val_labels, test_probs, test_labels","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-21T08:05:45.895432Z","iopub.execute_input":"2026-07-21T08:05:45.896364Z","iopub.status.idle":"2026-07-21T08:15:22.368779Z","shell.execute_reply.started":"2026-07-21T08:05:45.896288Z","shell.execute_reply":"2026-07-21T08:15:22.367553Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nfrom sklearn.metrics import roc_curve, precision_recall_curve, auc\n\n# ------------------------------------------------------------------\n# ROC + PR CURVES — per class, test split\n# ------------------------------------------------------------------\n# Note: these are computed once, not before/after temperature scaling.\n# Temp scaling (logits / T) is a monotonic transform — it doesn't\n# change the ranking of predictions, only their confidence, so ROC/PR\n# curves (which are rank-based) are identical before and after. That's\n# also why ROC-AUC/PR-AUC don't move in the calibration table.\n\nn_classes = len(LABEL_COLS)\nfig, axes = plt.subplots(2, n_classes, figsize=(4 * n_classes, 8))\n\nfor i, name in enumerate(LABEL_COLS):\n    y_true = test_labels[:, i]\n    y_prob = test_probs[:, i]\n\n    if len(np.unique(y_true)) < 2:\n        axes[0, i].set_visible(False)\n        axes[1, i].set_visible(False)\n        continue\n\n    # ROC\n    fpr, tpr, _ = roc_curve(y_true, y_prob)\n    roc_auc_val = auc(fpr, tpr)\n    ax_roc = axes[0, i]\n    ax_roc.plot(fpr, tpr, label=f\"AUC = {roc_auc_val:.3f}\")\n    ax_roc.plot([0, 1], [0, 1], \"k--\", alpha=0.4)\n    ax_roc.set_title(f\"{name}\\nROC curve\")\n    ax_roc.set_xlabel(\"False Positive Rate\")\n    ax_roc.set_ylabel(\"True Positive Rate\")\n    ax_roc.legend(fontsize=8, loc=\"lower right\")\n\n    # PR\n    precision, recall, _ = precision_recall_curve(y_true, y_prob)\n    pr_auc_val = average_precision_score(y_true, y_prob)\n    base_rate = y_true.mean()\n    ax_pr = axes[1, i]\n    ax_pr.plot(recall, precision, label=f\"AP = {pr_auc_val:.3f}\")\n    ax_pr.axhline(base_rate, color=\"k\", linestyle=\"--\", alpha=0.4,\n                  label=f\"baseline = {base_rate:.3f}\")\n    ax_pr.set_title(f\"{name}\\nPR curve\")\n    ax_pr.set_xlabel(\"Recall\")\n    ax_pr.set_ylabel(\"Precision\")\n    ax_pr.legend(fontsize=8, loc=\"upper right\")\n\nplt.suptitle(f\"ROC & PR curves — {MODEL_NAME}, test split (n={len(test_labels)})\", y=1.02)\nplt.tight_layout()\nplt.savefig(f\"/kaggle/working/{MODEL_NAME}_roc_pr_curves.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\n\nprint(f\"Saved ROC/PR curve grid to /kaggle/working/{MODEL_NAME}_roc_pr_curves.png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-21T09:54:00.362387Z","iopub.execute_input":"2026-07-21T09:54:00.363129Z","iopub.status.idle":"2026-07-21T09:54:03.351449Z","shell.execute_reply.started":"2026-07-21T09:54:00.363065Z","shell.execute_reply":"2026-07-21T09:54:03.350541Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport matplotlib.pyplot as plt\nfrom sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss\n\nN_BINS = 15\n\n\nclass TemperatureScaler(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.log_T = nn.Parameter(torch.zeros(1))\n\n    def forward(self, logits):\n        return logits / torch.exp(self.log_T)\n\n\n@torch.no_grad()\ndef get_logits(loader):\n    all_logits, all_labels = [], []\n    for imgs, labels in loader:\n        imgs = imgs.to(DEVICE)\n        with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n            outputs = model(imgs)\n        all_logits.append(outputs.float().cpu())\n        all_labels.append(labels)\n    return torch.cat(all_logits), torch.cat(all_labels)\n\n\nprint(\"Running inference (logits) on val split (for fitting T)...\")\nval_logits, val_labels_t = get_logits(val_loader)\nprint(\"Running inference (logits) on test split (for reporting)...\")\ntest_logits, test_labels_t = get_logits(test_loader)\n\nscaler = TemperatureScaler()\noptimizer = torch.optim.LBFGS([scaler.log_T], lr=0.05, max_iter=100)\nbce = nn.BCEWithLogitsLoss()\n\ndef closure():\n    optimizer.zero_grad()\n    loss = bce(scaler(val_logits), val_labels_t)\n    loss.backward()\n    return loss\n\noptimizer.step(closure)\nT = torch.exp(scaler.log_T).item()\nprint(f\"\\nFitted temperature: T = {T:.4f}  \"\n      f\"({'softens' if T > 1 else 'sharpens'} the model's confidence)\")\n\n\ndef expected_and_max_calibration_error(y_true, y_prob, n_bins=15):\n    bin_edges = np.linspace(0, 1, n_bins + 1)\n    ece, mce = 0.0, 0.0\n    bin_accs, bin_confs, bin_counts = [], [], []\n    for lo, hi in zip(bin_edges[:-1], bin_edges[1:]):\n        mask = (y_prob > lo) & (y_prob <= hi)\n        count = mask.sum()\n        if count == 0:\n            bin_accs.append(np.nan)\n            bin_confs.append((lo + hi) / 2)\n            bin_counts.append(0)\n            continue\n        acc = y_true[mask].mean()\n        conf = y_prob[mask].mean()\n        gap = abs(acc - conf)\n        ece += (count / len(y_prob)) * gap\n        mce = max(mce, gap)\n        bin_accs.append(acc)\n        bin_confs.append(conf)\n        bin_counts.append(count)\n    return ece, mce, bin_edges, bin_accs, bin_confs, bin_counts\n\n\ndef nll(y_true, y_prob):\n    eps = 1e-12\n    p = np.clip(y_prob, eps, 1 - eps)\n    return -np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p))\n\n\ntest_labels_np = test_labels_t.numpy()\nprobs_before = torch.sigmoid(test_logits).numpy()\nprobs_after = torch.sigmoid(test_logits / T).numpy()\n\nrows = []\nfor i, name in enumerate(LABEL_COLS):\n    y_true = test_labels_np[:, i]\n    if len(np.unique(y_true)) < 2:\n        continue\n\n    p_before, p_after = probs_before[:, i], probs_after[:, i]\n    ece_b, mce_b, *_ = expected_and_max_calibration_error(y_true, p_before, N_BINS)\n    ece_a, mce_a, *_ = expected_and_max_calibration_error(y_true, p_after, N_BINS)\n\n    rows.append({\n        \"class\": name,\n        \"roc_auc\": roc_auc_score(y_true, p_before),\n        \"pr_auc\": average_precision_score(y_true, p_before),\n        \"ece_before\": ece_b, \"ece_after\": ece_a,\n        \"mce_before\": mce_b, \"mce_after\": mce_a,\n        \"brier_before\": brier_score_loss(y_true, p_before),\n        \"brier_after\": brier_score_loss(y_true, p_after),\n        \"nll_before\": nll(y_true, p_before),\n        \"nll_after\": nll(y_true, p_after),\n    })\n\ncalib_results_df = pd.DataFrame(rows).set_index(\"class\")\nprint(\"\\n\" + \"=\" * 100)\nprint(f\"CALIBRATION METRICS (test split, n={len(test_labels_np)}) — {MODEL_NAME}, T={T:.4f}\")\nprint(\"=\" * 100)\nprint(calib_results_df.round(4).to_string())\n\nmacro_ece_before = calib_results_df[\"ece_before\"].mean()\nmacro_ece_after = calib_results_df[\"ece_after\"].mean()\nmacro_brier_before = calib_results_df[\"brier_before\"].mean()\nmacro_brier_after = calib_results_df[\"brier_after\"].mean()\nprint(f\"\\nMacro-avg ECE:   before={macro_ece_before:.4f} -> after={macro_ece_after:.4f}\")\nprint(f\"Macro-avg Brier: before={macro_brier_before:.4f} -> after={macro_brier_after:.4f}\")\n\nany_idx = LABEL_COLS.index(\"any\")\ny_true_any = test_labels_np[:, any_idx]\np_before_any = probs_before[:, any_idx]\np_after_any = probs_after[:, any_idx]\n\nece_b, mce_b, edges_b, accs_b, confs_b, counts_b = expected_and_max_calibration_error(\n    y_true_any, p_before_any, N_BINS)\nece_a, mce_a, edges_a, accs_a, confs_a, counts_a = expected_and_max_calibration_error(\n    y_true_any, p_after_any, N_BINS)\n\nfig, axes = plt.subplots(2, 2, figsize=(11, 9))\nbin_centers = (edges_b[:-1] + edges_b[1:]) / 2\nbin_width = 1 / N_BINS\n\nfor col, (accs, counts, ece, mce, label) in enumerate([\n    (accs_b, counts_b, ece_b, mce_b, \"Before temp scaling (T=1.0)\"),\n    (accs_a, counts_a, ece_a, mce_a, f\"After temp scaling (T={T:.3f})\"),\n]):\n    valid = [not np.isnan(a) for a in accs]\n    ax_rel = axes[0, col]\n    ax_rel.bar(np.array(bin_centers)[valid], np.array(accs)[valid],\n               width=bin_width, edgecolor=\"black\", alpha=0.7, label=\"Model\")\n    ax_rel.plot([0, 1], [0, 1], \"k--\", label=\"Perfect calibration\")\n    ax_rel.set_xlabel(\"Predicted probability\")\n    ax_rel.set_ylabel(\"Observed frequency\")\n    ax_rel.set_title(f\"{label}\\nECE={ece:.4f}  MCE={mce:.4f}\")\n    ax_rel.legend(fontsize=8)\n\n    ax_hist = axes[1, col]\n    ax_hist.bar(bin_centers, counts, width=bin_width, edgecolor=\"black\", alpha=0.7, color=\"gray\")\n    ax_hist.set_xlabel(\"Predicted probability\")\n    ax_hist.set_ylabel(\"Count\")\n    ax_hist.set_title(\"Confidence histogram\")\n\nplt.suptitle(f\"Calibration — {MODEL_NAME}, class 'any' (test, n={len(test_labels_np)})\", y=1.00)\nplt.tight_layout()\nplt.savefig(f\"/kaggle/working/{MODEL_NAME}_calibration_reliability.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\n\nout_path = f\"/kaggle/working/{MODEL_NAME}_calibration_metrics.csv\"\ncalib_results_df.to_csv(out_path)\nwith open(f\"/kaggle/working/{MODEL_NAME}_temperature.txt\", \"w\") as f:\n    f.write(f\"T={T:.6f}\\n\")\nprint(f\"\\nSaved calibration table to {out_path}\")\nprint(f\"Saved fitted T to /kaggle/working/{MODEL_NAME}_temperature.txt\")\nprint(f\"Saved reliability diagram to /kaggle/working/{MODEL_NAME}_calibration_reliability.png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-21T09:34:43.127392Z","iopub.execute_input":"2026-07-21T09:34:43.127653Z","iopub.status.idle":"2026-07-21T09:46:09.755134Z","shell.execute_reply.started":"2026-07-21T09:34:43.127621Z","shell.execute_reply":"2026-07-21T09:46:09.75442Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\nimport os\nfrom IPython.display import FileLink\n\nzip_base_name = \"/kaggle/working/resmnet\"\noutput_dir = \"/kaggle/working/resmnet_outputs\"\n\nos.makedirs(output_dir, exist_ok=True)\n\nfiles_to_zip = [\n    f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n    f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\",\n    f\"/kaggle/working/{MODEL_NAME}_roc_pr_curves.png\",\n    f\"/kaggle/working/{MODEL_NAME}_calibration_metrics.csv\",\n    f\"/kaggle/working/{MODEL_NAME}_calibration_reliability.png\",\n    f\"/kaggle/working/{MODEL_NAME}_temperature.txt\",\n    \"/kaggle/working/test_ids.csv\",\n]\n\ncopied, missing = [], []\nfor f in files_to_zip:\n    if os.path.exists(f):\n        shutil.copy(f, output_dir)\n        copied.append(f)\n    else:\n        missing.append(f)\n\nif missing:\n    print(\"Skipped (not found — run the relevant cell first if you need these):\")\n    for f in missing:\n        print(f\"  - {f}\")\n\nshutil.make_archive(zip_base_name, \"zip\", output_dir)\nprint(f\"\\nZipped {len(copied)} files to {zip_base_name}.zip\")\n\nFileLink(f\"{zip_base_name}.zip\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-21T09:56:52.047094Z","iopub.execute_input":"2026-07-21T09:56:52.047901Z","iopub.status.idle":"2026-07-21T09:56:59.735298Z","shell.execute_reply.started":"2026-07-21T09:56:52.047858Z","shell.execute_reply":"2026-07-21T09:56:59.734382Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset\nfrom torchvision import models, transforms\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import roc_auc_score, average_precision_score, precision_recall_curve, f1_score\nfrom tqdm.auto import tqdm\n\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\n\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\nMIN_FILE_SIZE_KB = 40\nMIN_FILE_SIZE_BYTES = MIN_FILE_SIZE_KB * 1024\nVAL_SPLIT = 0.15\nTEST_SPLIT = 0.15\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\n\nclass ICHDataset(Dataset):\n    def __init__(self, df, id_to_path, transform=None):\n        self.df = df\n        self.id_to_path = id_to_path\n        self.transform = transform\n        self.ids = df.index.values\n        self.labels = df[LABEL_COLS].values.astype(np.float32)\n\n    def __len__(self):\n        return len(self.ids)\n\n    def __getitem__(self, idx):\n        img_id = self.ids[idx]\n        path = self.id_to_path[img_id]\n        img = Image.open(path).convert(\"RGB\")\n        if self.transform:\n            img = self.transform(img)\n        label = torch.tensor(self.labels[idx], dtype=torch.float32)\n        return img, label\n\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\n\ndef build_vit_b16(num_classes=len(LABEL_COLS)):\n    model = models.vit_b_16(weights=None)\n    model.heads = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.hidden_dim, num_classes))\n    return model.to(DEVICE)\n\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50, \"vit_b16\": build_vit_b16}\n\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\n\ndef scan_input_dirs(input_dirs, min_size_bytes):\n    id_to_path = {}\n    skipped_small = 0\n    for d in input_dirs:\n        if not os.path.isdir(d):\n            print(f\"WARNING: directory not found, skipping: {d}\")\n            continue\n        filenames = [f for f in os.listdir(d) if f.endswith(\".png\")]\n        for f in tqdm(filenames, desc=f\"scanning {os.path.basename(d)}\"):\n            full_path = os.path.join(d, f)\n            if os.path.getsize(full_path) <= min_size_bytes:\n                skipped_small += 1\n                continue\n            id_to_path[f[:-4]] = full_path\n    print(f\"Scanned {len(input_dirs)} directories: {len(id_to_path)} usable PNGs \"\n          f\"found, {skipped_small} filtered out as low-information (<= \"\n          f\"{min_size_bytes} bytes / {MIN_FILE_SIZE_KB}KB).\")\n    return id_to_path\n\n\ndef make_splits(df):\n    train_df, temp_df = train_test_split(\n        df, test_size=VAL_SPLIT + TEST_SPLIT, stratify=df[\"any\"], random_state=42\n    )\n    val_df, test_df = train_test_split(\n        temp_df, test_size=TEST_SPLIT / (VAL_SPLIT + TEST_SPLIT),\n        stratify=temp_df[\"any\"], random_state=42\n    )\n    return train_df, val_df, test_df\n\n\nprint(\"Scanning input directories (same as training run)...\")\nid_to_path = scan_input_dirs(INPUT_DIRS, MIN_FILE_SIZE_BYTES)\ndf = load_labels(TRAIN_CSV)\ndf = df[df.index.isin(id_to_path.keys())]\ntrain_df, val_df, test_df = make_splits(df)\nprint(f\"Rebuilt splits — train: {len(train_df)}, val: {len(val_df)}, test: {len(test_df)}\")\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-22T10:24:31.331184Z","iopub.execute_input":"2026-07-22T10:24:31.332177Z","iopub.status.idle":"2026-07-22T10:39:24.179039Z","shell.execute_reply.started":"2026-07-22T10:24:31.332141Z","shell.execute_reply":"2026-07-22T10:39:24.178267Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    roc_auc_score, average_precision_score, precision_recall_curve,\n    confusion_matrix, f1_score,\n)\nfrom tqdm.auto import tqdm\n\n# ------------------------------------------------------------------\n# CONFIG (same as training run)\n# ------------------------------------------------------------------\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\nprint(f\"Using device: {DEVICE}\")\n\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\nMIN_FILE_SIZE_KB = 40\nMIN_FILE_SIZE_BYTES = MIN_FILE_SIZE_KB * 1024\nVAL_SPLIT = 0.15\nTEST_SPLIT = 0.15\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\nCHECKPOINT_PATH = f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\"\n\n\nclass ICHDataset(Dataset):\n    def __init__(self, df, id_to_path, transform=None):\n        self.df = df\n        self.id_to_path = id_to_path\n        self.transform = transform\n        self.ids = df.index.values\n        self.labels = df[LABEL_COLS].values.astype(np.float32)\n\n    def __len__(self):\n        return len(self.ids)\n\n    def __getitem__(self, idx):\n        img_id = self.ids[idx]\n        path = self.id_to_path[img_id]\n        img = Image.open(path).convert(\"RGB\")\n        if self.transform:\n            img = self.transform(img)\n        label = torch.tensor(self.labels[idx], dtype=torch.float32)\n        return img, label\n\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\n\ndef build_vit_b16(num_classes=len(LABEL_COLS)):\n    model = models.vit_b_16(weights=None)\n    model.heads = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.hidden_dim, num_classes))\n    return model.to(DEVICE)\n\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50, \"vit_b16\": build_vit_b16}\n\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\n\ndef scan_input_dirs(input_dirs, min_size_bytes):\n    id_to_path = {}\n    skipped_small = 0\n    for d in input_dirs:\n        if not os.path.isdir(d):\n            print(f\"WARNING: directory not found, skipping: {d}\")\n            continue\n        filenames = [f for f in os.listdir(d) if f.endswith(\".png\")]\n        for f in tqdm(filenames, desc=f\"scanning {os.path.basename(d)}\"):\n            full_path = os.path.join(d, f)\n            if os.path.getsize(full_path) <= min_size_bytes:\n                skipped_small += 1\n                continue\n            id_to_path[f[:-4]] = full_path\n    print(f\"Scanned {len(input_dirs)} directories: {len(id_to_path)} usable PNGs \"\n          f\"found, {skipped_small} filtered out.\")\n    return id_to_path\n\n\ndef make_splits(df):\n    train_df, temp_df = train_test_split(\n        df, test_size=VAL_SPLIT + TEST_SPLIT, stratify=df[\"any\"], random_state=42\n    )\n    val_df, test_df = train_test_split(\n        temp_df, test_size=TEST_SPLIT / (VAL_SPLIT + TEST_SPLIT),\n        stratify=temp_df[\"any\"], random_state=42\n    )\n    return train_df, val_df, test_df\n\n\n# ------------------------------------------------------------------\n# REBUILD: data + model\n# ------------------------------------------------------------------\nprint(\"Scanning input directories...\")\nid_to_path = scan_input_dirs(INPUT_DIRS, MIN_FILE_SIZE_BYTES)\ndf = load_labels(TRAIN_CSV)\ndf = df[df.index.isin(id_to_path.keys())]\ntrain_df, val_df, test_df = make_splits(df)\nprint(f\"Rebuilt splits — train: {len(train_df)}, val: {len(val_df)}, test: {len(test_df)}\")\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\nval_ds = ICHDataset(val_df, id_to_path, transform=eval_transform)\ntest_ds = ICHDataset(test_df, id_to_path, transform=eval_transform)\n\nval_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2, pin_memory=True)\ntest_loader = DataLoader(test_ds, batch_size=32, shuffle=False, num_workers=2, pin_memory=True)\n\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(CHECKPOINT_PATH, map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")\n\n\n# ------------------------------------------------------------------\n# INFERENCE (with progress bars this time)\n# ------------------------------------------------------------------\n@torch.no_grad()\ndef run_inference(loader, desc=\"inference\"):\n    all_probs, all_labels = [], []\n    for imgs, labels in tqdm(loader, desc=desc):\n        imgs = imgs.to(DEVICE)\n        with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n            outputs = model(imgs)\n        probs = torch.sigmoid(outputs.float()).cpu().numpy()\n        all_probs.append(probs)\n        all_labels.append(labels.numpy())\n    return np.concatenate(all_probs), np.concatenate(all_labels)\n\n\nval_probs, val_labels = run_inference(val_loader, desc=\"val inference\")\ntest_probs, test_labels = run_inference(test_loader, desc=\"test inference\")\nprint(f\"Done: {len(val_probs)} val images, {len(test_probs)} test images.\")\n\n# Cache raw arrays to disk immediately — so if the session stops after\n# this, next time you can np.load() this instead of rerunning inference.\nnp.savez(\"/kaggle/working/test_inference_cache.npz\",\n         val_probs=val_probs, val_labels=val_labels,\n         test_probs=test_probs, test_labels=test_labels)\nprint(\"Cached val/test probs+labels to /kaggle/working/test_inference_cache.npz\")\n\n\n# ------------------------------------------------------------------\n# METRICS TABLE\n# ------------------------------------------------------------------\ndef best_f1_threshold(y_true, y_prob):\n    precisions, recalls, thresholds = precision_recall_curve(y_true, y_prob)\n    f1s = 2 * precisions * recalls / (precisions + recalls + 1e-12)\n    best_idx = np.nanargmax(f1s[:-1])\n    return thresholds[best_idx] if len(thresholds) > 0 else 0.5\n\n\nrows = []\nconfusion_matrices = {}\n\nfor i, name in enumerate(LABEL_COLS):\n    y_true_val = val_labels[:, i]\n    y_true_test = test_labels[:, i]\n    y_prob_test = test_probs[:, i]\n\n    if len(np.unique(y_true_val)) < 2 or len(np.unique(y_true_test)) < 2:\n        continue\n\n    thresh = best_f1_threshold(y_true_val, val_probs[:, i])\n    y_pred_test = (y_prob_test >= thresh).astype(int)\n\n    roc_auc = roc_auc_score(y_true_test, y_prob_test)\n    pr_auc = average_precision_score(y_true_test, y_prob_test)\n\n    tn, fp, fn, tp = confusion_matrix(y_true_test, y_pred_test, labels=[0, 1]).ravel()\n    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0\n    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0\n    specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0\n    f1 = f1_score(y_true_test, y_pred_test, zero_division=0)\n\n    confusion_matrices[name] = np.array([[tn, fp], [fn, tp]])\n\n    rows.append({\n        \"class\": name, \"roc_auc\": roc_auc, \"pr_auc\": pr_auc,\n        \"threshold_from_val\": thresh, \"precision\": precision,\n        \"recall_sensitivity\": recall, \"specificity\": specificity, \"f1\": f1,\n        \"n_positive\": int(y_true_test.sum()), \"n_total\": len(y_true_test),\n    })\n\nresults_df = pd.DataFrame(rows).set_index(\"class\")\nmacro_auc = roc_auc_score(test_labels, test_probs, average=\"macro\")\nmicro_auc = roc_auc_score(test_labels, test_probs, average=\"micro\")\n\nprint(\"\\n\" + \"=\" * 90)\nprint(f\"PER-CLASS METRICS (test split) — {MODEL_NAME}, checkpoint epoch {checkpoint['epoch']}\")\nprint(\"=\" * 90)\nprint(results_df.round(4).to_string())\nprint(f\"\\nMacro-average ROC-AUC: {macro_auc:.4f}\")\nprint(f\"Micro-average ROC-AUC: {micro_auc:.4f}\")\n\nout_path = f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\"\nresults_df.to_csv(out_path)\nprint(f\"\\nFull metrics table saved to {out_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-22T11:58:18.421068Z","iopub.execute_input":"2026-07-22T11:58:18.421506Z","iopub.status.idle":"2026-07-22T12:19:47.949883Z","shell.execute_reply.started":"2026-07-22T11:58:18.421469Z","shell.execute_reply":"2026-07-22T12:19:47.949005Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\nzip_base_name = \"/kaggle/working/resmnet_eval_backup\"\noutput_dir = \"/kaggle/working/resmnet_eval_backup_files\"\nimport os\nos.makedirs(output_dir, exist_ok=True)\n\nfor f in [f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\",\n          \"/kaggle/working/test_inference_cache.npz\"]:\n    if os.path.exists(f):\n        shutil.copy(f, output_dir)\n\nshutil.make_archive(zip_base_name, \"zip\", output_dir)\nprint(f\"Backed up to {zip_base_name}.zip\")\n\nfrom IPython.display import FileLink\nFileLink(f\"{zip_base_name}.zip\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-22T12:20:17.223187Z","iopub.execute_input":"2026-07-22T12:20:17.224236Z","iopub.status.idle":"2026-07-22T12:20:17.35784Z","shell.execute_reply.started":"2026-07-22T12:20:17.22419Z","shell.execute_reply":"2026-07-22T12:20:17.357204Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import json\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\nfrom PIL import Image\nimport os\n\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\n# ------------------------------------------------------------------\n# Load everything that's already saved — no inference rerun\n# ------------------------------------------------------------------\nresults_df = pd.read_csv(f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\", index_col=\"class\")\ncache = np.load(\"/kaggle/working/test_inference_cache.npz\")\nval_probs, val_labels = cache[\"val_probs\"], cache[\"val_labels\"]\ntest_probs, test_labels = cache[\"test_probs\"], cache[\"test_labels\"]\nprint(f\"Loaded cached results_df and inference arrays \"\n      f\"(val: {len(val_probs)}, test: {len(test_probs)})\")\n\n# Rebuild test_df from the saved id list + original labels CSV (fast, no image scan)\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\ntest_ids = pd.read_csv(\"/kaggle/working/test_ids.csv\")[\"id\"].values\nfull_labels = load_labels(TRAIN_CSV)\ntest_df = full_labels.loc[test_ids]\nprint(f\"Rebuilt test_df from test_ids.csv: {len(test_df)} rows — no image scan needed for this part.\")\n\n# ------------------------------------------------------------------\n# id_to_path — this part CANNOT be skipped without a prior cache.\n# Only scanning for ids that appear in test_df (not the full ~270K),\n# which is faster than a full training-time scan but still requires\n# listing each directory once.\n# ------------------------------------------------------------------\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\nneeded_ids = set(test_df.index)\nid_to_path = {}\nfor d in INPUT_DIRS:\n    if not os.path.isdir(d):\n        continue\n    for f in os.listdir(d):\n        if f.endswith(\".png\"):\n            img_id = f[:-4]\n            if img_id in needed_ids:\n                id_to_path[img_id] = os.path.join(d, f)\n\nprint(f\"Resolved paths for {len(id_to_path)} / {len(needed_ids)} test images.\")\n\n# Cache this mapping too, so next time even this scan is skippable\nwith open(\"/kaggle/working/test_id_to_path.json\", \"w\") as f:\n    json.dump(id_to_path, f)\nprint(\"Cached id_to_path to test_id_to_path.json for future sessions.\")\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50}\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\nprint(\"Model loaded and ready. Grad-CAM cell can now run directly.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-23T14:50:02.163933Z","iopub.execute_input":"2026-07-23T14:50:02.164234Z","iopub.status.idle":"2026-07-23T14:50:27.788466Z","shell.execute_reply.started":"2026-07-23T14:50:02.164208Z","shell.execute_reply":"2026-07-23T14:50:27.787763Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import json\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\nfrom PIL import Image\nimport os\n\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\n# ------------------------------------------------------------------\n# Load everything that's already saved — no inference rerun\n# ------------------------------------------------------------------\nresults_df = pd.read_csv(f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\", index_col=\"class\")\ncache = np.load(\"/kaggle/working/test_inference_cache.npz\")\nval_probs, val_labels = cache[\"val_probs\"], cache[\"val_labels\"]\ntest_probs, test_labels = cache[\"test_probs\"], cache[\"test_labels\"]\nprint(f\"Loaded cached results_df and inference arrays \"\n      f\"(val: {len(val_probs)}, test: {len(test_probs)})\")\n\n# Rebuild test_df from the saved id list + original labels CSV (fast, no image scan)\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\ntest_ids = pd.read_csv(\"/kaggle/working/test_ids.csv\")[\"id\"].values\nfull_labels = load_labels(TRAIN_CSV)\ntest_df = full_labels.loc[test_ids]\nprint(f\"Rebuilt test_df from test_ids.csv: {len(test_df)} rows — no image scan needed for this part.\")\n\n# ------------------------------------------------------------------\n# id_to_path — this part CANNOT be skipped without a prior cache.\n# Only scanning for ids that appear in test_df (not the full ~270K),\n# which is faster than a full training-time scan but still requires\n# listing each directory once.\n# ------------------------------------------------------------------\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\nneeded_ids = set(test_df.index)\nid_to_path = {}\nfor d in INPUT_DIRS:\n    if not os.path.isdir(d):\n        continue\n    for f in os.listdir(d):\n        if f.endswith(\".png\"):\n            img_id = f[:-4]\n            if img_id in needed_ids:\n                id_to_path[img_id] = os.path.join(d, f)\n\nprint(f\"Resolved paths for {len(id_to_path)} / {len(needed_ids)} test images.\")\n\n# Cache this mapping too, so next time even this scan is skippable\nwith open(\"/kaggle/working/test_id_to_path.json\", \"w\") as f:\n    json.dump(id_to_path, f)\nprint(\"Cached id_to_path to test_id_to_path.json for future sessions.\")\n\n# ------------------------------------------------------------------\n# Model + eval_transform\n# ------------------------------------------------------------------\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50}\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\nprint(\"Model loaded and ready. Grad-CAM cell can now run directly.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-23T15:27:45.331069Z","iopub.execute_input":"2026-07-23T15:27:45.33181Z","iopub.status.idle":"2026-07-23T15:28:09.377468Z","shell.execute_reply.started":"2026-07-23T15:27:45.331771Z","shell.execute_reply":"2026-07-23T15:28:09.376658Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-23T14:55:21.91215Z","iopub.execute_input":"2026-07-23T14:55:21.912622Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for name in [\"model\", \"test_df\", \"id_to_path\", \"eval_transform\",\n             \"results_df\", \"test_probs\", \"test_labels\", \"MODEL_BUILDERS\", \"LABEL_COLS\"]:\n    print(f\"{name}: {'present' if name in globals() else 'MISSING'}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-22T11:18:38.695576Z","iopub.execute_input":"2026-07-22T11:18:38.696856Z","iopub.status.idle":"2026-07-22T11:18:38.70361Z","shell.execute_reply.started":"2026-07-22T11:18:38.696805Z","shell.execute_reply":"2026-07-22T11:18:38.702459Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import zipfile, os\n\nSKIP_EXT = {\".zip\"}\nSKIP_DIRS = {\"checkpoints\"}  # drop this if you actually want the model weights included\n\nwith zipfile.ZipFile(\"/kaggle/working/all_outputs.zip\", \"w\", zipfile.ZIP_STORED) as zf:\n    for root, dirs, files in os.walk(\"/kaggle/working\"):\n        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]\n        for f in files:\n            if os.path.splitext(f)[1] in SKIP_EXT:\n                continue\n            full = os.path.join(root, f)\n            zf.write(full, os.path.relpath(full, \"/kaggle/working\"))\n\nprint(\"Done\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-23T16:13:06.086737Z","iopub.execute_input":"2026-07-23T16:13:06.086962Z","iopub.status.idle":"2026-07-23T16:13:11.098231Z","shell.execute_reply.started":"2026-07-23T16:13:06.086935Z","shell.execute_reply":"2026-07-23T16:13:11.097521Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader, Sampler\nfrom torchvision import models, transforms\nfrom PIL import Image\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.model_selection import train_test_split\nfrom tqdm.auto import tqdm\n\n# ------------------------------------------------------------------\n# CONFIG\n# ------------------------------------------------------------------\nMODEL_NAME = \"resnet50\"   # \"resnet50\" or \"vit_b16\"\n\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\n\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\nMIN_FILE_SIZE_KB = 40\nMIN_FILE_SIZE_BYTES = MIN_FILE_SIZE_KB * 1024\n\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\nBATCH_SIZE = 32\nNUM_EPOCHS = 15\nLR = 1e-4\n\n# Three-way split. 70/15/15 by default — change if you want 70/10/20 etc.\nVAL_SPLIT = 0.15\nTEST_SPLIT = 0.15\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\n\nEARLY_STOPPING_PATIENCE = 5\nCHECKPOINT_DIR = \"/kaggle/working/checkpoints\"\nTEST_IDS_PATH = \"/kaggle/working/test_ids.csv\"\n\nNUM_WORKERS = 4\n\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    df = y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n    return df\n\n\nclass ICHDataset(Dataset):\n    def __init__(self, df, id_to_path, transform=None):\n        self.df = df\n        self.id_to_path = id_to_path\n        self.transform = transform\n        self.ids = df.index.values\n        self.labels = df[LABEL_COLS].values.astype(np.float32)\n\n    def __len__(self):\n        return len(self.ids)\n\n    def __getitem__(self, idx):\n        img_id = self.ids[idx]\n        path = self.id_to_path[img_id]\n        img = Image.open(path).convert(\"RGB\")\n        if self.transform:\n            img = self.transform(img)\n        label = torch.tensor(self.labels[idx], dtype=torch.float32)\n        return img, label\n\n\nclass BalancedRandomSampler(Sampler):\n    def __init__(self, labels_any):\n        self.pos_idx = np.where(labels_any == 1)[0]\n        self.neg_idx = np.where(labels_any == 0)[0]\n\n    def __iter__(self):\n        n = min(len(self.neg_idx), max(len(self.pos_idx), 1))\n        neg_sample = np.random.choice(self.neg_idx, n, replace=False)\n        pos_sample = (np.random.choice(self.pos_idx, n, replace=False)\n                      if len(self.pos_idx) > 0 else np.array([], dtype=int))\n        ids = np.concatenate([pos_sample, neg_sample])\n        np.random.shuffle(ids)\n        return iter(ids.tolist())\n\n    def __len__(self):\n        return min(len(self.neg_idx), max(len(self.pos_idx), 1)) * 2\n\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)\n    for param in model.parameters():\n        param.requires_grad = False\n    for param in model.layer4.parameters():\n        param.requires_grad = True\n    model.fc = nn.Sequential(\n        nn.Dropout(0.3),\n        nn.Linear(model.fc.in_features, num_classes),\n    )\n    return model.to(DEVICE)\n\n\ndef build_vit_b16(num_classes=len(LABEL_COLS)):\n    model = models.vit_b_16(weights=models.ViT_B_16_Weights.IMAGENET1K_V1)\n    for param in model.parameters():\n        param.requires_grad = False\n    for param in model.encoder.layers[-1].parameters():\n        param.requires_grad = True\n    model.heads = nn.Sequential(\n        nn.Dropout(0.3),\n        nn.Linear(model.hidden_dim, num_classes),\n    )\n    for param in model.heads.parameters():\n        param.requires_grad = True\n    return model.to(DEVICE)\n\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50, \"vit_b16\": build_vit_b16}\n\nOPTIMIZER_CONFIG = {\n    \"resnet50\": {\"type\": \"sgd\", \"lr\": 1e-3, \"momentum\": 0.9},\n    \"vit_b16\": {\"type\": \"adamw\", \"lr\": 1e-4, \"weight_decay\": 1e-4},\n}\n\nSCHEDULER_CONFIG = {\n    \"resnet50\": {\"warmup_epochs\": 0},\n    \"vit_b16\": {\"warmup_epochs\": 1},\n}\n\n\ndef build_optimizer(model, model_name):\n    cfg = OPTIMIZER_CONFIG[model_name]\n    trainable_params = filter(lambda p: p.requires_grad, model.parameters())\n    if cfg[\"type\"] == \"sgd\":\n        return torch.optim.SGD(trainable_params, lr=cfg[\"lr\"], momentum=cfg[\"momentum\"])\n    elif cfg[\"type\"] == \"adamw\":\n        return torch.optim.AdamW(trainable_params, lr=cfg[\"lr\"],\n                                  weight_decay=cfg.get(\"weight_decay\", 0.0))\n    else:\n        raise ValueError(f\"Unknown optimizer type: {cfg['type']}\")\n\n\ndef build_scheduler(optimizer, model_name, num_epochs):\n    warmup_epochs = SCHEDULER_CONFIG[model_name][\"warmup_epochs\"]\n    warmup_epochs = min(warmup_epochs, max(num_epochs - 1, 0))\n\n    if warmup_epochs > 0:\n        warmup = torch.optim.lr_scheduler.LinearLR(\n            optimizer, start_factor=0.1, end_factor=1.0, total_iters=warmup_epochs\n        )\n        cosine = torch.optim.lr_scheduler.CosineAnnealingLR(\n            optimizer, T_max=max(num_epochs - warmup_epochs, 1)\n        )\n        scheduler = torch.optim.lr_scheduler.SequentialLR(\n            optimizer, schedulers=[warmup, cosine], milestones=[warmup_epochs]\n        )\n    else:\n        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)\n\n    return scheduler\n\n\ndef train_one_epoch(model, loader, optimizer, criterion, scaler, epoch, num_epochs):\n    model.train()\n    total_loss = 0.0\n    pbar = tqdm(loader, desc=f\"train epoch {epoch+1}/{num_epochs}\", leave=False)\n    for imgs, labels in pbar:\n        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n        optimizer.zero_grad()\n\n        with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n            outputs = model(imgs)\n            loss = criterion(outputs, labels)\n\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n\n        total_loss += loss.item() * imgs.size(0)\n        pbar.set_postfix(loss=f\"{loss.item():.4f}\")\n    return total_loss / len(loader.dataset)\n\n\n@torch.no_grad()\ndef evaluate(model, loader, criterion, epoch, num_epochs):\n    model.eval()\n    total_loss = 0.0\n    all_preds, all_labels = [], []\n    pbar = tqdm(loader, desc=f\"val epoch {epoch+1}/{num_epochs}\", leave=False)\n    for imgs, labels in pbar:\n        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n\n        with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n            outputs = model(imgs)\n            loss = criterion(outputs, labels)\n\n        total_loss += loss.item() * imgs.size(0)\n        all_preds.append(torch.sigmoid(outputs.float()).cpu().numpy())\n        all_labels.append(labels.cpu().numpy())\n\n    all_preds = np.concatenate(all_preds)\n    all_labels = np.concatenate(all_labels)\n\n    aucs = {}\n    for i, name in enumerate(LABEL_COLS):\n        if len(np.unique(all_labels[:, i])) > 1:\n            aucs[name] = roc_auc_score(all_labels[:, i], all_preds[:, i])\n        else:\n            aucs[name] = float(\"nan\")\n\n    return total_loss / len(loader.dataset), aucs\n\n\ndef run_training(model_name, train_ds, val_ds, y_train):\n    print(f\"\\n{'='*60}\")\n    print(f\"FULL TRAINING RUN: {model_name}\")\n    print(f\"{'='*60}\")\n\n    model = MODEL_BUILDERS[model_name]()\n    criterion = nn.BCEWithLogitsLoss()\n    optimizer = build_optimizer(model, model_name)\n    scheduler = build_scheduler(optimizer, model_name, NUM_EPOCHS)\n    scaler = torch.amp.GradScaler(device=DEVICE.type, enabled=USE_AMP)\n\n    sampler = BalancedRandomSampler(y_train[\"any\"].values)\n    train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, sampler=sampler,\n                               num_workers=NUM_WORKERS, pin_memory=True)\n    val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False,\n                             num_workers=NUM_WORKERS, pin_memory=True)\n\n    os.makedirs(CHECKPOINT_DIR, exist_ok=True)\n    checkpoint_path = os.path.join(CHECKPOINT_DIR, f\"{model_name}_best.pt\")\n\n    best_val_loss = float(\"inf\")\n    epochs_no_improve = 0\n\n    for epoch in range(NUM_EPOCHS):\n        current_lr = optimizer.param_groups[0][\"lr\"]\n\n        train_loss = train_one_epoch(model, train_loader, optimizer, criterion,\n                                      scaler, epoch, NUM_EPOCHS)\n        val_loss, val_aucs = evaluate(model, val_loader, criterion, epoch, NUM_EPOCHS)\n\n        auc_str = \", \".join(f\"{k}={v:.3f}\" for k, v in val_aucs.items())\n        print(f\"[{model_name}] Epoch {epoch+1}/{NUM_EPOCHS} | lr={current_lr:.2e} | \"\n              f\"train_loss={train_loss:.4f} | val_loss={val_loss:.4f} | {auc_str}\")\n\n        if val_loss < best_val_loss:\n            best_val_loss = val_loss\n            epochs_no_improve = 0\n            torch.save({\n                \"model_name\": model_name,\n                \"epoch\": epoch + 1,\n                \"model_state_dict\": model.state_dict(),\n                \"optimizer_state_dict\": optimizer.state_dict(),\n                \"val_loss\": val_loss,\n                \"val_aucs\": val_aucs,\n            }, checkpoint_path)\n            print(f\"[{model_name}]   -> val loss improved, checkpoint saved to {checkpoint_path}\")\n        else:\n            epochs_no_improve += 1\n            print(f\"[{model_name}]   -> no improvement ({epochs_no_improve}/{EARLY_STOPPING_PATIENCE})\")\n\n        scheduler.step()\n\n        if epochs_no_improve >= EARLY_STOPPING_PATIENCE:\n            print(f\"[{model_name}] Early stopping triggered at epoch {epoch+1}.\")\n            break\n\n    print(f\"[{model_name}] Training complete. Best val_loss={best_val_loss:.4f}, \"\n          f\"checkpoint at {checkpoint_path}\")\n\n\ndef scan_input_dirs(input_dirs, min_size_bytes):\n    id_to_path = {}\n    skipped_small = 0\n    for d in input_dirs:\n        if not os.path.isdir(d):\n            print(f\"WARNING: directory not found, skipping: {d}\")\n            continue\n        filenames = [f for f in os.listdir(d) if f.endswith(\".png\")]\n        for f in tqdm(filenames, desc=f\"scanning {os.path.basename(d)}\"):\n            full_path = os.path.join(d, f)\n            if os.path.getsize(full_path) <= min_size_bytes:\n                skipped_small += 1\n                continue\n            img_id = f[:-4]\n            id_to_path[img_id] = full_path\n    print(f\"Scanned {len(input_dirs)} directories: {len(id_to_path)} usable PNGs \"\n          f\"found, {skipped_small} filtered out as low-information (<= \"\n          f\"{min_size_bytes} bytes / {MIN_FILE_SIZE_KB}KB).\")\n    return id_to_path\n\n\ndef make_splits(df):\n    train_df, temp_df = train_test_split(\n        df, test_size=VAL_SPLIT + TEST_SPLIT, stratify=df[\"any\"], random_state=42\n    )\n    val_df, test_df = train_test_split(\n        temp_df, test_size=TEST_SPLIT / (VAL_SPLIT + TEST_SPLIT),\n        stratify=temp_df[\"any\"], random_state=42\n    )\n    return train_df, val_df, test_df\n\n\ndef main():\n    print(f\"*** FULL TRAINING RUN: {MODEL_NAME} ***\")\n    print(f\"Using device: {DEVICE} | AMP enabled: {USE_AMP}\")\n\n    id_to_path = scan_input_dirs(INPUT_DIRS, MIN_FILE_SIZE_BYTES)\n\n    if len(id_to_path) == 0:\n        print(\"\\nNo usable PNGs found across INPUT_DIRS. Check the paths are \"\n              \"correct and the datasets are actually attached as Input.\")\n        return\n\n    df = load_labels(TRAIN_CSV)\n    df = df[df.index.isin(id_to_path.keys())]\n    print(f\"Matched {len(df)} of those to labels. \"\n          f\"Positive rate: {df['any'].mean():.4f}\")\n\n    train_df, val_df, test_df = make_splits(df)\n    print(f\"Full run sizes — train: {len(train_df)}, val: {len(val_df)}, \"\n          f\"test: {len(test_df)}\")\n\n    os.makedirs(os.path.dirname(TEST_IDS_PATH), exist_ok=True)\n    test_df.index.to_series(name=\"id\").to_csv(TEST_IDS_PATH, index=False)\n    print(f\"Saved test set ids to {TEST_IDS_PATH}\")\n\n    train_transform = transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.RandomHorizontalFlip(),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                              std=[0.229, 0.224, 0.225]),\n    ])\n    eval_transform = transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                              std=[0.229, 0.224, 0.225]),\n    ])\n\n    global train_df_g, val_df_g, test_df_g, id_to_path_g\n    train_df_g, val_df_g, test_df_g, id_to_path_g = train_df, val_df, test_df, id_to_path\n\n    train_ds = ICHDataset(train_df, id_to_path, transform=train_transform)\n    val_ds = ICHDataset(val_df, id_to_path, transform=eval_transform)\n\n    global val_ds_g\n    val_ds_g = val_ds\n\n    run_training(MODEL_NAME, train_ds, val_ds, train_df)\n\n\nmain()","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-07-21T04:51:48.056664Z","iopub.execute_input":"2026-07-21T04:51:48.057273Z","iopub.status.idle":"2026-07-21T07:53:25.935299Z","shell.execute_reply.started":"2026-07-21T04:51:48.057229Z","shell.execute_reply":"2026-07-21T07:53:25.933895Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import json\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\nfrom PIL import Image\nimport os\n\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\nprint(f\"Using device: {DEVICE}\")\n\n# ------------------------------------------------------------------\n# Load everything already saved — no inference rerun, no full scan\n# ------------------------------------------------------------------\nresults_df = pd.read_csv(f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\", index_col=\"class\")\ncache = np.load(\"/kaggle/working/test_inference_cache.npz\")\nval_probs, val_labels = cache[\"val_probs\"], cache[\"val_labels\"]\ntest_probs, test_labels = cache[\"test_probs\"], cache[\"test_labels\"]\nprint(f\"Loaded cached results_df and inference arrays \"\n      f\"(val: {len(val_probs)}, test: {len(test_probs)})\")\n\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\ntest_ids = pd.read_csv(\"/kaggle/working/test_ids.csv\")[\"id\"].values\nfull_labels = load_labels(TRAIN_CSV)\ntest_df = full_labels.loc[test_ids]\nprint(f\"Rebuilt test_df from test_ids.csv: {len(test_df)} rows\")\n\n# id_to_path — load from cache if it exists, else scan (only for test ids)\ncache_path = \"/kaggle/working/test_id_to_path.json\"\nif os.path.exists(cache_path):\n    print(\"Loading cached id_to_path from disk — no scan needed.\")\n    with open(cache_path) as f:\n        id_to_path = json.load(f)\nelse:\n    print(\"No cached id_to_path found — scanning (test ids only)...\")\n    INPUT_DIRS = [\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n        \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n    ]\n    needed_ids = set(test_df.index)\n    id_to_path = {}\n    for d in INPUT_DIRS:\n        if not os.path.isdir(d):\n            continue\n        for f in os.listdir(d):\n            if f.endswith(\".png\"):\n                img_id = f[:-4]\n                if img_id in needed_ids:\n                    id_to_path[img_id] = os.path.join(d, f)\n    with open(cache_path, \"w\") as f:\n        json.dump(id_to_path, f)\nprint(f\"id_to_path ready: {len(id_to_path)} images resolved.\")\n\n# Model\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50}\nmodel = MODEL_BUILDERS[MODEL_NAME]()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\nprint(\"Ready — run the Grad-CAM cell next.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T06:21:43.180207Z","iopub.execute_input":"2026-07-24T06:21:43.181277Z","iopub.status.idle":"2026-07-24T06:22:04.348731Z","shell.execute_reply.started":"2026-07-24T06:21:43.181228Z","shell.execute_reply":"2026-07-24T06:22:04.347765Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install grad-cam -q\n\nimport os\nimport json\nimport pickle\nimport numpy as np\nimport pandas as pd\nimport torch\nimport matplotlib.pyplot as plt\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\nfrom pytorch_grad_cam.utils.image import show_cam_on_image\n\n# ------------------------------------------------------------------\n# GRAD-CAM — scaled up sample, for stable AOPC/Max-Sensitivity input.\n# Computes+caches CAMs for N_CAM_SAMPLES confirmed TPs per class,\n# but only visualizes a small subset (grid would be unusable at N=30).\n# ------------------------------------------------------------------\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\n\nN_CAM_SAMPLES = 30          # per class, capped by availability (esp. epidural)\nN_VISUALIZE_PER_CLASS = 2   # kept small — just a sanity-check grid\n\nGRADCAM_OUT_DIR = \"/kaggle/working/gradcam_examples\"\nCAM_CACHE_PATH = \"/kaggle/working/cam_cache.pkl\"\nos.makedirs(GRADCAM_OUT_DIR, exist_ok=True)\n\nCHANNEL_NAMES = [\"brain\", \"subdural\", \"bone\"]\n\ntarget_layers = [model.layer4[-1]]\ncam = GradCAM(model=model, target_layers=target_layers)\n\ncam_cache = {}          # img_id -> {class_name: grayscale_cam}\nimg_cache = {}          # img_id -> img_np (3-window array), reused by AOPC/Max-Sensitivity\nbone_corr_records = []\n\n\ndef load_image_for_cam(path):\n    img = Image.open(path).convert(\"RGB\").resize((224, 224))\n    img_np = np.array(img).astype(np.float32) / 255.0\n    input_tensor = eval_transform(img).unsqueeze(0).to(DEVICE)\n    return input_tensor, img_np\n\n\ndef bone_shortcut_correlation(grayscale_cam, bone_channel):\n    cam_flat = grayscale_cam.flatten()\n    bone_flat = bone_channel.flatten()\n    if cam_flat.std() < 1e-8 or bone_flat.std() < 1e-8:\n        return np.nan\n    return float(np.corrcoef(cam_flat, bone_flat)[0, 1])\n\n\ndef compute_gradcam(img_id, class_idx, class_name, id_to_path):\n    path = id_to_path[img_id]\n    input_tensor, img_np = load_image_for_cam(path)\n    targets = [ClassifierOutputTarget(class_idx)]\n    grayscale_cam = cam(input_tensor=input_tensor, targets=targets)[0, :]\n\n    cam_cache.setdefault(img_id, {})[class_name] = grayscale_cam\n    img_cache[img_id] = img_np\n\n    bone_channel = img_np[:, :, 2]\n    corr = bone_shortcut_correlation(grayscale_cam, bone_channel)\n    bone_corr_records.append({\"img_id\": img_id, \"class\": class_name, \"bone_corr\": corr})\n\n    visualization = show_cam_on_image(img_np, grayscale_cam, use_rgb=True)\n    return img_np, visualization, grayscale_cam, corr\n\n\ndef show_and_save_gradcam_panel(img_id, class_idx, class_name, id_to_path, fig_axes, tag=\"\"):\n    img_np, visualization, grayscale_cam, corr = compute_gradcam(\n        img_id, class_idx, class_name, id_to_path\n    )\n    ax_brain, ax_subdural, ax_bone, ax_composite, ax_cam = fig_axes\n\n    ax_brain.imshow(img_np[:, :, 0], cmap=\"gray\"); ax_brain.set_title(\"brain\", fontsize=8); ax_brain.axis(\"off\")\n    ax_subdural.imshow(img_np[:, :, 1], cmap=\"gray\"); ax_subdural.set_title(\"subdural\", fontsize=8); ax_subdural.axis(\"off\")\n    ax_bone.imshow(img_np[:, :, 2], cmap=\"gray\"); ax_bone.set_title(\"bone\", fontsize=8); ax_bone.axis(\"off\")\n    ax_composite.imshow(img_np); ax_composite.set_title(f\"{img_id}\\ncomposite\", fontsize=8); ax_composite.axis(\"off\")\n    ax_cam.imshow(visualization); ax_cam.set_title(f\"{class_name}{tag}\\nbone-corr={corr:.2f}\", fontsize=8); ax_cam.axis(\"off\")\n\n    out_path = os.path.join(GRADCAM_OUT_DIR, f\"{class_name}_{img_id}{tag.replace(' ', '_')}.png\")\n    Image.fromarray(visualization).save(out_path)\n\n\n# ------------------------------------------------------------------\n# Per-class predictions using val-derived thresholds\n# ------------------------------------------------------------------\ntest_ids_array = test_df.index.values\nclass_preds = {}\nfor class_name in results_df.index:\n    class_idx = LABEL_COLS.index(class_name)\n    thresh = results_df.loc[class_name, \"threshold_from_val\"]\n    class_preds[class_name] = (test_probs[:, class_idx] >= thresh)\n\n# ------------------------------------------------------------------\n# Compute + cache CAMs for N_CAM_SAMPLES confirmed TPs per class.\n# Only the first N_VISUALIZE_PER_CLASS of each are plotted.\n# ------------------------------------------------------------------\nclasses_to_show = [\"any\", \"epidural\", \"intraparenchymal\",\n                    \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\nn_cols = N_VISUALIZE_PER_CLASS * 5\nfig, axes = plt.subplots(len(classes_to_show), n_cols,\n                          figsize=(3 * n_cols / 2, 3.2 * len(classes_to_show)))\n\nsample_summary = []\n\nfor row, class_name in enumerate(classes_to_show):\n    class_idx = LABEL_COLS.index(class_name)\n    y_true = test_labels[:, class_idx]\n    y_pred = class_preds[class_name]\n\n    true_pos_mask = (y_true == 1) & (y_pred == 1)\n    true_pos_ids = test_ids_array[true_pos_mask]\n    true_pos_ids = np.array([i for i in true_pos_ids if i in id_to_path])\n\n    n_available = len(true_pos_ids)\n    n_sample = min(N_CAM_SAMPLES, n_available)\n    sample_summary.append({\"class\": class_name, \"n_available\": n_available, \"n_sampled\": n_sample})\n\n    if n_sample == 0:\n        for col in range(n_cols):\n            axes[row, col].set_visible(False)\n        print(f\"WARNING: no confirmed true positives with resolvable paths for '{class_name}'.\")\n        continue\n\n    sample_ids = np.random.choice(true_pos_ids, size=n_sample, replace=False)\n\n    print(f\"[{class_name}] computing CAMs for {n_sample} images \"\n          f\"({n_available} available)...\")\n    for i, img_id in enumerate(tqdm(sample_ids, desc=class_name, leave=False)):\n        if i < N_VISUALIZE_PER_CLASS:\n            panel_axes = axes[row, i * 5:(i + 1) * 5]\n            show_and_save_gradcam_panel(img_id, class_idx, class_name, id_to_path,\n                                         panel_axes, tag=\" (confirmed TP)\")\n        else:\n            # compute + cache only, no plotting\n            compute_gradcam(img_id, class_idx, class_name, id_to_path)\n\n    # hide unused visualization columns if fewer than N_VISUALIZE_PER_CLASS were available\n    for col in range(min(N_VISUALIZE_PER_CLASS, n_sample) * 5, n_cols):\n        axes[row, col].set_visible(False)\n\nplt.suptitle(f\"Grad-CAM — {MODEL_NAME}, sample visualization\\n\"\n             f\"(full cache: up to {N_CAM_SAMPLES}/class, cam_cache size = {len(cam_cache)})\",\n             y=1.001)\nplt.tight_layout()\nplt.savefig(f\"/kaggle/working/{MODEL_NAME}_gradcam_sample_visualization.png\",\n            dpi=200, bbox_inches=\"tight\")\nplt.show()\n\nprint(f\"\\nSample sizes used per class:\")\nprint(pd.DataFrame(sample_summary).to_string(index=False))\n\n# ------------------------------------------------------------------\n# Persist cam_cache + img_cache to disk — AOPC/Max-Sensitivity cell\n# can load these directly instead of recomputing.\n# ------------------------------------------------------------------\nwith open(CAM_CACHE_PATH, \"wb\") as f:\n    pickle.dump({\"cam_cache\": cam_cache, \"img_cache\": img_cache}, f)\nprint(f\"\\nSaved cam_cache + img_cache ({len(cam_cache)} images) to {CAM_CACHE_PATH}\")\n\n# ------------------------------------------------------------------\n# Bone-channel shortcut summary (now over the full sampled set)\n# ------------------------------------------------------------------\nbone_corr_df = pd.DataFrame(bone_corr_records)\nprint(\"\\n\" + \"=\" * 70)\nprint(\"BONE-CHANNEL SHORTCUT CHECK (full sampled set)\")\nprint(\"=\" * 70)\nprint(bone_corr_df.groupby(\"class\")[\"bone_corr\"].agg([\"mean\", \"std\", \"count\"]).round(3))\n\nbone_corr_out = f\"/kaggle/working/{MODEL_NAME}_bone_shortcut_correlations.csv\"\nbone_corr_df.to_csv(bone_corr_out, index=False)\nprint(f\"\\nSaved per-example bone-correlation records to {bone_corr_out}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T06:22:18.624295Z","iopub.execute_input":"2026-07-24T06:22:18.624807Z","iopub.status.idle":"2026-07-24T06:22:38.23159Z","shell.execute_reply.started":"2026-07-24T06:22:18.624779Z","shell.execute_reply":"2026-07-24T06:22:38.230724Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\n\n# ------------------------------------------------------------------\n# CONFIG — run this cell first, every session, right after switching\n# accelerators / restarting the kernel. Defines everything lightweight\n# that every later cell (rebuild, eval, Grad-CAM, AOPC) depends on.\n# ------------------------------------------------------------------\nMODEL_NAME = \"resnet50\"   # \"resnet50\" or \"vit_b16\"\n\nINPUT_DIRS = [\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part2\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-negative-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-positive-part-1\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs-part3\",\n    \"/kaggle/input/datasets/anushakirand/rsna-ich-preprocessed-pngs\",\n]\n\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\nMIN_FILE_SIZE_KB = 40\nMIN_FILE_SIZE_BYTES = MIN_FILE_SIZE_KB * 1024\n\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\nBATCH_SIZE = 32\nNUM_EPOCHS = 15\nLR = 1e-4\n\nVAL_SPLIT = 0.15\nTEST_SPLIT = 0.15\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\n\nEARLY_STOPPING_PATIENCE = 5\nCHECKPOINT_DIR = \"/kaggle/working/checkpoints\"\nCHECKPOINT_PATH = f\"{CHECKPOINT_DIR}/{MODEL_NAME}_best.pt\"\nTEST_IDS_PATH = \"/kaggle/working/test_ids.csv\"\n\nNUM_WORKERS = 4\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\n\ndef build_vit_b16(num_classes=len(LABEL_COLS)):\n    model = models.vit_b_16(weights=None)\n    model.heads = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.hidden_dim, num_classes))\n    return model.to(DEVICE)\n\n\nMODEL_BUILDERS = {\"resnet50\": build_resnet50, \"vit_b16\": build_vit_b16}\n\nprint(f\"Config loaded. Device: {DEVICE} | AMP: {USE_AMP} | Model: {MODEL_NAME}\")\nprint(\"Ready for: loader cell -> eval cell -> Grad-CAM cell -> AOPC cell, in that order.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T06:36:51.949139Z","iopub.execute_input":"2026-07-24T06:36:51.94947Z","iopub.status.idle":"2026-07-24T06:36:51.960957Z","shell.execute_reply.started":"2026-07-24T06:36:51.949446Z","shell.execute_reply":"2026-07-24T06:36:51.959944Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import gc\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\n# ------------------------------------------------------------------\n# AOPC + MAX-SENSITIVITY — ResNet50, HiResCAM (FAST / BATCHED)\n# Same maths, same RNG stream, same sampling as the old cell. Changes:\n#  1. Freeze everything except layer4[-1] -> HiResCAM backward stops at\n#     layer4[-1] instead of walking (and storing grads for) the whole net.\n#     Big speed win AND big memory win.\n#  2. AOPC: 10 perturbed images + original go through the model as ONE batch.\n#  3. Max-Sensitivity: 10 noisy images go through HiResCAM as ONE batch,\n#     and the clean CAM is reused instead of recomputed.\n#  4. Patch ranking / perturbation vectorised (no python loops).\n#  5. PNG decoding is threaded.\n# ------------------------------------------------------------------\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\n\nN_SAMPLES_PER_CLASS = 50\nN_AOPC_STEPS = 10\nGRID_SIZE = 16\nN_SENSITIVITY_REPEATS = 10\nNOISE_STD_FRACTION = 0.05\npatch_size = 224 // GRID_SIZE\nTOTAL_PATCHES = GRID_SIZE * GRID_SIZE\n\n# --- free leftover GPU memory from earlier cells (old models / CAM objects) ---\ngc.collect()\ntorch.cuda.empty_cache()\ntorch.backends.cudnn.benchmark = True   # fixed shapes -> free speedup\n\n# --- (1) only layer4[-1] needs grads for HiResCAM ---\nfor p in model.parameters():\n    p.requires_grad_(False)\nfor p in model.layer4[-1].parameters():\n    p.requires_grad_(True)\nmodel.eval()\n\nMEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)\nSTD = np.array([0.229, 0.224, 0.225], dtype=np.float32)\nAOPC_NS = np.array([int(TOTAL_PATCHES * s / N_AOPC_STEPS)\n                    for s in range(1, N_AOPC_STEPS + 1)])\n\n\ndef to_tensor_batch(imgs_np):\n    \"\"\"[B,H,W,3] float in [0,1] -> normalized [B,3,H,W] tensor on DEVICE.\"\"\"\n    x = (imgs_np - MEAN) / STD\n    x = np.ascontiguousarray(x.transpose(0, 3, 1, 2))\n    return torch.from_numpy(x).float().to(DEVICE)\n\n\ndef load_np(path):\n    img = Image.open(path).convert(\"RGB\").resize((224, 224))\n    return np.asarray(img).astype(np.float32) / 255.0\n\n\ndef cam_to_patch_ranking(gc_map):\n    scores = gc_map.reshape(GRID_SIZE, patch_size, GRID_SIZE, patch_size).mean(axis=(1, 3))\n    return np.argsort(-scores.ravel())\n\n\ndef build_aopc_batch(img_np, ranking):\n    \"\"\"[1 original + N_AOPC_STEPS progressively mean-filled] images, [S+1,H,W,3].\"\"\"\n    pos = np.empty(TOTAL_PATCHES, dtype=int)\n    pos[ranking] = np.arange(TOTAL_PATCHES)                       # rank of each patch\n    px_rank = pos.reshape(GRID_SIZE, GRID_SIZE).repeat(patch_size, 0).repeat(patch_size, 1)\n    masks = px_rank[None] < AOPC_NS[:, None, None]                # [S,H,W]\n    ch_means = img_np.reshape(-1, 3).mean(axis=0)\n    perturbed = np.where(masks[..., None], ch_means, img_np[None])\n    return np.concatenate([img_np[None], perturbed], axis=0)\n\n\n@torch.no_grad()\ndef compute_aopc(img_np, grayscale_cam, class_idx):\n    ranking = cam_to_patch_ranking(grayscale_cam)\n    batch = build_aopc_batch(img_np, ranking)\n    with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n        out = model(to_tensor_batch(batch))\n    probs = torch.sigmoid(out.float())[:, class_idx].cpu().numpy()\n    return float(np.mean(probs[0] - probs[1:]))\n\n\ndef compute_max_sensitivity(img_np, orig_cam, class_idx):\n    noise_std = NOISE_STD_FRACTION * img_np.std()\n    noise = np.random.normal(0, noise_std,\n                             (N_SENSITIVITY_REPEATS,) + img_np.shape).astype(np.float32)\n    noisy = np.clip(img_np[None] + noise, 0, 1)\n    targets = [ClassifierOutputTarget(class_idx)] * N_SENSITIVITY_REPEATS\n    noisy_cams = hirescam(input_tensor=to_tensor_batch(noisy), targets=targets)  # [R,H,W]\n    diffs = np.linalg.norm((noisy_cams - orig_cam[None]).reshape(N_SENSITIVITY_REPEATS, -1),\n                           axis=1)\n    return float(diffs.max())\n\n\n# ------------------------------------------------------------------\n# Run over confirmed true positives per class\n# ------------------------------------------------------------------\ntest_ids_array = test_df.index.values\nrecords = []\npool = ThreadPoolExecutor(max_workers=4)\n\nfor class_name in results_df.index:\n    class_idx = LABEL_COLS.index(class_name)\n    y_true = test_labels[:, class_idx]\n    y_pred = class_preds[class_name]\n\n    true_pos_mask = (y_true == 1) & (y_pred == 1)\n    true_pos_ids = test_ids_array[true_pos_mask]\n    true_pos_ids = np.array([i for i in true_pos_ids if i in id_to_path])\n\n    n_sample = min(N_SAMPLES_PER_CLASS, len(true_pos_ids))\n    if n_sample == 0:\n        print(f\"Skipping '{class_name}' — no confirmed true positives with resolvable paths.\")\n        continue\n\n    sample_ids = np.random.choice(true_pos_ids, size=n_sample, replace=False)\n    print(f\"\\n{class_name}: AOPC + Max-Sensitivity (HiResCAM) on {n_sample} confirmed TPs \"\n          f\"(available: {len(true_pos_ids)})\")\n\n    # decode all PNGs for this class in parallel (≤50 imgs ≈ 30 MB)\n    imgs_np = list(pool.map(load_np, [id_to_path[i] for i in sample_ids]))\n\n    for img_id, img_np in zip(tqdm(sample_ids, desc=class_name), imgs_np):\n        if img_id in hirescam_cache and class_name in hirescam_cache[img_id]:\n            grayscale_cam = hirescam_cache[img_id][class_name]\n        else:\n            grayscale_cam = hirescam(input_tensor=to_tensor_batch(img_np[None]),\n                                     targets=[ClassifierOutputTarget(class_idx)])[0, :]\n            hirescam_cache.setdefault(img_id, {})[class_name] = grayscale_cam\n\n        aopc = compute_aopc(img_np, grayscale_cam, class_idx)\n        max_sens = compute_max_sensitivity(img_np, grayscale_cam, class_idx)\n\n        records.append({\"img_id\": img_id, \"class\": class_name,\n                        \"aopc\": aopc, \"max_sensitivity\": max_sens})\n\npool.shutdown()\n\naopc_sens_df = pd.DataFrame(records)\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"AOPC + MAX-SENSITIVITY SUMMARY — HiResCAM (mean ± std, per class)\")\nprint(\"=\" * 80)\nsummary = aopc_sens_df.groupby(\"class\").agg(\n    aopc_mean=(\"aopc\", \"mean\"), aopc_std=(\"aopc\", \"std\"),\n    sens_mean=(\"max_sensitivity\", \"mean\"), sens_std=(\"max_sensitivity\", \"std\"),\n    n=(\"aopc\", \"count\"),\n).round(4)\nprint(summary.to_string())\n\nout_path = f\"/kaggle/working/{MODEL_NAME}_aopc_hirescam_max_sensitivity.csv\"\naopc_sens_df.to_csv(out_path, index=False)\nsummary_out_path = f\"/kaggle/working/{MODEL_NAME}_aopc_hirescam_max_sensitivity_summary.csv\"\nsummary.to_csv(summary_out_path)\nprint(f\"\\nSaved per-image results to {out_path}\")\nprint(f\"Saved per-class summary to {summary_out_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T06:37:11.045137Z","iopub.execute_input":"2026-07-24T06:37:11.045611Z","iopub.status.idle":"2026-07-24T06:39:19.977758Z","shell.execute_reply.started":"2026-07-24T06:37:11.045579Z","shell.execute_reply":"2026-07-24T06:39:19.977045Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"AOPC and Max-Sensitivity were computed for 50 confirmed true positives per class (capped at 127 for epidural, its full confirmed-TP pool). Epidural showed both the lowest mean AOPC (0.114 ± 0.059) and among the highest Max-Sensitivity (32.6 ± 9.4) of all subtypes, indicating that even on correctly classified cases, the model's explanations for epidural are less faithful to its decision process and less robust to input perturbation than for better-performing subtypes. This corroborates the classification-level finding (Section X) and the qualitative observation of a flat, spatially undifferentiated CAM on at least one epidural false negative (Figure X), suggesting the model's difficulty with epidural reflects a genuine representational limitation rather than a thresholding artifact alone.","metadata":{}},{"cell_type":"code","source":"import os\nimport pickle\nimport numpy as np\nimport torch\nimport torch.nn as nn\nfrom torchvision import models\n\n# ------------------------------------------------------------------\n# MINIMAL RELOAD — no eval rerun, no rescan, no training. Just enough\n# state for HiResCAM: model weights + the cached CAM images.\n# ------------------------------------------------------------------\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\nmodel = build_resnet50()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")\n\nwith open(\"/kaggle/working/cam_cache.pkl\", \"rb\") as f:\n    _cached = pickle.load(f)\ncam_cache, img_cache = _cached[\"cam_cache\"], _cached[\"img_cache\"]\nprint(f\"Loaded cam_cache + img_cache ({len(cam_cache)} images) from disk.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:14:11.533565Z","iopub.execute_input":"2026-07-27T11:14:11.534382Z","iopub.status.idle":"2026-07-27T11:14:12.937439Z","shell.execute_reply.started":"2026-07-27T11:14:11.534347Z","shell.execute_reply":"2026-07-27T11:14:12.936689Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport json\nimport pickle\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\nfrom PIL import Image\n\n# ------------------------------------------------------------------\n# ONE-CELL RELOAD — everything the HiResCAM + AOPC cells need,\n# reconstructed from disk. No eval rerun, no rescan, no training.\n# ------------------------------------------------------------------\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\ndef load_image_for_cam(path):\n    img = Image.open(path).convert(\"RGB\").resize((224, 224))\n    img_np = np.array(img).astype(np.float32) / 255.0\n    input_tensor = eval_transform(img).unsqueeze(0).to(DEVICE)\n    return input_tensor, img_np\n\n# --- model ---\ndef build_resnet50(num_classes=len(LABEL_COLS)):\n    model = models.resnet50(weights=None)\n    model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, num_classes))\n    return model.to(DEVICE)\n\nmodel = build_resnet50()\ncheckpoint = torch.load(f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\",\n                         map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\nmodel.eval()\nprint(f\"Loaded checkpoint: epoch {checkpoint['epoch']}, val_loss={checkpoint['val_loss']:.4f}\")\n\n# --- metrics + cached probs/labels ---\nresults_df = pd.read_csv(f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\", index_col=\"class\")\ncache = np.load(\"/kaggle/working/test_inference_cache.npz\")\nval_probs, val_labels = cache[\"val_probs\"], cache[\"val_labels\"]\ntest_probs, test_labels = cache[\"test_probs\"], cache[\"test_labels\"]\nprint(f\"Loaded results_df + inference arrays (test: {len(test_probs)} images)\")\n\n# --- test_df ---\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\ntest_ids = pd.read_csv(\"/kaggle/working/test_ids.csv\")[\"id\"].values\nfull_labels = load_labels(TRAIN_CSV)\ntest_df = full_labels.loc[test_ids]\ntest_ids_array = test_df.index.values\nprint(f\"Rebuilt test_df: {len(test_df)} rows\")\n\n# --- id_to_path ---\nwith open(\"/kaggle/working/test_id_to_path.json\") as f:\n    id_to_path = json.load(f)\nprint(f\"Loaded id_to_path: {len(id_to_path)} images\")\n\n# --- class_preds ---\nclass_preds = {}\nfor class_name in results_df.index:\n    class_idx = LABEL_COLS.index(class_name)\n    thresh = results_df.loc[class_name, \"threshold_from_val\"]\n    class_preds[class_name] = (test_probs[:, class_idx] >= thresh)\n\n# --- cam_cache / img_cache from Grad-CAM's pickle ---\nwith open(\"/kaggle/working/cam_cache.pkl\", \"rb\") as f:\n    _cached = pickle.load(f)\ncam_cache, img_cache = _cached[\"cam_cache\"], _cached[\"img_cache\"]\nprint(f\"Loaded cam_cache + img_cache ({len(cam_cache)} images)\")\n\nprint(\"\\nReady. Now re-run the HiResCAM cell in full, then the AOPC-for-HiResCAM cell.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:38:27.449072Z","iopub.execute_input":"2026-07-27T11:38:27.449401Z","iopub.status.idle":"2026-07-27T11:38:50.131034Z","shell.execute_reply.started":"2026-07-27T11:38:27.449377Z","shell.execute_reply":"2026-07-27T11:38:50.130219Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pickle\nimport numpy as np\nimport pandas as pd\nimport torch\nimport matplotlib.pyplot as plt\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nfrom pytorch_grad_cam import HiResCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\nfrom pytorch_grad_cam.utils.image import show_cam_on_image\n\n# ------------------------------------------------------------------\n# HiResCAM — reuses the exact images already sampled + decoded by the\n# Grad-CAM cell (img_cache), so this is a like-for-like comparison,\n# not a fresh random draw. Runs BATCHED: one forward+backward pass per\n# batch of images instead of one pass per image, since target_layers\n# is fixed and ClassifierOutputTarget can be supplied per-example\n# across a stacked batch tensor.\n# ------------------------------------------------------------------\nassert \"cam_cache\" in globals() and \"img_cache\" in globals(), \\\n    \"Run the Grad-CAM cell first — this reuses its cached images.\"\n\nHIRESCAM_OUT_DIR = \"/kaggle/working/hirescam_examples\"\nHIRESCAM_CACHE_PATH = \"/kaggle/working/hirescam_cache.pkl\"\nos.makedirs(HIRESCAM_OUT_DIR, exist_ok=True)\n\nBATCH_SIZE_CAM = 16     # tune to GPU memory — this is what makes it non-naive\nN_VISUALIZE_PER_CLASS = 2\n\nmean_arr = np.array([0.485, 0.456, 0.406], dtype=np.float32)\nstd_arr = np.array([0.229, 0.224, 0.225], dtype=np.float32)\n\nhirescam_target_layers = [model.layer4[-1]]\nhirescam = HiResCAM(model=model, target_layers=hirescam_target_layers)\n\nhirescam_cache = {}              # img_id -> {class_name: grayscale_cam}\nhirescam_bone_corr_records = []\n\n\ndef img_np_to_tensor(img_np):\n    normed = (img_np - mean_arr) / std_arr\n    return torch.from_numpy(normed.transpose(2, 0, 1)).float()\n\n\ndef batched_hirescam(img_ids, class_idx, class_name):\n    \"\"\"One forward+backward pass for the whole batch, not one per image.\"\"\"\n    tensors = torch.stack([img_np_to_tensor(img_cache[i]) for i in img_ids]).to(DEVICE)\n    targets = [ClassifierOutputTarget(class_idx) for _ in img_ids]\n    grayscale_cams = hirescam(input_tensor=tensors, targets=targets)  # [B, H, W]\n\n    for i, img_id in enumerate(img_ids):\n        gc = grayscale_cams[i]\n        hirescam_cache.setdefault(img_id, {})[class_name] = gc\n\n        bone_channel = img_cache[img_id][:, :, 2]\n        cam_flat, bone_flat = gc.flatten(), bone_channel.flatten()\n        corr = (float(np.corrcoef(cam_flat, bone_flat)[0, 1])\n                if cam_flat.std() > 1e-8 and bone_flat.std() > 1e-8 else np.nan)\n        hirescam_bone_corr_records.append(\n            {\"img_id\": img_id, \"class\": class_name, \"bone_corr\": corr}\n        )\n    return grayscale_cams\n\n\n# ------------------------------------------------------------------\n# Walk the same img_id -> class_name pairs already present in cam_cache\n# (from the Grad-CAM cell), batched per class instead of one-by-one.\n# ------------------------------------------------------------------\nclasses_to_show = [\"any\", \"epidural\", \"intraparenchymal\",\n                    \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\nviz_examples = {}  # class_name -> list of (img_id, visualization) for the sanity panel\n\nfor class_name in classes_to_show:\n    img_ids_for_class = [img_id for img_id, per_class in cam_cache.items()\n                          if class_name in per_class]\n    if not img_ids_for_class:\n        print(f\"Skipping '{class_name}' — no cached images for this class.\")\n        continue\n\n    class_idx = LABEL_COLS.index(class_name)\n    print(f\"[{class_name}] running HiResCAM on {len(img_ids_for_class)} cached images \"\n          f\"in batches of {BATCH_SIZE_CAM}...\")\n\n    all_gcs = []\n    for start in tqdm(range(0, len(img_ids_for_class), BATCH_SIZE_CAM),\n                       desc=class_name, leave=False):\n        batch_ids = img_ids_for_class[start:start + BATCH_SIZE_CAM]\n        gcs = batched_hirescam(batch_ids, class_idx, class_name)\n        all_gcs.extend(zip(batch_ids, gcs))\n\n    viz_examples[class_name] = all_gcs[:N_VISUALIZE_PER_CLASS]\n\n# ------------------------------------------------------------------\n# Small sanity-check visualization panel (same layout style as Grad-CAM cell)\n# ------------------------------------------------------------------\nn_cols = N_VISUALIZE_PER_CLASS * 5\nrows_with_examples = [c for c in classes_to_show if c in viz_examples]\nfig, axes = plt.subplots(len(rows_with_examples), n_cols,\n                          figsize=(3 * n_cols / 2, 3.2 * len(rows_with_examples)))\nif len(rows_with_examples) == 1:\n    axes = axes[np.newaxis, :]\n\nfor row, class_name in enumerate(rows_with_examples):\n    for i, (img_id, gc) in enumerate(viz_examples[class_name]):\n        img_np = img_cache[img_id]\n        visualization = show_cam_on_image(img_np, gc, use_rgb=True)\n        Image.fromarray(visualization).save(\n            os.path.join(HIRESCAM_OUT_DIR, f\"{class_name}_{img_id}_hirescam.png\")\n        )\n\n        ax_brain, ax_subdural, ax_bone, ax_composite, ax_cam = axes[row, i * 5:(i + 1) * 5]\n        ax_brain.imshow(img_np[:, :, 0], cmap=\"gray\"); ax_brain.set_title(\"brain\", fontsize=8); ax_brain.axis(\"off\")\n        ax_subdural.imshow(img_np[:, :, 1], cmap=\"gray\"); ax_subdural.set_title(\"subdural\", fontsize=8); ax_subdural.axis(\"off\")\n        ax_bone.imshow(img_np[:, :, 2], cmap=\"gray\"); ax_bone.set_title(\"bone\", fontsize=8); ax_bone.axis(\"off\")\n        ax_composite.imshow(img_np); ax_composite.set_title(f\"{img_id}\\ncomposite\", fontsize=8); ax_composite.axis(\"off\")\n        ax_cam.imshow(visualization); ax_cam.set_title(f\"{class_name} (HiResCAM)\", fontsize=8); ax_cam.axis(\"off\")\n\n    for col in range(len(viz_examples[class_name]) * 5, n_cols):\n        axes[row, col].set_visible(False)\n\nplt.suptitle(f\"HiResCAM — {MODEL_NAME}, sample visualization (batched, reused cache)\", y=1.001)\nplt.tight_layout()\nplt.savefig(f\"/kaggle/working/{MODEL_NAME}_hirescam_sample_visualization.png\",\n            dpi=200, bbox_inches=\"tight\")\nplt.show()\n\n# ------------------------------------------------------------------\n# Persist cache + bone-shortcut correlations\n# ------------------------------------------------------------------\nwith open(HIRESCAM_CACHE_PATH, \"wb\") as f:\n    pickle.dump({\"hirescam_cache\": hirescam_cache}, f)\nprint(f\"\\nSaved hirescam_cache ({len(hirescam_cache)} images) to {HIRESCAM_CACHE_PATH}\")\n\nhirescam_bone_corr_df = pd.DataFrame(hirescam_bone_corr_records)\nprint(\"\\n\" + \"=\" * 70)\nprint(\"BONE-CHANNEL SHORTCUT CHECK — HiResCAM\")\nprint(\"=\" * 70)\nprint(hirescam_bone_corr_df.groupby(\"class\")[\"bone_corr\"].agg([\"mean\", \"std\", \"count\"]).round(3))\n\nhirescam_bone_corr_out = f\"/kaggle/working/{MODEL_NAME}_hirescam_bone_shortcut_correlations.csv\"\nhirescam_bone_corr_df.to_csv(hirescam_bone_corr_out, index=False)\nprint(f\"\\nSaved per-example bone-correlation records to {hirescam_bone_corr_out}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:39:04.310358Z","iopub.execute_input":"2026-07-27T11:39:04.311119Z","iopub.status.idle":"2026-07-27T11:39:12.48655Z","shell.execute_reply.started":"2026-07-27T11:39:04.311092Z","shell.execute_reply":"2026-07-27T11:39:12.485845Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pickle\nimport pandas as pd\n\nHIRESCAM_CACHE_PATH = \"/kaggle/working/hirescam_cache.pkl\"\nHIRESCAM_BONE_CORR_PATH = \"/kaggle/working/resnet50_hirescam_bone_shortcut_correlations.csv\"\n\n# --- Check 1: hirescam_cache.pkl ---\nif os.path.exists(HIRESCAM_CACHE_PATH):\n    with open(HIRESCAM_CACHE_PATH, \"rb\") as f:\n        _cached = pickle.load(f)\n    n_images = len(_cached.get(\"hirescam_cache\", {}))\n    print(f\"FOUND: {HIRESCAM_CACHE_PATH}\")\n    print(f\"  -> hirescam_cache contains {n_images} images\")\nelse:\n    print(f\"MISSING: {HIRESCAM_CACHE_PATH}\")\n\nprint()\n\n# --- Check 2: bone-correlation CSV ---\nif os.path.exists(HIRESCAM_BONE_CORR_PATH):\n    df = pd.read_csv(HIRESCAM_BONE_CORR_PATH)\n    print(f\"FOUND: {HIRESCAM_BONE_CORR_PATH}\")\n    print(f\"  -> {len(df)} rows\")\n    print(df.groupby(\"class\")[\"bone_corr\"].agg([\"mean\", \"std\", \"count\"]).round(3))\nelse:\n    print(f\"MISSING: {HIRESCAM_BONE_CORR_PATH}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:40:31.072453Z","iopub.execute_input":"2026-07-27T11:40:31.072737Z","iopub.status.idle":"2026-07-27T11:40:31.097831Z","shell.execute_reply.started":"2026-07-27T11:40:31.072715Z","shell.execute_reply":"2026-07-27T11:40:31.097214Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport json\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom torchvision import transforms\n\nMODEL_NAME = \"resnet50\"\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nUSE_AMP = DEVICE.type == \"cuda\"\nRANDOM_SEED = 42\nLABEL_COLS = [\"any\", \"epidural\", \"intraparenchymal\",\n              \"intraventricular\", \"subarachnoid\", \"subdural\"]\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n])\n\n# --- metrics + cached probs/labels ---\nresults_df = pd.read_csv(f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\", index_col=\"class\")\ncache = np.load(\"/kaggle/working/test_inference_cache.npz\")\nval_probs, val_labels = cache[\"val_probs\"], cache[\"val_labels\"]\ntest_probs, test_labels = cache[\"test_probs\"], cache[\"test_labels\"]\nprint(f\"Loaded results_df + inference arrays (test: {len(test_probs)} images)\")\n\n# --- test_df (labels only, for indexing) ---\nTRAIN_CSV = (\"/kaggle/input/competitions/rsna-intracranial-hemorrhage-detection/\"\n             \"rsna-intracranial-hemorrhage-detection/stage_2_train.csv\")\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n    y = pd.concat([id_split, y.Label], axis=1)\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n    y = y.drop_duplicates(subset=[\"id\", \"sub_type\"])\n    return y.pivot(index=\"id\", columns=\"sub_type\", values=\"label\")\n\ntest_ids = pd.read_csv(\"/kaggle/working/test_ids.csv\")[\"id\"].values\nfull_labels = load_labels(TRAIN_CSV)\ntest_df = full_labels.loc[test_ids]\nprint(f\"Rebuilt test_df: {len(test_df)} rows\")\n\n# --- id_to_path (cached, no rescan) ---\nwith open(\"/kaggle/working/test_id_to_path.json\") as f:\n    id_to_path = json.load(f)\nprint(f\"Loaded id_to_path: {len(id_to_path)} images\")\n\n# --- class_preds (needed by the AOPC cell's true_pos_mask logic) ---\ntest_ids_array = test_df.index.values\nclass_preds = {}\nfor class_name in results_df.index:\n    class_idx = LABEL_COLS.index(class_name)\n    thresh = results_df.loc[class_name, \"threshold_from_val\"]\n    class_preds[class_name] = (test_probs[:, class_idx] >= thresh)\n\nprint(\"Ready — test_df, id_to_path, results_df, test_probs/labels, class_preds all set.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:34:54.479003Z","iopub.execute_input":"2026-07-27T11:34:54.479853Z","iopub.status.idle":"2026-07-27T11:35:14.989724Z","shell.execute_reply.started":"2026-07-27T11:34:54.479823Z","shell.execute_reply":"2026-07-27T11:35:14.988988Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import gc\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\n# ------------------------------------------------------------------\n# AOPC + MAX-SENSITIVITY — ResNet50, HiResCAM (FAST / BATCHED)\n# Same maths, same RNG stream, same sampling as the old cell. Changes:\n#  1. Freeze everything except layer4[-1] -> HiResCAM backward stops at\n#     layer4[-1] instead of walking (and storing grads for) the whole net.\n#     Big speed win AND big memory win.\n#  2. AOPC: 10 perturbed images + original go through the model as ONE batch.\n#  3. Max-Sensitivity: 10 noisy images go through HiResCAM as ONE batch,\n#     and the clean CAM is reused instead of recomputed.\n#  4. Patch ranking / perturbation vectorised (no python loops).\n#  5. PNG decoding is threaded.\n# ------------------------------------------------------------------\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\n\nN_SAMPLES_PER_CLASS = 50\nN_AOPC_STEPS = 10\nGRID_SIZE = 16\nN_SENSITIVITY_REPEATS = 10\nNOISE_STD_FRACTION = 0.05\npatch_size = 224 // GRID_SIZE\nTOTAL_PATCHES = GRID_SIZE * GRID_SIZE\n\n# --- free leftover GPU memory from earlier cells (old models / CAM objects) ---\ngc.collect()\ntorch.cuda.empty_cache()\ntorch.backends.cudnn.benchmark = True   # fixed shapes -> free speedup\n\n# --- (1) only layer4[-1] needs grads for HiResCAM ---\nfor p in model.parameters():\n    p.requires_grad_(False)\nfor p in model.layer4[-1].parameters():\n    p.requires_grad_(True)\nmodel.eval()\n\nMEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)\nSTD = np.array([0.229, 0.224, 0.225], dtype=np.float32)\nAOPC_NS = np.array([int(TOTAL_PATCHES * s / N_AOPC_STEPS)\n                    for s in range(1, N_AOPC_STEPS + 1)])\n\n\ndef to_tensor_batch(imgs_np):\n    \"\"\"[B,H,W,3] float in [0,1] -> normalized [B,3,H,W] tensor on DEVICE.\"\"\"\n    x = (imgs_np - MEAN) / STD\n    x = np.ascontiguousarray(x.transpose(0, 3, 1, 2))\n    return torch.from_numpy(x).float().to(DEVICE)\n\n\ndef load_np(path):\n    img = Image.open(path).convert(\"RGB\").resize((224, 224))\n    return np.asarray(img).astype(np.float32) / 255.0\n\n\ndef cam_to_patch_ranking(gc_map):\n    scores = gc_map.reshape(GRID_SIZE, patch_size, GRID_SIZE, patch_size).mean(axis=(1, 3))\n    return np.argsort(-scores.ravel())\n\n\ndef build_aopc_batch(img_np, ranking):\n    \"\"\"[1 original + N_AOPC_STEPS progressively mean-filled] images, [S+1,H,W,3].\"\"\"\n    pos = np.empty(TOTAL_PATCHES, dtype=int)\n    pos[ranking] = np.arange(TOTAL_PATCHES)                       # rank of each patch\n    px_rank = pos.reshape(GRID_SIZE, GRID_SIZE).repeat(patch_size, 0).repeat(patch_size, 1)\n    masks = px_rank[None] < AOPC_NS[:, None, None]                # [S,H,W]\n    ch_means = img_np.reshape(-1, 3).mean(axis=0)\n    perturbed = np.where(masks[..., None], ch_means, img_np[None])\n    return np.concatenate([img_np[None], perturbed], axis=0)\n\n\n@torch.no_grad()\ndef compute_aopc(img_np, grayscale_cam, class_idx):\n    ranking = cam_to_patch_ranking(grayscale_cam)\n    batch = build_aopc_batch(img_np, ranking)\n    with torch.autocast(device_type=DEVICE.type, enabled=USE_AMP):\n        out = model(to_tensor_batch(batch))\n    probs = torch.sigmoid(out.float())[:, class_idx].cpu().numpy()\n    return float(np.mean(probs[0] - probs[1:]))\n\n\ndef compute_max_sensitivity(img_np, orig_cam, class_idx):\n    noise_std = NOISE_STD_FRACTION * img_np.std()\n    noise = np.random.normal(0, noise_std,\n                             (N_SENSITIVITY_REPEATS,) + img_np.shape).astype(np.float32)\n    noisy = np.clip(img_np[None] + noise, 0, 1)\n    targets = [ClassifierOutputTarget(class_idx)] * N_SENSITIVITY_REPEATS\n    noisy_cams = hirescam(input_tensor=to_tensor_batch(noisy), targets=targets)  # [R,H,W]\n    diffs = np.linalg.norm((noisy_cams - orig_cam[None]).reshape(N_SENSITIVITY_REPEATS, -1),\n                           axis=1)\n    return float(diffs.max())\n\n\n# ------------------------------------------------------------------\n# Run over confirmed true positives per class\n# ------------------------------------------------------------------\ntest_ids_array = test_df.index.values\nrecords = []\npool = ThreadPoolExecutor(max_workers=4)\n\nfor class_name in results_df.index:\n    class_idx = LABEL_COLS.index(class_name)\n    y_true = test_labels[:, class_idx]\n    y_pred = class_preds[class_name]\n\n    true_pos_mask = (y_true == 1) & (y_pred == 1)\n    true_pos_ids = test_ids_array[true_pos_mask]\n    true_pos_ids = np.array([i for i in true_pos_ids if i in id_to_path])\n\n    n_sample = min(N_SAMPLES_PER_CLASS, len(true_pos_ids))\n    if n_sample == 0:\n        print(f\"Skipping '{class_name}' — no confirmed true positives with resolvable paths.\")\n        continue\n\n    sample_ids = np.random.choice(true_pos_ids, size=n_sample, replace=False)\n    print(f\"\\n{class_name}: AOPC + Max-Sensitivity (HiResCAM) on {n_sample} confirmed TPs \"\n          f\"(available: {len(true_pos_ids)})\")\n\n    # decode all PNGs for this class in parallel (≤50 imgs ≈ 30 MB)\n    imgs_np = list(pool.map(load_np, [id_to_path[i] for i in sample_ids]))\n\n    for img_id, img_np in zip(tqdm(sample_ids, desc=class_name), imgs_np):\n        if img_id in hirescam_cache and class_name in hirescam_cache[img_id]:\n            grayscale_cam = hirescam_cache[img_id][class_name]\n        else:\n            grayscale_cam = hirescam(input_tensor=to_tensor_batch(img_np[None]),\n                                     targets=[ClassifierOutputTarget(class_idx)])[0, :]\n            hirescam_cache.setdefault(img_id, {})[class_name] = grayscale_cam\n\n        aopc = compute_aopc(img_np, grayscale_cam, class_idx)\n        max_sens = compute_max_sensitivity(img_np, grayscale_cam, class_idx)\n\n        records.append({\"img_id\": img_id, \"class\": class_name,\n                        \"aopc\": aopc, \"max_sensitivity\": max_sens})\n\npool.shutdown()\n\naopc_sens_df = pd.DataFrame(records)\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"AOPC + MAX-SENSITIVITY SUMMARY — HiResCAM (mean ± std, per class)\")\nprint(\"=\" * 80)\nsummary = aopc_sens_df.groupby(\"class\").agg(\n    aopc_mean=(\"aopc\", \"mean\"), aopc_std=(\"aopc\", \"std\"),\n    sens_mean=(\"max_sensitivity\", \"mean\"), sens_std=(\"max_sensitivity\", \"std\"),\n    n=(\"aopc\", \"count\"),\n).round(4)\nprint(summary.to_string())\n\nout_path = f\"/kaggle/working/{MODEL_NAME}_aopc_hirescam_max_sensitivity.csv\"\naopc_sens_df.to_csv(out_path, index=False)\nsummary_out_path = f\"/kaggle/working/{MODEL_NAME}_aopc_hirescam_max_sensitivity_summary.csv\"\nsummary.to_csv(summary_out_path)\nprint(f\"\\nSaved per-image results to {out_path}\")\nprint(f\"Saved per-class summary to {summary_out_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:42:25.701631Z","iopub.execute_input":"2026-07-27T11:42:25.702301Z","iopub.status.idle":"2026-07-27T11:44:26.986948Z","shell.execute_reply.started":"2026-07-27T11:42:25.702273Z","shell.execute_reply":"2026-07-27T11:44:26.986331Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import subprocess\nprint(subprocess.run([\"du\", \"-sh\", \"/kaggle/working\"], capture_output=True, text=True).stdout)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:45:31.89596Z","iopub.execute_input":"2026-07-27T11:45:31.896272Z","iopub.status.idle":"2026-07-27T11:45:31.905878Z","shell.execute_reply.started":"2026-07-27T11:45:31.896245Z","shell.execute_reply":"2026-07-27T11:45:31.905182Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport zipfile\n\n# ------------------------------------------------------------------\n# ZIP EVERYTHING IN /kaggle/working — except previous zip files and\n# the folders that were only ever staging copies for those old zips.\n# Includes checkpoints (.pt), pickles (.pkl), csvs, pngs, json, npz,\n# txt — everything that isn't explicitly excluded below.\n# ------------------------------------------------------------------\nWORKING_DIR = \"/kaggle/working\"\nOUTPUT_ZIP_PATH = \"/kaggle/working/all_outputs_full.zip\"\n\n# Edit this list if you spot something else that shouldn't go in.\nEXCLUDE_NAMES = {\n    \"all_outputs.zip\",\n    \"resnet.zip\",\n    \"resnet_eval_backup.zip\",\n    \"resnet_eval_backup_files\",   # staging folder for resnet_eval_backup.zip\n    \".virtual_documents\",         # Jupyter/Kaggle internal, not real output\n}\n\n# Also skip any .zip file by extension, in case there are others not listed above.\ndef should_skip(rel_path):\n    parts = rel_path.split(os.sep)\n    if parts[0] in EXCLUDE_NAMES:\n        return True\n    if rel_path.endswith(\".zip\"):\n        return True\n    if rel_path == os.path.basename(OUTPUT_ZIP_PATH):\n        return True\n    return False\n\nincluded, skipped = [], []\n\nwith zipfile.ZipFile(OUTPUT_ZIP_PATH, \"w\", zipfile.ZIP_DEFLATED) as zf:\n    for root, dirs, files in os.walk(WORKING_DIR):\n        rel_root = os.path.relpath(root, WORKING_DIR)\n\n        # prune excluded directories in-place so os.walk doesn't descend into them\n        if rel_root != \".\":\n            top_level = rel_root.split(os.sep)[0]\n            if top_level in EXCLUDE_NAMES:\n                dirs[:] = []\n                continue\n\n        for fname in files:\n            full_path = os.path.join(root, fname)\n            rel_path = os.path.relpath(full_path, WORKING_DIR)\n\n            if should_skip(rel_path):\n                skipped.append(rel_path)\n                continue\n\n            zf.write(full_path, arcname=rel_path)\n            included.append(rel_path)\n\nprint(f\"Included {len(included)} files:\")\nfor f in sorted(included):\n    print(f\"  + {f}\")\n\nprint(f\"\\nSkipped {len(skipped)} files/folders:\")\nfor f in sorted(skipped):\n    print(f\"  - {f}\")\n\nprint(f\"\\nZip saved to {OUTPUT_ZIP_PATH} ({os.path.getsize(OUTPUT_ZIP_PATH) / 1e6:.1f} MB)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T11:45:42.841667Z","iopub.execute_input":"2026-07-27T11:45:42.84208Z","iopub.status.idle":"2026-07-27T11:46:03.841911Z","shell.execute_reply.started":"2026-07-27T11:45:42.84205Z","shell.execute_reply":"2026-07-27T11:46:03.841211Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport zipfile\n\nOUTPUT_ZIP = \"/kaggle/working/afterhighres_output.zip\"\n\nFILES_TO_ZIP = [\n    \"/kaggle/working/resnet50_aopc_hirescam_max_sensitivity.csv\",\n    \"/kaggle/working/resnet50_aopc_hirescam_max_sensitivity_summary.csv\",\n    \"/kaggle/working/hirescam_cache.pkl\",\n    \"/kaggle/working/resnet50_hirescam_bone_shortcut_correlations.csv\",\n]\n\nwith zipfile.ZipFile(OUTPUT_ZIP, \"w\", zipfile.ZIP_DEFLATED) as zf:\n    for file_path in FILES_TO_ZIP:\n        if os.path.exists(file_path):\n            zf.write(file_path, arcname=os.path.basename(file_path))\n            print(f\"Added: {os.path.basename(file_path)}\")\n        else:\n            print(f\"Missing: {file_path}\")\n\nprint(f\"\\nZIP created successfully!\")\nprint(f\"Location: {OUTPUT_ZIP}\")\nprint(f\"Size: {os.path.getsize(OUTPUT_ZIP) / 1e6:.2f} MB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-27T12:09:38.152234Z","iopub.execute_input":"2026-07-27T12:09:38.152977Z","iopub.status.idle":"2026-07-27T12:09:39.150276Z","shell.execute_reply.started":"2026-07-27T12:09:38.152947Z","shell.execute_reply":"2026-07-27T12:09:39.149601Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# RESNET-50 — BONE-CHANNEL SHORTCUT / CORRELATION ANALYSIS\n# Consistent with ViT-B/16 and ConvNeXt-Base:\n#   - 30 confirmed true positives per class\n#   - random_state = 42\n#   - 224x224 input\n#   - same ImageNet normalization\n#   - Grad-CAM target = layer4[-1]\n#   - Pearson correlation between CAM and bone channel\n#   - NaN retained when CAM/bone channel has negligible variance\n#\n# IMPORTANT:\n#   This does NOT retrain ResNet-50.\n#   It loads the existing best checkpoint (epoch 14).\n# ================================================================\n\n# If pytorch-grad-cam is already installed, this does nothing.\n!pip install grad-cam -q\n\nimport os\nimport json\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\n\nfrom PIL import Image\nfrom torchvision import models, transforms\nfrom tqdm.auto import tqdm\n\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\n\n# ================================================================\n# 1. CONFIGURATION\n# ================================================================\n\nMODEL_NAME = \"resnet50\"\n\nLABEL_COLS = [\n    \"any\",\n    \"epidural\",\n    \"intraparenchymal\",\n    \"intraventricular\",\n    \"subarachnoid\",\n    \"subdural\",\n]\n\nRANDOM_SEED = 42\nN_CAM_SAMPLES = 30\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nCHECKPOINT_PATH = (\n    f\"/kaggle/working/checkpoints/{MODEL_NAME}_best.pt\"\n)\n\nEVAL_METRICS_PATH = (\n    f\"/kaggle/working/{MODEL_NAME}_eval_metrics.csv\"\n)\n\nINFERENCE_CACHE_PATH = (\n    \"/kaggle/working/test_inference_cache.npz\"\n)\n\nTEST_IDS_PATH = (\n    \"/kaggle/working/test_ids.csv\"\n)\n\nID_TO_PATH_CACHE = (\n    \"/kaggle/working/test_id_to_path.json\"\n)\n\nOUTPUT_PATH = (\n    f\"/kaggle/working/{MODEL_NAME}_bone_shortcut_correlations.csv\"\n)\n\nnp.random.seed(RANDOM_SEED)\n\nprint(f\"Device: {DEVICE}\")\nprint(f\"Sampling: up to {N_CAM_SAMPLES} confirmed TPs per class\")\nprint(f\"Random seed: {RANDOM_SEED}\")\n\n\n# ================================================================\n# 2. CHECK REQUIRED FILES\n# ================================================================\n\nrequired_files = [\n    CHECKPOINT_PATH,\n    EVAL_METRICS_PATH,\n    INFERENCE_CACHE_PATH,\n    TEST_IDS_PATH,\n]\n\nmissing = [p for p in required_files if not os.path.exists(p)]\n\nif missing:\n    raise FileNotFoundError(\n        \"The following required files are missing:\\n\"\n        + \"\\n\".join(f\"  - {p}\" for p in missing)\n        + \"\\n\\nMake sure the existing ResNet evaluation/checkpoint \"\n          \"outputs are available in this Kaggle session.\"\n    )\n\nprint(\"\\nRequired files found.\")\n\n\n# ================================================================\n# 3. LOAD EXISTING TEST PREDICTIONS / THRESHOLDS\n#    No model inference is rerun here.\n# ================================================================\n\nresults_df = pd.read_csv(\n    EVAL_METRICS_PATH,\n    index_col=\"class\"\n)\n\ncache = np.load(INFERENCE_CACHE_PATH)\n\nval_probs = cache[\"val_probs\"]\nval_labels = cache[\"val_labels\"]\ntest_probs = cache[\"test_probs\"]\ntest_labels = cache[\"test_labels\"]\n\nprint(\n    f\"\\nLoaded cached inference:\"\n    f\"\\n  validation: {len(val_probs)}\"\n    f\"\\n  test:       {len(test_probs)}\"\n)\n\nprint(\"\\nUsing validation-derived thresholds:\")\nprint(\n    results_df[[\"threshold_from_val\"]]\n    .round(6)\n    .to_string()\n)\n\n\n# ================================================================\n# 4. REBUILD TEST ID TABLE\n# ================================================================\n\nTRAIN_CSV = (\n    \"/kaggle/input/competitions/\"\n    \"rsna-intracranial-hemorrhage-detection/\"\n    \"rsna-intracranial-hemorrhage-detection/\"\n    \"stage_2_train.csv\"\n)\n\n\ndef load_labels(csv_path):\n    y = pd.read_csv(csv_path)\n\n    id_split = y.ID.str.rsplit(\"_\", n=1, expand=True)\n\n    y = pd.concat(\n        [id_split, y.Label],\n        axis=1\n    )\n\n    y.columns = [\"id\", \"sub_type\", \"label\"]\n\n    y = y.drop_duplicates(\n        subset=[\"id\", \"sub_type\"]\n    )\n\n    return y.pivot(\n        index=\"id\",\n        columns=\"sub_type\",\n        values=\"label\"\n    )\n\n\ntest_ids = pd.read_csv(TEST_IDS_PATH)[\"id\"].values\n\nfull_labels = load_labels(TRAIN_CSV)\n\ntest_df = full_labels.loc[test_ids]\n\nprint(\n    f\"\\nRebuilt test dataframe: {len(test_df)} images\"\n)\n\n\n# ================================================================\n# 5. RESOLVE IMAGE PATHS\n#    Reuse cached mapping if available.\n# ================================================================\n\nif os.path.exists(ID_TO_PATH_CACHE):\n\n    with open(ID_TO_PATH_CACHE, \"r\") as f:\n        id_to_path = json.load(f)\n\n    # Keep only IDs actually present in this test set.\n    id_to_path = {\n        img_id: path\n        for img_id, path in id_to_path.items()\n        if img_id in set(test_df.index)\n        and os.path.exists(path)\n    }\n\n    print(\n        f\"Loaded cached image paths: \"\n        f\"{len(id_to_path)} / {len(test_df)}\"\n    )\n\nelse:\n\n    INPUT_DIRS = [\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-negative-part1\",\n\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-negative-part2\",\n\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-negative-part3\",\n\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-pngs-positive-part-1\",\n\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-pngs-part3\",\n\n        \"/kaggle/input/datasets/anushakirand/\"\n        \"rsna-ich-preprocessed-pngs\",\n    ]\n\n    needed_ids = set(test_df.index)\n\n    id_to_path = {}\n\n    for directory in INPUT_DIRS:\n\n        if not os.path.isdir(directory):\n            print(\n                f\"WARNING: directory not found: {directory}\"\n            )\n            continue\n\n        for filename in os.listdir(directory):\n\n            if not filename.endswith(\".png\"):\n                continue\n\n            img_id = filename[:-4]\n\n            if img_id in needed_ids:\n                id_to_path[img_id] = os.path.join(\n                    directory,\n                    filename\n                )\n\n    with open(ID_TO_PATH_CACHE, \"w\") as f:\n        json.dump(id_to_path, f)\n\n    print(\n        f\"Resolved image paths: \"\n        f\"{len(id_to_path)} / {len(test_df)}\"\n    )\n\n\nif len(id_to_path) == 0:\n    raise RuntimeError(\n        \"No test image paths could be resolved.\"\n    )\n\n\n# ================================================================\n# 6. BUILD + LOAD THE EXISTING RESNET-50 CHECKPOINT\n# ================================================================\n\ndef build_resnet50(\n    num_classes=len(LABEL_COLS)\n):\n    model = models.resnet50(\n        weights=None\n    )\n\n    model.fc = nn.Sequential(\n        nn.Dropout(0.3),\n        nn.Linear(\n            model.fc.in_features,\n            num_classes\n        )\n    )\n\n    return model.to(DEVICE)\n\n\nmodel = build_resnet50()\n\ncheckpoint = torch.load(\n    CHECKPOINT_PATH,\n    map_location=DEVICE,\n    weights_only=False\n)\n\nmodel.load_state_dict(\n    checkpoint[\"model_state_dict\"]\n)\n\nmodel.eval()\n\nprint(\n    \"\\nLoaded ResNet-50 checkpoint:\"\n    f\"\\n  epoch:    {checkpoint['epoch']}\"\n    f\"\\n  val loss: {checkpoint['val_loss']:.4f}\"\n)\n\nassert checkpoint[\"epoch\"] == 14, (\n    f\"Expected ResNet best checkpoint at epoch 14, \"\n    f\"but loaded epoch {checkpoint['epoch']}.\"\n)\n\n\n# ================================================================\n# 7. SAME EVALUATION TRANSFORM\n# ================================================================\n\neval_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(\n        mean=[0.485, 0.456, 0.406],\n        std=[0.229, 0.224, 0.225]\n    ),\n])\n\n\n# ================================================================\n# 8. GRAD-CAM SETUP\n#    Same target layer used in the existing ResNet notebook.\n# ================================================================\n\ntarget_layers = [\n    model.layer4[-1]\n]\n\ncam = GradCAM(\n    model=model,\n    target_layers=target_layers\n)\n\nprint(\n    \"\\nGrad-CAM target layer:\"\n    \" model.layer4[-1]\"\n)\n\n\n# ================================================================\n# 9. IMAGE + BONE-CORRELATION FUNCTIONS\n# ================================================================\n\ndef load_image_for_cam(path):\n\n    img = (\n        Image.open(path)\n        .convert(\"RGB\")\n        .resize((224, 224))\n    )\n\n    img_np = (\n        np.array(img)\n        .astype(np.float32)\n        / 255.0\n    )\n\n    input_tensor = (\n        eval_transform(img)\n        .unsqueeze(0)\n        .to(DEVICE)\n    )\n\n    return input_tensor, img_np\n\n\ndef bone_shortcut_correlation(\n    grayscale_cam,\n    bone_channel\n):\n\n    cam_flat = grayscale_cam.flatten()\n    bone_flat = bone_channel.flatten()\n\n    # Same rule as the original notebook:\n    # retain NaN when either vector has negligible variance.\n    if (\n        cam_flat.std() < 1e-8\n        or bone_flat.std() < 1e-8\n    ):\n        return np.nan\n\n    return float(\n        np.corrcoef(\n            cam_flat,\n            bone_flat\n        )[0, 1]\n    )\n\n\n# ================================================================\n# 10. DETERMINE CONFIRMED TRUE POSITIVES\n#     Thresholds come from validation, exactly as in the report.\n# ================================================================\n\ntest_ids_array = test_df.index.values\n\nclass_preds = {}\n\nfor class_name in LABEL_COLS:\n\n    threshold = results_df.loc[\n        class_name,\n        \"threshold_from_val\"\n    ]\n\n    class_idx = LABEL_COLS.index(\n        class_name\n    )\n\n    class_preds[class_name] = (\n        test_probs[:, class_idx]\n        >= threshold\n    )\n\n\n# ================================================================\n# 11. RUN BONE-CORRELATION ANALYSIS\n# ================================================================\n\nbone_corr_records = []\n\nsample_summary = []\n\nfor class_name in LABEL_COLS:\n\n    class_idx = LABEL_COLS.index(\n        class_name\n    )\n\n    y_true = test_labels[:, class_idx]\n    y_pred = class_preds[class_name]\n\n    # Confirmed true positive:\n    # ground truth positive AND model positive\n    true_pos_mask = (\n        (y_true == 1)\n        & (y_pred == 1)\n    )\n\n    true_pos_ids = (\n        test_ids_array[true_pos_mask]\n    )\n\n    # Keep only images whose files can actually be resolved.\n    true_pos_ids = np.array([\n        img_id\n        for img_id in true_pos_ids\n        if img_id in id_to_path\n    ])\n\n    n_available = len(true_pos_ids)\n\n    n_sample = min(\n        N_CAM_SAMPLES,\n        n_available\n    )\n\n    sample_summary.append({\n        \"class\": class_name,\n        \"confirmed_TPs_available\": n_available,\n        \"n_sampled\": n_sample,\n    })\n\n    if n_sample == 0:\n\n        print(\n            f\"\\nWARNING: {class_name} — \"\n            \"no resolvable confirmed TPs.\"\n        )\n\n        continue\n\n    # Same sampling protocol:\n    # without replacement, random seed = 42.\n    sample_ids = np.random.choice(\n        true_pos_ids,\n        size=n_sample,\n        replace=False\n    )\n\n    print(\n        f\"\\n{class_name}: \"\n        f\"{n_sample} sampled / \"\n        f\"{n_available} confirmed TPs available\"\n    )\n\n    for img_id in tqdm(\n        sample_ids,\n        desc=class_name\n    ):\n\n        path = id_to_path[img_id]\n\n        input_tensor, img_np = (\n            load_image_for_cam(path)\n        )\n\n        targets = [\n            ClassifierOutputTarget(\n                class_idx\n            )\n        ]\n\n        grayscale_cam = cam(\n            input_tensor=input_tensor,\n            targets=targets\n        )[0, :]\n\n        # Channel 2 = B = bone window\n        bone_channel = img_np[:, :, 2]\n\n        corr = bone_shortcut_correlation(\n            grayscale_cam,\n            bone_channel\n        )\n\n        bone_corr_records.append({\n            \"img_id\": img_id,\n            \"class\": class_name,\n            \"bone_corr\": corr,\n        })\n\n\n# ================================================================\n# 12. SAVE RAW RESULTS\n# ================================================================\n\nbone_corr_df = pd.DataFrame(\n    bone_corr_records\n)\n\nbone_corr_df.to_csv(\n    OUTPUT_PATH,\n    index=False\n)\n\nprint(\n    \"\\nSaved raw per-example results to:\"\n    f\"\\n{OUTPUT_PATH}\"\n)\n\n\n# ================================================================\n# 13. PRINT SAMPLE COUNTS\n# ================================================================\n\nsample_summary_df = pd.DataFrame(\n    sample_summary\n)\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"SAMPLE COUNTS\")\nprint(\"=\" * 80)\n\nprint(\n    sample_summary_df.to_string(\n        index=False\n    )\n)\n\n\n# ================================================================\n# 14. SUMMARY STATISTICS FOR SECTION 6.4\n# ================================================================\n\nsummary = (\n    bone_corr_df\n    .groupby(\"class\")[\"bone_corr\"]\n    .agg(\n        mean=\"mean\",\n        std=\"std\",\n        n_finite=\"count\",\n        n_total=\"size\",\n        min=\"min\",\n        max=\"max\",\n    )\n)\n\nsummary[\"abs_r_gt_0.2\"] = (\n    bone_corr_df\n    .groupby(\"class\")[\"bone_corr\"]\n    .apply(\n        lambda x: (x.abs() > 0.2).sum()\n    )\n)\n\nsummary[\"abs_r_gt_0.3\"] = (\n    bone_corr_df\n    .groupby(\"class\")[\"bone_corr\"]\n    .apply(\n        lambda x: (x.abs() > 0.3).sum()\n    )\n)\n\nsummary = summary.round(4)\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"BONE-CHANNEL CORRELATION SUMMARY\")\nprint(\"=\" * 80)\n\nprint(\n    summary.to_string()\n)\n\n\n# ================================================================\n# 15. SANITY CHECK\n# ================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"SANITY CHECK\")\nprint(\"=\" * 80)\n\nfor class_name in LABEL_COLS:\n\n    class_rows = bone_corr_df[\n        bone_corr_df[\"class\"] == class_name\n    ]\n\n    print(\n        f\"{class_name:20s} \"\n        f\"total={len(class_rows):2d}, \"\n        f\"finite={class_rows['bone_corr'].notna().sum():2d}, \"\n        f\"NaN={class_rows['bone_corr'].isna().sum():2d}\"\n    )\n\nprint(\"\\nDone.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-13T08:07:40.991475Z","iopub.execute_input":"2026-08-13T08:07:40.991928Z","iopub.status.idle":"2026-08-13T08:14:53.175191Z","shell.execute_reply.started":"2026-08-13T08:07:40.991899Z","shell.execute_reply":"2026-08-13T08:14:53.174371Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Save ResNet-50 bone-channel correlation results as CSV\n\nOUTPUT_PATH = \"/kaggle/working/resnet50_bone_shortcut_correlations.csv\"\n\nbone_corr_df.to_csv(\n    OUTPUT_PATH,\n    index=False\n)\n\nprint(f\"Saved CSV to: {OUTPUT_PATH}\")\nprint(f\"Rows: {len(bone_corr_df)}\")\nprint(\"\\nPreview:\")\ndisplay(bone_corr_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-13T08:22:42.841259Z","iopub.execute_input":"2026-08-13T08:22:42.842038Z","iopub.status.idle":"2026-08-13T08:22:42.863391Z","shell.execute_reply.started":"2026-08-13T08:22:42.841997Z","shell.execute_reply":"2026-08-13T08:22:42.862574Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\n\nprint(\"PyTorch:\", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\n\nif torch.cuda.is_available():\n    print(\"GPU:\", torch.cuda.get_device_name(0))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-21T11:59:31.772672Z","iopub.execute_input":"2026-09-21T11:59:31.772951Z","iopub.status.idle":"2026-09-21T11:59:32.099479Z","shell.execute_reply.started":"2026-09-21T11:59:31.772929Z","shell.execute_reply":"2026-09-21T11:59:32.098838Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install -q grad-cam","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:43:41.16138Z","iopub.execute_input":"2026-09-29T10:43:41.162034Z","iopub.status.idle":"2026-09-29T10:43:51.122386Z","shell.execute_reply.started":"2026-09-29T10:43:41.161985Z","shell.execute_reply":"2026-09-29T10:43:51.121335Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torchvision\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\nprint(\"PyTorch:\", torch.__version__)\nprint(\"Torchvision:\", torchvision.__version__)\nprint(\"CUDA:\", torch.cuda.is_available())\nprint(\"Grad-CAM: OK\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:48:57.166626Z","iopub.execute_input":"2026-09-29T10:48:57.167511Z","iopub.status.idle":"2026-09-29T10:48:57.172817Z","shell.execute_reply.started":"2026-09-29T10:48:57.167477Z","shell.execute_reply":"2026-09-29T10:48:57.171964Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 1 — RESNET-50 ORDINARY GRAD-CAM SETUP\n# ================================================================\n\nimport os\nimport json\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\nprint(\"=\" * 80)\nprint(\"RESNET-50 GRAD-CAM SETUP\")\nprint(\"=\" * 80)\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nUSE_AMP = DEVICE.type == \"cuda\"\n\nprint(\"Device:\", DEVICE)\n\nif DEVICE.type == \"cuda\":\n    print(\"GPU:\", torch.cuda.get_device_name(0))\n\n\n# ------------------------------------------------\n# Load ResNet-50 checkpoint\n# ------------------------------------------------\n\nCHECKPOINT_PATH = (\n    \"/kaggle/working/\"\n    \"checkpoints/\"\n    \"resnet50_best.pt\"\n)\n\nassert os.path.exists(\n    CHECKPOINT_PATH\n), f\"Checkpoint not found: {CHECKPOINT_PATH}\"\n\n\n# ------------------------------------------------\n# IMPORTANT:\n#\n# If `model` already exists from recovery, reuse it.\n# Otherwise construct the same ResNet-50 architecture.\n# ------------------------------------------------\n\nif \"model\" not in globals():\n\n    import torchvision.models as models\n\n    model = models.resnet50(\n        weights=None\n    )\n\n    model.fc = torch.nn.Linear(\n        model.fc.in_features,\n        6\n    )\n\n    checkpoint = torch.load(\n        CHECKPOINT_PATH,\n        map_location=DEVICE,\n        weights_only=False\n    )\n\n    state_dict = checkpoint.get(\n        \"model_state_dict\",\n        checkpoint.get(\n            \"state_dict\",\n            checkpoint\n        )\n    )\n\n    model.load_state_dict(\n        state_dict\n    )\n\nelse:\n    print(\"Using existing ResNet-50 model in memory.\")\n\n\nmodel = model.to(DEVICE)\nmodel.eval()\n\n\n# ------------------------------------------------\n# Ordinary Grad-CAM\n# ------------------------------------------------\n\n# DO NOT replace this with HiResCAM.\n# Target layer remains model.layer4[-1].\n\ntarget_layers = [\n    model.layer4[-1]\n]\n\n\n# ------------------------------------------------\n# CAM FACTORY\n# ------------------------------------------------\n#\n# IMPORTANT:\n# We create a fresh GradCAM object whenever Cell 42\n# needs to perform a CAM computation.\n#\n# This prevents the same GradCAM object from retaining\n# computation state across the entire population run.\n# ------------------------------------------------\n\ndef get_cam():\n    return GradCAM(\n        model=model,\n        target_layers=[model.layer4[-1]]\n    )\n\n\n# ------------------------------------------------\n# Backwards compatibility\n# ------------------------------------------------\n#\n# Older helper functions in the notebook still expect\n# a global `cam`, so keep one here.\n#\n# Cell 42 will NOT reuse this global object.\n# ------------------------------------------------\n\ncam = get_cam()\n\n\nprint()\nprint(\"Model:\", \"ResNet-50\")\nprint(\"Target layer:\", \"model.layer4[-1]\")\nprint(\"CAM method:\", \"ordinary Grad-CAM\")\nprint(\"Grad-CAM factory: ready\")\nprint(\"Global compatibility CAM: ready\")\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:48:57.174077Z","iopub.execute_input":"2026-09-29T10:48:57.174378Z","iopub.status.idle":"2026-09-29T10:48:57.242144Z","shell.execute_reply.started":"2026-09-29T10:48:57.174346Z","shell.execute_reply":"2026-09-29T10:48:57.241345Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# RECOVER CACHED TEST INFERENCE\n# ================================================================\n\ncache_path = (\n    \"/kaggle/working/\"\n    \"test_inference_cache.npz\"\n)\n\ncache = np.load(\n    cache_path\n)\n\ntest_probs = cache[\"test_probs\"]\ntest_labels = cache[\"test_labels\"]\n\nprint(\n    f\"Loaded test probabilities: \"\n    f\"{test_probs.shape}\"\n)\n\nprint(\n    f\"Loaded test labels: \"\n    f\"{test_labels.shape}\"\n)\n\n\n# ================================================================\n# RECOVER TEST IDS\n# ================================================================\n\ntest_ids_path = (\n    \"/kaggle/working/\"\n    \"test_ids.csv\"\n)\n\ntest_ids_df = pd.read_csv(\n    test_ids_path\n)\n\n# Handle either a named ID column or a single-column CSV.\nif \"img_id\" in test_ids_df.columns:\n    test_ids_array = (\n        test_ids_df[\"img_id\"]\n        .astype(str)\n        .values\n    )\nelse:\n    test_ids_array = (\n        test_ids_df.iloc[:, 0]\n        .astype(str)\n        .values\n    )\n\nprint(\n    f\"Loaded test IDs: \"\n    f\"{len(test_ids_array):,}\"\n)\n\nassert len(test_probs) == len(\n    test_labels\n)\n\nassert len(test_probs) == len(\n    test_ids_array\n)\n\nprint(\"Inference cache and test IDs verified.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:48:57.243359Z","iopub.execute_input":"2026-09-29T10:48:57.243622Z","iopub.status.idle":"2026-09-29T10:48:57.28117Z","shell.execute_reply.started":"2026-09-29T10:48:57.2436Z","shell.execute_reply":"2026-09-29T10:48:57.280385Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# RECOVER RESNET-50 TEST ENVIRONMENT\n# Same label-loading logic + paths as ViT / ConvNeXt notebooks\n# ================================================================\n\nimport os\nimport json\nimport numpy as np\nimport pandas as pd\nfrom tqdm.auto import tqdm\n\n\n# ================================================================\n# 1. CONFIG — EXACT PATH FROM THE OTHER NOTEBOOKS\n# ================================================================\n\nTRAIN_CSV = (\n    \"/kaggle/input/competitions/\"\n    \"rsna-intracranial-hemorrhage-detection/\"\n    \"rsna-intracranial-hemorrhage-detection/\"\n    \"stage_2_train.csv\"\n)\n\nTEST_IDS_PATH = (\n    \"/kaggle/working/\"\n    \"test_ids.csv\"\n)\n\nID_TO_PATH_CACHE = (\n    \"/kaggle/working/\"\n    \"test_id_to_path.json\"\n)\n\nLABEL_COLS = [\n    \"any\",\n    \"epidural\",\n    \"intraparenchymal\",\n    \"intraventricular\",\n    \"subarachnoid\",\n    \"subdural\",\n]\n\n\n# ================================================================\n# 2. EXACT load_labels() FROM YOUR OTHER NOTEBOOKS\n# ================================================================\n\ndef load_labels(csv_path):\n\n    y = pd.read_csv(csv_path)\n\n    id_split = y.ID.str.rsplit(\n        \"_\",\n        n=1,\n        expand=True\n    )\n\n    y = pd.concat(\n        [\n            id_split,\n            y.Label\n        ],\n        axis=1\n    )\n\n    y.columns = [\n        \"id\",\n        \"sub_type\",\n        \"label\"\n    ]\n\n    y = y.drop_duplicates(\n        subset=[\n            \"id\",\n            \"sub_type\"\n        ]\n    )\n\n    df = y.pivot(\n        index=\"id\",\n        columns=\"sub_type\",\n        values=\"label\"\n    )\n\n    return df\n\n\n# ================================================================\n# 3. CHECK FILES\n# ================================================================\n\nprint(\"=\" * 80)\nprint(\"RECOVERING RESNET-50 TEST ENVIRONMENT\")\nprint(\"=\" * 80)\n\nprint(\"\\nChecking required files...\")\n\nfor path in [\n    TRAIN_CSV,\n    TEST_IDS_PATH,\n    ID_TO_PATH_CACHE,\n]:\n\n    print(\n        f\"  {'✓' if os.path.exists(path) else '✗'} \"\n        f\"{path}\"\n    )\n\nassert os.path.exists(TRAIN_CSV), (\n    f\"Training labels not found:\\n{TRAIN_CSV}\"\n)\n\nassert os.path.exists(TEST_IDS_PATH), (\n    f\"Test IDs not found:\\n{TEST_IDS_PATH}\"\n)\n\nassert os.path.exists(ID_TO_PATH_CACHE), (\n    f\"Image-path cache not found:\\n{ID_TO_PATH_CACHE}\"\n)\n\n\n# ================================================================\n# 4. LOAD TEST IDS\n# ================================================================\n\nprint(\"\\nLoading cached test IDs...\")\n\ntest_ids_df = pd.read_csv(\n    TEST_IDS_PATH\n)\n\n# Your notebooks save this column as \"id\"\nif \"id\" in test_ids_df.columns:\n\n    test_ids_array = (\n        test_ids_df[\"id\"]\n        .astype(str)\n        .values\n    )\n\nelse:\n\n    test_ids_array = (\n        test_ids_df.iloc[:, 0]\n        .astype(str)\n        .values\n    )\n\nprint(\n    f\"Test IDs loaded: \"\n    f\"{len(test_ids_array):,}\"\n)\n\n\n# ================================================================\n# 5. LOAD CACHED MODEL INFERENCE\n# ================================================================\n\nCACHE_PATH = (\n    \"/kaggle/working/\"\n    \"test_inference_cache.npz\"\n)\n\nprint(\"\\nLoading cached inference...\")\n\ncache = np.load(\n    CACHE_PATH\n)\n\ntest_probs = cache[\"test_probs\"]\ntest_labels = cache[\"test_labels\"]\n\nprint(\n    f\"  test_probs:  {test_probs.shape}\"\n)\n\nprint(\n    f\"  test_labels: {test_labels.shape}\"\n)\n\n\n# ================================================================\n# 6. LOAD FULL RSNA LABEL CSV\n# ================================================================\n\nprint(\"\\nLoading RSNA stage_2_train.csv...\")\nprint(\n    \"This is the large label CSV; this may take a moment.\"\n)\n\nfull_labels = load_labels(\n    TRAIN_CSV\n)\n\n# Make IDs consistent\nfull_labels.index = (\n    full_labels.index\n    .astype(str)\n)\n\nprint(\n    f\"Full labelled population: \"\n    f\"{len(full_labels):,} images\"\n)\n\n\n# ================================================================\n# 7. RECONSTRUCT TEST DATAFRAME\n# ================================================================\n\nprint(\"\\nReconstructing test dataframe...\")\n\ntest_df = full_labels.loc[\n    test_ids_array\n]\n\nprint(\n    f\"Test dataframe: \"\n    f\"{len(test_df):,} images\"\n)\n\n\n# ================================================================\n# 8. LOAD CACHED IMAGE PATH MAPPING\n# ================================================================\n\nprint(\"\\nLoading cached image-path mapping...\")\n\nwith open(\n    ID_TO_PATH_CACHE,\n    \"r\"\n) as f:\n\n    raw_id_to_path = json.load(f)\n\n\n# Normalize keys\nid_to_path = {\n    str(img_id): path\n    for img_id, path\n    in raw_id_to_path.items()\n}\n\n\n# Only retain test images\ntest_id_set = set(\n    test_ids_array\n)\n\nid_to_path = {\n    img_id: path\n    for img_id, path\n    in id_to_path.items()\n    if (\n        img_id in test_id_set\n        and os.path.exists(path)\n    )\n}\n\n\nprint(\n    f\"Resolved image paths: \"\n    f\"{len(id_to_path):,} / \"\n    f\"{len(test_df):,}\"\n)\n\n\n# ================================================================\n# 9. SANITY CHECKS\n# ================================================================\n\nassert (\n    len(test_df)\n    == len(test_probs)\n    == len(test_labels)\n    == len(test_ids_array)\n), (\n    \"Test population mismatch:\\n\"\n    f\"test_df={len(test_df)}\\n\"\n    f\"test_probs={len(test_probs)}\\n\"\n    f\"test_labels={len(test_labels)}\\n\"\n    f\"test_ids={len(test_ids_array)}\"\n)\n\nmissing_paths = [\n    img_id\n    for img_id in test_ids_array\n    if img_id not in id_to_path\n]\n\nassert len(missing_paths) == 0, (\n    f\"Missing {len(missing_paths)} image paths. \"\n    f\"First few: {missing_paths[:10]}\"\n)\n\n\n# Make sure labels have the expected columns\nmissing_label_cols = [\n    c for c in LABEL_COLS\n    if c not in test_df.columns\n]\n\nassert not missing_label_cols, (\n    f\"Missing label columns: {missing_label_cols}\"\n)\n\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"RECOVERY COMPLETE\")\nprint(\"=\" * 80)\n\nprint(\n    f\"Test images:       {len(test_df):,}\"\n)\n\nprint(\n    f\"Image paths:       {len(id_to_path):,}\"\n)\n\nprint(\n    f\"Probability shape: {test_probs.shape}\"\n)\n\nprint(\n    f\"Label shape:       {test_labels.shape}\"\n)\n\nprint(\n    f\"Classes:           {list(test_df.columns)}\"\n)\n\nprint(\"\\n✓ test_df recovered\")\nprint(\"✓ id_to_path recovered\")\nprint(\"✓ test_probs recovered\")\nprint(\"✓ test_labels recovered\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:48:58.057661Z","iopub.execute_input":"2026-09-29T10:48:58.058039Z","iopub.status.idle":"2026-09-29T10:51:59.082543Z","shell.execute_reply.started":"2026-09-29T10:48:58.058013Z","shell.execute_reply":"2026-09-29T10:51:59.081909Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 1 — RESNET-50 ORDINARY GRAD-CAM SETUP\n# ================================================================\n\nimport os\nimport json\nimport numpy as np\nimport pandas as pd\nimport torch\n\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nfrom pytorch_grad_cam import GradCAM\nfrom pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget\n\nprint(\"=\" * 80)\nprint(\"RESNET-50 GRAD-CAM SETUP\")\nprint(\"=\" * 80)\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nUSE_AMP = DEVICE.type == \"cuda\"\n\nprint(\"Device:\", DEVICE)\n\nif DEVICE.type == \"cuda\":\n    print(\"GPU:\", torch.cuda.get_device_name(0))\n\n\n# ------------------------------------------------\n# Load ResNet-50 checkpoint\n# ------------------------------------------------\n\nCHECKPOINT_PATH = (\n    \"/kaggle/working/\"\n    \"checkpoints/\"\n    \"resnet50_best.pt\"\n)\n\nassert os.path.exists(\n    CHECKPOINT_PATH\n), f\"Checkpoint not found: {CHECKPOINT_PATH}\"\n\n\n# ------------------------------------------------\n# IMPORTANT:\n# If `model` already exists from recovery, reuse it.\n# Otherwise construct the same ResNet-50 architecture.\n# ------------------------------------------------\n\nif \"model\" not in globals():\n\n    import torchvision.models as models\n\n    model = models.resnet50(\n        weights=None\n    )\n\n    model.fc = torch.nn.Linear(\n        model.fc.in_features,\n        6\n    )\n\n    checkpoint = torch.load(\n        CHECKPOINT_PATH,\n        map_location=DEVICE,\n        weights_only=False\n    )\n\n    state_dict = checkpoint.get(\n        \"model_state_dict\",\n        checkpoint.get(\n            \"state_dict\",\n            checkpoint\n        )\n    )\n\n    model.load_state_dict(\n        state_dict\n    )\n\nelse:\n    print(\"Using existing ResNet-50 model in memory.\")\n\n\nmodel = model.to(DEVICE)\nmodel.eval()\n\n\n# ------------------------------------------------\n# Ordinary Grad-CAM\n# ------------------------------------------------\n#\n# DO NOT replace this with HiResCAM.\n#\n# ResNet target layer used by the original notebook:\n# model.layer4[-1]\n# ------------------------------------------------\n\ntarget_layers = [\n    model.layer4[-1]\n]\n\ncam = GradCAM(\n    model=model,\n    target_layers=target_layers\n)\n\n\nprint()\nprint(\"Model:\", \"ResNet-50\")\nprint(\"Target layer:\", \"model.layer4[-1]\")\nprint(\"CAM method:\", \"ordinary Grad-CAM\")\nprint(\"Grad-CAM ready.\")\n\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.084302Z","iopub.execute_input":"2026-09-29T10:51:59.084531Z","iopub.status.idle":"2026-09-29T10:51:59.100286Z","shell.execute_reply.started":"2026-09-29T10:51:59.084509Z","shell.execute_reply":"2026-09-29T10:51:59.099536Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 2 — AOPC + MAX-SENSITIVITY CONFIGURATION\n# ================================================================\n\nLABEL_COLS = [\n    \"any\",\n    \"epidural\",\n    \"intraparenchymal\",\n    \"intraventricular\",\n    \"subarachnoid\",\n    \"subdural\"\n]\n\nRANDOM_SEED = 42\nnp.random.seed(RANDOM_SEED)\n\nN_AOPC_STEPS = 10\nGRID_SIZE = 16\n\nN_SENSITIVITY_REPEATS = 10\nNOISE_STD_FRACTION = 0.05\n\nIMAGE_SIZE = 224\n\nprint(\"=\" * 80)\nprint(\"RESNET-50 EXPLAINABILITY CONFIGURATION\")\nprint(\"=\" * 80)\n\nprint(\"Input resolution:\", f\"{IMAGE_SIZE}x{IMAGE_SIZE}\")\nprint(\"Patch grid:\", f\"{GRID_SIZE}x{GRID_SIZE}\")\nprint(\"AOPC steps:\", N_AOPC_STEPS)\nprint(\"Max-Sensitivity repeats:\", N_SENSITIVITY_REPEATS)\nprint(\"Noise std fraction:\", NOISE_STD_FRACTION)\nprint(\"Random seed:\", RANDOM_SEED)\n\nprint(\"=\" * 80)\n\n\n# ================================================================\n# IMAGE LOADING\n# ================================================================\n\ndef load_image_for_cam(img_id):\n\n    path = id_to_path[str(img_id)]\n\n    img = (\n        Image.open(path)\n        .convert(\"RGB\")\n        .resize(\n            (IMAGE_SIZE, IMAGE_SIZE)\n        )\n    )\n\n    img_np = (\n        np.asarray(img)\n        .astype(np.float32)\n        / 255.0\n    )\n\n    return img_np\n\n\n# ================================================================\n# NORMALIZATION\n# ================================================================\n\nMEAN = np.array(\n    [0.485, 0.456, 0.406],\n    dtype=np.float32\n)\n\nSTD = np.array(\n    [0.229, 0.224, 0.225],\n    dtype=np.float32\n)\n\n\ndef normalize_np_to_tensor(img_np):\n\n    normed = (\n        img_np - MEAN\n    ) / STD\n\n    tensor = (\n        torch.from_numpy(\n            normed.transpose(\n                2, 0, 1\n            )\n        )\n        .float()\n        .unsqueeze(0)\n        .to(DEVICE)\n    )\n\n    return tensor\n\n\n# ================================================================\n# MODEL PROBABILITY\n# ================================================================\n\n@torch.no_grad()\ndef get_class_prob(\n    img_np,\n    class_idx\n):\n\n    input_tensor = normalize_np_to_tensor(\n        img_np\n    )\n\n    with torch.autocast(\n        device_type=DEVICE.type,\n        enabled=USE_AMP\n    ):\n\n        outputs = model(\n            input_tensor\n        )\n\n    probs = torch.sigmoid(\n        outputs.float()\n    )\n\n    return float(\n        probs[\n            0,\n            class_idx\n        ].item()\n    )\n\n\n# ================================================================\n# ORDINARY GRAD-CAM\n# ================================================================\n\ndef get_gradcam(\n    img_np,\n    class_idx\n):\n\n    input_tensor = normalize_np_to_tensor(\n        img_np\n    )\n\n    targets = [\n        ClassifierOutputTarget(\n            class_idx\n        )\n    ]\n\n    grayscale_cam = cam(\n        input_tensor=input_tensor,\n        targets=targets\n    )[0, :]\n\n    return grayscale_cam\n\n\nprint(\"Image / probability / Grad-CAM helpers ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.101206Z","iopub.execute_input":"2026-09-29T10:51:59.101501Z","iopub.status.idle":"2026-09-29T10:51:59.115407Z","shell.execute_reply.started":"2026-09-29T10:51:59.101478Z","shell.execute_reply":"2026-09-29T10:51:59.114658Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 3 — PATCH RANKING + PERTURBATION\n# ================================================================\n\npatch_size = (\n    IMAGE_SIZE // GRID_SIZE\n)\n\n\ndef cam_to_patch_ranking(\n    grayscale_cam\n):\n\n    patch_scores = np.zeros(\n        (GRID_SIZE, GRID_SIZE),\n        dtype=np.float32\n    )\n\n    for i in range(\n        GRID_SIZE\n    ):\n\n        for j in range(\n            GRID_SIZE\n        ):\n\n            patch = grayscale_cam[\n                i * patch_size:\n                (i + 1) * patch_size,\n\n                j * patch_size:\n                (j + 1) * patch_size\n            ]\n\n            patch_scores[\n                i,\n                j\n            ] = patch.mean()\n\n\n    ranking = np.argsort(\n        -patch_scores.flatten()\n    )\n\n    return ranking\n\n\ndef perturb_patches(\n    img_np,\n    patch_indices_to_remove\n):\n\n    perturbed = img_np.copy()\n\n    # Per-channel mean fill,\n    # computed from the original image.\n    channel_means = (\n        img_np\n        .reshape(-1, 3)\n        .mean(axis=0)\n    )\n\n    for flat_idx in (\n        patch_indices_to_remove\n    ):\n\n        i, j = divmod(\n            int(flat_idx),\n            GRID_SIZE\n        )\n\n        perturbed[\n            i * patch_size:\n            (i + 1) * patch_size,\n\n            j * patch_size:\n            (j + 1) * patch_size,\n\n            :\n        ] = channel_means\n\n    return perturbed\n\n\nprint(\"=\" * 80)\nprint(\"PATCH FUNCTIONS READY\")\nprint(\"=\" * 80)\n\nprint(\n    \"Patch size:\",\n    f\"{patch_size}x{patch_size}\"\n)\n\nprint(\n    \"Total patches:\",\n    GRID_SIZE * GRID_SIZE\n)\n\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.11634Z","iopub.execute_input":"2026-09-29T10:51:59.116637Z","iopub.status.idle":"2026-09-29T10:51:59.136966Z","shell.execute_reply.started":"2026-09-29T10:51:59.116592Z","shell.execute_reply":"2026-09-29T10:51:59.13623Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 4 — BATCHED AOPC\n# ================================================================\n\ndef compute_aopc(\n    img_np,\n    grayscale_cam,\n    class_idx\n):\n\n    # ------------------------------------------------------------\n    # Rank patches by ordinary Grad-CAM\n    # ------------------------------------------------------------\n\n    ranking = cam_to_patch_ranking(\n        grayscale_cam\n    )\n\n    total_patches = (\n        GRID_SIZE * GRID_SIZE\n    )\n\n    # ------------------------------------------------------------\n    # Build all 10 perturbation levels\n    # ------------------------------------------------------------\n\n    perturbed_imgs = []\n\n    for step in range(\n        1,\n        N_AOPC_STEPS + 1\n    ):\n\n        n_remove = int(\n            total_patches\n            * step\n            / N_AOPC_STEPS\n        )\n\n        perturbed_imgs.append(\n            perturb_patches(\n                img_np,\n                ranking[:n_remove]\n            )\n        )\n\n    # ------------------------------------------------------------\n    # Stack:\n    # [10, 224, 224, 3]\n    # ------------------------------------------------------------\n\n    batch_np = np.stack(\n        perturbed_imgs,\n        axis=0\n    )\n\n    # ------------------------------------------------------------\n    # Normalize\n    # ------------------------------------------------------------\n\n    normed = (\n        batch_np - MEAN\n    ) / STD\n\n    batch_tensor = (\n        torch.from_numpy(\n            normed.transpose(\n                0,\n                3,\n                1,\n                2\n            )\n        )\n        .float()\n        .to(DEVICE)\n    )\n\n    # ------------------------------------------------------------\n    # ONE batched forward pass\n    # ------------------------------------------------------------\n\n    with torch.no_grad():\n\n        with torch.autocast(\n            device_type=DEVICE.type,\n            enabled=USE_AMP\n        ):\n\n            outputs = model(\n                batch_tensor\n            )\n\n        probs = torch.sigmoid(\n            outputs.float()\n        )[\n            :,\n            class_idx\n        ].cpu().numpy()\n\n    # ------------------------------------------------------------\n    # Original probability\n    #\n    # Keep this as a separate clean forward so that this function\n    # remains equivalent to the existing AOPC implementation.\n    # ------------------------------------------------------------\n\n    original_prob = get_class_prob(\n        img_np,\n        class_idx\n    )\n\n    # ------------------------------------------------------------\n    # Mean probability drop\n    # ------------------------------------------------------------\n\n    drops = (\n        original_prob - probs\n    )\n\n    return float(\n        drops.mean()\n    )\n\n\nprint(\"=\" * 80)\nprint(\"BATCHED AOPC READY\")\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.13876Z","iopub.execute_input":"2026-09-29T10:51:59.139198Z","iopub.status.idle":"2026-09-29T10:51:59.153751Z","shell.execute_reply.started":"2026-09-29T10:51:59.139176Z","shell.execute_reply":"2026-09-29T10:51:59.153158Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# RECOVER RESNET-50 EVALUATION METRICS / THRESHOLDS\n# ================================================================\n\nEVAL_METRICS_PATH = (\n    \"/kaggle/working/\"\n    \"resnet50_eval_metrics.csv\"\n)\n\nassert os.path.exists(\n    EVAL_METRICS_PATH\n), f\"Missing: {EVAL_METRICS_PATH}\"\n\nresults_df = pd.read_csv(\n    EVAL_METRICS_PATH,\n    index_col=\"class\"\n)\n\nprint(\"=\" * 80)\nprint(\"RESNET-50 EVALUATION METRICS RECOVERED\")\nprint(\"=\" * 80)\n\nprint(\n    results_df[\n        [\n            \"threshold_from_val\",\n            \"precision\",\n            \"recall_sensitivity\",\n            \"specificity\",\n            \"f1\"\n        ]\n    ].round(4).to_string()\n)\n\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.154669Z","iopub.execute_input":"2026-09-29T10:51:59.155036Z","iopub.status.idle":"2026-09-29T10:51:59.183776Z","shell.execute_reply.started":"2026-09-29T10:51:59.155006Z","shell.execute_reply":"2026-09-29T10:51:59.183016Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 6 — FULL POPULATION CONFUSION GROUPS\n# ================================================================\n\nprint(\"=\" * 80)\nprint(\"BUILDING FULL TEST POPULATION\")\nprint(\"=\" * 80)\n\n\n# ------------------------------------------------\n# Validation-derived thresholds\n# EXACTLY from the existing ResNet evaluation file\n# ------------------------------------------------\n\nthresholds = (\n    results_df[\n        \"threshold_from_val\"\n    ]\n    .to_dict()\n)\n\nprint(\"\\nValidation-derived thresholds:\")\n\nfor class_name in LABEL_COLS:\n    print(\n        f\"  {class_name:20s}: \"\n        f\"{thresholds[class_name]:.6f}\"\n    )\n\n\n# ------------------------------------------------\n# Predictions\n# ------------------------------------------------\n\nclass_preds = {}\n\nfor class_idx, class_name in enumerate(\n    LABEL_COLS\n):\n\n    class_preds[class_name] = (\n        test_probs[:, class_idx]\n        >= thresholds[class_name]\n    ).astype(int)\n\n\n# ------------------------------------------------\n# Full TP / FP / TN / FN population\n# ------------------------------------------------\n\nGROUP_NAMES = [\n    \"TP\",\n    \"FP\",\n    \"TN\",\n    \"FN\"\n]\n\nall_group_ids = {\n    class_name: {\n        group_name: []\n        for group_name in GROUP_NAMES\n    }\n    for class_name in LABEL_COLS\n}\n\n\ntest_ids_array = (\n    test_df.index\n    .astype(str)\n    .to_numpy()\n)\n\n\nfor class_idx, class_name in enumerate(\n    LABEL_COLS\n):\n\n    y_true = (\n        test_labels[:, class_idx]\n        .astype(int)\n    )\n\n    y_pred = (\n        class_preds[class_name]\n    )\n\n\n    masks = {\n        \"TP\": (\n            (y_true == 1)\n            & (y_pred == 1)\n        ),\n\n        \"FP\": (\n            (y_true == 0)\n            & (y_pred == 1)\n        ),\n\n        \"TN\": (\n            (y_true == 0)\n            & (y_pred == 0)\n        ),\n\n        \"FN\": (\n            (y_true == 1)\n            & (y_pred == 0)\n        )\n    }\n\n\n    for group_name, mask in masks.items():\n\n        ids = test_ids_array[mask]\n\n        ids = np.array([\n            str(img_id)\n            for img_id in ids\n            if str(img_id) in id_to_path\n        ])\n\n        all_group_ids[\n            class_name\n        ][\n            group_name\n        ] = ids\n\n\n# ------------------------------------------------\n# Validate population\n# ------------------------------------------------\n\nprint(\"\\nPopulation counts:\\n\")\n\nfor class_name in LABEL_COLS:\n\n    counts = {\n        group_name: len(\n            all_group_ids[\n                class_name\n            ][group_name]\n        )\n        for group_name in GROUP_NAMES\n    }\n\n    total = sum(\n        counts.values()\n    )\n\n    print(\n        f\"{class_name:20s} \"\n        f\"TP={counts['TP']:>6,}  \"\n        f\"FP={counts['FP']:>6,}  \"\n        f\"TN={counts['TN']:>6,}  \"\n        f\"FN={counts['FN']:>6,}  \"\n        f\"TOTAL={total:>6,}\"\n    )\n\n    assert total == len(test_df), (\n        f\"{class_name}: \"\n        f\"expected {len(test_df):,}, \"\n        f\"got {total:,}\"\n    )\n\n\nprint()\nprint(\"=\" * 80)\nprint(\"FULL TEST POPULATION READY\")\nprint(\"=\" * 80)\n\nprint(\n    f\"Images:       {len(test_df):,}\"\n)\n\nprint(\n    f\"Classes:      {len(LABEL_COLS)}\"\n)\n\nprint(\n    f\"Evaluations:  \"\n    f\"{len(test_df) * len(LABEL_COLS):,}\"\n)\n\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.184644Z","iopub.execute_input":"2026-09-29T10:51:59.184893Z","iopub.status.idle":"2026-09-29T10:51:59.369212Z","shell.execute_reply.started":"2026-09-29T10:51:59.18486Z","shell.execute_reply":"2026-09-29T10:51:59.368425Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# CELL 42 — MEMORY-SAFE UNIFIED AOPC + MAX-SENSITIVITY\n# ================================================================\n#\n# ResNet-50\n# Ordinary Grad-CAM\n# Target layer: model.layer4[-1]\n#\n# MEMORY FIX:\n#   A persistent global GradCAM object can retain its latest\n#   activations / gradients / outputs across observations.\n#\n#   This version therefore creates a FRESH GradCAM object for:\n#\n#       1. Every clean Grad-CAM\n#       2. Every noisy Grad-CAM batch\n#\n#   The CAM result is copied to NumPy before the GradCAM object\n#   is destroyed.\n#\n#   No scientific definitions are changed:\n#       - Ordinary Grad-CAM\n#       - Same target layer\n#       - Same 10 sensitivity repeats\n#       - Same noise distribution\n#       - Same RNG / deterministic seed\n#       - Same 16 x 16 patch ranking\n#       - Same 10 AOPC steps\n#\n# MEMORY-SAFE NOISY CAM STRATEGY:\n#   10 noisy images are processed in batches of 2.\n#\n#       batch 1: repeats 1-2\n#       batch 2: repeats 3-4\n#       batch 3: repeats 5-6\n#       batch 4: repeats 7-8\n#       batch 5: repeats 9-10\n#\n#   This avoids the CUDA peak-memory cost of sending all 10\n#   noisy images through Grad-CAM simultaneously.\n#\n# ================================================================\n\nimport gc\nimport hashlib\nimport numpy as np\nimport torch\n\n\n# ================================================================\n# CONFIG\n# ================================================================\n\nN_AOPC_STEPS = 10\nGRID_SIZE = 16\n\nN_SENSITIVITY_REPEATS = 10\nNOISE_STD_FRACTION = 0.05\n\nRANDOM_SEED = 42\n\n# ------------------------------------------------\n# Memory-safe Grad-CAM batch size\n# ------------------------------------------------\n# IMPORTANT:\n# Do NOT change this to 10 if Kaggle is OOMing.\n#\nGRADCAM_NOISY_BATCH_SIZE = 2\n\n# Ordinary inference for AOPC.\n# This is NOT Grad-CAM, so it is much cheaper.\nAOPC_BATCH_SIZE = 10\n\n\nprint(\"=\" * 80)\nprint(\"MEMORY-SAFE UNIFIED AOPC + MAX-SENSITIVITY\")\nprint(\"=\" * 80)\n\nprint(\"Method: ordinary Grad-CAM\")\nprint(\"Target layer: model.layer4[-1]\")\nprint(f\"AOPC steps: {N_AOPC_STEPS}\")\nprint(f\"Max Sensitivity repeats: {N_SENSITIVITY_REPEATS}\")\nprint(f\"Noise std fraction: {NOISE_STD_FRACTION}\")\nprint(f\"Noisy Grad-CAM batch size: {GRADCAM_NOISY_BATCH_SIZE}\")\nprint(f\"AOPC inference batch size: {AOPC_BATCH_SIZE}\")\nprint()\nprint(\"Grad-CAM lifecycle:\")\nprint(\"  Clean image: fresh GradCAM object\")\nprint(\n    f\"  Noisy images: \"\n    f\"{N_SENSITIVITY_REPEATS} images in \"\n    f\"{GRADCAM_NOISY_BATCH_SIZE}-image batches\"\n)\nprint(\"  Every GradCAM object is destroyed after use\")\nprint(\"  AOPC inference: ordinary batched forward pass\")\nprint(\"=\" * 80)\n\n\n# ================================================================\n# DETERMINISTIC SEED\n# ================================================================\n\ndef stable_seed(\n    img_id,\n    class_name,\n    group_name,\n    base_seed=42\n):\n    key_string = (\n        f\"{base_seed}|\"\n        f\"{img_id}|\"\n        f\"{class_name}|\"\n        f\"{group_name}\"\n    )\n\n    digest = hashlib.sha256(\n        key_string.encode(\"utf-8\")\n    ).digest()\n\n    return int.from_bytes(\n        digest[:4],\n        byteorder=\"little\",\n        signed=False\n    )\n\n\n# ================================================================\n# CUDA / MEMORY CLEANUP\n# ================================================================\n\ndef cleanup_cuda():\n    \"\"\"\n    Release Python references and unused CUDA allocator blocks.\n\n    This does NOT remove model hooks because each GradCAM object\n    is allowed to go out of scope naturally before this function\n    is called.\n    \"\"\"\n\n    gc.collect()\n\n    if DEVICE.type == \"cuda\":\n        torch.cuda.empty_cache()\n\n\n# ================================================================\n# FRESH GRADCAM FACTORY\n# ================================================================\n#\n# IMPORTANT:\n# Do NOT reuse the global `cam` object inside the metric function.\n#\n# Each call creates a new GradCAM instance with the same scientific\n# configuration.\n#\n# ================================================================\n\ndef get_fresh_gradcam():\n\n    return GradCAM(\n        model=model,\n        target_layers=[\n            model.layer4[-1]\n        ]\n    )\n\n\n# ================================================================\n# UNIFIED METRIC FUNCTION\n# ================================================================\n\ndef compute_aopc_and_max_sensitivity(\n    img_np,\n    class_idx,\n    rng\n):\n    \"\"\"\n    Compute AOPC + Max Sensitivity for one image/class observation.\n\n    Scientific definitions are unchanged.\n\n    CLEAN CAM\n    ----------\n    One ordinary Grad-CAM call using a fresh GradCAM object.\n\n    MAX SENSITIVITY\n    ---------------\n    Ten noisy images are generated exactly as before.\n\n    They are processed in batches of two using fresh GradCAM\n    objects. The CAM outputs are copied to NumPy immediately,\n    allowing the GradCAM object and its internal tensors to be\n    destroyed before the next batch.\n\n    AOPC\n    ----\n    Ten perturbed images are evaluated using ordinary model\n    inference in a batch. No Grad-CAM is involved in AOPC.\n    \"\"\"\n\n    # ============================================================\n    # 1. CLEAN GRAD-CAM\n    # ============================================================\n\n    clean_tensor = None\n    clean_cam_result = None\n    cam_local = None\n\n    try:\n\n        clean_tensor = image_np_to_tensor(\n            img_np\n        )\n\n        # --------------------------------------------------------\n        # FRESH GradCAM OBJECT\n        # --------------------------------------------------------\n\n        cam_local = get_fresh_gradcam()\n\n        clean_cam_result = cam_local(\n            input_tensor=clean_tensor,\n            targets=[\n                ClassifierOutputTarget(\n                    class_idx\n                )\n            ],\n        )\n\n        # --------------------------------------------------------\n        # IMPORTANT:\n        # Copy the CAM out of the GradCAM object's lifecycle.\n        # --------------------------------------------------------\n\n        clean_cam = np.asarray(\n            clean_cam_result[0]\n        ).copy()\n\n    finally:\n\n        # --------------------------------------------------------\n        # Destroy everything associated with this CAM call.\n        # --------------------------------------------------------\n\n        if clean_tensor is not None:\n            del clean_tensor\n\n        if clean_cam_result is not None:\n            del clean_cam_result\n\n        if cam_local is not None:\n            del cam_local\n\n        cleanup_cuda()\n\n\n    # ============================================================\n    # 2. GENERATE NOISY IMAGES\n    # ============================================================\n\n    noise_std = (\n        NOISE_STD_FRACTION\n        * img_np.std()\n    )\n\n    noisy_imgs = []\n\n    for _ in range(\n        N_SENSITIVITY_REPEATS\n    ):\n\n        noise = rng.normal(\n            loc=0.0,\n            scale=noise_std,\n            size=img_np.shape\n        ).astype(\n            np.float32\n        )\n\n        noisy_img = np.clip(\n            img_np + noise,\n            0.0,\n            1.0\n        ).astype(\n            np.float32\n        )\n\n        noisy_imgs.append(\n            noisy_img\n        )\n\n\n    # ============================================================\n    # 3. NOISY GRAD-CAMs\n    # ============================================================\n    #\n    # Process noisy images in small batches.\n    #\n    # This preserves the exact 10 perturbations while limiting\n    # peak CUDA memory.\n    #\n    # ============================================================\n\n    noisy_cams_list = []\n\n    for batch_start in range(\n        0,\n        N_SENSITIVITY_REPEATS,\n        GRADCAM_NOISY_BATCH_SIZE\n    ):\n\n        batch_end = min(\n            batch_start\n            + GRADCAM_NOISY_BATCH_SIZE,\n            N_SENSITIVITY_REPEATS\n        )\n\n        batch_np = np.stack(\n            noisy_imgs[\n                batch_start:batch_end\n            ],\n            axis=0\n        )\n\n        batch_normed = (\n            batch_np - MEAN\n        ) / STD\n\n        batch_tensor = (\n            torch.from_numpy(\n                batch_normed.transpose(\n                    0,\n                    3,\n                    1,\n                    2\n                )\n            )\n            .float()\n            .to(DEVICE)\n        )\n\n        batch_targets = [\n            ClassifierOutputTarget(\n                class_idx\n            )\n            for _ in range(\n                batch_end - batch_start\n            )\n        ]\n\n        batch_cam_result = None\n        cam_local = None\n\n        try:\n\n            # ----------------------------------------------------\n            # FRESH GradCAM OBJECT FOR THIS BATCH\n            # ----------------------------------------------------\n\n            cam_local = get_fresh_gradcam()\n\n            batch_cam_result = cam_local(\n                input_tensor=batch_tensor,\n                targets=batch_targets,\n            )\n\n            # ----------------------------------------------------\n            # IMPORTANT:\n            # Copy every CAM to NumPy before destroying\n            # the GradCAM object.\n            # ----------------------------------------------------\n\n            for cam_result in batch_cam_result:\n\n                noisy_cams_list.append(\n                    np.asarray(\n                        cam_result\n                    ).copy()\n                )\n\n        finally:\n\n            # ----------------------------------------------------\n            # Destroy this batch's GradCAM lifecycle.\n            # ----------------------------------------------------\n\n            if batch_tensor is not None:\n                del batch_tensor\n\n            if batch_cam_result is not None:\n                del batch_cam_result\n\n            if cam_local is not None:\n                del cam_local\n\n            del batch_np\n            del batch_normed\n            del batch_targets\n\n            cleanup_cuda()\n\n\n    # ============================================================\n    # 4. RELEASE NOISY IMAGE REFERENCES\n    # ============================================================\n\n    del noisy_imgs\n\n\n    # ============================================================\n    # 5. STACK NOISY CAMs\n    # ============================================================\n\n    noisy_cams = np.stack(\n        noisy_cams_list,\n        axis=0\n    )\n\n    del noisy_cams_list\n\n\n    # ============================================================\n    # 6. MAX SENSITIVITY\n    # ============================================================\n\n    differences = (\n        noisy_cams - clean_cam\n    ).reshape(\n        N_SENSITIVITY_REPEATS,\n        -1\n    )\n\n    sensitivity_values = np.linalg.norm(\n        differences,\n        axis=1\n    )\n\n    max_sensitivity = float(\n        sensitivity_values.max()\n    )\n\n\n    # ============================================================\n    # 7. RANK PATCHES USING CLEAN CAM\n    # ============================================================\n\n    ranking = cam_to_patch_ranking(\n        clean_cam\n    )\n\n    total_patches = (\n        GRID_SIZE * GRID_SIZE\n    )\n\n\n    # ============================================================\n    # 8. CREATE AOPC PERTURBATIONS\n    # ============================================================\n\n    perturbed_imgs = []\n\n    for step in range(\n        1,\n        N_AOPC_STEPS + 1\n    ):\n\n        n_remove = int(\n            total_patches\n            * step\n            / N_AOPC_STEPS\n        )\n\n        perturbed_imgs.append(\n            perturb_patches(\n                img_np,\n                ranking[:n_remove]\n            )\n        )\n\n\n    # ============================================================\n    # 9. AOPC — BATCHED ORDINARY INFERENCE\n    # ============================================================\n    #\n    # This is NOT Grad-CAM.\n    #\n    # No gradients are required here, so batching the 10\n    # perturbations is memory-efficient.\n    #\n    # ============================================================\n\n    perturbed_probs_list = []\n\n    for batch_start in range(\n        0,\n        N_AOPC_STEPS,\n        AOPC_BATCH_SIZE\n    ):\n\n        batch_end = min(\n            batch_start\n            + AOPC_BATCH_SIZE,\n            N_AOPC_STEPS\n        )\n\n        aopc_batch_np = np.stack(\n            perturbed_imgs[\n                batch_start:batch_end\n            ],\n            axis=0\n        )\n\n        aopc_batch_normed = (\n            aopc_batch_np - MEAN\n        ) / STD\n\n        aopc_batch_tensor = (\n            torch.from_numpy(\n                aopc_batch_normed.transpose(\n                    0,\n                    3,\n                    1,\n                    2\n                )\n            )\n            .float()\n            .to(DEVICE)\n        )\n\n        try:\n\n            with torch.no_grad():\n\n                with torch.autocast(\n                    device_type=DEVICE.type,\n                    enabled=USE_AMP\n                ):\n\n                    outputs = model(\n                        aopc_batch_tensor\n                    )\n\n                batch_probs = (\n                    torch.sigmoid(\n                        outputs.float()\n                    )[:, class_idx]\n                    .cpu()\n                    .numpy()\n                )\n\n            perturbed_probs_list.append(\n                batch_probs.copy()\n            )\n\n        finally:\n\n            del aopc_batch_tensor\n            del aopc_batch_np\n            del aopc_batch_normed\n\n            if \"outputs\" in locals():\n                del outputs\n\n            cleanup_cuda()\n\n\n    perturbed_probs = np.concatenate(\n        perturbed_probs_list,\n        axis=0\n    )\n\n    del perturbed_probs_list\n\n\n    # ============================================================\n    # 10. CLEAN PROBABILITY\n    # ============================================================\n\n    original_prob = get_class_prob(\n        img_np,\n        class_idx\n    )\n\n\n    # ============================================================\n    # 11. AOPC\n    # ============================================================\n\n    drops = (\n        original_prob\n        - perturbed_probs\n    )\n\n    aopc = float(\n        drops.mean()\n    )\n\n\n    # ============================================================\n    # 12. FINAL OBSERVATION CLEANUP\n    # ============================================================\n\n    del clean_cam\n    del noisy_cams\n    del differences\n    del sensitivity_values\n    del ranking\n    del perturbed_imgs\n    del perturbed_probs\n    del drops\n\n    cleanup_cuda()\n\n\n    # ============================================================\n    # 13. RETURN SCALARS ONLY\n    # ============================================================\n\n    return (\n        original_prob,\n        aopc,\n        max_sensitivity\n    )\n\n\n# ================================================================\n# READY\n# ================================================================\n\nprint()\nprint(\"✓ stable_seed() ready\")\nprint(\"✓ get_fresh_gradcam() ready\")\nprint(\n    \"✓ compute_aopc_and_max_sensitivity() ready\"\n)\nprint(\n    \"✓ Fresh GradCAM object per clean/noisy batch\"\n)\nprint(\n    f\"✓ Noisy Grad-CAM batch size: \"\n    f\"{GRADCAM_NOISY_BATCH_SIZE}\"\n)\nprint(\n    \"✓ AOPC remains ordinary batched inference\"\n)\nprint(\n    \"✓ Scientific definitions unchanged\"\n)\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.370395Z","iopub.execute_input":"2026-09-29T10:51:59.370726Z","iopub.status.idle":"2026-09-29T10:51:59.398276Z","shell.execute_reply.started":"2026-09-29T10:51:59.370669Z","shell.execute_reply":"2026-09-29T10:51:59.397414Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# MAX-SENSITIVITY — ORDINARY GRAD-CAM\n# ================================================================\n\ndef compute_max_sensitivity(\n    img_np,\n    original_cam,\n    class_idx\n):\n\n    noise_std = (\n        NOISE_STD_FRACTION\n        * img_np.std()\n    )\n\n    noisy_imgs = []\n\n    for _ in range(\n        N_SENSITIVITY_REPEATS\n    ):\n\n        noise = np.random.normal(\n            0.0,\n            noise_std,\n            img_np.shape\n        ).astype(np.float32)\n\n        noisy_img = np.clip(\n            img_np + noise,\n            0.0,\n            1.0\n        )\n\n        noisy_imgs.append(\n            noisy_img\n        )\n\n    batch_np = np.stack(\n        noisy_imgs,\n        axis=0\n    )\n\n    normed = (\n        batch_np - MEAN\n    ) / STD\n\n    batch_tensor = (\n        torch.from_numpy(\n            normed.transpose(\n                0, 3, 1, 2\n            )\n        )\n        .float()\n        .to(DEVICE)\n    )\n\n    targets = [\n        ClassifierOutputTarget(class_idx)\n        for _ in range(\n            N_SENSITIVITY_REPEATS\n        )\n    ]\n\n    noisy_cams = cam(\n        input_tensor=batch_tensor,\n        targets=targets\n    )\n\n    diffs = np.linalg.norm(\n        (\n            noisy_cams - original_cam\n        ).reshape(\n            N_SENSITIVITY_REPEATS,\n            -1\n        ),\n        axis=1\n    )\n\n    return float(diffs.max())\n\n\nprint(\"✅ compute_max_sensitivity() defined.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T10:51:59.399246Z","iopub.execute_input":"2026-09-29T10:51:59.399751Z","iopub.status.idle":"2026-09-29T10:51:59.419143Z","shell.execute_reply.started":"2026-09-29T10:51:59.399726Z","shell.execute_reply":"2026-09-29T10:51:59.418408Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =================================================================\n# CELL 42 — BATCHED UNIFIED AOPC + MAX-SENSITIVITY\n#              OPTIMIZED RESNET-50 VERSION\n# =================================================================\n#\n# ResNet-50\n# Ordinary Grad-CAM\n# Target layer: model.layer4[-1]\n#\n# IMPORTANT:\n#\n# This version preserves the original analysis methodology:\n#\n#   - Ordinary Grad-CAM\n#   - model.layer4[-1]\n#   - 10 AOPC perturbations\n#   - 10 Max-Sensitivity noise replicates\n#   - noise std = 0.05 * image std\n#   - deterministic rng-based noise\n#   - 16 x 16 patch grid\n#\n# SPEED OPTIMIZATIONS:\n#\n#   1. Clean Grad-CAM:\n#          1 Grad-CAM call\n#\n#   2. Max-Sensitivity:\n#          ALL 10 noisy images are stacked into ONE batch\n#          and processed by ONE Grad-CAM call.\n#\n#      OLD:\n#          cam(batch=2)\n#          cam(batch=2)\n#          cam(batch=2)\n#          cam(batch=2)\n#          cam(batch=2)\n#\n#      NEW:\n#          cam(batch=10)\n#\n#   3. AOPC:\n#          all 10 perturbations already use ONE normal\n#          batched forward pass.\n#\n#   4. CUDA cache is NOT emptied after every observation.\n#      Cleanup is performed periodically instead.\n#\n# =================================================================\n\n\nimport gc\nimport hashlib\nimport numpy as np\nimport torch\n\n\n# =================================================================\n# CONFIG\n# =================================================================\n\nN_AOPC_STEPS = 10\n\nGRID_SIZE = 16\n\nN_SENSITIVITY_REPEATS = 10\n\nNOISE_STD_FRACTION = 0.05\n\nRANDOM_SEED = 42\n\n\n# =================================================================\n# PERFORMANCE / MEMORY CONFIG\n# =================================================================\n\n# All 10 noisy Grad-CAM images are processed in ONE call.\n#\n# IMPORTANT:\n# This is intentionally 10 rather than 2.\n#\n# If this causes a CUDA OOM on the current Kaggle GPU,\n# fallback value = 5.\n#\n# Do NOT reduce N_SENSITIVITY_REPEATS.\n#\nNOISY_GRADCAM_BATCH_SIZE = N_SENSITIVITY_REPEATS\n\n\n# AOPC is ordinary inference and is already batched.\nAOPC_BATCH_SIZE = N_AOPC_STEPS\n\n\n# IMPORTANT:\n# We do NOT call:\n#\n#     torch.cuda.empty_cache()\n#     gc.collect()\n#\n# after every observation.\n#\n# That was creating unnecessary overhead.\n#\n# Cleanup happens periodically instead.\nCLEANUP_EVERY = 100\n\n\n# Internal counter used only for periodic memory cleanup.\n# It does NOT affect the analysis.\n_METRIC_CALL_COUNT = 0\n\n\nprint(\"=\" * 80)\nprint(\"BATCHED UNIFIED AOPC + MAX-SENSITIVITY\")\nprint(\"OPTIMIZED RESNET-50 ORDINARY GRAD-CAM\")\nprint(\"=\" * 80)\n\nprint()\nprint(\"Method: ordinary Grad-CAM\")\nprint(\"Target layer: model.layer4[-1]\")\nprint()\n\nprint(f\"AOPC steps:              {N_AOPC_STEPS}\")\nprint(f\"Max-Sensitivity repeats: {N_SENSITIVITY_REPEATS}\")\nprint(f\"Noise std fraction:      {NOISE_STD_FRACTION}\")\nprint()\n\nprint(\"Grad-CAM strategy:\")\nprint(\"  Clean image:           1 Grad-CAM call\")\nprint(\n    f\"  Noisy images:          \"\n    f\"{N_SENSITIVITY_REPEATS} images in ONE batched Grad-CAM call\"\n)\nprint(\n    f\"  AOPC inference:        \"\n    f\"{N_AOPC_STEPS} images in ONE batched forward pass\"\n)\n\nprint()\nprint(\"Memory strategy:\")\nprint(\n    f\"  Periodic CUDA cleanup: every \"\n    f\"{CLEANUP_EVERY} observations\"\n)\nprint(\"  No empty_cache() after every observation\")\n\nprint(\"=\" * 80)\n\n\n# =================================================================\n# DETERMINISTIC SEED\n# =================================================================\n\ndef stable_seed(\n    img_id,\n    class_name,\n    group_name,\n    base_seed=42\n):\n    \"\"\"\n    Generate a deterministic per-observation seed.\n\n    The seed depends on:\n        base_seed\n        image ID\n        class name\n        confusion-group name\n    \"\"\"\n\n    key_string = (\n        f\"{base_seed}|\"\n        f\"{img_id}|\"\n        f\"{class_name}|\"\n        f\"{group_name}\"\n    )\n\n    digest = hashlib.sha256(\n        key_string.encode(\"utf-8\")\n    ).digest()\n\n    return int.from_bytes(\n        digest[:4],\n        byteorder=\"little\",\n        signed=False\n    )\n\n\n# =================================================================\n# UNIFIED METRIC FUNCTION\n# =================================================================\n\ndef compute_aopc_and_max_sensitivity(\n    img_np,\n    class_idx,\n    rng\n):\n    \"\"\"\n    Compute:\n\n        1. Original probability\n        2. AOPC\n        3. Max-Sensitivity\n\n    using:\n\n        - ordinary Grad-CAM\n        - model.layer4[-1]\n        - 10 AOPC perturbations\n        - 10 noisy Grad-CAM replicates\n\n    IMPORTANT PERFORMANCE CHANGE:\n\n        The 10 noisy images used for\n        Max-Sensitivity are processed in ONE\n        batched Grad-CAM call.\n\n    This preserves the same 10 perturbations\n    and therefore does not change the metric\n    definition.\n    \"\"\"\n\n    global _METRIC_CALL_COUNT\n\n    _METRIC_CALL_COUNT += 1\n\n\n    # =============================================================\n    # 1. CLEAN GRAD-CAM\n    # =============================================================\n\n    clean_tensor = image_np_to_tensor(\n        img_np\n    )\n\n    clean_cam_result = cam(\n        input_tensor=clean_tensor,\n        targets=[\n            ClassifierOutputTarget(class_idx)\n        ]\n    )\n\n    clean_cam = clean_cam_result[0]\n\n    del clean_tensor\n    del clean_cam_result\n\n\n    # =============================================================\n    # 2. GENERATE NOISY IMAGES\n    # =============================================================\n\n    noise_std = (\n        NOISE_STD_FRACTION\n        * img_np.std()\n    )\n\n    noisy_imgs = []\n\n    for _ in range(\n        N_SENSITIVITY_REPEATS\n    ):\n\n        noise = rng.normal(\n            loc=0.0,\n            scale=noise_std,\n            size=img_np.shape\n        ).astype(\n            np.float32\n        )\n\n        noisy_img = np.clip(\n            img_np + noise,\n            0.0,\n            1.0\n        ).astype(\n            np.float32\n        )\n\n        noisy_imgs.append(\n            noisy_img\n        )\n\n        del noise\n\n\n    # =============================================================\n    # 3. NOISY GRAD-CAM\n    #    ALL 10 IMAGES IN ONE BATCH\n    # =============================================================\n\n    noisy_batch_np = np.stack(\n        noisy_imgs,\n        axis=0\n    )\n\n    # Shape:\n    #     [10, H, W, 3]\n\n    noisy_normed = (\n        noisy_batch_np - MEAN\n    ) / STD\n\n    noisy_batch_tensor = (\n        torch.from_numpy(\n            noisy_normed.transpose(\n                0,\n                3,\n                1,\n                2\n            )\n        )\n        .float()\n        .to(DEVICE)\n    )\n\n    noisy_targets = [\n        ClassifierOutputTarget(class_idx)\n        for _ in range(\n            N_SENSITIVITY_REPEATS\n        )\n    ]\n\n    # =============================================================\n    # ONE Grad-CAM CALL\n    # =============================================================\n\n    noisy_cams = cam(\n        input_tensor=noisy_batch_tensor,\n        targets=noisy_targets\n    )\n\n    del noisy_batch_tensor\n    del noisy_imgs\n    del noisy_batch_np\n    del noisy_normed\n\n\n    # =============================================================\n    # 4. MAX-SENSITIVITY\n    # =============================================================\n\n    differences = (\n        noisy_cams - clean_cam\n    ).reshape(\n        N_SENSITIVITY_REPEATS,\n        -1\n    )\n\n    sensitivity_values = np.linalg.norm(\n        differences,\n        axis=1\n    )\n\n    max_sensitivity = float(\n        sensitivity_values.max()\n    )\n\n\n    # =============================================================\n    # 5. RANK PATCHES USING CLEAN CAM\n    # =============================================================\n\n    ranking = cam_to_patch_ranking(\n        clean_cam\n    )\n\n    total_patches = (\n        GRID_SIZE * GRID_SIZE\n    )\n\n\n    # =============================================================\n    # 6. CREATE AOPC PERTURBATIONS\n    # =============================================================\n\n    perturbed_imgs = []\n\n    for step in range(\n        1,\n        N_AOPC_STEPS + 1\n    ):\n\n        n_remove = int(\n            total_patches\n            * step\n            / N_AOPC_STEPS\n        )\n\n        perturbed_imgs.append(\n            perturb_patches(\n                img_np,\n                ranking[:n_remove]\n            )\n        )\n\n\n    # =============================================================\n    # 7. AOPC — BATCHED NORMAL INFERENCE\n    # =============================================================\n\n    perturbed_batch_np = np.stack(\n        perturbed_imgs,\n        axis=0\n    )\n\n    perturbed_normed = (\n        perturbed_batch_np - MEAN\n    ) / STD\n\n    perturbed_tensor = (\n        torch.from_numpy(\n            perturbed_normed.transpose(\n                0,\n                3,\n                1,\n                2\n            )\n        )\n        .float()\n        .to(DEVICE)\n    )\n\n    with torch.no_grad():\n\n        with torch.autocast(\n            device_type=DEVICE.type,\n            enabled=USE_AMP\n        ):\n\n            outputs = model(\n                perturbed_tensor\n            )\n\n        perturbed_probs = (\n            torch.sigmoid(\n                outputs.float()\n            )[:, class_idx]\n            .cpu()\n            .numpy()\n        )\n\n\n    # =============================================================\n    # 8. CLEAN PROBABILITY\n    # =============================================================\n\n    original_prob = get_class_prob(\n        img_np,\n        class_idx\n    )\n\n\n    # =============================================================\n    # 9. AOPC\n    # =============================================================\n\n    drops = (\n        original_prob\n        - perturbed_probs\n    )\n\n    aopc = float(\n        drops.mean()\n    )\n\n\n    # =============================================================\n    # 10. RELEASE TEMPORARY OBJECTS\n    # =============================================================\n\n    del perturbed_tensor\n    del outputs\n\n    del perturbed_batch_np\n    del perturbed_normed\n    del perturbed_imgs\n    del perturbed_probs\n\n    del clean_cam\n    del noisy_cams\n    del differences\n    del sensitivity_values\n    del ranking\n    del drops\n\n\n    # =============================================================\n    # 11. PERIODIC CUDA / PYTHON CLEANUP\n    #\n    # IMPORTANT:\n    # This replaces the old per-observation:\n    #\n    #     torch.cuda.empty_cache()\n    #     gc.collect()\n    #\n    # which was unnecessarily expensive.\n    # =============================================================\n\n    if (\n        _METRIC_CALL_COUNT\n        % CLEANUP_EVERY\n        == 0\n    ):\n\n        gc.collect()\n\n        if DEVICE.type == \"cuda\":\n            torch.cuda.empty_cache()\n\n\n    # =============================================================\n    # 12. RETURN SCALARS ONLY\n    # =============================================================\n\n    return (\n        original_prob,\n        aopc,\n        max_sensitivity\n    )\n\n\nprint()\nprint(\"✓ stable_seed() ready\")\nprint(\n    \"✓ compute_aopc_and_max_sensitivity() ready\"\n)\nprint(\n    \"✓ 10 noisy Grad-CAMs are now processed \"\n    \"in ONE batched cam() call\"\n)\nprint(\n    \"✓ AOPC remains fully batched\"\n)\nprint(\n    f\"✓ CUDA cleanup every {CLEANUP_EVERY} observations\"\n)\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T11:16:40.741482Z","iopub.execute_input":"2026-09-29T11:16:40.741693Z","iopub.status.idle":"2026-09-29T11:16:40.761848Z","shell.execute_reply.started":"2026-09-29T11:16:40.741673Z","shell.execute_reply":"2026-09-29T11:16:40.761135Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# SANITY CHECK — ONE RESNET-50 IMAGE\n# ================================================================\n\ntest_img_id = str(test_df.index[0])\ntest_class_idx = 0\n\nprint(\"Testing image:\", test_img_id)\nprint(\"Class:\", LABEL_COLS[test_class_idx])\n\nimg_np = load_image_for_cam(test_img_id)\n\nprint(\"Image shape:\", img_np.shape)\nprint(\"Image range:\", img_np.min(), img_np.max())\n\ntest_cam = get_gradcam(\n    img_np,\n    test_class_idx\n)\n\nprint(\"Grad-CAM shape:\", test_cam.shape)\nprint(\n    \"Grad-CAM range:\",\n    test_cam.min(),\n    test_cam.max()\n)\n\ntest_aopc = compute_aopc(\n    img_np,\n    test_cam,\n    test_class_idx\n)\n\nprint(\"AOPC:\", test_aopc)\n\ntest_sensitivity = compute_max_sensitivity(\n    img_np,\n    test_cam,\n    test_class_idx\n)\n\nprint(\n    \"Max Sensitivity:\",\n    test_sensitivity\n)\n\nprint()\nprint(\"✅ RESNET-50 SANITY CHECK PASSED\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T11:20:59.733227Z","iopub.execute_input":"2026-09-29T11:20:59.73379Z","iopub.status.idle":"2026-09-29T11:20:59.961891Z","shell.execute_reply.started":"2026-09-29T11:20:59.73376Z","shell.execute_reply":"2026-09-29T11:20:59.961255Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ================================================================\n# HELPER — IMAGE NUMPY → RESNET-50 INPUT TENSOR\n# ================================================================\n\ndef image_np_to_tensor(img_np):\n    \"\"\"\n    Convert an RGB image in [0, 1] with shape\n    [H, W, 3] into a ResNet-50 input tensor\n    with shape [1, 3, H, W].\n\n    Uses the same MEAN / STD normalization\n    already used by the ResNet-50 pipeline.\n    \"\"\"\n\n    img_np = np.asarray(\n        img_np,\n        dtype=np.float32\n    )\n\n    # Safety check\n    if img_np.ndim != 3:\n        raise ValueError(\n            f\"Expected HxWx3 image, got \"\n            f\"shape {img_np.shape}\"\n        )\n\n    if img_np.shape[-1] != 3:\n        raise ValueError(\n            f\"Expected RGB image with 3 channels, \"\n            f\"got shape {img_np.shape}\"\n        )\n\n    # Normalize exactly as ResNet-50 expects\n    normalized = (\n        img_np - MEAN\n    ) / STD\n\n    # HWC → CHW\n    tensor = torch.from_numpy(\n        normalized.transpose(\n            2,\n            0,\n            1\n        )\n    ).float()\n\n    # Add batch dimension and move to GPU\n    tensor = tensor.unsqueeze(\n        0\n    ).to(\n        DEVICE\n    )\n\n    return tensor\n\n\nprint(\"✓ image_np_to_tensor() ready\")\n\n# ================================================================\n# CELL 6 — FULL POPULATION AOPC + MAX-SENSITIVITY\n# ================================================================\n#\n# Population:\n#   image × class × confusion group\n#\n# Groups:\n#   TP / FP / TN / FN\n#\n# Metrics:\n#   AOPC\n#   Max Sensitivity\n#\n# Method:\n#   ordinary Grad-CAM\n#\n# Target layer:\n#   model.layer4[-1]\n#\n# Checkpoint:\n#   /kaggle/working/\n#   resnet50_aopc_max_sensitivity_population_checkpoint.csv\n#\n# Final:\n#   /kaggle/working/\n#   resnet50_FULL_population_AOPC_MaxSensitivity.csv\n#\n# Resume:\n#   YES\n#\n# Checkpoint interval:\n#   200 completed observations\n#\n# ================================================================\n\nimport os\nimport pandas as pd\nimport numpy as np\nfrom tqdm.auto import tqdm\n\n\n# ================================================================\n# CONFIG\n# ================================================================\n\nPOP_CHECKPOINT = (\n    \"/kaggle/working/\"\n    \"resnet50_aopc_max_sensitivity_population_checkpoint.csv\"\n)\n\nPOP_FINAL = (\n    \"/kaggle/working/\"\n    \"resnet50_FULL_population_AOPC_MaxSensitivity.csv\"\n)\n\nPOP_SUMMARY = (\n    \"/kaggle/working/\"\n    \"resnet50_FULL_population_AOPC_MaxSensitivity_summary.csv\"\n)\n\nCHECKPOINT_EVERY = 200\n\n\nREQUIRED_COLUMNS = [\n    \"img_id\",\n    \"class\",\n    \"group\",\n    \"probability\",\n    \"aopc\",\n    \"max_sensitivity\"\n]\n\n\nprint(\"=\" * 80)\nprint(\"RESNET-50 FULL POPULATION\")\nprint(\"AOPC + MAX-SENSITIVITY\")\nprint(\"=\" * 80)\n\nprint(\n    \"Method:\",\n    \"ordinary Grad-CAM\"\n)\n\nprint(\n    \"Target layer:\",\n    \"model.layer4[-1]\"\n)\n\nprint(\n    \"Checkpoint every:\",\n    CHECKPOINT_EVERY,\n    \"rows\"\n)\n\nprint(\n    \"Checkpoint:\",\n    POP_CHECKPOINT\n)\n\nprint(\n    \"Final output:\",\n    POP_FINAL\n)\n\nprint(\"=\" * 80)\n\n\n# ================================================================\n# LOAD EXISTING CHECKPOINT\n# ================================================================\n\nif os.path.exists(\n    POP_CHECKPOINT\n):\n\n    existing_df = pd.read_csv(\n        POP_CHECKPOINT\n    )\n\n    missing_columns = [\n        col\n        for col in REQUIRED_COLUMNS\n        if col not in existing_df.columns\n    ]\n\n    if missing_columns:\n\n        raise ValueError(\n            \"Existing checkpoint has \"\n            f\"wrong columns. Missing: \"\n            f\"{missing_columns}\"\n        )\n\n    completed_keys = set(\n        zip(\n            existing_df[\"img_id\"].astype(str),\n            existing_df[\"class\"].astype(str),\n            existing_df[\"group\"].astype(str)\n        )\n    )\n\n    print()\n    print(\n        \"✓ Existing unified checkpoint found.\"\n    )\n\n    print(\n        f\"  Completed rows: \"\n        f\"{len(existing_df):,}\"\n    )\n\nelse:\n\n    existing_df = pd.DataFrame(\n        columns=REQUIRED_COLUMNS\n    )\n\n    completed_keys = set()\n\n    print()\n    print(\n        \"No existing unified checkpoint found.\"\n    )\n\n    print(\n        \"Starting from zero.\"\n    )\n\n\n# ================================================================\n# TOTAL POPULATION\n# ================================================================\n\ntotal_jobs = sum(\n    len(ids)\n    for class_groups\n    in all_group_ids.values()\n    for ids in class_groups.values()\n)\n\ncompleted_jobs = len(\n    completed_keys\n)\n\nremaining_jobs = (\n    total_jobs\n    - completed_jobs\n)\n\n\nprint()\nprint(\"=\" * 80)\n\nprint(\n    f\"Total jobs:     {total_jobs:,}\"\n)\n\nprint(\n    f\"Completed:      {completed_jobs:,}\"\n)\n\nprint(\n    f\"Remaining:      {remaining_jobs:,}\"\n)\n\nprint(\"=\" * 80)\n\n\n# ================================================================\n# BUFFERED CHECKPOINTING\n# ================================================================\n#\n# We keep the checkpoint interval at 200 as requested.\n#\n# Instead of rewriting a 100k+ row CSV every 200 rows,\n# we append only the newly completed rows.\n#\n# This is substantially cheaper.\n# ================================================================\n\ncheckpoint_buffer = []\n\n\ndef flush_checkpoint():\n\n    global checkpoint_buffer\n\n    if not checkpoint_buffer:\n        return\n\n    chunk_df = pd.DataFrame(\n        checkpoint_buffer,\n        columns=REQUIRED_COLUMNS\n    )\n\n    file_exists = os.path.exists(\n        POP_CHECKPOINT\n    )\n\n    chunk_df.to_csv(\n        POP_CHECKPOINT,\n        mode=\"a\",\n        header=not file_exists,\n        index=False\n    )\n\n    checkpoint_buffer = []\n\n\n# ================================================================\n# OVERALL PROGRESS BAR\n# ================================================================\n\noverall_progress = tqdm(\n    total=total_jobs,\n    initial=completed_jobs,\n    desc=\"ResNet-50 AOPC + MaxSens\",\n    unit=\"eval\"\n)\n\n\nsuccessful = completed_jobs\nfailed = 0\n\n\n# ================================================================\n# MAIN POPULATION LOOP\n# ================================================================\n\nfor class_name in LABEL_COLS:\n\n    class_idx = LABEL_COLS.index(\n        class_name\n    )\n\n    for group_name in GROUP_NAMES:\n\n        group_ids = (\n            all_group_ids[\n                class_name\n            ][group_name]\n        )\n\n        if len(group_ids) == 0:\n            continue\n\n        print()\n        print(\n            f\"Starting: \"\n            f\"{class_name} / {group_name} \"\n            f\"({len(group_ids):,} images)\"\n        )\n\n        for img_id in group_ids:\n\n            img_id = str(\n                img_id\n            )\n\n            key = (\n                img_id,\n                str(class_name),\n                str(group_name)\n            )\n\n            # ----------------------------------------------------\n            # RESUME\n            # ----------------------------------------------------\n\n            if key in completed_keys:\n\n                overall_progress.update(\n                    0\n                )\n\n                continue\n\n            try:\n\n                # ------------------------------------------------\n                # Load image\n                # ------------------------------------------------\n\n                img_np = load_image_for_cam(\n                    img_id\n                )\n\n                # ------------------------------------------------\n                # Deterministic RNG\n                #\n                # Important:\n                # Python's built-in hash() is intentionally\n                # randomized between processes.\n                #\n                # stable_seed() makes resumed runs reproducible.\n                # ------------------------------------------------\n\n                seed = stable_seed(\n                    img_id,\n                    class_name,\n                    group_name,\n                    RANDOM_SEED\n                )\n\n                rng = np.random.default_rng(\n                    seed\n                )\n\n                # ------------------------------------------------\n                # UNIFIED AOPC + MAX-SENSITIVITY\n                # ------------------------------------------------\n\n                (\n                    probability,\n                    aopc,\n                    max_sensitivity\n                ) = compute_aopc_and_max_sensitivity(\n                    img_np,\n                    class_idx,\n                    rng\n                )\n\n                # ------------------------------------------------\n                # Record\n                # ------------------------------------------------\n\n                row = {\n\n                    \"img_id\":\n                        img_id,\n\n                    \"class\":\n                        class_name,\n\n                    \"group\":\n                        group_name,\n\n                    \"probability\":\n                        probability,\n\n                    \"aopc\":\n                        aopc,\n\n                    \"max_sensitivity\":\n                        max_sensitivity\n                }\n\n                checkpoint_buffer.append(\n                    row\n                )\n\n                completed_keys.add(\n                    key\n                )\n\n                successful += 1\n\n                overall_progress.update(\n                    1\n                )\n\n                overall_progress.set_postfix(\n                    class_name=class_name,\n                    group=group_name,\n                    done=f\"{successful:,}\",\n                    failed=f\"{failed:,}\"\n                )\n\n                # ------------------------------------------------\n                # CHECKPOINT EVERY 200\n                # ------------------------------------------------\n\n                if (\n                    successful\n                    % CHECKPOINT_EVERY\n                    == 0\n                ):\n\n                    flush_checkpoint()\n\n                    print(\n                        f\"\\n[CHECKPOINT] \"\n                        f\"{successful:,} / \"\n                        f\"{total_jobs:,}\"\n                    )\n\n            except Exception as exc:\n\n                failed += 1\n\n                print(\n                    f\"\\nWARNING: \"\n                    f\"{img_id} / \"\n                    f\"{class_name} / \"\n                    f\"{group_name} \"\n                    f\"failed:\"\n                )\n\n                print(\n                    repr(exc)\n                )\n\n                overall_progress.update(\n                    1\n                )\n\n        # --------------------------------------------------------\n        # CHECKPOINT AFTER EVERY GROUP\n        # --------------------------------------------------------\n\n        flush_checkpoint()\n\n        print(\n            f\"[GROUP CHECKPOINT] \"\n            f\"{class_name} / {group_name} — \"\n            f\"{successful:,} completed\"\n        )\n\n\n# ================================================================\n# FINAL CHECKPOINT FLUSH\n# ================================================================\n\nflush_checkpoint()\n\noverall_progress.close()\n\n\n# ================================================================\n# LOAD COMPLETE CHECKPOINT\n# ================================================================\n\nassert os.path.exists(\n    POP_CHECKPOINT\n), \"Checkpoint file was not created.\"\n\n\npopulation_df = pd.read_csv(\n    POP_CHECKPOINT\n)\n\n\n# ================================================================\n# REMOVE ANY DUPLICATE KEYS\n# ================================================================\n\npopulation_df = (\n    population_df\n    .drop_duplicates(\n        subset=[\n            \"img_id\",\n            \"class\",\n            \"group\"\n        ],\n        keep=\"last\"\n    )\n    .reset_index(\n        drop=True\n    )\n)\n\n\n# ================================================================\n# SAVE FINAL POPULATION\n# ================================================================\n\npopulation_df.to_csv(\n    POP_FINAL,\n    index=False\n)\n\n\n# ================================================================\n# SUMMARY\n# ================================================================\n\nsummary_df = (\n    population_df\n    .groupby(\n        [\n            \"class\",\n            \"group\"\n        ],\n        as_index=False\n    )\n    .agg(\n        n=(\n            \"img_id\",\n            \"count\"\n        ),\n\n        probability_mean=(\n            \"probability\",\n            \"mean\"\n        ),\n\n        probability_std=(\n            \"probability\",\n            \"std\"\n        ),\n\n        aopc_mean=(\n            \"aopc\",\n            \"mean\"\n        ),\n\n        aopc_std=(\n            \"aopc\",\n            \"std\"\n        ),\n\n        max_sensitivity_mean=(\n            \"max_sensitivity\",\n            \"mean\"\n        ),\n\n        max_sensitivity_std=(\n            \"max_sensitivity\",\n            \"std\"\n        )\n    )\n)\n\n\nsummary_df.to_csv(\n    POP_SUMMARY,\n    index=False\n)\n\n\n# ================================================================\n# FINAL VALIDATION\n# ================================================================\n\nexpected_rows = (\n    len(test_df)\n    * len(LABEL_COLS)\n)\n\nprint()\nprint(\"=\" * 80)\nprint(\"RESNET-50 AOPC + MAX-SENSITIVITY COMPLETE\")\nprint(\"=\" * 80)\n\nprint(\n    f\"Expected rows: \"\n    f\"{expected_rows:,}\"\n)\n\nprint(\n    f\"Actual rows:   \"\n    f\"{len(population_df):,}\"\n)\n\nprint(\n    f\"Successful in this run: \"\n    f\"{successful - completed_jobs:,}\"\n)\n\nprint(\n    f\"Failed: \"\n    f\"{failed:,}\"\n)\n\nprint()\n\nprint(\n    \"Output columns:\"\n)\n\nprint(\n    population_df.columns.tolist()\n)\n\nprint()\n\nprint(\n    \"Final population:\",\n    POP_FINAL\n)\n\nprint(\n    \"Summary:\",\n    POP_SUMMARY\n)\n\nprint(\n    \"Checkpoint:\",\n    POP_CHECKPOINT\n)\n\nprint(\"=\" * 80)\n\n\n# ------------------------------------------------\n# Population completeness check\n# ------------------------------------------------\n\nif len(population_df) == expected_rows:\n\n    print(\n        \"✅ FULL POPULATION COMPLETE\"\n    )\n\nelse:\n\n    print(\n        \"⚠️ POPULATION INCOMPLETE\"\n    )\n\n    print(\n        f\"Missing approximately \"\n        f\"{expected_rows - len(population_df):,} rows.\"\n    )\n\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-29T11:21:04.723499Z","iopub.execute_input":"2026-09-29T11:21:04.723921Z","iopub.status.idle":"2026-09-29T17:25:01.912752Z","shell.execute_reply.started":"2026-09-29T11:21:04.72389Z","shell.execute_reply":"2026-09-29T17:25:01.910163Z"}},"outputs":[],"execution_count":null}]}