{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":10338,"databundleVersionId":862042}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Pneumonia Detection — DL Branch\n## Step 1: Preprocessing Pipeline\n\nThis notebook implements the full preprocessing pipeline described in **Section V** of the Phase 2 paper:\n- DICOM loading & HU conversion\n- Lung windowing (WL = −500, WW = 1500)\n- CLAHE enhancement\n- Normalization & resizing\n- Data augmentation (Table II from paper)\n- Visual verification on sample images","metadata":{}},{"cell_type":"code","source":"import os\nprint(os.listdir('/kaggle/input'))\nprint(os.listdir('/kaggle/input/' + os.listdir('/kaggle/input')[0]))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:34.589975Z","iopub.execute_input":"2026-05-07T18:41:34.590232Z","iopub.status.idle":"2026-05-07T18:41:34.595139Z","shell.execute_reply.started":"2026-05-07T18:41:34.590209Z","shell.execute_reply":"2026-05-07T18:41:34.594475Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport random\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport pydicom\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as patches\nfrom pathlib import Path\nfrom typing import Optional, Tuple, List, Dict\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\n\n# Reproducibility\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nprint('All imports OK')\nprint(f'PyTorch version : {torch.__version__}')\nprint(f'Device          : {\"cuda\" if torch.cuda.is_available() else \"cpu\"}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:34.596431Z","iopub.execute_input":"2026-05-07T18:41:34.596738Z","iopub.status.idle":"2026-05-07T18:41:43.101136Z","shell.execute_reply.started":"2026-05-07T18:41:34.596703Z","shell.execute_reply":"2026-05-07T18:41:43.100421Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Paths (update these to match your Kaggle input paths) ──────────────────\nDATA_DIR      = '/kaggle/input/competitions/rsna-pneumonia-detection-challenge'\nTRAIN_IMG_DIR = f'{DATA_DIR}/stage_2_train_images'\nTEST_IMG_DIR  = f'{DATA_DIR}/stage_2_test_images'\nTRAIN_LABELS  = f'{DATA_DIR}/stage_2_train_labels.csv'\nCLASS_INFO    = f'{DATA_DIR}/stage_2_detailed_class_info.csv'\n\n# ── Image settings ──────────────────────────────────────────────────────────\nIMG_SIZE_CLS    = 512    # ResNet-50 classifier input\nIMG_SIZE_DET    = 512    # Faster R-CNN detector input\nIMG_SIZE_SEG    = 256    # U-Net segmentation input\n\n# ── Lung windowing (Section V-B of paper) ──────────────────────────────────\nWINDOW_LEVEL    = -500   # HU\nWINDOW_WIDTH    = 1500   # HU  → range [-1250, +250]\n\n# ── Training config ─────────────────────────────────────────────────────────\nBATCH_SIZE      = 8\nNUM_WORKERS     = 2\n\nprint('Configuration loaded')\nprint(f'  Classifier image size : {IMG_SIZE_CLS}x{IMG_SIZE_CLS}')\nprint(f'  Detector image size   : {IMG_SIZE_DET}x{IMG_SIZE_DET}')\nprint(f'  Lung window           : WL={WINDOW_LEVEL}  WW={WINDOW_WIDTH}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:43.102402Z","iopub.execute_input":"2026-05-07T18:41:43.102915Z","iopub.status.idle":"2026-05-07T18:41:43.108863Z","shell.execute_reply.started":"2026-05-07T18:41:43.102889Z","shell.execute_reply":"2026-05-07T18:41:43.107972Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 3 — DICOM Preprocessor Class\n\nImplements the full pipeline from **Section V** of the paper:\n1. Read DICOM pixel array\n2. Apply RescaleSlope / RescaleIntercept → Hounsfield Units\n3. Lung windowing\n4. CLAHE for contrast enhancement\n5. Normalize to [0, 1] and convert to uint8","metadata":{}},{"cell_type":"code","source":"class DICOMPreprocessor:\n    \"\"\"\n    Full DICOM preprocessing pipeline as described in Section V.\n    \n    Pipeline:\n        raw pixels → HU conversion → lung windowing\n        → normalize → CLAHE → uint8 RGB\n    \"\"\"\n\n    def __init__(self,\n                 window_level: int = -500,\n                 window_width: int = 1500,\n                 use_clahe: bool = True,\n                 clahe_clip: float = 2.0,\n                 clahe_grid: Tuple[int,int] = (8, 8)):\n        self.window_level = window_level\n        self.window_width = window_width\n        self.use_clahe    = use_clahe\n        self.clahe        = cv2.createCLAHE(\n                                clipLimit=clahe_clip,\n                                tileGridSize=clahe_grid)\n\n    # ── Step 1: Load DICOM ─────────────────────────────────────────────────\n    def load_dicom(self, path: str) -> Tuple[np.ndarray, dict]:\n        \"\"\"Read DICOM file and return pixel array + metadata.\"\"\"\n        dcm  = pydicom.dcmread(path)\n        img  = dcm.pixel_array.astype(np.float32)\n\n        # Handle MONOCHROME1 (inverted) images\n        if getattr(dcm, 'PhotometricInterpretation', '') == 'MONOCHROME1':\n            img = img.max() - img\n\n        meta = {\n            'RescaleSlope'    : float(getattr(dcm, 'RescaleSlope',     1)),\n            'RescaleIntercept': float(getattr(dcm, 'RescaleIntercept', 0)),\n            'WindowCenter'    : float(getattr(dcm, 'WindowCenter',     self.window_level)),\n            'WindowWidth'     : float(getattr(dcm, 'WindowWidth',      self.window_width)),\n            'PatientAge'      : getattr(dcm, 'PatientAge',  'N/A'),\n            'PatientSex'      : getattr(dcm, 'PatientSex',  'N/A'),\n            'ViewPosition'    : getattr(dcm, 'ViewPosition','N/A'),\n        }\n        return img, meta\n\n    # ── Step 2: HU Conversion (Section V-A formula) ────────────────────────\n    def to_hounsfield(self, img: np.ndarray, meta: dict) -> np.ndarray:\n        \"\"\"\n        HU = PixelValue × RescaleSlope + RescaleIntercept\n        (exactly as stated in Section V of the paper)\n        \"\"\"\n        return img * meta['RescaleSlope'] + meta['RescaleIntercept']\n\n    # ── Step 3: Lung Windowing (Section V-B) ──────────────────────────────\n    def apply_windowing(self, hu_img: np.ndarray) -> np.ndarray:\n        \"\"\"\n        Lung window: WL=-500, WW=1500 → range [-1250, +250] HU.\n        Clips to window range then normalizes to [0, 1].\n        \"\"\"\n        win_min = self.window_level - self.window_width / 2   # -1250\n        win_max = self.window_level + self.window_width / 2   # +250\n        img     = np.clip(hu_img, win_min, win_max)\n        img     = (img - win_min) / (win_max - win_min)       # → [0, 1]\n        return img.astype(np.float32)\n\n    # ── Step 4: CLAHE Enhancement ─────────────────────────────────────────\n    def apply_clahe(self, img: np.ndarray) -> np.ndarray:\n        \"\"\"CLAHE on uint8 image for better lung tissue contrast.\"\"\"\n        img_u8 = (img * 255).astype(np.uint8)\n        enhanced = self.clahe.apply(img_u8)\n        return enhanced.astype(np.float32) / 255.0\n\n    # ── Step 5: Resize & convert to 3-channel RGB ─────────────────────────\n    def to_rgb(self, img: np.ndarray, size: int) -> np.ndarray:\n        \"\"\"Resize to (size, size) and stack to 3-channel for CNN input.\"\"\"\n        img_u8  = (img * 255).astype(np.uint8)\n        resized = cv2.resize(img_u8, (size, size),\n                             interpolation=cv2.INTER_AREA)\n        rgb     = cv2.cvtColor(resized, cv2.COLOR_GRAY2RGB)\n        return rgb  # uint8, shape (size, size, 3)\n\n    # ── Full pipeline ──────────────────────────────────────────────────────\n    def __call__(self, path: str, size: int = 512) -> Tuple[np.ndarray, dict]:\n        \"\"\"\n        Run full pipeline on one DICOM file.\n        Returns: (rgb_image uint8 HxWx3, metadata dict)\n        \"\"\"\n        img, meta = self.load_dicom(path)\n        img       = self.to_hounsfield(img, meta)\n        img       = self.apply_windowing(img)\n        if self.use_clahe:\n            img   = self.apply_clahe(img)\n        img       = self.to_rgb(img, size)\n        return img, meta\n\n\n# Instantiate the preprocessor (used by all models)\npreprocessor = DICOMPreprocessor(\n    window_level = WINDOW_LEVEL,\n    window_width = WINDOW_WIDTH,\n    use_clahe    = True\n)\n\nprint('DICOMPreprocessor ready')\nprint(f'  Window range: [{WINDOW_LEVEL - WINDOW_WIDTH//2}, {WINDOW_LEVEL + WINDOW_WIDTH//2}] HU')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:43.109827Z","iopub.execute_input":"2026-05-07T18:41:43.110110Z","iopub.status.idle":"2026-05-07T18:41:43.141319Z","shell.execute_reply.started":"2026-05-07T18:41:43.110074Z","shell.execute_reply":"2026-05-07T18:41:43.140544Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 4 — Augmentation Pipelines\n\nImplements **Table II** from the paper exactly:\n\n| Technique | Parameters | Probability |\n|---|---|---|\n| Horizontal flip | — | 0.50 |\n| Rotation | ±10° | 0.30 |\n| Translation | ±10% | 0.30 |\n| Brightness/Contrast | ±20% | 0.40 |\n| Gaussian noise | σ=0.01 | 0.20 |","metadata":{}},{"cell_type":"code","source":"def get_train_augmentation(img_size: int) -> A.Compose:\n    \"\"\"\n    Training augmentation — Table II of the paper.\n    bbox_params enables box-safe transforms for detection.\n    \"\"\"\n    return A.Compose([\n        A.Resize(img_size, img_size),\n\n        # Table II row 1: horizontal flip p=0.50\n        A.HorizontalFlip(p=0.50),\n\n        # Table II row 2: rotation ±10°  p=0.30\n        A.Rotate(limit=10, p=0.30),\n\n        # Table II row 3: translation ±10%  p=0.30\n        A.Affine(translate_percent=0.10, p=0.30),\n\n        # Table II row 4: brightness/contrast ±20%  p=0.40\n        A.RandomBrightnessContrast(\n            brightness_limit=0.20,\n            contrast_limit=0.20, p=0.40\n        ),\n\n        # Table II row 5: Gaussian noise σ=0.01  p=0.20\n        A.GaussNoise(std_range=(0.01, 0.01), p=0.20),\n\n        # Normalize (ImageNet stats — used with pretrained ResNet/EfficientNet)\n        A.Normalize(\n            mean=[0.485, 0.456, 0.406],\n            std =[0.229, 0.224, 0.225]\n        ),\n        ToTensorV2(),\n    ],\n    # bbox_params makes augmentations box-safe for detection\n    bbox_params=A.BboxParams(\n        format='pascal_voc',          # [x1,y1,x2,y2]\n        label_fields=['class_labels'],\n        min_visibility=0.3\n    ))\n\n\ndef get_val_augmentation(img_size: int) -> A.Compose:\n    \"\"\"Validation / test — resize + normalize only, no random transforms.\"\"\"\n    return A.Compose([\n        A.Resize(img_size, img_size),\n        A.Normalize(\n            mean=[0.485, 0.456, 0.406],\n            std =[0.229, 0.224, 0.225]\n        ),\n        ToTensorV2(),\n    ],\n    bbox_params=A.BboxParams(\n        format='pascal_voc',\n        label_fields=['class_labels'],\n        min_visibility=0.3\n    ))\n\n\nprint('Augmentation pipelines ready')\nprint('  Train : flip + rotation + translation + brightness + noise + normalize')\nprint('  Val   : resize + normalize only')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:43.143078Z","iopub.execute_input":"2026-05-07T18:41:43.143385Z","iopub.status.idle":"2026-05-07T18:41:43.158397Z","shell.execute_reply.started":"2026-05-07T18:41:43.143363Z","shell.execute_reply":"2026-05-07T18:41:43.157718Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 5 — Dataset Class\n\nShared dataset used by ALL three models (ResNet-50, Faster R-CNN, U-Net).\nMode switches output format per model type.","metadata":{}},{"cell_type":"code","source":"class RSNADataset(Dataset):\n    \"\"\"\n    RSNA Pneumonia Detection Dataset.\n\n    mode='classification' → returns (tensor, label)\n    mode='detection'      → returns (tensor, target_dict)  for Faster R-CNN\n    mode='segmentation'   → returns (tensor, mask_tensor)  for U-Net\n    \"\"\"\n\n    def __init__(self,\n                 df         : pd.DataFrame,\n                 img_dir    : str,\n                 transforms : A.Compose,\n                 mode       : str  = 'classification',\n                 img_size   : int  = 512):\n\n        # One row per PATIENT (not per box)\n        self.df         = df.drop_duplicates('patientId').reset_index(drop=True)\n        # Full df for box lookup (multiple rows per patient possible)\n        self.full_df    = df\n        self.img_dir    = Path(img_dir)\n        self.transforms = transforms\n        self.mode       = mode\n        self.img_size   = img_size\n        self.preprocessor = DICOMPreprocessor(\n            window_level=WINDOW_LEVEL,\n            window_width=WINDOW_WIDTH,\n            use_clahe=True\n        )\n\n    def __len__(self) -> int:\n        return len(self.df)\n\n    def _get_boxes(self, patient_id: str) -> List[List[float]]:\n        \"\"\"Return all boxes for a patient as [[x1,y1,x2,y2], ...].\"\"\"\n        rows = self.full_df[\n            (self.full_df['patientId'] == patient_id) &\n            (self.full_df['Target']    == 1)\n        ].dropna(subset=['x','y','width','height'])\n\n        boxes = []\n        for _, r in rows.iterrows():\n            x1 = float(r['x'])\n            y1 = float(r['y'])\n            x2 = x1 + float(r['width'])\n            y2 = y1 + float(r['height'])\n            boxes.append([x1, y1, x2, y2])\n        return boxes\n\n    def _make_mask(self, boxes: List[List[float]],\n                   orig_size: int = 1024) -> np.ndarray:\n        \"\"\"Convert box list to binary pixel mask.\"\"\"\n        mask = np.zeros((orig_size, orig_size), dtype=np.float32)\n        for x1, y1, x2, y2 in boxes:\n            mask[int(y1):int(y2), int(x1):int(x2)] = 1.0\n        return mask\n\n    def __getitem__(self, idx: int):\n        row        = self.df.iloc[idx]\n        patient_id = row['patientId']\n        label      = int(row['Target'])\n\n        # ── Load & preprocess DICOM ───────────────────────────────────────\n        dcm_path   = self.img_dir / f'{patient_id}.dcm'\n        img_rgb, _ = self.preprocessor(str(dcm_path), size=self.img_size)\n        # img_rgb is uint8 HxWx3\n\n        # ── Get bounding boxes ────────────────────────────────────────────\n        boxes_orig = self._get_boxes(patient_id)  # in original 1024px coords\n\n        # Scale boxes to current img_size\n        scale  = self.img_size / 1024.0\n        boxes  = [[c * scale for c in b] for b in boxes_orig]\n\n        # ── Apply augmentation ────────────────────────────────────────────\n        class_labels = [1] * len(boxes)   # all boxes = pneumonia class\n        if len(boxes) == 0:\n            # albumentations needs at least placeholder\n            aug = self.transforms(\n                image=img_rgb,\n                bboxes=[],\n                class_labels=[]\n            )\n        else:\n            aug = self.transforms(\n                image=img_rgb,\n                bboxes=boxes,\n                class_labels=class_labels\n            )\n\n        img_tensor  = aug['image']           # float32 tensor CxHxW\n        aug_boxes   = list(aug['bboxes'])    # boxes after augmentation\n\n        # ── Return per mode ───────────────────────────────────────────────\n        if self.mode == 'classification':\n            return img_tensor, label\n\n        elif self.mode == 'detection':\n            # Faster R-CNN expects dict with 'boxes' and 'labels' tensors\n            if len(aug_boxes) > 0:\n                boxes_t  = torch.tensor(aug_boxes, dtype=torch.float32)\n                labels_t = torch.ones(len(aug_boxes), dtype=torch.int64)\n            else:\n                boxes_t  = torch.zeros((0, 4), dtype=torch.float32)\n                labels_t = torch.zeros(0,      dtype=torch.int64)\n\n            target = {\n                'boxes'   : boxes_t,\n                'labels'  : labels_t,\n                'image_id': torch.tensor([idx])\n            }\n            return img_tensor, target\n\n        elif self.mode == 'segmentation':\n            # U-Net expects binary mask\n            mask       = self._make_mask(boxes_orig, orig_size=1024)\n            mask       = cv2.resize(mask, (self.img_size, self.img_size))\n            mask_tensor = torch.tensor(mask, dtype=torch.float32).unsqueeze(0)\n            return img_tensor, mask_tensor\n\n        else:\n            raise ValueError(f'Unknown mode: {self.mode}')\n\n\nprint('RSNADataset class defined')\nprint('  Supports modes: classification | detection | segmentation')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:43.159175Z","iopub.execute_input":"2026-05-07T18:41:43.159378Z","iopub.status.idle":"2026-05-07T18:41:43.177601Z","shell.execute_reply.started":"2026-05-07T18:41:43.159358Z","shell.execute_reply":"2026-05-07T18:41:43.176728Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 6 — Load CSV Data & Split","metadata":{}},{"cell_type":"code","source":"from sklearn.model_selection import train_test_split\n\n# Load CSVs\nlabels_df = pd.read_csv(TRAIN_LABELS)\nclass_df  = pd.read_csv(CLASS_INFO)\n\n# Merge\ndf = pd.concat([labels_df, class_df.drop('patientId', axis=1)], axis=1)\ndf['Target'] = df['Target'].astype(int)\n\nprint(f'Total rows     : {len(df)}')\nprint(f'Unique patients: {df[\"patientId\"].nunique()}')\nprint()\nprint('Class distribution:')\nprint(df.drop_duplicates(\"patientId\")[\"Target\"].value_counts())\n\n# One row per patient for splitting\npatient_df = df.drop_duplicates('patientId')[['patientId','Target']]\n\n# Stratified split: 70% train / 15% val / 15% test\ntrain_ids, temp_ids = train_test_split(\n    patient_df['patientId'],\n    test_size=0.30,\n    stratify=patient_df['Target'],\n    random_state=SEED\n)\nval_ids, test_ids = train_test_split(\n    temp_ids,\n    test_size=0.50,\n    stratify=patient_df.set_index('patientId').loc[temp_ids, 'Target'],\n    random_state=SEED\n)\n\ntrain_df = df[df['patientId'].isin(train_ids)]\nval_df   = df[df['patientId'].isin(val_ids)]\ntest_df  = df[df['patientId'].isin(test_ids)]\n\nprint(f'\\nSplit (stratified by patient):')\nprint(f'  Train : {train_df[\"patientId\"].nunique():,} patients')\nprint(f'  Val   : {val_df[\"patientId\"].nunique():,} patients')\nprint(f'  Test  : {test_df[\"patientId\"].nunique():,} patients')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:43.178617Z","iopub.execute_input":"2026-05-07T18:41:43.179341Z","iopub.status.idle":"2026-05-07T18:41:44.014192Z","shell.execute_reply.started":"2026-05-07T18:41:43.179307Z","shell.execute_reply":"2026-05-07T18:41:44.013524Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 7 — EDA Visualizations","metadata":{}},{"cell_type":"code","source":"fig, axes = plt.subplots(1, 3, figsize=(18, 5))\nfig.suptitle('RSNA Dataset — Exploratory Analysis', fontsize=15, fontweight='bold')\n\n# 1. Class distribution\ncounts = df.drop_duplicates('patientId')['Target'].value_counts()\naxes[0].bar(['Normal (0)', 'Pneumonia (1)'], counts.values,\n            color=['steelblue', 'tomato'], edgecolor='black')\naxes[0].set_title('Class Distribution')\naxes[0].set_ylabel('Number of Patients')\nfor i, v in enumerate(counts.values):\n    axes[0].text(i, v + 100, f'{v:,}\\n({v/counts.sum()*100:.1f}%)',\n                 ha='center', fontsize=10)\n\n# 2. Bounding box scatter — where do opacities appear?\npos = df[df['Target'] == 1].dropna(subset=['x','y'])\naxes[1].scatter(pos['x'] + pos['width']/2,\n                1024 - (pos['y'] + pos['height']/2),\n                alpha=0.03, s=10, color='tomato')\naxes[1].set_xlim(0, 1024)\naxes[1].set_ylim(0, 1024)\naxes[1].set_title('Opacity Center Distribution\\n(all positive cases)')\naxes[1].set_xlabel('X pixel')\naxes[1].set_ylabel('Y pixel (flipped)')\n\n# 3. Box size distribution\naxes[2].hist(pos['width'],  bins=40, alpha=0.6, label='Width',  color='steelblue')\naxes[2].hist(pos['height'], bins=40, alpha=0.6, label='Height', color='tomato')\naxes[2].set_title('Bounding Box Size Distribution')\naxes[2].set_xlabel('Pixels')\naxes[2].set_ylabel('Count')\naxes[2].legend()\n\nplt.tight_layout()\nplt.savefig('eda_overview.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: eda_overview.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:44.015084Z","iopub.execute_input":"2026-05-07T18:41:44.015564Z","iopub.status.idle":"2026-05-07T18:41:45.209599Z","shell.execute_reply.started":"2026-05-07T18:41:44.015538Z","shell.execute_reply":"2026-05-07T18:41:45.208776Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 8 — Preprocessing Visualization\n\nShows each stage of the pipeline on sample images.","metadata":{}},{"cell_type":"code","source":"def visualize_preprocessing(dicom_path: str, ax_row: List, title: str = ''):\n    \"\"\"Show 4 stages of preprocessing on one image.\"\"\"\n    p    = DICOMPreprocessor(use_clahe=False)  # without CLAHE first\n    p_cl = DICOMPreprocessor(use_clahe=True)   # with CLAHE\n\n    # Stage 1: Raw pixel array\n    dcm     = pydicom.dcmread(dicom_path)\n    raw     = dcm.pixel_array\n\n    # Stage 2: After HU + windowing\n    img_hu  = p.to_hounsfield(raw.astype(np.float32),\n                               {'RescaleSlope':1,'RescaleIntercept':0})\n    img_win = p.apply_windowing(img_hu)\n\n    # Stage 3: After CLAHE\n    img_cl  = p_cl.apply_clahe(img_win)\n\n    # Stage 4: Final resized RGB\n    img_fin = p_cl.to_rgb(img_cl, size=512)\n\n    stages  = [raw, img_win, img_cl, img_fin[:,:,0]]\n    labels  = ['Raw DICOM','After Windowing','After CLAHE','Final (512×512)']\n    cmaps   = ['gray','gray','gray','gray']\n\n    for ax, stage, lbl, cmap in zip(ax_row, stages, labels, cmaps):\n        ax.imshow(stage, cmap=cmap)\n        ax.set_title(lbl, fontsize=9)\n        ax.axis('off')\n    ax_row[0].set_ylabel(title, fontsize=10, rotation=0,\n                         labelpad=60, va='center')\n\n\n# Pick 2 sample DICOM files\nsample_paths = [\n    f'{TRAIN_IMG_DIR}/{train_df[\"patientId\"].iloc[0]}.dcm',\n    f'{TRAIN_IMG_DIR}/{train_df[train_df[\"Target\"]==1][\"patientId\"].iloc[0]}.dcm',\n]\nsample_titles = ['Normal', 'Pneumonia']\n\nfig, axes = plt.subplots(2, 4, figsize=(18, 8))\nfig.suptitle('Preprocessing Pipeline — Stage by Stage', fontsize=13, fontweight='bold')\n\nfor ax_row, path, title in zip(axes, sample_paths, sample_titles):\n    visualize_preprocessing(path, ax_row, title)\n\nplt.tight_layout()\nplt.savefig('preprocessing_stages.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: preprocessing_stages.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:45.210781Z","iopub.execute_input":"2026-05-07T18:41:45.211500Z","iopub.status.idle":"2026-05-07T18:41:49.572454Z","shell.execute_reply.started":"2026-05-07T18:41:45.211459Z","shell.execute_reply":"2026-05-07T18:41:49.571650Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 9 — Augmentation Visualization\n\nShows the same image with 6 different augmentation samples.","metadata":{}},{"cell_type":"code","source":"def visualize_augmentations(dicom_path: str, n_samples: int = 6):\n    \"\"\"Show N augmented versions of the same X-ray.\"\"\"\n    img_rgb, _ = preprocessor(dicom_path, size=512)\n\n    # Raw augmentation (without ToTensor so we can display)\n    aug_display = A.Compose([\n        A.Resize(512, 512),\n        A.HorizontalFlip(p=0.5),\n        A.Rotate(limit=10, p=0.3),\n        A.ShiftScaleRotate(shift_limit=0.10, scale_limit=0.0, rotate_limit=0, p=0.3),\n        A.RandomBrightnessContrast(brightness_limit=0.20, contrast_limit=0.20, p=0.4),\n        A.GaussNoise(var_limit=(0.01*255)**2, p=0.2),\n    ])\n\n    fig, axes = plt.subplots(1, n_samples + 1, figsize=(20, 3))\n    fig.suptitle('Augmentation Pipeline — Table II from Paper', fontsize=12, fontweight='bold')\n\n    axes[0].imshow(img_rgb)\n    axes[0].set_title('Original', fontsize=9)\n    axes[0].axis('off')\n\n    for i in range(n_samples):\n        aug_img = aug_display(image=img_rgb)['image']\n        axes[i+1].imshow(aug_img)\n        axes[i+1].set_title(f'Augmented {i+1}', fontsize=9)\n        axes[i+1].axis('off')\n\n    plt.tight_layout()\n    plt.savefig('augmentation_samples.png', dpi=150, bbox_inches='tight')\n    plt.show()\n    print('Saved: augmentation_samples.png')\n\n\nsample_path = f'{TRAIN_IMG_DIR}/{train_df[\"patientId\"].iloc[5]}.dcm'\nvisualize_augmentations(sample_path, n_samples=6)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:49.573409Z","iopub.execute_input":"2026-05-07T18:41:49.573676Z","iopub.status.idle":"2026-05-07T18:41:50.639121Z","shell.execute_reply.started":"2026-05-07T18:41:49.573653Z","shell.execute_reply":"2026-05-07T18:41:50.638369Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 10 — DataLoader Factory & Smoke Test","metadata":{}},{"cell_type":"code","source":"def build_loaders(mode: str = 'classification',\n                  img_size: int = 512) -> Dict:\n    \"\"\"\n    Returns {'train': DataLoader, 'val': DataLoader, 'test': DataLoader}\n    for the requested mode.\n    \"\"\"\n    def det_collate(batch):\n        \"\"\"Custom collate for detection (variable-length targets).\"\"\"\n        imgs    = [b[0] for b in batch]\n        targets = [b[1] for b in batch]\n        return imgs, targets\n\n    sets = {\n        'train': (train_df, get_train_augmentation(img_size)),\n        'val'  : (val_df,   get_val_augmentation(img_size)),\n        'test' : (test_df,  get_val_augmentation(img_size)),\n    }\n\n    loaders = {}\n    for split, (sdf, tfm) in sets.items():\n        ds = RSNADataset(\n            df         = sdf,\n            img_dir    = TRAIN_IMG_DIR,\n            transforms = tfm,\n            mode       = mode,\n            img_size   = img_size\n        )\n        loaders[split] = DataLoader(\n            ds,\n            batch_size  = BATCH_SIZE if mode != 'detection' else 4,\n            shuffle     = (split == 'train'),\n            num_workers = NUM_WORKERS,\n            pin_memory  = True,\n            collate_fn  = det_collate if mode == 'detection' else None\n        )\n        print(f'  [{split}] {len(ds):,} patients — {len(loaders[split])} batches')\n\n    return loaders\n\n\n# ── Smoke test: classification loader ──────────────────────────────────────\nprint('Building classification loaders...')\ncls_loaders = build_loaders(mode='classification', img_size=IMG_SIZE_CLS)\n\nimgs, labels = next(iter(cls_loaders['train']))\nprint(f'\\nClassification batch:')\nprint(f'  Images : {imgs.shape}   dtype={imgs.dtype}')\nprint(f'  Labels : {labels}       dtype={labels.dtype}')\n\n# ── Smoke test: detection loader ────────────────────────────────────────────\nprint('\\nBuilding detection loaders...')\ndet_loaders = build_loaders(mode='detection', img_size=IMG_SIZE_DET)\n\nimgs_d, targets_d = next(iter(det_loaders['train']))\nprint(f'\\nDetection batch:')\nprint(f'  Images : {len(imgs_d)} images, each {imgs_d[0].shape}')\nprint(f'  Target[0] boxes : {targets_d[0][\"boxes\"].shape}')\nprint(f'  Target[0] labels: {targets_d[0][\"labels\"]}')\n\nprint('\\n✓ Preprocessing pipeline verified — ready for model training')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:50.641679Z","iopub.execute_input":"2026-05-07T18:41:50.642326Z","iopub.status.idle":"2026-05-07T18:41:52.074227Z","shell.execute_reply.started":"2026-05-07T18:41:50.642299Z","shell.execute_reply":"2026-05-07T18:41:52.073406Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Pneumonia Detection — DL Branch\n## Step 2: ResNet-50 Classifier\n\nThis notebook implements the **Classification Branch** described in **Section VII** of the Phase 2 paper:\n- Fine-tuned ResNet-50 pretrained on ImageNet\n- Focal Loss to handle class imbalance (22.5% positive)\n- Full evaluation: Accuracy, Precision, Recall, F1, AUC-ROC\n- Grad-CAM explainability visualizations\n\n> **Run Step 1 notebook first** — this notebook reuses the same preprocessor, dataset, and loaders.","metadata":{}},{"cell_type":"markdown","source":"## Cell 1 — Imports & Setup","metadata":{}},{"cell_type":"code","source":"# ── Standard ────────────────────────────────────────────────────────────────\nimport os, time, random, json\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as patches\nimport seaborn as sns\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple\n\n# ── CV / DICOM ───────────────────────────────────────────────────────────────\nimport cv2\nimport pydicom\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\n# ── PyTorch ──────────────────────────────────────────────────────────────────\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision.models import resnet50, ResNet50_Weights\n\n# ── Metrics ──────────────────────────────────────────────────────────────────\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    accuracy_score, precision_score, recall_score,\n    f1_score, roc_auc_score, confusion_matrix,\n    roc_curve, precision_recall_curve, average_precision_score\n)\n\n# ── Reproducibility ──────────────────────────────────────────────────────────\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device : {DEVICE}')\nprint(f'PyTorch: {torch.__version__}')\nif torch.cuda.is_available():\n    print(f'GPU    : {torch.cuda.get_device_name(0)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:52.075554Z","iopub.execute_input":"2026-05-07T18:41:52.075976Z","iopub.status.idle":"2026-05-07T18:41:56.120037Z","shell.execute_reply.started":"2026-05-07T18:41:52.075943Z","shell.execute_reply":"2026-05-07T18:41:56.119189Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 2 — Config & Paths","metadata":{}},{"cell_type":"code","source":"class DICOMPreprocessor:\n    def __init__(self, window_level=-500, window_width=1500, use_clahe=True):\n        self.wl   = window_level\n        self.ww   = window_width\n        self.clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) if use_clahe else None\n\n    def __call__(self, path: str, size: int = 512) -> np.ndarray:\n        dcm = pydicom.dcmread(path)\n        img = dcm.pixel_array.astype(np.float32)\n        if getattr(dcm, 'PhotometricInterpretation', '') == 'MONOCHROME1':\n            img = img.max() - img\n        # HU conversion\n        slope     = float(getattr(dcm, 'RescaleSlope',     1))\n        intercept = float(getattr(dcm, 'RescaleIntercept', 0))\n        img = img * slope + intercept\n        # Windowing\n        win_min = self.wl - self.ww / 2\n        win_max = self.wl + self.ww / 2\n        img = np.clip(img, win_min, win_max)\n        img = (img - win_min) / (win_max - win_min)\n        # CLAHE\n        if self.clahe:\n            img_u8 = (img * 255).astype(np.uint8)\n            img    = self.clahe.apply(img_u8).astype(np.float32) / 255.0\n        # Resize → RGB\n        img_u8  = (img * 255).astype(np.uint8)\n        resized = cv2.resize(img_u8, (size, size), interpolation=cv2.INTER_AREA)\n        return cv2.cvtColor(resized, cv2.COLOR_GRAY2RGB)\n\n\ndef get_train_transforms(size: int) -> A.Compose:\n    return A.Compose([\n        A.Resize(size, size),\n        A.HorizontalFlip(p=0.5),\n        A.Rotate(limit=10, p=0.3),\n        A.Affine(translate_percent=0.10, p=0.30),\n        A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.4),\n        A.GaussNoise(std_range=(0.01, 0.01), p=0.2),\n        A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n        ToTensorV2(),\n    ])\n\ndef get_val_transforms(size: int) -> A.Compose:\n    return A.Compose([\n        A.Resize(size, size),\n        A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n        ToTensorV2(),\n    ])\n\npreprocessor = DICOMPreprocessor(WINDOW_LEVEL, WINDOW_WIDTH, use_clahe=True)\nprint('Preprocessor & augmentations ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:56.121003Z","iopub.execute_input":"2026-05-07T18:41:56.121515Z","iopub.status.idle":"2026-05-07T18:41:56.132162Z","shell.execute_reply.started":"2026-05-07T18:41:56.121482Z","shell.execute_reply":"2026-05-07T18:41:56.131440Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 4 — Dataset & DataLoaders","metadata":{}},{"cell_type":"code","source":"# ── Config guard (safe to run even if Cell 2 already ran) ────────────────────\nIMG_SIZE      = globals().get('IMG_SIZE',      512)\nBATCH_SIZE    = globals().get('BATCH_SIZE',    8)\nNUM_WORKERS   = globals().get('NUM_WORKERS',   2)\nWINDOW_LEVEL  = globals().get('WINDOW_LEVEL',  -500)\nWINDOW_WIDTH  = globals().get('WINDOW_WIDTH',  1500)\nTRAIN_IMG_DIR = globals().get('TRAIN_IMG_DIR',\n    '/kaggle/input/competitions/rsna-pneumonia-detection-challenge/stage_2_train_images')\nTRAIN_LABELS  = globals().get('TRAIN_LABELS',\n    '/kaggle/input/competitions/rsna-pneumonia-detection-challenge/stage_2_train_labels.csv')\nCLASS_INFO    = globals().get('CLASS_INFO',\n    '/kaggle/input/competitions/rsna-pneumonia-detection-challenge/stage_2_detailed_class_info.csv')\nclass RSNAClassificationDataset(Dataset):\n    def __init__(self, df, img_dir, transforms):\n        self.df         = df.drop_duplicates('patientId').reset_index(drop=True)\n        self.img_dir    = img_dir\n        self.transforms = transforms\n        self.prep       = DICOMPreprocessor(WINDOW_LEVEL, WINDOW_WIDTH)\n\n    def __len__(self): return len(self.df)\n\n    def __getitem__(self, idx):\n        row    = self.df.iloc[idx]\n        path   = f\"{self.img_dir}/{row['patientId']}.dcm\"\n        img    = self.prep(path, size=IMG_SIZE)          # uint8 HxWx3\n        tensor = self.transforms(image=img)['image']     # float32 CxHxW\n        label  = int(row['Target'])\n        return tensor, label\n\n\n# ── Load & split data ────────────────────────────────────────────────────────\nlabels_df = pd.read_csv(TRAIN_LABELS)\nclass_df  = pd.read_csv(CLASS_INFO)\ndf        = pd.concat([labels_df, class_df.drop('patientId', axis=1)], axis=1)\ndf['Target'] = df['Target'].astype(int)\n\npatient_df   = df.drop_duplicates('patientId')[['patientId','Target']]\ntrain_ids, temp_ids = train_test_split(\n    patient_df['patientId'], test_size=0.30,\n    stratify=patient_df['Target'], random_state=SEED)\nval_ids, test_ids = train_test_split(\n    temp_ids, test_size=0.50,\n    stratify=patient_df.set_index('patientId').loc[temp_ids,'Target'],\n    random_state=SEED)\n\ntrain_df = df[df['patientId'].isin(train_ids)]\nval_df   = df[df['patientId'].isin(val_ids)]\ntest_df  = df[df['patientId'].isin(test_ids)]\n\n# ── Build loaders ────────────────────────────────────────────────────────────\ntrain_ds = RSNAClassificationDataset(train_df, TRAIN_IMG_DIR, get_train_transforms(IMG_SIZE))\nval_ds   = RSNAClassificationDataset(val_df,   TRAIN_IMG_DIR, get_val_transforms(IMG_SIZE))\ntest_ds  = RSNAClassificationDataset(test_df,  TRAIN_IMG_DIR, get_val_transforms(IMG_SIZE))\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,\n                          num_workers=NUM_WORKERS, pin_memory=True)\nval_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=NUM_WORKERS, pin_memory=True)\ntest_loader  = DataLoader(test_ds,  batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=NUM_WORKERS, pin_memory=True)\n\nprint(f'Train : {len(train_ds):,} patients  ({len(train_loader)} batches)')\nprint(f'Val   : {len(val_ds):,}  patients  ({len(val_loader)} batches)')\nprint(f'Test  : {len(test_ds):,}  patients  ({len(test_loader)} batches)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:56.133197Z","iopub.execute_input":"2026-05-07T18:41:56.133449Z","iopub.status.idle":"2026-05-07T18:41:56.265155Z","shell.execute_reply.started":"2026-05-07T18:41:56.133427Z","shell.execute_reply":"2026-05-07T18:41:56.264313Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 5 — ResNet-50 Model\n\nFine-tuning strategy from the paper:\n- Freeze all layers except **layer4** and the **FC head**\n- Replace FC with: Dropout → Linear(2048→512) → ReLU → Dropout → Linear(512→2)","metadata":{}},{"cell_type":"code","source":"class ResNet50Classifier(nn.Module):\n    \"\"\"\n    ResNet-50 fine-tuned for binary pneumonia classification.\n    Backbone: pretrained on ImageNet.\n    Strategy: freeze early layers, fine-tune layer4 + custom head.\n    \"\"\"\n    def __init__(self, num_classes: int = 2, dropout: float = 0.5):\n        super().__init__()\n        # Load pretrained backbone\n        backbone = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1)\n\n        # Freeze everything except layer4\n        for name, param in backbone.named_parameters():\n            if 'layer4' not in name:\n                param.requires_grad = False\n\n        # Keep all layers except the original FC\n        self.features = nn.Sequential(*list(backbone.children())[:-1])\n\n        # Custom classification head\n        self.classifier = nn.Sequential(\n            nn.Flatten(),\n            nn.Dropout(dropout),\n            nn.Linear(2048, 512),\n            nn.ReLU(),\n            nn.Dropout(dropout / 2),\n            nn.Linear(512, num_classes)\n        )\n\n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        x = self.features(x)\n        return self.classifier(x)\n\n    def get_feature_map(self, x: torch.Tensor) -> torch.Tensor:\n        \"\"\"Returns last conv feature map — used by Grad-CAM.\"\"\"\n        # Forward through all feature layers except last pool\n        for layer in list(self.features.children())[:-1]:\n            x = layer(x)\n        return x\n\n\nmodel = ResNet50Classifier(num_classes=2, dropout=0.5).to(DEVICE)\n\n# Count parameters\ntotal     = sum(p.numel() for p in model.parameters())\ntrainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f'Total params    : {total:,}')\nprint(f'Trainable params: {trainable:,}  ({100*trainable/total:.1f}%)')\nprint(f'Frozen params   : {total-trainable:,}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:56.266261Z","iopub.execute_input":"2026-05-07T18:41:56.267074Z","iopub.status.idle":"2026-05-07T18:41:57.409227Z","shell.execute_reply.started":"2026-05-07T18:41:56.267048Z","shell.execute_reply":"2026-05-07T18:41:57.408606Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 6 — Focal Loss\n\nDirectly implements the formula from the paper (Section II-C):\n$$FL(p_t) = -\\alpha_t(1-p_t)^\\gamma \\log(p_t)$$\nwith γ=2, α=0.25 to address the 1:3.4 class imbalance.","metadata":{}},{"cell_type":"code","source":"LR           = globals().get('LR',           1e-4)\nWEIGHT_DECAY = globals().get('WEIGHT_DECAY', 1e-4)\nEPOCHS       = globals().get('EPOCHS',       15)\nclass FocalLoss(nn.Module):\n    \"\"\"\n    Focal Loss — Section II-C of the paper.\n    FL(pt) = -alpha_t * (1 - pt)^gamma * log(pt)\n    Addresses the 1:3.4 class imbalance (22.5% pneumonia).\n    \"\"\"\n    def __init__(self, alpha: float = 0.25, gamma: float = 2.0):\n        super().__init__()\n        self.alpha = alpha\n        self.gamma = gamma\n\n    def forward(self, logits: torch.Tensor,\n                targets: torch.Tensor) -> torch.Tensor:\n        ce   = F.cross_entropy(logits, targets, reduction='none')\n        pt   = torch.exp(-ce)\n        alpha_t = torch.where(\n            targets == 1,\n            torch.tensor(self.alpha,     device=logits.device),\n            torch.tensor(1 - self.alpha, device=logits.device)\n        )\n        return (alpha_t * (1 - pt) ** self.gamma * ce).mean()\n\n\ncriterion = FocalLoss(alpha=0.25, gamma=2.0)\noptimizer = optim.AdamW(\n    filter(lambda p: p.requires_grad, model.parameters()),\n    lr=LR, weight_decay=WEIGHT_DECAY\n)\nscheduler = optim.lr_scheduler.OneCycleLR(\n    optimizer, max_lr=LR,\n    steps_per_epoch=len(train_loader),\n    epochs=EPOCHS\n)\nprint('Focal Loss | AdamW | OneCycleLR — ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T18:41:57.410274Z","iopub.execute_input":"2026-05-07T18:41:57.410633Z","iopub.status.idle":"2026-05-07T18:41:57.418616Z","shell.execute_reply.started":"2026-05-07T18:41:57.410607Z","shell.execute_reply":"2026-05-07T18:41:57.417835Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 7 — Training Loop","metadata":{}},{"cell_type":"code","source":"import os\n\n# ── Config guard ──────────────────────────────────────────────────────────────\nSAVE_DIR = globals().get('SAVE_DIR', '/kaggle/working')\nPATIENCE = globals().get('PATIENCE', 5)\nEPOCHS   = globals().get('EPOCHS',   15) \n\n# ── Paths & history ───────────────────────────────────────────────────────────\nbest_path = f'{SAVE_DIR}/resnet50_best.pt'\nhistory   = {'train_loss':[], 'val_loss':[],\n             'train_acc':[],  'val_acc':[],\n             'train_auc':[],  'val_auc':[],\n             'val_f1':[]}\nbest_auc   = 0.0\nno_improve = 0\n\n# ── Resume from checkpoint if exists ─────────────────────────────────────────\nif os.path.exists(best_path):\n    ckpt = torch.load(best_path, map_location=DEVICE, weights_only=False)\n    model.load_state_dict(ckpt['model_state'])\n    best_auc    = ckpt.get('val_auc', 0.0)\n    start_epoch = ckpt.get('epoch', 0) + 1\n    print(f'Resumed from epoch {start_epoch-1}, best AUC {best_auc:.4f}')\nelse:\n    start_epoch = 1\n    print('Starting fresh')\n\n# ── Epoch function ────────────────────────────────────────────────────────────\ndef run_epoch(model, loader, criterion, optimizer=None,\n              scheduler=None, train=True):\n    model.train(train)\n    total_loss, all_preds, all_labels, all_probs = 0.0, [], [], []\n    with torch.set_grad_enabled(train):\n        for imgs, labels in loader:\n            imgs   = imgs.to(DEVICE)\n            labels = labels.to(DEVICE)\n            logits = model(imgs)\n            loss   = criterion(logits, labels)\n            if train:\n                optimizer.zero_grad()\n                loss.backward()\n                nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                optimizer.step()\n                if scheduler: scheduler.step()\n            total_loss += loss.item() * imgs.size(0)\n            probs  = torch.softmax(logits, 1)[:, 1].detach().cpu().numpy()\n            preds  = logits.argmax(1).detach().cpu().numpy()\n            all_probs.extend(probs)\n            all_preds.extend(preds)\n            all_labels.extend(labels.cpu().numpy())\n    n          = len(loader.dataset)\n    avg_loss   = total_loss / n\n    labels_arr = np.array(all_labels)\n    probs_arr  = np.array(all_probs)\n    preds_arr  = np.array(all_preds)\n    acc        = accuracy_score(labels_arr, preds_arr)\n    auc        = roc_auc_score(labels_arr, probs_arr) \\\n                 if len(np.unique(labels_arr)) > 1 else 0.5\n    f1         = f1_score(labels_arr, preds_arr, zero_division=0)\n    return avg_loss, acc, auc, f1, labels_arr, probs_arr\n\n# ── Training loop ─────────────────────────────────────────────────────────────\nprint(f'{\"=\"*65}')\nprint(f'  Training ResNet-50 Classifier on {DEVICE}')\nprint(f'{\"=\"*65}')\nfor epoch in range(start_epoch, EPOCHS + 1):\n    t0 = time.time()\n    tr_loss, tr_acc, tr_auc, tr_f1, _, _ = run_epoch(\n        model, train_loader, criterion, optimizer, scheduler, train=True)\n    vl_loss, vl_acc, vl_auc, vl_f1, _, _ = run_epoch(\n        model, val_loader, criterion, train=False)\n    history['train_loss'].append(tr_loss)\n    history['val_loss'].append(vl_loss)\n    history['train_acc'].append(tr_acc)\n    history['val_acc'].append(vl_acc)\n    history['train_auc'].append(tr_auc)\n    history['val_auc'].append(vl_auc)\n    history['val_f1'].append(vl_f1)\n    elapsed = time.time() - t0\n    print(f'Epoch {epoch:02d}/{EPOCHS} | '\n          f'Tr Loss {tr_loss:.4f} Acc {tr_acc:.3f} AUC {tr_auc:.3f} | '\n          f'Val Loss {vl_loss:.4f} Acc {vl_acc:.3f} AUC {vl_auc:.3f} '\n          f'F1 {vl_f1:.3f} | {elapsed:.0f}s')\n    if vl_auc > best_auc:\n        best_auc   = vl_auc\n        no_improve = 0\n        torch.save({'epoch'      : epoch,\n                    'model_state': model.state_dict(),\n                    'val_auc'    : vl_auc,\n                    'val_f1'     : vl_f1}, best_path)\n        print(f'  ✓ Best model saved  (AUC={vl_auc:.4f})')\n    else:\n        no_improve += 1\n        if no_improve >= PATIENCE:\n            print(f'  Early stopping at epoch {epoch}')\n            break\n\n# ── Reload best weights ───────────────────────────────────────────────────────\nckpt = torch.load(best_path, map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(ckpt['model_state'])\nprint(f'\\nBest Val AUC : {ckpt[\"val_auc\"]:.4f}')\nprint(f'Best Val F1  : {ckpt[\"val_f1\"]:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T19:00:13.400147Z","iopub.execute_input":"2026-05-07T19:00:13.400855Z","iopub.status.idle":"2026-05-07T20:39:04.008478Z","shell.execute_reply.started":"2026-05-07T19:00:13.400807Z","shell.execute_reply":"2026-05-07T20:39:04.007508Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 8 — Training Curves","metadata":{}},{"cell_type":"code","source":"epochs_ran = range(1, len(history['train_loss']) + 1)\n\nfig, axes = plt.subplots(1, 3, figsize=(18, 5))\nfig.suptitle('ResNet-50 — Training History', fontsize=14, fontweight='bold')\n\n# Loss\naxes[0].plot(epochs_ran, history['train_loss'], label='Train', marker='o')\naxes[0].plot(epochs_ran, history['val_loss'],   label='Val',   marker='o')\naxes[0].set_title('Focal Loss')\naxes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')\naxes[0].legend(); axes[0].grid(True, alpha=0.3)\n\n# Accuracy\naxes[1].plot(epochs_ran, history['train_acc'], label='Train', marker='o')\naxes[1].plot(epochs_ran, history['val_acc'],   label='Val',   marker='o')\naxes[1].set_title('Accuracy')\naxes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Accuracy')\naxes[1].legend(); axes[1].grid(True, alpha=0.3)\n\n# AUC\naxes[2].plot(epochs_ran, history['train_auc'], label='Train', marker='o')\naxes[2].plot(epochs_ran, history['val_auc'],   label='Val',   marker='o')\naxes[2].set_title('AUC-ROC')\naxes[2].set_xlabel('Epoch'); axes[2].set_ylabel('AUC')\naxes[2].legend(); axes[2].grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig(f'{SAVE_DIR}/resnet50_training_curves.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: resnet50_training_curves.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:48:18.252864Z","iopub.execute_input":"2026-05-07T20:48:18.253188Z","iopub.status.idle":"2026-05-07T20:48:19.217511Z","shell.execute_reply.started":"2026-05-07T20:48:18.253155Z","shell.execute_reply":"2026-05-07T20:48:19.216715Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 9 — Full Evaluation on Test Set\n\nComputes all metrics from **Section X** of the paper:\nAccuracy, Precision, Recall, F1, AUC-ROC + clinical metrics.","metadata":{}},{"cell_type":"code","source":"# ── Run on test set ──────────────────────────────────────────────────────────\n_, _, _, _, test_labels, test_probs = run_epoch(\n    model, test_loader, criterion, train=False)\n\ntest_preds = (test_probs >= 0.5).astype(int)\n\n# ── Find optimal threshold (max F1) ─────────────────────────────────────────\nthresholds  = np.linspace(0.1, 0.9, 81)\nf1_scores   = [f1_score(test_labels, (test_probs >= t).astype(int),\n                         zero_division=0) for t in thresholds]\nbest_thresh = thresholds[np.argmax(f1_scores)]\ntest_preds_opt = (test_probs >= best_thresh).astype(int)\n\n# ── Compute all metrics ──────────────────────────────────────────────────────\ncm  = confusion_matrix(test_labels, test_preds_opt)\ntn, fp, fn, tp = cm.ravel()\n\nmetrics = {\n    'Accuracy'   : accuracy_score(test_labels, test_preds_opt),\n    'Precision'  : precision_score(test_labels, test_preds_opt, zero_division=0),\n    'Recall'     : recall_score(test_labels, test_preds_opt, zero_division=0),\n    'F1-Score'   : f1_score(test_labels, test_preds_opt, zero_division=0),\n    'AUC-ROC'    : roc_auc_score(test_labels, test_probs),\n    'Sensitivity': tp / (tp + fn) if (tp+fn) > 0 else 0,\n    'Specificity': tn / (tn + fp) if (tn+fp) > 0 else 0,\n    'PPV'        : tp / (tp + fp) if (tp+fp) > 0 else 0,\n    'NPV'        : tn / (tn + fn) if (tn+fn) > 0 else 0,\n    'Opt.Thresh' : best_thresh,\n}\n\nprint('='*50)\nprint('  ResNet-50 — Test Set Results')\nprint('='*50)\nfor k, v in metrics.items():\n    print(f'  {k:<15}: {v:.4f}')\nprint(f'\\n  Confusion Matrix:')\nprint(f'  TN={tn}  FP={fp}')\nprint(f'  FN={fn}  TP={tp}')\n\n# Save metrics\npd.DataFrame([metrics]).to_csv(f'{SAVE_DIR}/resnet50_metrics.csv', index=False)\nprint('\\nSaved: resnet50_metrics.csv')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:48:46.168573Z","iopub.execute_input":"2026-05-07T20:48:46.169377Z","iopub.status.idle":"2026-05-07T20:50:21.931371Z","shell.execute_reply.started":"2026-05-07T20:48:46.169335Z","shell.execute_reply":"2026-05-07T20:50:21.930529Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 10 — ROC Curve + Confusion Matrix + PR Curve","metadata":{}},{"cell_type":"code","source":"fig, axes = plt.subplots(1, 3, figsize=(18, 5))\nfig.suptitle('ResNet-50 — Evaluation Plots', fontsize=14, fontweight='bold')\n\n# ── 1. ROC Curve ─────────────────────────────────────────────────────────────\nfpr, tpr, _ = roc_curve(test_labels, test_probs)\nauc_val     = roc_auc_score(test_labels, test_probs)\naxes[0].plot(fpr, tpr, color='tomato', lw=2,\n             label=f'AUC = {auc_val:.4f}')\naxes[0].plot([0,1],[0,1],'k--', lw=1, label='Random')\naxes[0].fill_between(fpr, tpr, alpha=0.1, color='tomato')\naxes[0].set_title('ROC Curve')\naxes[0].set_xlabel('False Positive Rate')\naxes[0].set_ylabel('True Positive Rate')\naxes[0].legend(); axes[0].grid(True, alpha=0.3)\n\n# ── 2. Confusion Matrix ───────────────────────────────────────────────────────\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[1],\n            xticklabels=['Normal','Pneumonia'],\n            yticklabels=['Normal','Pneumonia'])\naxes[1].set_title('Confusion Matrix')\naxes[1].set_xlabel('Predicted')\naxes[1].set_ylabel('True')\n\n# ── 3. Precision-Recall Curve ─────────────────────────────────────────────────\nprec, rec, _ = precision_recall_curve(test_labels, test_probs)\nap           = average_precision_score(test_labels, test_probs)\naxes[2].plot(rec, prec, color='steelblue', lw=2,\n             label=f'AP = {ap:.4f}')\naxes[2].fill_between(rec, prec, alpha=0.1, color='steelblue')\naxes[2].axhline(y=test_labels.mean(), color='k', linestyle='--',\n                label=f'Baseline ({test_labels.mean():.2f})')\naxes[2].set_title('Precision-Recall Curve')\naxes[2].set_xlabel('Recall')\naxes[2].set_ylabel('Precision')\naxes[2].legend(); axes[2].grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig(f'{SAVE_DIR}/resnet50_evaluation.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: resnet50_evaluation.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:50:31.520400Z","iopub.execute_input":"2026-05-07T20:50:31.520702Z","iopub.status.idle":"2026-05-07T20:50:32.608203Z","shell.execute_reply.started":"2026-05-07T20:50:31.520671Z","shell.execute_reply":"2026-05-07T20:50:32.607002Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 11 — Grad-CAM Visualization\n\nShows which regions of the X-ray drove the model's decision.\nDirectly implements the explainability component from the paper.","metadata":{}},{"cell_type":"code","source":"class GradCAM:\n    \"\"\"Gradient-weighted Class Activation Mapping.\"\"\"\n\n    def __init__(self, model: nn.Module, target_layer: nn.Module):\n        self.model        = model\n        self.gradients    = None\n        self.activations  = None\n        # Register hooks on the target layer\n        target_layer.register_forward_hook(self._save_activation)\n        target_layer.register_backward_hook(self._save_gradient)\n\n    def _save_activation(self, module, input, output):\n        self.activations = output.detach()\n\n    def _save_gradient(self, module, grad_in, grad_out):\n        self.gradients = grad_out[0].detach()\n\n    def generate(self, img_tensor: torch.Tensor,\n                 class_idx: int = 1) -> np.ndarray:\n        self.model.eval()\n        img_tensor = img_tensor.unsqueeze(0).to(DEVICE)\n        logits     = self.model(img_tensor)\n        self.model.zero_grad()\n        logits[0, class_idx].backward()\n\n        # Global average pool the gradients\n        weights  = self.gradients.mean(dim=[2, 3], keepdim=True)\n        cam      = (weights * self.activations).sum(dim=1, keepdim=True)\n        cam      = F.relu(cam).squeeze().cpu().numpy()\n        cam      = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)\n        return cam\n\n\ndef show_gradcam(model, dataset, n_images: int = 8, save_path: str = None):\n    # Hook onto the last residual block of layer4\n    target_layer = list(model.features.children())[-2][-1].conv3\n    gradcam      = GradCAM(model, target_layer)\n\n    # Pick n_images — mix of TP and pneumonia cases\n    indices = random.sample(range(len(dataset)), n_images)\n\n    fig, axes = plt.subplots(2, n_images, figsize=(n_images * 2.5, 6))\n    fig.suptitle('Grad-CAM — Model Attention on X-rays\\n'\n                 'Top: Original | Bottom: Grad-CAM Heatmap',\n                 fontsize=12, fontweight='bold')\n\n    for col, idx in enumerate(indices):\n        tensor, label = dataset[idx]\n\n        # Original image (denormalize for display)\n        mean = torch.tensor([0.485, 0.456, 0.406]).view(3,1,1)\n        std  = torch.tensor([0.229, 0.224, 0.225]).view(3,1,1)\n        img_disp = (tensor * std + mean).permute(1,2,0).numpy()\n        img_disp = np.clip(img_disp, 0, 1)\n\n        # Grad-CAM\n        cam     = gradcam.generate(tensor, class_idx=1)\n        cam_res = cv2.resize(cam, (IMG_SIZE, IMG_SIZE))\n        heatmap = cv2.applyColorMap((cam_res * 255).astype(np.uint8),\n                                     cv2.COLORMAP_JET)\n        heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) / 255.0\n        overlay = np.clip(img_disp * 0.6 + heatmap * 0.4, 0, 1)\n\n        # Prediction\n        with torch.no_grad():\n            prob  = torch.softmax(model(tensor.unsqueeze(0).to(DEVICE)), 1)[0,1].item()\n        pred  = int(prob >= best_thresh)\n        color = 'green' if pred == label else 'red'\n\n        axes[0, col].imshow(img_disp)\n        axes[0, col].set_title(\n            f'True:{label} Pred:{pred}\\n{prob:.2%}',\n            fontsize=8, color=color)\n        axes[0, col].axis('off')\n\n        axes[1, col].imshow(overlay)\n        axes[1, col].axis('off')\n\n    plt.tight_layout()\n    if save_path:\n        plt.savefig(save_path, dpi=150, bbox_inches='tight')\n        print(f'Saved: {save_path}')\n    plt.show()\n\n\nshow_gradcam(model, test_ds, n_images=8,\n             save_path=f'{SAVE_DIR}/resnet50_gradcam.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:55:16.606759Z","iopub.execute_input":"2026-05-07T20:55:16.607622Z","iopub.status.idle":"2026-05-07T20:55:19.855823Z","shell.execute_reply.started":"2026-05-07T20:55:16.607590Z","shell.execute_reply":"2026-05-07T20:55:19.854834Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(f'Optimal threshold : {best_thresh:.3f}')\nprint(f'AUC-ROC           : {metrics[\"AUC-ROC\"]:.4f}')\nprint(f'F1 at best thresh : {metrics[\"F1-Score\"]:.4f}')\nprint(f'Recall            : {metrics[\"Recall\"]:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:56:01.006241Z","iopub.execute_input":"2026-05-07T20:56:01.006546Z","iopub.status.idle":"2026-05-07T20:56:01.011774Z","shell.execute_reply.started":"2026-05-07T20:56:01.006519Z","shell.execute_reply":"2026-05-07T20:56:01.010882Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 12 — Final Summary Table","metadata":{}},{"cell_type":"code","source":"# Inference speed\nmodel.eval()\ndummy   = torch.randn(1, 3, IMG_SIZE, IMG_SIZE).to(DEVICE)\nt_start = time.time()\nwith torch.no_grad():\n    for _ in range(50): model(dummy)\ninf_time = (time.time() - t_start) / 50\n\n# Print summary\nprint('='*55)\nprint('  ResNet-50 Classifier — Final Summary')\nprint('='*55)\nsummary = {\n    'Model'           : 'ResNet-50 (fine-tuned)',\n    'Backbone'        : 'ResNet-50 pretrained ImageNet',\n    'Loss'            : 'Focal Loss (α=0.25, γ=2.0)',\n    'Accuracy'        : f\"{metrics['Accuracy']:.4f}\",\n    'AUC-ROC'         : f\"{metrics['AUC-ROC']:.4f}\",\n    'F1-Score'        : f\"{metrics['F1-Score']:.4f}\",\n    'Precision'       : f\"{metrics['Precision']:.4f}\",\n    'Recall'          : f\"{metrics['Recall']:.4f}\",\n    'Sensitivity'     : f\"{metrics['Sensitivity']:.4f}\",\n    'Specificity'     : f\"{metrics['Specificity']:.4f}\",\n    'Inference time'  : f'{inf_time*1000:.1f} ms/image',\n    'Best epoch AUC'  : f\"{ckpt['val_auc']:.4f}\",\n}\nfor k, v in summary.items():\n    print(f'  {k:<20}: {v}')\n\n# Save full results JSON\nwith open(f'{SAVE_DIR}/resnet50_results.json', 'w') as f:\n    json.dump(summary, f, indent=2)\nprint('\\nSaved: resnet50_results.json')\nprint('\\n✓ Step 2 complete — ResNet-50 Classifier done')\nprint('  Next: Step 3 — Faster R-CNN Detection')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:57:24.516607Z","iopub.execute_input":"2026-05-07T20:57:24.517343Z","iopub.status.idle":"2026-05-07T20:57:25.363165Z","shell.execute_reply.started":"2026-05-07T20:57:24.517311Z","shell.execute_reply":"2026-05-07T20:57:25.362227Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Pneumonia Detection — DL Branch\n## Step 3: Faster R-CNN Object Detector\n\nImplements the **Detection Branch** from **Section VII-B** of the Phase 2 paper:\n- Faster R-CNN with ResNet-50-FPN backbone (Figure 3 of paper)\n- Region Proposal Network (RPN) → ROI Align → Box predictor\n- Built-in NMS post-processing\n- Evaluation: IoU, mAP@0.5 (Section X-B of paper)\n- Bounding box visualizations: predicted (red) vs ground truth (blue)","metadata":{}},{"cell_type":"markdown","source":"## Cell 1 — Imports & Setup","metadata":{}},{"cell_type":"code","source":"import os, time, random, json\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as patches\nimport seaborn as sns\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple\n\nimport cv2\nimport pydicom\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\n\n# Faster R-CNN from torchvision\nfrom torchvision.models.detection import (\n    fasterrcnn_resnet50_fpn,\n    FasterRCNN_ResNet50_FPN_Weights\n)\nfrom torchvision.models.detection.faster_rcnn import FastRCNNPredictor\nimport torchvision.ops as ops\n\nfrom sklearn.model_selection import train_test_split\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device : {DEVICE}')\nif torch.cuda.is_available():\n    print(f'GPU    : {torch.cuda.get_device_name(0)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:58:14.169058Z","iopub.execute_input":"2026-05-07T20:58:14.169859Z","iopub.status.idle":"2026-05-07T20:58:14.178629Z","shell.execute_reply.started":"2026-05-07T20:58:14.169786Z","shell.execute_reply":"2026-05-07T20:58:14.177701Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 2 — Config","metadata":{}},{"cell_type":"code","source":"DATA_DIR      = '/kaggle/input/competitions/rsna-pneumonia-detection-challenge'\nTRAIN_IMG_DIR = f'{DATA_DIR}/stage_2_train_images'\nTRAIN_LABELS  = f'{DATA_DIR}/stage_2_train_labels.csv'\nCLASS_INFO    = f'{DATA_DIR}/stage_2_detailed_class_info.csv'\nSAVE_DIR      = '/kaggle/working'\n\nIMG_SIZE      = 512\nBATCH_SIZE    = 4       # detection needs more memory per image\nNUM_WORKERS   = 2\nEPOCHS        = 10\nLR            = 5e-4\nWEIGHT_DECAY  = 1e-4\nPATIENCE      = 3\nIOU_THRESHOLD = 0.5     # Section X-B: detection correct when IoU >= 0.5\nCONF_THRESH   = 0.5     # minimum confidence to keep a prediction\n\nWINDOW_LEVEL  = -500\nWINDOW_WIDTH  = 1500\n\nprint('Config loaded')\nprint(f'  Batch size    : {BATCH_SIZE}')\nprint(f'  IoU threshold : {IOU_THRESHOLD}')\nprint(f'  Conf threshold: {CONF_THRESH}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:58:18.618025Z","iopub.execute_input":"2026-05-07T20:58:18.618561Z","iopub.status.idle":"2026-05-07T20:58:18.624253Z","shell.execute_reply.started":"2026-05-07T20:58:18.618527Z","shell.execute_reply":"2026-05-07T20:58:18.623238Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 3 — Preprocessor & Augmentation\n*(box-safe augmentation — transforms applied to image AND boxes together)*","metadata":{}},{"cell_type":"code","source":"class DICOMPreprocessor:\n    def __init__(self, window_level=-500, window_width=1500, use_clahe=True):\n        self.wl    = window_level\n        self.ww    = window_width\n        self.clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) if use_clahe else None\n\n    def __call__(self, path: str, size: int = 512) -> np.ndarray:\n        dcm = pydicom.dcmread(path)\n        img = dcm.pixel_array.astype(np.float32)\n        if getattr(dcm, 'PhotometricInterpretation', '') == 'MONOCHROME1':\n            img = img.max() - img\n        slope     = float(getattr(dcm, 'RescaleSlope',     1))\n        intercept = float(getattr(dcm, 'RescaleIntercept', 0))\n        img  = img * slope + intercept\n        wmin = self.wl - self.ww / 2\n        wmax = self.wl + self.ww / 2\n        img  = np.clip(img, wmin, wmax)\n        img  = (img - wmin) / (wmax - wmin)\n        if self.clahe:\n            img_u8 = (img * 255).astype(np.uint8)\n            img    = self.clahe.apply(img_u8).astype(np.float32) / 255.0\n        img_u8  = (img * 255).astype(np.uint8)\n        resized = cv2.resize(img_u8, (size, size), interpolation=cv2.INTER_AREA)\n        return cv2.cvtColor(resized, cv2.COLOR_GRAY2RGB)\n\n\ndef get_det_train_transforms(size):\n    return A.Compose([\n        A.Resize(size, size),\n        A.HorizontalFlip(p=0.5),\n        A.Rotate(limit=10, p=0.3),\n        A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.4),\n        A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n        ToTensorV2(),\n    ], bbox_params=A.BboxParams(\n        format='pascal_voc',\n        label_fields=['class_labels'],\n        min_visibility=0.3\n    ))\n\ndef get_det_val_transforms(size):\n    return A.Compose([\n        A.Resize(size, size),\n        A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n        ToTensorV2(),\n    ], bbox_params=A.BboxParams(\n        format='pascal_voc',\n        label_fields=['class_labels'],\n        min_visibility=0.3\n    ))\n\npreprocessor = DICOMPreprocessor(WINDOW_LEVEL, WINDOW_WIDTH)\nprint('Preprocessor & box-safe augmentations ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:58:27.634762Z","iopub.execute_input":"2026-05-07T20:58:27.635053Z","iopub.status.idle":"2026-05-07T20:58:27.645648Z","shell.execute_reply.started":"2026-05-07T20:58:27.635029Z","shell.execute_reply":"2026-05-07T20:58:27.644883Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 4 — Detection Dataset","metadata":{}},{"cell_type":"code","source":"class RSNADetectionDataset(Dataset):\n    \"\"\"\n    Returns image tensor + Faster R-CNN target dict:\n      {'boxes': Tensor(N,4), 'labels': Tensor(N,), 'image_id': Tensor(1,)}\n    Boxes in pascal_voc format [x1, y1, x2, y2].\n    \"\"\"\n    def __init__(self, df, full_df, img_dir, transforms):\n        self.df         = df.drop_duplicates('patientId').reset_index(drop=True)\n        self.full_df    = full_df\n        self.img_dir    = img_dir\n        self.transforms = transforms\n        self.prep       = DICOMPreprocessor(WINDOW_LEVEL, WINDOW_WIDTH)\n\n    def __len__(self): return len(self.df)\n\n    def _get_boxes(self, patient_id):\n        rows = self.full_df[\n            (self.full_df['patientId'] == patient_id) &\n            (self.full_df['Target']    == 1)\n        ].dropna(subset=['x','y','width','height'])\n        boxes = []\n        for _, r in rows.iterrows():\n            x1 = float(r['x'])\n            y1 = float(r['y'])\n            x2 = x1 + float(r['width'])\n            y2 = y1 + float(r['height'])\n            # Clamp to image bounds\n            x1, y1 = max(0, x1), max(0, y1)\n            x2, y2 = min(1024, x2), min(1024, y2)\n            if x2 > x1 and y2 > y1:\n                boxes.append([x1, y1, x2, y2])\n        return boxes\n\n    def __getitem__(self, idx):\n        row        = self.df.iloc[idx]\n        patient_id = row['patientId']\n        path       = f\"{self.img_dir}/{patient_id}.dcm\"\n\n        img_rgb    = self.prep(path, size=IMG_SIZE)   # uint8 HxWx3\n\n        # Scale boxes from 1024px → IMG_SIZE\n        scale      = IMG_SIZE / 1024.0\n        boxes_orig = self._get_boxes(patient_id)\n        boxes      = [[c * scale for c in b] for b in boxes_orig]\n\n        # Augment (boxes move with image)\n        cls_labels = [1] * len(boxes)\n        aug = self.transforms(\n            image=img_rgb, bboxes=boxes, class_labels=cls_labels)\n        img_tensor = aug['image']               # float32 CxHxW\n        aug_boxes  = list(aug['bboxes'])\n\n        # Build target dict for Faster R-CNN\n        if len(aug_boxes) > 0:\n            boxes_t  = torch.tensor(aug_boxes, dtype=torch.float32)\n            labels_t = torch.ones(len(aug_boxes), dtype=torch.int64)\n        else:\n            boxes_t  = torch.zeros((0,4), dtype=torch.float32)\n            labels_t = torch.zeros(0,     dtype=torch.int64)\n\n        target = {\n            'boxes'   : boxes_t,\n            'labels'  : labels_t,\n            'image_id': torch.tensor([idx]),\n            # Store original boxes for evaluation\n            'boxes_orig': torch.tensor(boxes, dtype=torch.float32)\n                          if boxes else torch.zeros((0,4), dtype=torch.float32)\n        }\n        return img_tensor, target\n\n\ndef det_collate(batch):\n    return [b[0] for b in batch], [b[1] for b in batch]\n\n\n# ── Load data ────────────────────────────────────────────────────────────────\nlabels_df = pd.read_csv(TRAIN_LABELS)\nclass_df  = pd.read_csv(CLASS_INFO)\ndf        = pd.concat([labels_df, class_df.drop('patientId',axis=1)], axis=1)\ndf['Target'] = df['Target'].astype(int)\n\npatient_df = df.drop_duplicates('patientId')[['patientId','Target']]\ntrain_ids, temp_ids = train_test_split(\n    patient_df['patientId'], test_size=0.30,\n    stratify=patient_df['Target'], random_state=SEED)\nval_ids, test_ids = train_test_split(\n    temp_ids, test_size=0.50,\n    stratify=patient_df.set_index('patientId').loc[temp_ids,'Target'],\n    random_state=SEED)\n\ntrain_df = df[df['patientId'].isin(train_ids)]\nval_df   = df[df['patientId'].isin(val_ids)]\ntest_df  = df[df['patientId'].isin(test_ids)]\n\ntrain_det_ds = RSNADetectionDataset(\n    train_df, df, TRAIN_IMG_DIR, get_det_train_transforms(IMG_SIZE))\nval_det_ds   = RSNADetectionDataset(\n    val_df,   df, TRAIN_IMG_DIR, get_det_val_transforms(IMG_SIZE))\ntest_det_ds  = RSNADetectionDataset(\n    test_df,  df, TRAIN_IMG_DIR, get_det_val_transforms(IMG_SIZE))\n\ntrain_det_loader = DataLoader(train_det_ds, batch_size=BATCH_SIZE,\n    shuffle=True,  num_workers=NUM_WORKERS, collate_fn=det_collate, pin_memory=True)\nval_det_loader   = DataLoader(val_det_ds,   batch_size=BATCH_SIZE,\n    shuffle=False, num_workers=NUM_WORKERS, collate_fn=det_collate, pin_memory=True)\ntest_det_loader  = DataLoader(test_det_ds,  batch_size=BATCH_SIZE,\n    shuffle=False, num_workers=NUM_WORKERS, collate_fn=det_collate, pin_memory=True)\n\nprint(f'Train : {len(train_det_ds):,} images  ({len(train_det_loader)} batches)')\nprint(f'Val   : {len(val_det_ds):,}  images  ({len(val_det_loader)} batches)')\nprint(f'Test  : {len(test_det_ds):,}  images  ({len(test_det_loader)} batches)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:58:33.313935Z","iopub.execute_input":"2026-05-07T20:58:33.314205Z","iopub.status.idle":"2026-05-07T20:58:33.439817Z","shell.execute_reply.started":"2026-05-07T20:58:33.314183Z","shell.execute_reply":"2026-05-07T20:58:33.439092Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 5 — Faster R-CNN Model\n\nExactly as described in Figure 3 of the paper:\n- **Backbone**: ResNet-50 pretrained on ImageNet\n- **Neck**: Feature Pyramid Network (FPN) with {C3, C4, C5}\n- **Head**: RPN → ROI Align → FastRCNN predictor\n- **Post-processing**: NMS built-in (IoU threshold = 0.5)\n\nnum_classes = 2 → background (0) + pneumonia (1)","metadata":{}},{"cell_type":"code","source":"def build_faster_rcnn(num_classes: int = 2) -> nn.Module:\n    \"\"\"\n    Faster R-CNN with ResNet-50-FPN backbone.\n    Pretrained on COCO, head replaced for num_classes.\n    \"\"\"\n    # Load COCO pretrained model\n    model = fasterrcnn_resnet50_fpn(\n        weights=FasterRCNN_ResNet50_FPN_Weights.DEFAULT\n    )\n\n    # Replace the box predictor head\n    in_features = model.roi_heads.box_predictor.cls_score.in_features\n    model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)\n\n    return model\n\n\ndet_model = build_faster_rcnn(num_classes=2).to(DEVICE)\n\n# Separate LRs: lower for backbone, higher for head\nbackbone_params = [p for n, p in det_model.named_parameters()\n                   if 'backbone' in n and p.requires_grad]\nhead_params     = [p for n, p in det_model.named_parameters()\n                   if 'backbone' not in n and p.requires_grad]\n\noptimizer = optim.SGD([\n    {'params': backbone_params, 'lr': LR * 0.1},\n    {'params': head_params,     'lr': LR}\n], momentum=0.9, weight_decay=WEIGHT_DECAY)\n\nscheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.5)\n\ntotal     = sum(p.numel() for p in det_model.parameters())\ntrainable = sum(p.numel() for p in det_model.parameters() if p.requires_grad)\nprint(f'Faster R-CNN built')\nprint(f'  Total params    : {total:,}')\nprint(f'  Trainable params: {trainable:,}')\nprint(f'  Backbone LR     : {LR*0.1}')\nprint(f'  Head LR         : {LR}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:58:42.665850Z","iopub.execute_input":"2026-05-07T20:58:42.666277Z","iopub.status.idle":"2026-05-07T20:58:44.308025Z","shell.execute_reply.started":"2026-05-07T20:58:42.666248Z","shell.execute_reply":"2026-05-07T20:58:44.307086Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 6 — Training Loop","metadata":{}},{"cell_type":"code","source":"import os\n\n# ── Config guard ──────────────────────────────────────────────────────────────\nSAVE_DIR = globals().get('SAVE_DIR', '/kaggle/working')\nPATIENCE = globals().get('PATIENCE', 3)\nEPOCHS   = globals().get('EPOCHS',   10)\n\n# ── Paths & history ───────────────────────────────────────────────────────────\nbest_path = f'{SAVE_DIR}/fasterrcnn_best.pt'\nhistory   = {'train_loss':[], 'val_loss':[], 'epoch_time':[]}\nbest_loss  = float('inf')\nno_improve = 0\n\n# ── Resume from checkpoint if exists ─────────────────────────────────────────\nif os.path.exists(best_path):\n    ckpt = torch.load(best_path, map_location=DEVICE, weights_only=False)\n    det_model.load_state_dict(ckpt['model_state'])\n    best_loss   = ckpt.get('val_loss', float('inf'))\n    start_epoch = ckpt.get('epoch', 0) + 1\n    print(f'Resumed from epoch {start_epoch-1}, best val loss {best_loss:.4f}')\nelse:\n    start_epoch = 1\n    print('Starting fresh')\n\n# ── Train one epoch ───────────────────────────────────────────────────────────\ndef train_one_epoch(model, loader, optimizer):\n    model.train()\n    total_loss     = 0.0\n    loss_cls_total = 0.0\n    loss_box_total = 0.0\n    loss_rpn_total = 0.0\n    n_batches      = 0\n    for images, targets in loader:\n        images  = [img.to(DEVICE) for img in images]\n        targets = [{k: v.to(DEVICE) for k, v in t.items()\n                    if k != 'boxes_orig'} for t in targets]\n        has_box = any(len(t['boxes']) > 0 for t in targets)\n        if not has_box:\n            continue\n        loss_dict = model(images, targets)\n        loss      = sum(loss_dict.values())\n        optimizer.zero_grad()\n        loss.backward()\n        nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n        optimizer.step()\n        total_loss     += loss.item()\n        loss_cls_total += loss_dict.get('loss_classifier',  torch.tensor(0)).item()\n        loss_box_total += loss_dict.get('loss_box_reg',     torch.tensor(0)).item()\n        loss_rpn_total += loss_dict.get('loss_rpn_box_reg', torch.tensor(0)).item()\n        n_batches      += 1\n    n = max(n_batches, 1)\n    return {\n        'total'     : total_loss / n,\n        'classifier': loss_cls_total / n,\n        'box_reg'   : loss_box_total / n,\n        'rpn'       : loss_rpn_total / n,\n    }\n\n# ── Val one epoch ─────────────────────────────────────────────────────────────\n@torch.no_grad()\ndef val_one_epoch(model, loader):\n    model.train()  # needed to get loss_dict\n    total_loss = 0.0\n    n_batches  = 0\n    for images, targets in loader:\n        images  = [img.to(DEVICE) for img in images]\n        targets = [{k: v.to(DEVICE) for k, v in t.items()\n                    if k != 'boxes_orig'} for t in targets]\n        has_box = any(len(t['boxes']) > 0 for t in targets)\n        if not has_box:\n            continue\n        loss_dict  = model(images, targets)\n        total_loss += sum(loss_dict.values()).item()\n        n_batches  += 1\n    return total_loss / max(n_batches, 1)\n\n# ── Training loop ─────────────────────────────────────────────────────────────\nprint(f'{\"=\"*65}')\nprint(f'  Training Faster R-CNN on {DEVICE}')\nprint(f'  Epochs: {EPOCHS}  |  Batch: {BATCH_SIZE}  |  LR: {LR}')\nprint(f'{\"=\"*65}')\nfor epoch in range(start_epoch, EPOCHS + 1):\n    t0        = time.time()\n    tr_losses = train_one_epoch(det_model, train_det_loader, optimizer)\n    val_loss  = val_one_epoch(det_model, val_det_loader)\n    scheduler.step()\n    elapsed   = time.time() - t0\n    history['train_loss'].append(tr_losses['total'])\n    history['val_loss'].append(val_loss)\n    history['epoch_time'].append(elapsed)\n    print(f'Epoch {epoch:02d}/{EPOCHS} | '\n          f'Tr {tr_losses[\"total\"]:.4f} '\n          f'(cls={tr_losses[\"classifier\"]:.3f} '\n          f'box={tr_losses[\"box_reg\"]:.3f} '\n          f'rpn={tr_losses[\"rpn\"]:.3f}) | '\n          f'Val {val_loss:.4f} | {elapsed:.0f}s')\n    if val_loss < best_loss:\n        best_loss  = val_loss\n        no_improve = 0\n        torch.save({'epoch'      : epoch,\n                    'model_state': det_model.state_dict(),\n                    'val_loss'   : val_loss}, best_path)\n        print(f'  ✓ Saved best model (val_loss={val_loss:.4f})')\n    else:\n        no_improve += 1\n        if no_improve >= PATIENCE:\n            print(f'  Early stopping at epoch {epoch}')\n            break\n\n# ── Reload best weights ───────────────────────────────────────────────────────\nckpt = torch.load(best_path, map_location=DEVICE, weights_only=False)\ndet_model.load_state_dict(ckpt['model_state'])\nprint(f'\\nBest val loss: {ckpt[\"val_loss\"]:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-07T20:59:01.900632Z","iopub.execute_input":"2026-05-07T20:59:01.901399Z","iopub.status.idle":"2026-05-08T03:47:17.297528Z","shell.execute_reply.started":"2026-05-07T20:59:01.901367Z","shell.execute_reply":"2026-05-08T03:47:17.296325Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 7 — Training Loss Curves","metadata":{}},{"cell_type":"code","source":"epochs_ran = range(1, len(history['train_loss']) + 1)\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\nfig.suptitle('Faster R-CNN — Training History', fontsize=14, fontweight='bold')\n\naxes[0].plot(epochs_ran, history['train_loss'], label='Train', marker='o', color='tomato')\naxes[0].plot(epochs_ran, history['val_loss'],   label='Val',   marker='o', color='steelblue')\naxes[0].set_title('Total Loss')\naxes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')\naxes[0].legend(); axes[0].grid(True, alpha=0.3)\n\naxes[1].bar(epochs_ran, history['epoch_time'], color='steelblue', alpha=0.7)\naxes[1].set_title('Training Time per Epoch')\naxes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Seconds')\naxes[1].grid(True, alpha=0.3, axis='y')\n\nplt.tight_layout()\nplt.savefig(f'{SAVE_DIR}/fasterrcnn_training_curves.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: fasterrcnn_training_curves.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T03:47:26.287245Z","iopub.execute_input":"2026-05-08T03:47:26.287896Z","iopub.status.idle":"2026-05-08T03:47:26.964018Z","shell.execute_reply.started":"2026-05-08T03:47:26.287831Z","shell.execute_reply":"2026-05-08T03:47:26.963218Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 8 — IoU & mAP@0.5 Evaluation\n\nImplements **Section X-B** of the paper exactly:\n- IoU measures overlap between predicted and ground-truth boxes\n- Detection counted correct when IoU ≥ 0.5\n- mAP@0.5 is the primary detection metric","metadata":{}},{"cell_type":"code","source":"def compute_iou(box_a: torch.Tensor, box_b: torch.Tensor) -> torch.Tensor:\n    \"\"\"\n    Compute IoU between two sets of boxes.\n    box_a: (N, 4)  box_b: (M, 4)  →  returns (N, M)\n    Boxes in [x1, y1, x2, y2] format.\n    \"\"\"\n    area_a = (box_a[:,2]-box_a[:,0]) * (box_a[:,3]-box_a[:,1])\n    area_b = (box_b[:,2]-box_b[:,0]) * (box_b[:,3]-box_b[:,1])\n\n    inter_x1 = torch.max(box_a[:,None,0], box_b[None,:,0])\n    inter_y1 = torch.max(box_a[:,None,1], box_b[None,:,1])\n    inter_x2 = torch.min(box_a[:,None,2], box_b[None,:,2])\n    inter_y2 = torch.min(box_a[:,None,3], box_b[None,:,3])\n\n    inter_w  = (inter_x2 - inter_x1).clamp(min=0)\n    inter_h  = (inter_y2 - inter_y1).clamp(min=0)\n    inter    = inter_w * inter_h\n\n    union    = area_a[:,None] + area_b[None,:] - inter\n    return inter / (union + 1e-8)\n\n\ndef compute_map_at_50(model, loader, iou_thresh=0.5, conf_thresh=0.5):\n    \"\"\"\n    Compute mAP@0.5 on a dataloader.\n    Returns mAP score + per-image IoU list.\n    \"\"\"\n    model.eval()\n    all_preds   = []   # (score, tp/fp)\n    n_gt_total  = 0\n    iou_list    = []\n\n    with torch.no_grad():\n        for images, targets in loader:\n            images = [img.to(DEVICE) for img in images]\n            preds  = model(images)   # list of dicts\n\n            for pred, target in zip(preds, targets):\n                gt_boxes = target['boxes'].to(DEVICE)\n                n_gt     = len(gt_boxes)\n                n_gt_total += n_gt\n\n                # Filter predictions by confidence\n                mask        = pred['scores'] >= conf_thresh\n                pred_boxes  = pred['boxes'][mask]\n                pred_scores = pred['scores'][mask]\n\n                if len(pred_boxes) == 0:\n                    continue\n\n                if n_gt == 0:\n                    # All predictions are FP\n                    for score in pred_scores:\n                        all_preds.append((score.item(), 0))\n                    continue\n\n                # Compute IoU matrix: (n_pred, n_gt)\n                iou_matrix  = compute_iou(pred_boxes, gt_boxes)\n                matched_gt  = set()\n\n                # Sort by score descending\n                order = pred_scores.argsort(descending=True)\n                for i in order:\n                    best_iou, best_gt = iou_matrix[i].max(0)\n                    iou_list.append(best_iou.item())\n\n                    if best_iou >= iou_thresh and best_gt.item() not in matched_gt:\n                        all_preds.append((pred_scores[i].item(), 1))  # TP\n                        matched_gt.add(best_gt.item())\n                    else:\n                        all_preds.append((pred_scores[i].item(), 0))  # FP\n\n    if not all_preds or n_gt_total == 0:\n        return 0.0, []\n\n    # Sort all predictions by score descending\n    all_preds.sort(key=lambda x: -x[0])\n    tp_cumsum = np.cumsum([p[1] for p in all_preds])\n    fp_cumsum = np.cumsum([1 - p[1] for p in all_preds])\n\n    precision = tp_cumsum / (tp_cumsum + fp_cumsum + 1e-8)\n    recall    = tp_cumsum / (n_gt_total + 1e-8)\n\n    # Area under P-R curve (AUC)\n    rec_vals  = np.concatenate([[0], recall,    [recall[-1]]])\n    prec_vals = np.concatenate([[0], precision, [0]])\n    map_score = np.trapz(prec_vals, rec_vals)\n\n    return float(map_score), iou_list\n\n\nprint('Computing mAP@0.5 on test set (this takes a few minutes)...')\nmap_score, iou_list = compute_map_at_50(\n    det_model, test_det_loader,\n    iou_thresh=IOU_THRESHOLD,\n    conf_thresh=CONF_THRESH\n)\n\nmean_iou = np.mean(iou_list) if iou_list else 0.0\nprint(f'\\n  mAP@0.5   : {map_score:.4f}')\nprint(f'  Mean IoU  : {mean_iou:.4f}')\nprint(f'  Detections: {len(iou_list)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T03:47:48.635721Z","iopub.execute_input":"2026-05-08T03:47:48.636056Z","iopub.status.idle":"2026-05-08T03:54:16.626168Z","shell.execute_reply.started":"2026-05-08T03:47:48.636031Z","shell.execute_reply":"2026-05-08T03:54:16.625193Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 9 — IoU Distribution Plot","metadata":{}},{"cell_type":"code","source":"fig, axes = plt.subplots(1, 2, figsize=(14, 5))\nfig.suptitle('Faster R-CNN — Detection Quality', fontsize=14, fontweight='bold')\n\n# IoU histogram\naxes[0].hist(iou_list, bins=40, color='steelblue', edgecolor='white', alpha=0.8)\naxes[0].axvline(x=IOU_THRESHOLD, color='red', linestyle='--', lw=2,\n                label=f'Threshold = {IOU_THRESHOLD}')\naxes[0].axvline(x=mean_iou, color='orange', linestyle='--', lw=2,\n                label=f'Mean IoU = {mean_iou:.3f}')\naxes[0].set_title('IoU Distribution')\naxes[0].set_xlabel('IoU Score')\naxes[0].set_ylabel('Count')\naxes[0].legend()\naxes[0].grid(True, alpha=0.3)\n\n# mAP summary bar\nmodels   = ['Faster R-CNN\\n(ResNet-50-FPN)']\nmap_vals = [map_score]\nbars = axes[1].bar(models, map_vals, color=['tomato'], edgecolor='black', width=0.4)\naxes[1].set_title(f'mAP@0.5 = {map_score:.4f}')\naxes[1].set_ylabel('mAP@0.5')\naxes[1].set_ylim(0, 1.0)\naxes[1].axhline(y=0.48, color='green', linestyle='--', lw=1.5,\n                label='Paper baseline (Shih et al. 0.48)')\nfor bar, v in zip(bars, map_vals):\n    axes[1].text(bar.get_x() + bar.get_width()/2,\n                 bar.get_height() + 0.01, f'{v:.4f}', ha='center', fontsize=11)\naxes[1].legend()\naxes[1].grid(True, alpha=0.3, axis='y')\n\nplt.tight_layout()\nplt.savefig(f'{SAVE_DIR}/fasterrcnn_detection_metrics.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint('Saved: fasterrcnn_detection_metrics.png')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T04:05:10.266910Z","iopub.execute_input":"2026-05-08T04:05:10.267526Z","iopub.status.idle":"2026-05-08T04:05:10.941991Z","shell.execute_reply.started":"2026-05-08T04:05:10.267488Z","shell.execute_reply":"2026-05-08T04:05:10.941161Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 10 — Bounding Box Visualization\n\nGround truth boxes (blue) vs predicted boxes (red) on real X-rays.","metadata":{}},{"cell_type":"code","source":"def denormalize(tensor):\n    mean = torch.tensor([0.485,0.456,0.406]).view(3,1,1)\n    std  = torch.tensor([0.229,0.224,0.225]).view(3,1,1)\n    return (tensor * std + mean).permute(1,2,0).numpy().clip(0,1)\n\n\ndef visualize_detections(model, dataset, n_images=8, save_path=None):\n    model.eval()\n\n    # Pick samples with ground-truth boxes\n    pos_indices = [\n        i for i in range(len(dataset))\n        if len(dataset.df.iloc[i % len(dataset.df)]['patientId']) > 0\n        and dataset.full_df[\n            (dataset.full_df['patientId'] == dataset.df.iloc[i % len(dataset.df)]['patientId']) &\n            (dataset.full_df['Target'] == 1)\n        ].shape[0] > 0\n    ][:n_images * 3]\n\n    selected = random.sample(pos_indices, min(n_images, len(pos_indices)))\n\n    n_cols = 4\n    n_rows = (len(selected) + n_cols - 1) // n_cols\n    fig, axes = plt.subplots(n_rows, n_cols,\n                             figsize=(n_cols * 4, n_rows * 4))\n    axes = axes.flatten() if n_rows > 1 else [axes] if n_cols == 1 else axes.flatten()\n    fig.suptitle('Faster R-CNN — Predictions vs Ground Truth\\n'\n                 '🟦 Ground Truth  🟥 Predicted',\n                 fontsize=13, fontweight='bold')\n\n    for ax_idx, idx in enumerate(selected):\n        tensor, target = dataset[idx]\n        img_disp = denormalize(tensor)\n\n        with torch.no_grad():\n            pred = model([tensor.to(DEVICE)])[0]\n\n        ax = axes[ax_idx]\n        ax.imshow(img_disp)\n\n        # Ground truth — blue\n        for box in target['boxes'].numpy():\n            x1,y1,x2,y2 = box\n            rect = patches.Rectangle(\n                (x1,y1), x2-x1, y2-y1,\n                linewidth=2, edgecolor='dodgerblue', facecolor='none')\n            ax.add_patch(rect)\n\n        # Predictions — red\n        mask = pred['scores'] >= CONF_THRESH\n        for box, score in zip(pred['boxes'][mask].cpu().numpy(),\n                              pred['scores'][mask].cpu().numpy()):\n            x1,y1,x2,y2 = box\n            rect = patches.Rectangle(\n                (x1,y1), x2-x1, y2-y1,\n                linewidth=2, edgecolor='red', facecolor='none')\n            ax.add_patch(rect)\n            ax.text(x1, y1-4, f'{score:.2f}',\n                    color='red', fontsize=7, fontweight='bold',\n                    bbox=dict(facecolor='white', alpha=0.5, pad=1))\n\n        n_pred = mask.sum().item()\n        n_gt   = len(target['boxes'])\n        ax.set_title(f'GT:{n_gt} Pred:{n_pred}', fontsize=9)\n        ax.axis('off')\n\n    # Hide unused axes\n    for i in range(len(selected), len(axes)):\n        axes[i].axis('off')\n\n    plt.tight_layout()\n    if save_path:\n        plt.savefig(save_path, dpi=150, bbox_inches='tight')\n        print(f'Saved: {save_path}')\n    plt.show()\n\n\nvisualize_detections(\n    det_model, test_det_ds, n_images=8,\n    save_path=f'{SAVE_DIR}/fasterrcnn_detections.png'\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T04:06:09.449328Z","iopub.execute_input":"2026-05-08T04:06:09.449622Z","iopub.status.idle":"2026-05-08T04:06:24.134313Z","shell.execute_reply.started":"2026-05-08T04:06:09.449598Z","shell.execute_reply":"2026-05-08T04:06:24.133470Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 11 — Inference Speed Benchmark","metadata":{}},{"cell_type":"code","source":"det_model.eval()\ndummy = [torch.randn(3, IMG_SIZE, IMG_SIZE).to(DEVICE)]\n\n# Warmup\nwith torch.no_grad():\n    for _ in range(5): det_model(dummy)\n\n# Benchmark\nt_start = time.time()\nwith torch.no_grad():\n    for _ in range(50): det_model(dummy)\ninf_time = (time.time() - t_start) / 50\n\nprint(f'Faster R-CNN inference time: {inf_time*1000:.1f} ms/image')\nprint(f'  (Paper reports ~120ms for Faster R-CNN)')\nprint(f'  Equivalent FPS: {1/inf_time:.1f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T04:08:02.818246Z","iopub.execute_input":"2026-05-08T04:08:02.818605Z","iopub.status.idle":"2026-05-08T04:08:07.848608Z","shell.execute_reply.started":"2026-05-08T04:08:02.818574Z","shell.execute_reply":"2026-05-08T04:08:07.847581Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Cell 12 — Final Summary","metadata":{}},{"cell_type":"code","source":"det_summary = {\n    'Model'          : 'Faster R-CNN',\n    'Backbone'       : 'ResNet-50-FPN (pretrained COCO)',\n    'Neck'           : 'FPN {C3, C4, C5}',\n    'Head'           : 'RPN + ROI Align + FastRCNN Predictor',\n    'Loss'           : 'RPN cls + RPN box + ROI cls + ROI box',\n    'mAP@0.5'        : f'{map_score:.4f}',\n    'mAP@0.3'        : '0.2606',\n    'Mean IoU'       : f'{mean_iou:.4f}',\n    'Inference time' : f'{inf_time*1000:.1f} ms/image',\n    'Best val loss'  : f'{min(history[\"val_loss\"]):.4f}',   # fixed\n    'Image size'     : f'{IMG_SIZE}x{IMG_SIZE}',\n    'IoU threshold'  : IOU_THRESHOLD,\n    'Conf threshold' : CONF_THRESH,\n}\n\nprint('='*55)\nprint('  Faster R-CNN — Final Summary')\nprint('='*55)\nfor k, v in det_summary.items():\n    print(f'  {k:<20}: {v}')\n\nimport json\nwith open(f'{SAVE_DIR}/fasterrcnn_results.json', 'w') as f:\n    json.dump(det_summary, f, indent=2)\n\nprint('\\nSaved: fasterrcnn_results.json')\nprint('\\n✓ Step 3 complete — Faster R-CNN done!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-08T04:09:21.136359Z","iopub.execute_input":"2026-05-08T04:09:21.136982Z","iopub.status.idle":"2026-05-08T04:09:21.144062Z","shell.execute_reply.started":"2026-05-08T04:09:21.136951Z","shell.execute_reply":"2026-05-08T04:09:21.143261Z"}},"outputs":[],"execution_count":null}]}