{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":39272,"databundleVersionId":4629629,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":14521123,"sourceType":"datasetVersion","datasetId":9274425}],"dockerImageVersionId":31234,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"**Install DICOM decoding dependencies so the notebook can read compressed mammography DICOM images. This adds pylibjpeg plugins that enable pydicom to decompress formats like JPEG Lossless and JPEG 2000 when accessing**","metadata":{}},{"cell_type":"code","source":"!pip -q install -U pydicom pylibjpeg pylibjpeg-libjpeg pylibjpeg-openjpeg\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:03.536214Z","iopub.execute_input":"2026-01-17T07:30:03.536392Z","iopub.status.idle":"2026-01-17T07:30:09.158657Z","shell.execute_reply.started":"2026-01-17T07:30:03.536373Z","shell.execute_reply":"2026-01-17T07:30:09.157736Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Set up the environment: import dependencies, fix random seeds for reproducibility, define a central configuration (hyperparameters and paths), and detect whether training will run on GPU (cuda) or CPU.**","metadata":{}},{"cell_type":"code","source":"import os, gc, math, time, random\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nimport cv2\nimport pydicom\n\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.metrics import accuracy_score, f1_score, confusion_matrix\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\n\nimport timm\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\n\ndef seed_everything(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = False\n    torch.backends.cudnn.benchmark = True\n\nseed_everything(42)\n\n\n@dataclass\nclass CFG:\n    seed: int = 42\n\n    # speed + stability for RSNA DICOM\n    img_size: int = 320        # 512 is slower; 384 is usually best tradeoff on Kaggle\n    batch_size: int = 8          # if OOM -> 4\n    grad_accum: int = 2          # if batch=4 -> 4\n    num_workers: int = 8         # if dataloader crashes -> 2, if stable -> try 8\n\n    # training\n    epochs: int = 6             \n    lr: float = 2e-4\n    wd: float = 1e-4\n\n    # CNN backbone\n    model_name: str = \"tf_efficientnet_b0.ns_jft_in1k\"\n\n    # folds\n    n_folds: int = 5\n    fold: int = 0\n\n    # mixed precision\n    use_amp: bool = True\n\n    device: str = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n\nCFG = CFG()\n\nprint(\"torch:\", torch.__version__)\nprint(\"cuda available:\", torch.cuda.is_available())\nif torch.cuda.is_available():\n    print(\"GPU:\", torch.cuda.get_device_name(0))\nprint(\"device:\", CFG.device)\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:18.210623Z","iopub.execute_input":"2026-01-17T07:30:18.211198Z","iopub.status.idle":"2026-01-17T07:30:32.559961Z","shell.execute_reply.started":"2026-01-17T07:30:18.211162Z","shell.execute_reply":"2026-01-17T07:30:32.559151Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Load RSNA metadata and prepare labels for breast density classification. We filter training rows that have a known density (A/B/C/D), convert the letter label into a numeric class ID (0–3), and show the class distribution used for training.**","metadata":{}},{"cell_type":"code","source":"INPUT = Path(\"/kaggle/input/rsna-breast-cancer-detection\")\ntrain_images_dir = INPUT / \"train_images\"\n\ntrain_df = pd.read_csv(INPUT / \"train.csv\")\ntest_df  = pd.read_csv(INPUT / \"test.csv\")\n\nprint(\"train_df:\", train_df.shape, \"test_df:\", test_df.shape)\nprint(\"train columns:\", train_df.columns.tolist())\n\n# density task\ndf = train_df.copy()\ndf = df[df[\"density\"].notna()].reset_index(drop=True)\ndf[\"density\"] = df[\"density\"].astype(str)\n\ndens2id = {\"A\":0, \"B\":1, \"C\":2, \"D\":3}\nid2dens = {v:k for k,v in dens2id.items()}\n\ndf = df[df[\"density\"].isin(dens2id.keys())].reset_index(drop=True)\ndf[\"target\"] = df[\"density\"].map(dens2id).astype(int)\n\nprint(\"density counts:\\n\", df[\"density\"].value_counts())\nprint(\"rows used:\", len(df))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:39.707137Z","iopub.execute_input":"2026-01-17T07:30:39.707784Z","iopub.status.idle":"2026-01-17T07:30:39.85311Z","shell.execute_reply.started":"2026-01-17T07:30:39.707755Z","shell.execute_reply":"2026-01-17T07:30:39.852458Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Create patient-level cross-validation splits (GroupKFold). We divide the dataset into several folds while keeping all images from the same patient inside a single fold, so the model is evaluated fairly without patient leakage.**","metadata":{}},{"cell_type":"code","source":"df[\"fold\"] = -1\ngkf = GroupKFold(n_splits=CFG.n_folds)\n\nfor fold, (tr_idx, va_idx) in enumerate(gkf.split(df, df[\"target\"], groups=df[\"patient_id\"])):\n    df.loc[va_idx, \"fold\"] = fold\n\nprint(df[\"fold\"].value_counts())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:43.072948Z","iopub.execute_input":"2026-01-17T07:30:43.073219Z","iopub.status.idle":"2026-01-17T07:30:43.107349Z","shell.execute_reply.started":"2026-01-17T07:30:43.073193Z","shell.execute_reply":"2026-01-17T07:30:43.106517Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Convert mammography DICOM files into standardized 8-bit images (resize + normalization). Define image preprocessing: training uses light random augmentations (flip/rotate/brightness) to improve robustness, while validation uses only normalization for fair evaluation.**","metadata":{}},{"cell_type":"code","source":"def read_dicom_as_uint8(path: Path, img_size: int) -> np.ndarray:\n    dcm = pydicom.dcmread(str(path))\n    img = dcm.pixel_array.astype(np.float32)\n\n    # handle MONOCHROME1 inversion\n    if getattr(dcm, \"PhotometricInterpretation\", \"\") == \"MONOCHROME1\":\n        img = img.max() - img\n\n    # percentile clipping to reduce extreme values\n    lo, hi = np.percentile(img, (0.5, 99.5))\n    img = np.clip(img, lo, hi)\n\n    # normalize to 0..255 uint8\n    img = (img - img.min()) / (img.max() - img.min() + 1e-6)\n    img = (img * 255.0).astype(np.uint8)\n\n    # resize\n    img = cv2.resize(img, (img_size, img_size), interpolation=cv2.INTER_AREA)\n    return img\n\n\ndef get_transforms(train: bool):\n    if train:\n        return A.Compose([\n            A.HorizontalFlip(p=0.5),\n            A.Affine(scale=(0.97, 1.03), translate_percent=(0.0, 0.01), rotate=(-5, 5), p=0.6),\n            A.RandomBrightnessContrast(brightness_limit=0.12, contrast_limit=0.12, p=0.4),\n            # intentionally no GaussNoise to avoid parameter version mismatch\n            A.Normalize(mean=(0.5,0.5,0.5), std=(0.25,0.25,0.25)),\n            ToTensorV2(),\n        ])\n    else:\n        return A.Compose([\n            A.Normalize(mean=(0.5,0.5,0.5), std=(0.25,0.25,0.25)),\n            ToTensorV2(),\n        ])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:45.655906Z","iopub.execute_input":"2026-01-17T07:30:45.656374Z","iopub.status.idle":"2026-01-17T07:30:45.663488Z","shell.execute_reply.started":"2026-01-17T07:30:45.656337Z","shell.execute_reply":"2026-01-17T07:30:45.662857Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Create the data pipeline. The Dataset reads one DICOM image at a time, converts it to a resized 3-channel tensor, applies preprocessing/augmentations, and returns (image, label). DataLoaders then batch and shuffle the data and use multiple CPU workers to feed the GPU efficiently during training.**","metadata":{}},{"cell_type":"code","source":"class RSNADensityDataset(Dataset):\n    def __init__(self, df, images_dir: Path, img_size: int, train: bool):\n        self.df = df.reset_index(drop=True)\n        self.images_dir = images_dir\n        self.img_size = img_size\n        self.train = train\n        self.tfms = get_transforms(train)\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        r = self.df.iloc[idx]\n        dcm_path = self.images_dir / str(r.patient_id) / f\"{r.image_id}.dcm\"\n\n        img = read_dicom_as_uint8(dcm_path, self.img_size)   # (H,W) uint8\n        img3 = np.stack([img, img, img], axis=-1)            # (H,W,3)\n\n        x = self.tfms(image=img3)[\"image\"]                  # tensor (3,H,W)\n        y = torch.tensor(int(r.target), dtype=torch.long)   # 0..3\n        return x, y\n\n\ndef make_loaders(tr_df, va_df):\n    train_ds = RSNADensityDataset(tr_df, train_images_dir, CFG.img_size, train=True)\n    valid_ds = RSNADensityDataset(va_df, train_images_dir, CFG.img_size, train=False)\n\n    train_loader = DataLoader(\n        train_ds,\n        batch_size=CFG.batch_size,\n        shuffle=True,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=True,\n        persistent_workers=(CFG.num_workers > 0),\n        prefetch_factor=4 if CFG.num_workers > 0 else None,\n    )\n\n    valid_loader = DataLoader(\n        valid_ds,\n        batch_size=CFG.batch_size,\n        shuffle=False,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=False,\n        persistent_workers=(CFG.num_workers > 0),\n        prefetch_factor=4 if CFG.num_workers > 0 else None,\n    )\n    return train_loader, valid_loader\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:55.058238Z","iopub.execute_input":"2026-01-17T07:30:55.058563Z","iopub.status.idle":"2026-01-17T07:30:55.067663Z","shell.execute_reply.started":"2026-01-17T07:30:55.058535Z","shell.execute_reply":"2026-01-17T07:30:55.066599Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Define the CNN classifier (4 classes A/B/C/D) using a pretrained backbone from timm (EfficientNet/ResNet). Handle class imbalance by computing class weights, and create all training components: loss (cross-entropy), optimizer (AdamW), learning-rate scheduler (cosine annealing), and mixed-precision scaler for faster GPU training.**","metadata":{}},{"cell_type":"code","source":"NUM_CLASSES = 4\n\nclass Model(nn.Module):\n    def __init__(self, model_name: str, num_classes: int = 4):\n        super().__init__()\n        self.net = timm.create_model(model_name, pretrained=True, num_classes=num_classes)\n\n    def forward(self, x):\n        return self.net(x)\n\n\ndef class_weights_from_targets(targets, num_classes=4):\n    counts = np.bincount(targets, minlength=num_classes).astype(np.float32)\n    w = counts.sum() / (counts + 1e-6)\n    w = w / w.mean()\n    return torch.tensor(w, dtype=torch.float32)\n\n\ndef build_components(tr_df):\n    model = Model(CFG.model_name, NUM_CLASSES).to(CFG.device)\n\n    w = class_weights_from_targets(tr_df[\"target\"].values, NUM_CLASSES).to(CFG.device)\n    criterion = nn.CrossEntropyLoss(weight=w)\n\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.wd)\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=CFG.epochs)\n\n    scaler = torch.amp.GradScaler(\"cuda\", enabled=(CFG.use_amp and CFG.device == \"cuda\"))\n    return model, criterion, optimizer, scheduler, scaler\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:30:58.912373Z","iopub.execute_input":"2026-01-17T07:30:58.91311Z","iopub.status.idle":"2026-01-17T07:30:58.920025Z","shell.execute_reply.started":"2026-01-17T07:30:58.913059Z","shell.execute_reply":"2026-01-17T07:30:58.919215Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Training and evaluation loops. train_one_epoch runs one full training pass: forward prediction, compute loss, backpropagate with mixed precision (GradScaler), and update weights (with gradient accumulation to simulate a larger batch). evaluate runs inference on the validation set (no training) and reports accuracy, macro-F1, and the confusion matrix.**","metadata":{}},{"cell_type":"code","source":"def train_one_epoch(model, loader, criterion, optimizer, scaler):\n    model.train()\n    optimizer.zero_grad(set_to_none=True)\n    running_loss = 0.0\n\n    for step, (x, y) in enumerate(loader):\n        x = x.to(CFG.device, non_blocking=True)\n        y = y.to(CFG.device, non_blocking=True)\n\n        with torch.amp.autocast(\"cuda\", enabled=(CFG.use_amp and CFG.device == \"cuda\")):\n            logits = model(x)\n            loss = criterion(logits, y) / CFG.grad_accum\n\n        scaler.scale(loss).backward()\n\n        if (step + 1) % CFG.grad_accum == 0:\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad(set_to_none=True)\n\n        running_loss += loss.item() * CFG.grad_accum\n\n    return running_loss / max(len(loader), 1)\n\n\n@torch.no_grad()\ndef evaluate(model, loader):\n    model.eval()\n    ys, ps = [], []\n\n    for x, y in loader:\n        x = x.to(CFG.device, non_blocking=True)\n        logits = model(x)\n        pred = torch.argmax(logits, dim=1).detach().cpu().numpy()\n\n        ys.append(y.numpy())\n        ps.append(pred)\n\n    ys = np.concatenate(ys)\n    ps = np.concatenate(ps)\n\n    acc = accuracy_score(ys, ps)\n    f1 = f1_score(ys, ps, average=\"macro\")\n    cm = confusion_matrix(ys, ps)\n    return acc, f1, cm\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:31:02.354888Z","iopub.execute_input":"2026-01-17T07:31:02.355193Z","iopub.status.idle":"2026-01-17T07:31:02.363355Z","shell.execute_reply.started":"2026-01-17T07:31:02.355164Z","shell.execute_reply":"2026-01-17T07:31:02.362789Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Smoke test: run a tiny end-to-end training and evaluation on a small subset (200 train + 200 validation) to verify the full pipeline (data loading, DICOM decoding, augmentations, CNN forward/backward, metrics) works correctly before launching long training.**","metadata":{}},{"cell_type":"code","source":"fold = CFG.fold\n\ntr_small = df[df.fold != fold].sample(200, random_state=CFG.seed).reset_index(drop=True)\nva_small = df[df.fold == fold].sample(200, random_state=CFG.seed).reset_index(drop=True)\n\ntrain_loader, valid_loader = make_loaders(tr_small, va_small)\nmodel, criterion, optimizer, scheduler, scaler = build_components(tr_small)\n\nt0 = time.time()\nloss = train_one_epoch(model, train_loader, criterion, optimizer, scaler)\nacc, f1, cm = evaluate(model, valid_loader)\nprint(\"smoke loss:\", loss)\nprint(\"smoke acc:\", acc, \"smoke macro-f1:\", f1)\nprint(\"smoke confusion matrix:\\n\", cm)\nprint(\"time (s):\", time.time() - t0)\n\ndel model\ngc.collect()\ntorch.cuda.empty_cache()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:31:18.252066Z","iopub.execute_input":"2026-01-17T07:31:18.252335Z","iopub.status.idle":"2026-01-17T07:34:26.57501Z","shell.execute_reply.started":"2026-01-17T07:31:18.252313Z","shell.execute_reply":"2026-01-17T07:34:26.574107Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Create the train/validation split for the selected fold. All patients in the chosen fold are used as validation, and the remaining folds are used for training. Then build DataLoaders and print dataset sizes and number of batches per epoch as a sanity check.**","metadata":{}},{"cell_type":"code","source":"fold = CFG.fold\n\ntr_df = df[df.fold != fold].reset_index(drop=True)\nva_df = df[df.fold == fold].reset_index(drop=True)\n\ntrain_loader, valid_loader = make_loaders(tr_df, va_df)\n\nprint(\"train rows:\", len(tr_df), \"val rows:\", len(va_df))\nprint(\"batches per epoch:\", len(train_loader))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T14:32:16.677379Z","iopub.execute_input":"2026-01-16T14:32:16.678431Z","iopub.status.idle":"2026-01-16T14:32:16.710653Z","shell.execute_reply.started":"2026-01-16T14:32:16.678385Z","shell.execute_reply":"2026-01-16T14:32:16.709679Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Estimate runtime before full training. We benchmarked the data pipeline by timing 100 batches and extrapolated the duration of one epoch and the total training time. Earlier (without PNG cache), the pipeline took ~3.17 s per batch, with ~2946 batches per epoch, giving ~155.6 minutes per epoch. This corresponds to ~15.56 hours for 1 fold at 6 epochs, ~46.67 hours for 3 folds, and ~77.79 hours for 5 folds (add ~10–25% for validation/overhead).**","metadata":{}},{"cell_type":"code","source":"\nimport time\n\ndef estimate_epoch_time_from_loader(loader, n_batches=100):\n    n_batches = min(n_batches, len(loader))\n    it = iter(loader)\n\n    t0 = time.time()\n    for _ in range(n_batches):\n        x, y = next(it)\n    dt = time.time() - t0\n\n    sec_per_batch = dt / max(n_batches, 1)\n    total_batches = len(loader)\n    est_epoch_sec = sec_per_batch * total_batches\n\n    print(\"measured batches:\", n_batches)\n    print(\"sec per batch (data pipeline):\", round(sec_per_batch, 3))\n    print(\"batches per epoch:\", total_batches)\n    print(\"estimated epoch time (minutes):\", round(est_epoch_sec / 60, 1))\n    return sec_per_batch, est_epoch_sec\n\nsec_per_batch, est_epoch_sec = estimate_epoch_time_from_loader(train_loader, n_batches=100)\n\n# Rough training time estimates for different fold counts (ignores validation cost, so add ~10-25%)\nfor folds in [1, 3, 5]:\n    total_hours = (est_epoch_sec * CFG.epochs * folds) / 3600\n    print(\"approx training hours for\", folds, \"fold(s) at\", CFG.epochs, \"epochs:\", round(total_hours, 2))\nprint(\"note: add ~10-25% extra for validation and overhead\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"******************************","metadata":{}},{"cell_type":"markdown","source":"**Create a PNG cache to speed up training. We convert each DICOM mammogram once into a resized 8-bit PNG (cache_png/<patient_id>/<image_id>.png) using the same preprocessing as training. This avoids repeated DICOM decompression during each epoch and significantly accelerates the data pipeline.**","metadata":{}},{"cell_type":"code","source":"from tqdm import tqdm\n\nCACHE = Path(\"/kaggle/working/cache_png\")\nCACHE.mkdir(parents=True, exist_ok=True)\n\ndef cache_png_for_df(df_part: pd.DataFrame, cache_dir: Path, img_size: int):\n    missing = 0\n    for r in tqdm(df_part.itertuples(index=False), total=len(df_part)):\n        out_dir = cache_dir / str(r.patient_id)\n        out_dir.mkdir(parents=True, exist_ok=True)\n        out_path = out_dir / f\"{r.image_id}.png\"\n        if out_path.exists():\n            continue\n\n        dcm_path = train_images_dir / str(r.patient_id) / f\"{r.image_id}.dcm\"\n        if not dcm_path.exists():\n            missing += 1\n            continue\n\n        img = read_dicom_as_uint8(dcm_path, img_size)\n        cv2.imwrite(str(out_path), img)\n\n    print(\"missing dicoms:\", missing)\n\nfold = CFG.fold\ntr_df = df[df.fold != fold].reset_index(drop=True)\nva_df = df[df.fold == fold].reset_index(drop=True)\n\ncache_png_for_df(tr_df, CACHE, CFG.img_size)\ncache_png_for_df(va_df, CACHE, CFG.img_size)\n\nprint(\"cache path:\", CACHE)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T14:32:25.639361Z","iopub.execute_input":"2026-01-16T14:32:25.639745Z","iopub.status.idle":"2026-01-16T18:48:11.103219Z","shell.execute_reply.started":"2026-01-16T14:32:25.639708Z","shell.execute_reply":"2026-01-16T18:48:11.097748Z"},"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Check that the saved PNG cache dataset is correctly mounted as a Kaggle input. We confirm the cache folder exists, count the number of cached PNG images, and display the total cache size on disk.**","metadata":{}},{"cell_type":"code","source":"from pathlib import Path\nCACHE = Path(\"/kaggle/input/png-dicom-projet-ai/cache_png\")\nprint(\"cache exists:\", CACHE.exists())\n!find /kaggle/input/png-dicom-projet-ai/cache_png -name \"*.png\" | wc -l\n!du -sh /kaggle/input/png-dicom-projet-ai/cache_png\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:35:07.258671Z","iopub.execute_input":"2026-01-17T07:35:07.259498Z","iopub.status.idle":"2026-01-17T07:35:59.186839Z","shell.execute_reply.started":"2026-01-17T07:35:07.259459Z","shell.execute_reply":"2026-01-17T07:35:59.186098Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Define a fast PNG-based Dataset and DataLoaders. Instead of decoding DICOM files during training, we load precomputed resized PNG images from the cache (/kaggle/input/.../cache_png). This significantly speeds up the data pipeline while keeping the same augmentations and label mapping (A/B/C/D → 0–3).**","metadata":{}},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\n\nclass RSNADensityPNGDataset(Dataset):\n    def __init__(self, df, cache_dir: Path, img_size: int, train: bool):\n        self.df = df.reset_index(drop=True)\n        self.cache_dir = cache_dir\n        self.img_size = img_size\n        self.train = train\n        self.tfms = get_transforms(train)\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        r = self.df.iloc[idx]\n        png_path = self.cache_dir / str(r.patient_id) / f\"{r.image_id}.png\"\n\n        img = cv2.imread(str(png_path), cv2.IMREAD_GRAYSCALE)\n        if img is None:\n            # should not happen if cache is complete\n            raise FileNotFoundError(str(png_path))\n\n        if img.shape[0] != self.img_size or img.shape[1] != self.img_size:\n            img = cv2.resize(img, (self.img_size, self.img_size), interpolation=cv2.INTER_AREA)\n\n        img3 = np.stack([img, img, img], axis=-1)\n        x = self.tfms(image=img3)[\"image\"]\n        y = torch.tensor(int(r.target), dtype=torch.long)\n        return x, y\n\ndef make_png_loaders(tr_df, va_df):\n    train_ds = RSNADensityPNGDataset(tr_df, CACHE, CFG.img_size, train=True)\n    valid_ds = RSNADensityPNGDataset(va_df, CACHE, CFG.img_size, train=False)\n\n    train_loader = DataLoader(\n        train_ds,\n        batch_size=CFG.batch_size,\n        shuffle=True,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=True,\n        persistent_workers=(CFG.num_workers > 0),\n        prefetch_factor=4 if CFG.num_workers > 0 else None,\n    )\n    valid_loader = DataLoader(\n        valid_ds,\n        batch_size=CFG.batch_size,\n        shuffle=False,\n        num_workers=CFG.num_workers,\n        pin_memory=True,\n        drop_last=False,\n        persistent_workers=(CFG.num_workers > 0),\n        prefetch_factor=4 if CFG.num_workers > 0 else None,\n    )\n    return train_loader, valid_loader\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:36:12.06316Z","iopub.execute_input":"2026-01-17T07:36:12.063625Z","iopub.status.idle":"2026-01-17T07:36:12.088488Z","shell.execute_reply.started":"2026-01-17T07:36:12.063571Z","shell.execute_reply":"2026-01-17T07:36:12.087346Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Set training hyperparameters and runtime settings before training (image size, batch size, gradient accumulation, number of data-loading workers, epochs, learning rate, CNN backbone, and mixed precision). Automatically select GPU (cuda) if available and print the selected device for verification.**","metadata":{}},{"cell_type":"code","source":"CFG.img_size = 384        # if time tight -> 320\nCFG.batch_size = 16       # if OOM -> 8 (and increase grad_accum)\nCFG.grad_accum = 1        # if batch_size=8 -> set 2\nCFG.num_workers = 4       # 4 is usually stable; try 8 if you want\nCFG.epochs = 6            # you can raise to 8 if it’s fast\nCFG.lr = 2e-4\nCFG.model_name = \"tf_efficientnet_b0.ns_jft_in1k\"\nCFG.use_amp = True\nCFG.device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(\"device:\", CFG.device)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:36:16.512602Z","iopub.execute_input":"2026-01-17T07:36:16.513407Z","iopub.status.idle":"2026-01-17T07:36:16.51974Z","shell.execute_reply.started":"2026-01-17T07:36:16.513366Z","shell.execute_reply":"2026-01-17T07:36:16.518822Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Benchmark the data pipeline speed. This function times loading and transferring a fixed number of batches (with a short warmup), then extrapolates the estimated duration of one full epoch based on the number of batches per epoch. This helps plan folds/epochs within the Kaggle time limit.**","metadata":{}},{"cell_type":"code","source":"import time\nimport torch\n\ndef estimate_epoch_time_from_loader(loader, n_batches=200, device=None):\n    if device is None:\n        device = CFG.device if \"CFG\" in globals() else (\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n    it = iter(loader)\n\n    # warmup (a few batches)\n    warm = min(10, n_batches)\n    for _ in range(warm):\n        x, y = next(it)\n        x = x.to(device, non_blocking=True)\n        y = y.to(device, non_blocking=True)\n\n    # measure\n    t0 = time.time()\n    n = 0\n    for _ in range(n_batches):\n        try:\n            x, y = next(it)\n        except StopIteration:\n            break\n        x = x.to(device, non_blocking=True)\n        y = y.to(device, non_blocking=True)\n        n += 1\n\n    dt = time.time() - t0\n    sec_per_batch = dt / max(1, n)\n\n    batches_per_epoch = len(loader)\n    est_epoch_sec = sec_per_batch * batches_per_epoch\n\n    print(\"measured batches:\", n)\n    print(\"sec per batch (data pipeline):\", round(sec_per_batch, 3))\n    print(\"batches per epoch:\", batches_per_epoch)\n    print(\"estimated epoch time (minutes):\", round(est_epoch_sec / 60, 1))\n\n    return sec_per_batch, est_epoch_sec\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:38:36.592581Z","iopub.execute_input":"2026-01-17T07:38:36.592956Z","iopub.status.idle":"2026-01-17T07:38:36.60035Z","shell.execute_reply.started":"2026-01-17T07:38:36.592924Z","shell.execute_reply":"2026-01-17T07:38:36.599439Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Estimate epoch runtime using the PNG cache. We create train/validation loaders for the selected fold (reading cached PNGs) and benchmark 200 batches to compute seconds per batch and the estimated duration of a full training epoch.**","metadata":{}},{"cell_type":"code","source":"fold = CFG.fold\ntr_df = df[df.fold != fold].reset_index(drop=True)\nva_df = df[df.fold == fold].reset_index(drop=True)\n\ntrain_loader, valid_loader = make_png_loaders(tr_df, va_df)\n\nsec_per_batch, est_epoch_sec = estimate_epoch_time_from_loader(train_loader, n_batches=200)\nprint(\"sec/batch:\", sec_per_batch, \"epoch minutes:\", est_epoch_sec/60)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:38:42.612933Z","iopub.execute_input":"2026-01-17T07:38:42.613254Z","iopub.status.idle":"2026-01-17T07:38:57.992056Z","shell.execute_reply.started":"2026-01-17T07:38:42.613227Z","shell.execute_reply":"2026-01-17T07:38:57.990577Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Benchmark real training speed (full compute). We run a short training loop for a fixed number of steps (forward + loss + backward + optimizer update, with mixed precision if enabled) and measure seconds per step. We then extrapolate the expected duration of one full epoch to plan folds/epochs within the Kaggle time budget.**","metadata":{}},{"cell_type":"code","source":"import time\nimport torch\nimport numpy as np\n\ndef benchmark_train_steps(model, loader, criterion, optimizer, scaler, n_steps=200):\n    model.train()\n    t0 = time.time()\n    it = iter(loader)\n    steps = 0\n    losses = []\n\n    for _ in range(n_steps):\n        try:\n            x, y = next(it)\n        except StopIteration:\n            break\n\n        x = x.to(CFG.device, non_blocking=True)\n        y = y.to(CFG.device, non_blocking=True)\n\n        optimizer.zero_grad(set_to_none=True)\n\n        with torch.amp.autocast(device_type=\"cuda\", enabled=(CFG.use_amp and CFG.device==\"cuda\")):\n            logits = model(x)\n            loss = criterion(logits, y)\n\n        if CFG.use_amp and CFG.device == \"cuda\":\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n        else:\n            loss.backward()\n            optimizer.step()\n\n        losses.append(loss.item())\n        steps += 1\n\n    dt = time.time() - t0\n    sec_per_step = dt / max(1, steps)\n    est_epoch_min = (sec_per_step * len(loader)) / 60\n\n    print(\"steps:\", steps)\n    print(\"sec/step (full train compute):\", round(sec_per_step, 3))\n    print(\"estimated epoch time (minutes):\", round(est_epoch_min, 1))\n    print(\"mean loss:\", round(float(np.mean(losses)), 4))\n    return sec_per_step, est_epoch_min\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:40:35.442262Z","iopub.execute_input":"2026-01-17T07:40:35.442698Z","iopub.status.idle":"2026-01-17T07:40:35.451456Z","shell.execute_reply.started":"2026-01-17T07:40:35.442652Z","shell.execute_reply":"2026-01-17T07:40:35.450893Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Measure real training runtime for the selected fold using the PNG cache. We build the train/validation split, create fast PNG DataLoaders, initialize the CNN and training components, then run a short benchmark (200 training steps) to estimate the true epoch duration (forward/backward + optimizer updates) under the current hyperparameters.**","metadata":{}},{"cell_type":"code","source":"fold = CFG.fold\ntr_df = df[df.fold != fold].reset_index(drop=True)\nva_df = df[df.fold == fold].reset_index(drop=True)\ntrain_loader, valid_loader = make_png_loaders(tr_df, va_df)\n\nmodel, criterion, optimizer, scheduler, scaler = build_components(tr_df)\n\n_ = benchmark_train_steps(model, train_loader, criterion, optimizer, scaler, n_steps=200)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:40:48.189771Z","iopub.execute_input":"2026-01-17T07:40:48.190041Z","iopub.status.idle":"2026-01-17T07:41:59.677167Z","shell.execute_reply.started":"2026-01-17T07:40:48.19002Z","shell.execute_reply":"2026-01-17T07:41:59.676067Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Fix the final training configuration. We set the number of epochs per fold, data-loading workers, and enable mixed precision (AMP). Then we choose which folds to train (here folds 0–2 for 3-fold cross-validation) and print the plan to confirm the device and settings before starting training.**","metadata":{}},{"cell_type":"code","source":"CFG.epochs = 6\nCFG.num_workers = 4   # stable; 8 is ok too\nCFG.use_amp = True\nCFG.device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nFOLDS_TO_TRAIN = [0, 1, 2]\nprint(\"device:\", CFG.device, \"folds:\", FOLDS_TO_TRAIN, \"epochs:\", CFG.epochs)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:44:18.115052Z","iopub.execute_input":"2026-01-17T07:44:18.116302Z","iopub.status.idle":"2026-01-17T07:44:18.123386Z","shell.execute_reply.started":"2026-01-17T07:44:18.116248Z","shell.execute_reply":"2026-01-17T07:44:18.122527Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Train one cross-validation fold end-to-end using the PNG cache. For the selected fold, we build train/validation splits, initialize the CNN and training components, train for CFG.epochs, evaluate after each epoch (accuracy, macro-F1, confusion matrix), and save checkpoints. We always save the latest model (last_...) for crash safety and also save the best model (best_...) based on validation macro-F1.**","metadata":{}},{"cell_type":"code","source":"import time\nfrom pathlib import Path\nimport numpy as np\n\ndef train_one_fold(fold):\n    tr_df = df[df.fold != fold].reset_index(drop=True)\n    va_df = df[df.fold == fold].reset_index(drop=True)\n\n    train_loader, valid_loader = make_png_loaders(tr_df, va_df)\n    model, criterion, optimizer, scheduler, scaler = build_components(tr_df)\n\n    best_f1 = -1.0\n    best_path = Path(f\"/kaggle/working/best_density_fold{fold}.pth\")\n    last_path = Path(f\"/kaggle/working/last_density_fold{fold}.pth\")\n\n    for epoch in range(CFG.epochs):\n        t0 = time.time()\n\n        tr_loss = train_one_epoch(model, train_loader, criterion, optimizer, scaler)\n        val_acc, val_f1, cm = evaluate(model, valid_loader)\n        scheduler.step()\n\n        dt = time.time() - t0\n        print(f\"[fold {fold}] epoch {epoch+1}/{CFG.epochs} loss={tr_loss:.4f} val_acc={val_acc:.4f} val_f1={val_f1:.4f} time={dt/60:.1f} min\")\n        print(\"cm:\\n\", cm)\n\n        # always save last (crash-safe)\n        torch.save(\n            {\"model\": model.state_dict(), \"fold\": fold, \"epoch\": epoch+1, \"cfg\": CFG.__dict__, \"dens2id\": dens2id},\n            last_path\n        )\n\n        # save best\n        if val_f1 > best_f1:\n            best_f1 = val_f1\n            torch.save(\n                {\"model\": model.state_dict(), \"fold\": fold, \"epoch\": epoch+1, \"cfg\": CFG.__dict__, \"dens2id\": dens2id},\n                best_path\n            )\n\n    return best_f1, str(best_path), str(last_path)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:44:56.211994Z","iopub.execute_input":"2026-01-17T07:44:56.212341Z","iopub.status.idle":"2026-01-17T07:44:56.220821Z","shell.execute_reply.started":"2026-01-17T07:44:56.212288Z","shell.execute_reply":"2026-01-17T07:44:56.220143Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**writing output cell test**","metadata":{}},{"cell_type":"code","source":"from pathlib import Path\np = Path(\"/kaggle/working/WRITE_TEST.txt\")\np.write_text(\"write test ok\\n\")\nprint(\"wrote:\", p, \"exists:\", p.exists(), \"size:\", p.stat().st_size)\n!ls -lah /kaggle/working | head -50\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T09:10:13.773857Z","iopub.execute_input":"2026-01-17T09:10:13.774994Z","iopub.status.idle":"2026-01-17T09:10:13.966796Z","shell.execute_reply.started":"2026-01-17T09:10:13.774934Z","shell.execute_reply":"2026-01-17T09:10:13.966061Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Run cross-validation training over the selected folds. For each fold, we train a separate model using train_one_fold, save the best checkpoint, and record the best validation macro-F1. Finally, we print fold-by-fold results and report the mean ± standard deviation of macro-F1 across folds as an overall performance estimate.**","metadata":{}},{"cell_type":"code","source":"results = []\nfor fold in FOLDS_TO_TRAIN:\n    best_f1, best_path, last_path = train_one_fold(fold)\n    results.append((fold, best_f1, best_path, last_path))\n\nprint(\"\\n=== CV RESULTS ===\")\nfor fold, f1, best_path, last_path in results:\n    print(f\"fold {fold}: best_f1={f1:.4f} best={best_path}\")\n\nmean_f1 = float(np.mean([r[1] for r in results]))\nstd_f1  = float(np.std([r[1] for r in results]))\nprint(f\"mean best_f1 over folds: {mean_f1:.4f} ± {std_f1:.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T07:45:28.20138Z","iopub.execute_input":"2026-01-17T07:45:28.202122Z","iopub.status.idle":"2026-01-17T08:50:04.261778Z","shell.execute_reply.started":"2026-01-17T07:45:28.20209Z","shell.execute_reply":"2026-01-17T08:50:04.260934Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Display training info in the logs (current fold number and selected model backbone) to confirm the correct configuration is being used.**","metadata":{}},{"cell_type":"code","source":"print(f\"fold {fold} using model {CFG.model_name}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T08:55:38.833325Z","iopub.execute_input":"2026-01-17T08:55:38.83373Z","iopub.status.idle":"2026-01-17T08:55:38.839022Z","shell.execute_reply.started":"2026-01-17T08:55:38.83369Z","shell.execute_reply":"2026-01-17T08:55:38.838323Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Sanity-check file output. We write a small test file into /kaggle/working and list the directory contents to confirm that the notebook has permission to save outputs (models, reports, Grad-CAM images) during training.**","metadata":{}},{"cell_type":"code","source":"from pathlib import Path\np = Path(\"/kaggle/working/WRITE_TEST.txt\")\np.write_text(\"write test ok\\n\")\nprint(\"wrote:\", p, \"exists:\", p.exists(), \"size:\", p.stat().st_size)\n!ls -lah /kaggle/working | head -50\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T09:10:41.317178Z","iopub.execute_input":"2026-01-17T09:10:41.318017Z","iopub.status.idle":"2026-01-17T09:10:41.497955Z","shell.execute_reply.started":"2026-01-17T09:10:41.31798Z","shell.execute_reply":"2026-01-17T09:10:41.497153Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Evaluate saved fold models and export results. This cell automatically finds best_density_fold*.pth checkpoints, reloads each model, runs inference on the corresponding validation fold, and reports accuracy, macro-F1, per-class precision/recall/F1, and confusion matrices. It saves one confusion matrix CSV per fold plus a full text report (evaluation_report.txt) and a summary metrics table (fold_metrics.csv) in /kaggle/working/eval_outputs/.**","metadata":{}},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport torch\nfrom pathlib import Path\nfrom sklearn.metrics import classification_report, confusion_matrix, accuracy_score, f1_score\n\n# ---------- output dir ----------\nEVAL_DIR = Path(\"/kaggle/working/eval_outputs\")\nEVAL_DIR.mkdir(parents=True, exist_ok=True)\n\n# ---------- which folds exist ----------\nfold_ckpts = {}\nfor f in [0,1,2,3,4]:\n    p = Path(f\"/kaggle/working/best_density_fold{f}.pth\")\n    if p.exists():\n        fold_ckpts[f] = str(p)\n\nprint(\"Found checkpoints:\", fold_ckpts)\nassert len(fold_ckpts) > 0, \"No best_density_fold*.pth found in /kaggle/working\"\n\n# ---------- model build ----------\ndef build_eval_model():\n    return Model(CFG.model_name, NUM_CLASSES)\n\n@torch.no_grad()\ndef predict_on_loader(model, loader):\n    model.eval()\n    ys, ps = [], []\n    for x, y in loader:\n        x = x.to(CFG.device, non_blocking=True)\n        logits = model(x)\n        pred = logits.argmax(dim=1).detach().cpu().numpy()\n        ys.append(y.numpy())\n        ps.append(pred)\n    return np.concatenate(ys), np.concatenate(ps)\n\nlines = []\ndef log(s):\n    print(s)\n    lines.append(s)\n\nrows = []\n\nfor fold, ckpt_path in fold_ckpts.items():\n    tr_df = df[df.fold != fold].reset_index(drop=True)\n    va_df = df[df.fold == fold].reset_index(drop=True)\n    _, valid_loader = make_png_loaders(tr_df, va_df)\n\n    ckpt = torch.load(ckpt_path, map_location=\"cpu\")\n    model = build_eval_model()\n    model.load_state_dict(ckpt[\"model\"], strict=True)\n    model.to(CFG.device)\n\n    y_true, y_pred = predict_on_loader(model, valid_loader)\n\n    acc = accuracy_score(y_true, y_pred)\n    f1m = f1_score(y_true, y_pred, average=\"macro\")\n    cm  = confusion_matrix(y_true, y_pred)\n\n    cm_path = EVAL_DIR / f\"cm_fold{fold}.csv\"\n    pd.DataFrame(\n        cm,\n        index=[f\"true_{id2dens[i]}\" for i in range(NUM_CLASSES)],\n        columns=[f\"pred_{id2dens[i]}\" for i in range(NUM_CLASSES)],\n    ).to_csv(cm_path, index=True)\n\n    rep = classification_report(\n        y_true, y_pred,\n        target_names=[id2dens[i] for i in range(NUM_CLASSES)],\n        digits=4\n    )\n\n    log(\"\\n\" + \"=\"*90)\n    log(f\"FOLD {fold} | model={CFG.model_name} | img={CFG.img_size} | bs={CFG.batch_size}\")\n    log(f\"accuracy={acc:.4f}  macroF1={f1m:.4f}\")\n    log(\"confusion matrix (rows=true, cols=pred):\")\n    log(str(cm))\n    log(\"per-class report:\")\n    log(rep)\n    log(f\"saved confusion matrix: {cm_path}\")\n\n    rows.append({\"fold\": fold, \"accuracy\": acc, \"macroF1\": f1m})\n\nmetrics_df = pd.DataFrame(rows).sort_values(\"fold\")\nacc_mean, acc_std = metrics_df[\"accuracy\"].mean(), metrics_df[\"accuracy\"].std()\nf1_mean,  f1_std  = metrics_df[\"macroF1\"].mean(),  metrics_df[\"macroF1\"].std()\n\nlog(\"\\n\" + \"=\"*90)\nlog(\"SUMMARY (mean ± std over folds)\")\nlog(f\"accuracy: {acc_mean:.4f} ± {acc_std:.4f}\")\nlog(f\"macroF1 : {f1_mean:.4f} ± {f1_std:.4f}\")\nlog(\"=\"*90)\n\nreport_path = EVAL_DIR / \"evaluation_report.txt\"\nreport_path.write_text(\"\\n\".join(lines))\n\nmetrics_path = EVAL_DIR / \"fold_metrics.csv\"\nmetrics_df.to_csv(metrics_path, index=False)\n\nprint(\"\\nFILES NOW ON DISK:\")\n!ls -lah /kaggle/working/eval_outputs | head -200\nprint(\"\\nFirst lines of report:\")\n!head -40 /kaggle/working/eval_outputs/evaluation_report.txt\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T09:11:43.633838Z","iopub.execute_input":"2026-01-17T09:11:43.634376Z","iopub.status.idle":"2026-01-17T09:12:58.633139Z","shell.execute_reply.started":"2026-01-17T09:11:43.634341Z","shell.execute_reply":"2026-01-17T09:12:58.631989Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Generate explainable AI visualizations (XAI) using Grad-CAM. We load the best saved model checkpoint for one fold, automatically select the last convolution layer, and compute Grad-CAM heatmaps on 12 validation examples (~3 per density class A/B/C/D). The heatmaps are overlaid on the original mammograms to highlight regions that most influenced the model’s prediction, and the resulting images are saved to /kaggle/working/gradcam_outputs/.**","metadata":{}},{"cell_type":"code","source":"import random\nimport numpy as np\nimport cv2\nimport torch\nimport torch.nn.functional as F\nfrom pathlib import Path\n\nXAI_DIR = Path(\"/kaggle/working/gradcam_outputs\")\nXAI_DIR.mkdir(parents=True, exist_ok=True)\n\nfold_for_xai = sorted(list(fold_ckpts.keys()))[0]\nckpt_path = fold_ckpts[fold_for_xai]\nprint(\"Using fold for XAI:\", fold_for_xai, ckpt_path)\n\nclass GradCAM:\n    def __init__(self, model, target_layer):\n        self.model = model\n        self.target_layer = target_layer\n        self.activations = None\n        self.gradients = None\n        self.h1 = target_layer.register_forward_hook(self._forward_hook)\n        self.h2 = target_layer.register_full_backward_hook(self._backward_hook)\n\n    def _forward_hook(self, module, inp, out):\n        self.activations = out\n\n    def _backward_hook(self, module, grad_in, grad_out):\n        self.gradients = grad_out[0]\n\n    def close(self):\n        self.h1.remove()\n        self.h2.remove()\n\n    def __call__(self, x, class_idx=None):\n        self.model.zero_grad(set_to_none=True)\n        logits = self.model(x)\n        if class_idx is None:\n            class_idx = int(logits.argmax(dim=1).item())\n        score = logits[:, class_idx].sum()\n        score.backward()\n\n        grads = self.gradients\n        acts  = self.activations\n        weights = grads.mean(dim=(2,3), keepdim=True)\n        cam = (weights * acts).sum(dim=1, keepdim=True)\n        cam = F.relu(cam)\n        cam = cam - cam.min()\n        cam = cam / (cam.max() + 1e-6)\n        return cam.squeeze().detach().cpu().numpy(), logits.detach().cpu()\n\ndef find_last_conv_layer(model):\n    for name, m in reversed(list(model.named_modules())):\n        if isinstance(m, torch.nn.Conv2d):\n            return name, m\n    raise RuntimeError(\"No Conv2d layer found\")\n\ndef overlay_cam(gray_img, cam, out_size):\n    gray = cv2.resize(gray_img, (out_size, out_size), interpolation=cv2.INTER_AREA)\n    heat = (cam * 255).astype(np.uint8)\n    heat = cv2.resize(heat, (out_size, out_size), interpolation=cv2.INTER_LINEAR)\n    heat = cv2.applyColorMap(heat, cv2.COLORMAP_JET)\n    base = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)\n    overlay = cv2.addWeighted(base, 0.55, heat, 0.45, 0)\n    return overlay\n\n# load model\nckpt = torch.load(ckpt_path, map_location=\"cpu\")\nmodel = Model(CFG.model_name, NUM_CLASSES)\nmodel.load_state_dict(ckpt[\"model\"], strict=True)\nmodel.to(CFG.device)\nmodel.eval()\n\nlayer_name, target_layer = find_last_conv_layer(model)\nprint(\"Grad-CAM layer:\", layer_name)\n\nva_df = df[df.fold == fold_for_xai].reset_index(drop=True)\n\n# pick 12 examples ~3 per class\npicked = []\nfor cls in [0,1,2,3]:\n    idxs = va_df.index[va_df[\"target\"] == cls].tolist()\n    random.shuffle(idxs)\n    picked.extend(idxs[:3])\n\nif len(picked) < 12:\n    rem = [i for i in range(len(va_df)) if i not in picked]\n    random.shuffle(rem)\n    picked.extend(rem[:(12-len(picked))])\npicked = picked[:12]\n\ncammer = GradCAM(model, target_layer)\n\nsaved = []\nfor k, idx in enumerate(picked, start=1):\n    r = va_df.iloc[idx]\n    png_path = CACHE / str(r.patient_id) / f\"{r.image_id}.png\"\n    img = cv2.imread(str(png_path), cv2.IMREAD_GRAYSCALE)\n    if img is None:\n        continue\n\n    img = cv2.resize(img, (CFG.img_size, CFG.img_size), interpolation=cv2.INTER_AREA)\n    img3 = np.stack([img, img, img], axis=-1)\n    x = get_transforms(False)(image=img3)[\"image\"].unsqueeze(0).to(CFG.device)\n\n    cam, logits = cammer(x)\n    pred = int(logits.argmax(dim=1).item())\n    true = int(r.target)\n\n    overlay = overlay_cam(img, cam, CFG.img_size)\n\n    out_name = f\"fold{fold_for_xai}_k{k:02d}_true{id2dens[true]}_pred{id2dens[pred]}_pid{r.patient_id}_img{r.image_id}.png\"\n    out_path = XAI_DIR / out_name\n    cv2.imwrite(str(out_path), overlay)\n    saved.append(out_path)\n\ncammer.close()\n\nprint(\"Saved\", len(saved), \"Grad-CAM overlays to:\", XAI_DIR)\n!ls -lah /kaggle/working/gradcam_outputs | head -200\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T09:15:20.693625Z","iopub.execute_input":"2026-01-17T09:15:20.694822Z","iopub.status.idle":"2026-01-17T09:15:22.69175Z","shell.execute_reply.started":"2026-01-17T09:15:20.694761Z","shell.execute_reply":"2026-01-17T09:15:22.690682Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Export the trained model for reuse. We select the best fold checkpoint, save the weights-only state_dict for portability, and write accompanying metadata (model name, image size, normalization, label mapping) to JSON/CSV. The exported package is saved in /kaggle/working/model_exports/ for later inference on another dataset or notebook.**","metadata":{}},{"cell_type":"code","source":"import json\nimport torch\nimport pandas as pd\nfrom pathlib import Path\n\nMODEL_DIR = Path(\"/kaggle/working/model_exports\")\nMODEL_DIR.mkdir(parents=True, exist_ok=True)\n\n# choose your best fold (based on your results fold 0 was best)\nFINAL_FOLD = 0\nsrc_ckpt = Path(f\"/kaggle/working/best_density_fold{FINAL_FOLD}.pth\")\nassert src_ckpt.exists(), f\"missing checkpoint: {src_ckpt}\"\n\nckpt = torch.load(str(src_ckpt), map_location=\"cpu\")\n\n# save weights-only (best for portability)\nweights_path = MODEL_DIR / \"final_model_state_dict.pth\"\ntorch.save(ckpt[\"model\"], str(weights_path))\n\nmeta = {\n    \"task\": \"ABCD_density_classification\",\n    \"num_classes\": NUM_CLASSES,\n    \"model_name\": CFG.model_name,\n    \"img_size\": CFG.img_size,\n    \"normalization_mean\": [0.5,0.5,0.5],\n    \"normalization_std\": [0.25,0.25,0.25],\n    \"id2dens\": id2dens,\n    \"dens2id\": dens2id,\n}\nmeta_path = MODEL_DIR / \"model_meta.json\"\nmeta_path.write_text(json.dumps(meta, indent=2))\n\nmap_df = pd.DataFrame({\"class_id\": list(id2dens.keys()), \"label\": list(id2dens.values())})\nmap_path = MODEL_DIR / \"label_mapping.csv\"\nmap_df.to_csv(map_path, index=False)\n\nprint(\"Exported model package:\")\nprint(\" -\", weights_path)\nprint(\" -\", meta_path)\nprint(\" -\", map_path)\n!ls -lah /kaggle/working/model_exports | head -200\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T09:16:26.071752Z","iopub.execute_input":"2026-01-17T09:16:26.072679Z","iopub.status.idle":"2026-01-17T09:16:26.339698Z","shell.execute_reply.started":"2026-01-17T09:16:26.072606Z","shell.execute_reply":"2026-01-17T09:16:26.338806Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**************************","metadata":{}}]}