{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Pneumonia Detection: Fast R-CNN & Faster R-CNN with Selective Search\n**Dataset:** RSNA Pneumonia Detection Challenge  \n**Models:** Fast R-CNN (with Selective Search) → Faster R-CNN (with RPN)  \n\n## Pipeline Overview\n1. Load & explore RSNA DICOM data\n2. **Fast R-CNN**: Selective Search → ROI Pooling → Classification + BBox Regression\n3. **Faster R-CNN**: End-to-end with Region Proposal Network (RPN)\n4. Evaluation with mAP @ IoU 0.4 (RSNA metric)","metadata":{}},{"cell_type":"code","source":"# ── Install dependencies ──────────────────────────────────────────────────────\n!pip install -q pydicom opencv-contrib-python-headless albumentations torchvision","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:16.959030Z","iopub.execute_input":"2026-06-28T12:32:16.959415Z","iopub.status.idle":"2026-06-28T12:32:24.773209Z","shell.execute_reply.started":"2026-06-28T12:32:16.959391Z","shell.execute_reply":"2026-06-28T12:32:24.772280Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Imports ───────────────────────────────────────────────────────────────────\nimport os, glob, time, random, warnings\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport pydicom\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as patches\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision\nfrom torchvision.models.detection.rpn import AnchorGenerator\nfrom torchvision.ops import MultiScaleRoIAlign, roi_pool\n\nimport albumentations as A\n\nwarnings.filterwarnings('ignore')\ntorch.manual_seed(42)\nnp.random.seed(42)\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device: {DEVICE}')\nprint(f'PyTorch: {torch.__version__}  |  TorchVision: {torchvision.__version__}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:24.775203Z","iopub.execute_input":"2026-06-28T12:32:24.775550Z","iopub.status.idle":"2026-06-28T12:32:35.006132Z","shell.execute_reply.started":"2026-06-28T12:32:24.775521Z","shell.execute_reply":"2026-06-28T12:32:35.005432Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Configuration","metadata":{}},{"cell_type":"code","source":"# ── Paths (Kaggle input layout) ───────────────────────────────────────────────\nBASE_DIR        = '/kaggle/input/competitions/rsna-pneumonia-detection-challenge'\nTRAIN_IMG_DIR   = os.path.join(BASE_DIR, 'stage_2_train_images')\nTEST_IMG_DIR    = os.path.join(BASE_DIR, 'stage_2_test_images')\nTRAIN_LABELS    = os.path.join(BASE_DIR, 'stage_2_train_labels.csv')\nCLASS_INFO      = os.path.join(BASE_DIR, 'stage_2_detailed_class_info.csv')\n\n# ── Hyper-parameters ──────────────────────────────────────────────────────────\nCFG = dict(\n    IMG_SIZE        = 512,          # resize to square\n    BATCH_SIZE      = 4,\n    NUM_EPOCHS      = 3,\n    LR              = 1e-4,\n    WEIGHT_DECAY    = 5e-4,\n    IOU_THRESH_POS  = 0.5,          # IoU ≥ 0.5 → positive ROI\n    IOU_THRESH_NEG  = 0.1,          # IoU <  0.1 → negative ROI\n    SS_MAX_ROIS     = 200,          # selective search proposals kept per image\n    NUM_CLASSES     = 2,            # background + pneumonia\n    SCORE_THRESH    = 0.05,\n    NMS_THRESH      = 0.3,\n    EVAL_IOU        = 0.4,          # RSNA competition metric\n    TRAIN_FRAC      = 0.8,\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:35.007082Z","iopub.execute_input":"2026-06-28T12:32:35.007552Z","iopub.status.idle":"2026-06-28T12:32:35.013588Z","shell.execute_reply.started":"2026-06-28T12:32:35.007529Z","shell.execute_reply":"2026-06-28T12:32:35.012928Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Data Loading & EDA","metadata":{}},{"cell_type":"code","source":"# ── Load annotations ─────────────────────────────────────────────────────────\ndf_labels = pd.read_csv(TRAIN_LABELS)\ndf_class  = pd.read_csv(CLASS_INFO)\n\nprint('Labels shape:', df_labels.shape)\nprint(df_labels.head())\nprint('\\nTarget distribution:')\nprint(df_labels['Target'].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:35.015369Z","iopub.execute_input":"2026-06-28T12:32:35.015689Z","iopub.status.idle":"2026-06-28T12:32:35.140123Z","shell.execute_reply.started":"2026-06-28T12:32:35.015669Z","shell.execute_reply":"2026-06-28T12:32:35.139274Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Build image-level annotation dict ────────────────────────────────────────\n# Each key = patientId, value = {'boxes': [[x,y,w,h],...], 'label': 0/1}\ndef build_annotation_dict(df):\n    ann = {}\n    for pid, grp in df.groupby('patientId'):\n        boxes = []\n        for _, row in grp.iterrows():\n            if row['Target'] == 1:\n                boxes.append([row['x'], row['y'], row['width'], row['height']])\n        ann[pid] = {'boxes': boxes, 'label': int(len(boxes) > 0)}\n    return ann\n\nannotation = build_annotation_dict(df_labels)\nall_pids   = list(annotation.keys())\nrandom.shuffle(all_pids)\n\n# ── NEW: Limit the dataset to 10,000 images ──────────────────────────────────\nall_pids = all_pids[:1000]\n# ─────────────────────────────────────────────────────────────────────────────\n\nn_train = int(len(all_pids) * CFG['TRAIN_FRAC'])\ntrain_pids = all_pids[:n_train]\nval_pids   = all_pids[n_train:]\n\nprint(f'Total images : {len(all_pids)}')\nprint(f'Train        : {len(train_pids)}')\nprint(f'Val          : {len(val_pids)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:35.140978Z","iopub.execute_input":"2026-06-28T12:32:35.141208Z","iopub.status.idle":"2026-06-28T12:32:37.905993Z","shell.execute_reply.started":"2026-06-28T12:32:35.141188Z","shell.execute_reply":"2026-06-28T12:32:37.905300Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Visualise samples ─────────────────────────────────────────────────────────\ndef load_dicom(pid, img_dir):\n    path = os.path.join(img_dir, pid + '.dcm')\n    dcm  = pydicom.dcmread(path)\n    img  = dcm.pixel_array.astype(np.float32)\n    img  = (img - img.min()) / (img.max() - img.min() + 1e-6)\n    img  = (img * 255).astype(np.uint8)\n    # Convert grayscale → RGB\n    img  = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)\n    return img\n\nfig, axes = plt.subplots(2, 3, figsize=(14, 9))\npos_pids = [p for p in train_pids if annotation[p]['label'] == 1][:3]\nneg_pids = [p for p in train_pids if annotation[p]['label'] == 0][:3]\n\nfor i, (pid, ax) in enumerate(zip(pos_pids + neg_pids, axes.flatten())):\n    img = load_dicom(pid, TRAIN_IMG_DIR)\n    ax.imshow(img)\n    for x, y, w, h in annotation[pid]['boxes']:\n        rect = patches.Rectangle((x, y), w, h,\n                                  linewidth=2, edgecolor='red', facecolor='none')\n        ax.add_patch(rect)\n    title = 'Pneumonia' if annotation[pid]['label'] else 'Normal'\n    ax.set_title(f'{title}\\n{pid[:12]}')\n    ax.axis('off')\n\nplt.suptitle('Sample Images (red = ground-truth bounding boxes)', fontsize=14)\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:37.906816Z","iopub.execute_input":"2026-06-28T12:32:37.907127Z","iopub.status.idle":"2026-06-28T12:32:38.892085Z","shell.execute_reply.started":"2026-06-28T12:32:37.907104Z","shell.execute_reply":"2026-06-28T12:32:38.891069Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Selective Search — Region Proposal Generation","metadata":{}},{"cell_type":"code","source":"def run_selective_search(img_rgb, max_rois=200, min_size=20):\n    \"\"\"\n    Run OpenCV Selective Search (fast mode) on an RGB uint8 image.\n    Returns proposals as (x1, y1, x2, y2) in pixel coordinates.\n    \"\"\"\n    ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()\n    ss.setBaseImage(img_rgb)\n    ss.switchToSelectiveSearchFast()   # fast mode for speed\n    rects = ss.process()               # returns (x, y, w, h)\n\n    proposals = []\n    for x, y, w, h in rects:\n        if w < min_size or h < min_size:\n            continue\n        proposals.append([x, y, x + w, y + h])\n        if len(proposals) >= max_rois:\n            break\n\n    return np.array(proposals, dtype=np.float32)   # (N, 4)\n\n\ndef iou_numpy(box, boxes):\n    \"\"\"Compute IoU between one box and an array of boxes (x1y1x2y2).\"\"\"\n    ix1 = np.maximum(box[0], boxes[:, 0])\n    iy1 = np.maximum(box[1], boxes[:, 1])\n    ix2 = np.minimum(box[2], boxes[:, 2])\n    iy2 = np.minimum(box[3], boxes[:, 3])\n    inter = np.maximum(0, ix2 - ix1) * np.maximum(0, iy2 - iy1)\n    area_box   = (box[2] - box[0]) * (box[3] - box[1])\n    area_boxes = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])\n    union = area_box + area_boxes - inter\n    return inter / (union + 1e-6)\n\n\ndef assign_labels_to_proposals(proposals, gt_boxes,\n                               pos_thresh=0.5, neg_thresh=0.1):\n    \"\"\"\n    Assign class labels & regression targets to proposals.\n    Returns:\n        labels       (N,)  0 = bg, 1 = pneumonia, -1 = ignore\n        reg_targets  (N,4) delta encoding (tx, ty, tw, th)\n        best_gt      (N,4) matched GT box\n    \"\"\"\n    N = len(proposals)\n    labels      = -np.ones(N, dtype=np.int64)\n    reg_targets = np.zeros((N, 4), dtype=np.float32)\n    best_gt     = np.zeros((N, 4), dtype=np.float32)\n\n    if len(gt_boxes) == 0:\n        labels[:] = 0           # all background\n        return labels, reg_targets, best_gt\n\n    gt_boxes = np.array(gt_boxes, dtype=np.float32)  # (M,4) x1y1x2y2\n\n    for i, prop in enumerate(proposals):\n        ious      = iou_numpy(prop, gt_boxes)\n        best_idx  = np.argmax(ious)\n        best_iou  = ious[best_idx]\n        gt        = gt_boxes[best_idx]\n        best_gt[i] = gt\n\n        if best_iou >= pos_thresh:\n            labels[i] = 1\n        elif best_iou < neg_thresh:\n            labels[i] = 0\n        # else: ignore (-1)\n\n        # regression targets (Girshick delta encoding)\n        px, py = (prop[0]+prop[2])/2, (prop[1]+prop[3])/2\n        pw, ph = prop[2]-prop[0],     prop[3]-prop[1]\n        gx, gy = (gt[0]+gt[2])/2,   (gt[1]+gt[3])/2\n        gw, gh = gt[2]-gt[0],        gt[3]-gt[1]\n        reg_targets[i] = [\n            (gx - px) / (pw + 1e-6),\n            (gy - py) / (ph + 1e-6),\n            np.log((gw + 1e-6) / (pw + 1e-6)),\n            np.log((gh + 1e-6) / (ph + 1e-6)),\n        ]\n\n    return labels, reg_targets, best_gt\n\n\n# ── Quick demo ────────────────────────────────────────────────────────────────\ndemo_pid = pos_pids[0]\ndemo_img = load_dicom(demo_pid, TRAIN_IMG_DIR)\ndemo_img_resized = cv2.resize(demo_img, (CFG['IMG_SIZE'], CFG['IMG_SIZE']))\n\nt0 = time.time()\nprops = run_selective_search(demo_img_resized, max_rois=CFG['SS_MAX_ROIS'])\nprint(f'Selective Search produced {len(props)} proposals in {time.time()-t0:.2f}s')\n\nfig, axes = plt.subplots(1, 2, figsize=(12, 5))\naxes[0].imshow(demo_img_resized)\naxes[0].set_title('Original')\n\naxes[1].imshow(demo_img_resized)\nfor x1, y1, x2, y2 in props[:50]:\n    rect = patches.Rectangle((x1, y1), x2-x1, y2-y1,\n                              linewidth=0.5, edgecolor='cyan', facecolor='none', alpha=0.6)\n    axes[1].add_patch(rect)\naxes[1].set_title(f'Top-50 Selective Search proposals')\nfor ax in axes: ax.axis('off')\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:38.893200Z","iopub.execute_input":"2026-06-28T12:32:38.893503Z","iopub.status.idle":"2026-06-28T12:32:41.299886Z","shell.execute_reply.started":"2026-06-28T12:32:38.893481Z","shell.execute_reply":"2026-06-28T12:32:41.299307Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Dataset","metadata":{}},{"cell_type":"code","source":"class PneumoniaDataset(Dataset):\n    \"\"\"\n    Returns:\n        image   : (3, H, W) float tensor [0,1]\n        target  : dict with 'boxes' (N,4) xyxy, 'labels' (N,) int\n    \"\"\"\n    def __init__(self, pids, annotation, img_dir,\n                 img_size=512, augment=False):\n        self.pids       = pids\n        self.annotation = annotation\n        self.img_dir    = img_dir\n        self.img_size   = img_size\n        self.augment    = augment\n\n        self.transforms = A.Compose(\n            [\n                A.RandomBrightnessContrast(p=0.4),\n                A.HorizontalFlip(p=0.5),\n                A.ShiftScaleRotate(shift_limit=0.05,\n                                   scale_limit=0.1,\n                                   rotate_limit=10, p=0.4),\n            ],\n            bbox_params=A.BboxParams(format='pascal_voc',\n                                     label_fields=['class_labels'])\n        ) if augment else None\n\n    def __len__(self):\n        return len(self.pids)\n\n    def __getitem__(self, idx):\n        pid = self.pids[idx]\n        img = load_dicom(pid, self.img_dir)\n\n        H0, W0 = img.shape[:2]\n        img    = cv2.resize(img, (self.img_size, self.img_size))\n        sx     = self.img_size / W0\n        sy     = self.img_size / H0\n\n        # Convert xywh → xyxy and scale\n        raw_boxes = self.annotation[pid]['boxes']\n        boxes = []\n        for x, y, w, h in raw_boxes:\n            x1, y1 = x * sx, y * sy\n            x2, y2 = (x + w) * sx, (y + h) * sy\n            boxes.append([x1, y1, x2, y2])\n\n        labels = [1] * len(boxes)\n\n        if self.augment and self.transforms and len(boxes):\n            aug = self.transforms(image=img,\n                                  bboxes=boxes,\n                                  class_labels=labels)\n            img    = aug['image']\n            boxes  = [list(b) for b in aug['bboxes']]\n            labels = list(aug['class_labels'])\n\n        # Normalise → tensor\n        img_t = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0\n\n        target = {}\n        if len(boxes):\n            target['boxes']  = torch.tensor(boxes,  dtype=torch.float32)\n            target['labels'] = torch.tensor(labels, dtype=torch.int64)\n        else:\n            target['boxes']  = torch.zeros((0, 4), dtype=torch.float32)\n            target['labels'] = torch.zeros((0,),   dtype=torch.int64)\n\n        return img_t, target\n\n\ndef collate_fn(batch):\n    images, targets = zip(*batch)\n    return list(images), list(targets)\n\n\ntrain_ds = PneumoniaDataset(train_pids, annotation, TRAIN_IMG_DIR,\n                            img_size=CFG['IMG_SIZE'], augment=True)\nval_ds   = PneumoniaDataset(val_pids,   annotation, TRAIN_IMG_DIR,\n                            img_size=CFG['IMG_SIZE'], augment=False)\n\ntrain_loader = DataLoader(train_ds, batch_size=CFG['BATCH_SIZE'],\n                          shuffle=True,  num_workers=2,\n                          collate_fn=collate_fn, pin_memory=True)\nval_loader   = DataLoader(val_ds,   batch_size=CFG['BATCH_SIZE'],\n                          shuffle=False, num_workers=2,\n                          collate_fn=collate_fn, pin_memory=True)\n\nprint(f'Train batches: {len(train_loader)} | Val batches: {len(val_loader)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:41.300735Z","iopub.execute_input":"2026-06-28T12:32:41.301016Z","iopub.status.idle":"2026-06-28T12:32:41.316823Z","shell.execute_reply.started":"2026-06-28T12:32:41.300994Z","shell.execute_reply":"2026-06-28T12:32:41.316183Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Fast R-CNN  \nArchitecture: **Selective Search → ROI Pool → [FC Head] → cls + bbox**","metadata":{}},{"cell_type":"code","source":"# ─────────────────────────────────────────────────────────────────────────────\n# 5.1  Backbone (VGG16 feature extractor, layers up to conv5_3)\n# ─────────────────────────────────────────────────────────────────────────────\nclass VGG16Backbone(nn.Module):\n    def __init__(self, pretrained=True):\n        super().__init__()\n        vgg = torchvision.models.vgg16(weights=torchvision.models.VGG16_Weights.DEFAULT if pretrained else None)\n        # Keep only the conv features (up to pool5 → stride 32)\n        # We stop at pool4 (stride 16) so ROI pool output is reasonable\n        self.features   = vgg.features[:30]  # up to pool4 (inclusive)\n        self.out_channels = 512\n\n    def forward(self, x):\n        return self.features(x)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 5.2  ROI Pooling helper (uses torchvision.ops.roi_pool)\n# ─────────────────────────────────────────────────────────────────────────────\ndef build_roi_batch(proposals_list, image_size=512, feat_stride=16):\n    \"\"\"\n    Convert list-of-arrays proposals (one per image) to the\n    torchvision roi_pool format: (batch_idx, x1, y1, x2, y2) scaled to feature map.\n    \"\"\"\n    scale = 1.0 / feat_stride\n    rois  = []\n    for i, props in enumerate(proposals_list):\n        if len(props) == 0:\n            continue\n        idx_col = torch.full((len(props), 1), i, dtype=torch.float32)\n        rois.append(torch.cat([idx_col,\n                               torch.tensor(props, dtype=torch.float32)], dim=1))\n    if not rois:\n        return torch.zeros((0, 5))\n    return torch.cat(rois, dim=0)   # (total_rois, 5)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 5.3  Fast R-CNN head\n# ─────────────────────────────────────────────────────────────────────────────\nclass FastRCNNHead(nn.Module):\n    def __init__(self, in_channels=512, roi_size=7, num_classes=2):\n        super().__init__()\n        self.roi_size   = roi_size\n        flat_dim        = in_channels * roi_size * roi_size\n        self.fc1        = nn.Linear(flat_dim, 4096)\n        self.fc2        = nn.Linear(4096, 4096)\n        self.cls_score  = nn.Linear(4096, num_classes)\n        self.bbox_pred  = nn.Linear(4096, num_classes * 4)\n        nn.init.normal_(self.cls_score.weight, std=0.01)\n        nn.init.normal_(self.bbox_pred.weight, std=0.001)\n\n    def forward(self, roi_feats):\n        x = roi_feats.flatten(1)\n        x = F.relu(self.fc1(x))\n        x = F.relu(self.fc2(x))\n        cls_logits  = self.cls_score(x)\n        bbox_deltas = self.bbox_pred(x)\n        return cls_logits, bbox_deltas\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 5.4  Full Fast R-CNN model\n# ─────────────────────────────────────────────────────────────────────────────\nclass FastRCNN(nn.Module):\n    def __init__(self, num_classes=2, roi_size=7, feat_stride=16):\n        super().__init__()\n        self.backbone    = VGG16Backbone(pretrained=True)  # uses weights= API internally\n        self.head        = FastRCNNHead(in_channels=512,\n                                        roi_size=roi_size,\n                                        num_classes=num_classes)\n        self.roi_size    = roi_size\n        self.feat_stride = feat_stride\n        self.num_classes = num_classes\n\n    def forward(self, images, proposals_list):\n        \"\"\"\n        images          : list of (3,H,W) tensors\n        proposals_list  : list of (N,4) numpy arrays (xyxy in image coords)\n        Returns cls_logits, bbox_deltas, rois\n        \"\"\"\n        imgs_t = torch.stack(images).to(DEVICE)        # (B, 3, H, W)\n        feat   = self.backbone(imgs_t)                  # (B, 512, H/16, W/16)\n\n        scale  = 1.0 / self.feat_stride\n        rois   = build_roi_batch(proposals_list).to(DEVICE)  # (R, 5)\n\n        if rois.shape[0] == 0:\n            dummy = torch.zeros(0, self.num_classes,   device=DEVICE)\n            dummy_box = torch.zeros(0, self.num_classes*4, device=DEVICE)\n            return dummy, dummy_box, rois\n\n        roi_feats = roi_pool(feat, rois, output_size=(self.roi_size, self.roi_size),\n                             spatial_scale=scale)         # (R, 512, 7, 7)\n        cls_logits, bbox_deltas = self.head(roi_feats)\n        return cls_logits, bbox_deltas, rois\n\n    # ── Losses ──────────────────────────────────────────────────────────────\n    def compute_loss(self, cls_logits, bbox_deltas, labels_t, reg_targets_t):\n        valid = labels_t >= 0\n        cls_loss = F.cross_entropy(cls_logits[valid], labels_t[valid])\n\n        pos      = labels_t == 1\n        if pos.sum() > 0:\n            # bbox loss only for positive ROIs, class-specific\n            idx         = labels_t[pos]                   # class index per positive\n            pred_idx    = torch.stack([idx*4, idx*4+1,\n                                       idx*4+2, idx*4+3], dim=1)  # (P,4)\n            pred_deltas = bbox_deltas[pos].gather(1, pred_idx)    # (P,4)\n            reg_loss    = F.smooth_l1_loss(pred_deltas,\n                                           reg_targets_t[pos])\n        else:\n            reg_loss = torch.tensor(0.0, device=DEVICE)\n\n        return cls_loss + reg_loss, cls_loss.item(), reg_loss.item()\n\n\nfast_rcnn = FastRCNN(num_classes=CFG['NUM_CLASSES']).to(DEVICE)\nn_params  = sum(p.numel() for p in fast_rcnn.parameters() if p.requires_grad)\nprint(f'Fast R-CNN trainable parameters: {n_params/1e6:.1f}M')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:41.317866Z","iopub.execute_input":"2026-06-28T12:32:41.318366Z","iopub.status.idle":"2026-06-28T12:32:46.513332Z","shell.execute_reply.started":"2026-06-28T12:32:41.318344Z","shell.execute_reply":"2026-06-28T12:32:46.512597Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─────────────────────────────────────────────────────────────────────────────\n# 5.5  Fast R-CNN Training Loop\n# ─────────────────────────────────────────────────────────────────────────────\ndef get_fast_rcnn_batch_labels(targets, proposals_list,\n                               pos_thresh, neg_thresh):\n    \"\"\"Assign labels & regression targets for a batch of images.\"\"\"\n    all_labels = []\n    all_reg    = []\n    for i, (props, target) in enumerate(zip(proposals_list, targets)):\n        gt_boxes = target['boxes'].numpy() if len(target['boxes']) else []\n        labels_i, reg_i, _ = assign_labels_to_proposals(\n            props, gt_boxes, pos_thresh, neg_thresh)\n        all_labels.append(labels_i)\n        all_reg.append(reg_i)\n    if not all_labels or all(len(l) == 0 for l in all_labels):\n        empty_l = torch.zeros(0, dtype=torch.long).to(DEVICE)\n        empty_r = torch.zeros((0, 4), dtype=torch.float32).to(DEVICE)\n        return empty_l, empty_r\n    return (torch.tensor(np.concatenate(all_labels), dtype=torch.long ).to(DEVICE),\n            torch.tensor(np.concatenate(all_reg),    dtype=torch.float32).to(DEVICE))\n\n\noptimizer_fast = torch.optim.SGD(\n    [p for p in fast_rcnn.parameters() if p.requires_grad],\n    lr=CFG['LR'], momentum=0.9, weight_decay=CFG['WEIGHT_DECAY'])\nscheduler_fast = torch.optim.lr_scheduler.StepLR(optimizer_fast, step_size=4, gamma=0.1)\n\nfast_rcnn_history = {'train_loss': [], 'val_loss': []}\n\nprint('='*60)\nprint('           FAST R-CNN TRAINING')\nprint('='*60)\n\nfor epoch in range(CFG['NUM_EPOCHS']):\n    # ── Train ────────────────────────────────────────────────────────────────\n    fast_rcnn.train()\n    train_loss = 0.0\n    for batch_idx, (images, targets) in enumerate(train_loader):\n\n        # Generate Selective Search proposals for this batch\n        proposals_list = []\n        for img_t in images:\n            img_np = (img_t.permute(1,2,0).numpy() * 255).astype(np.uint8)\n            props  = run_selective_search(img_np, max_rois=CFG['SS_MAX_ROIS'])\n            proposals_list.append(props)\n\n        labels_t, reg_t = get_fast_rcnn_batch_labels(\n            targets, proposals_list,\n            CFG['IOU_THRESH_POS'], CFG['IOU_THRESH_NEG'])\n\n        cls_logits, bbox_deltas, _ = fast_rcnn(images, proposals_list)\n        if cls_logits.shape[0] == 0:\n            continue\n\n        loss, c, r = fast_rcnn.compute_loss(cls_logits, bbox_deltas,\n                                             labels_t, reg_t)\n        optimizer_fast.zero_grad()\n        loss.backward()\n        nn.utils.clip_grad_norm_(fast_rcnn.parameters(), 10.0)\n        optimizer_fast.step()\n        train_loss += loss.item()\n\n        if (batch_idx + 1) % 50 == 0:\n            print(f'  Epoch {epoch+1} | Batch {batch_idx+1}/{len(train_loader)} '\n                  f'| cls={c:.4f} reg={r:.4f}')\n\n    scheduler_fast.step()\n    avg_train = train_loss / len(train_loader)\n\n    # ── Val ──────────────────────────────────────────────────────────────────\n    fast_rcnn.eval()\n    val_loss = 0.0\n    with torch.no_grad():\n        for images, targets in val_loader:\n            proposals_list = []\n            for img_t in images:\n                img_np = (img_t.permute(1,2,0).numpy() * 255).astype(np.uint8)\n                props  = run_selective_search(img_np, max_rois=CFG['SS_MAX_ROIS'])\n                proposals_list.append(props)\n\n            labels_t, reg_t = get_fast_rcnn_batch_labels(\n                targets, proposals_list,\n                CFG['IOU_THRESH_POS'], CFG['IOU_THRESH_NEG'])\n\n            cls_logits, bbox_deltas, _ = fast_rcnn(images, proposals_list)\n            if cls_logits.shape[0] == 0:\n                continue\n            loss, _, _ = fast_rcnn.compute_loss(cls_logits, bbox_deltas,\n                                                 labels_t, reg_t)\n            val_loss += loss.item()\n\n    avg_val = val_loss / max(len(val_loader), 1)\n    fast_rcnn_history['train_loss'].append(avg_train)\n    fast_rcnn_history['val_loss'].append(avg_val)\n\n    print(f'\\n>>> Epoch {epoch+1}/{CFG[\"NUM_EPOCHS\"]}  '\n          f'train_loss={avg_train:.4f}  val_loss={avg_val:.4f}\\n')\n\ntorch.save(fast_rcnn.state_dict(), '/kaggle/working/fast_rcnn.pth')\nprint('Fast R-CNN weights saved.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-28T12:32:46.515493Z","iopub.execute_input":"2026-06-28T12:32:46.515703Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Plot training curves ──────────────────────────────────────────────────────\nplt.figure(figsize=(8,4))\nplt.plot(fast_rcnn_history['train_loss'], label='Train Loss')\nplt.plot(fast_rcnn_history['val_loss'],   label='Val Loss')\nplt.xlabel('Epoch'); plt.ylabel('Loss')\nplt.title('Fast R-CNN — Training Curve')\nplt.legend(); plt.tight_layout()\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Faster R-CNN  \nArchitecture: **ResNet-50 FPN backbone + RPN + ROI Align + box head**  \n_(Selective Search replaced by the learned Region Proposal Network)_","metadata":{}},{"cell_type":"code","source":"from torchvision.models.detection import fasterrcnn_resnet50_fpn, FasterRCNN_ResNet50_FPN_Weights\nfrom torchvision.models.detection.faster_rcnn import FastRCNNPredictor\n\ndef build_faster_rcnn(num_classes=2, pretrained_backbone=True):\n    \"\"\"\n    Load pretrained Faster R-CNN (COCO) and replace the box predictor\n    head for binary (background + pneumonia) detection.\n    \"\"\"\n    model = fasterrcnn_resnet50_fpn(\n        weights=FasterRCNN_ResNet50_FPN_Weights.DEFAULT,\n        # Tune RPN anchors to cover chest X-ray scales\n        rpn_anchor_generator=AnchorGenerator(\n            sizes=((16,), (32,), (64,), (128,), (256,)),\n            aspect_ratios=((0.5, 1.0, 2.0),) * 5\n        ),\n        box_roi_pool=MultiScaleRoIAlign(\n            featmap_names=['0', '1', '2', '3'],\n            output_size=7,\n            sampling_ratio=2\n        ),\n        min_size=512, max_size=512,\n        box_score_thresh=CFG['SCORE_THRESH'],\n        box_nms_thresh=CFG['NMS_THRESH'],\n        rpn_nms_thresh=0.7,\n        rpn_post_nms_top_n_train=200,\n        rpn_post_nms_top_n_test=100,\n    )\n\n    # Replace classification 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\nfaster_rcnn_model = build_faster_rcnn(num_classes=CFG['NUM_CLASSES']).to(DEVICE)\nn_params = sum(p.numel() for p in faster_rcnn_model.parameters() if p.requires_grad)\nprint(f'Faster R-CNN trainable parameters: {n_params/1e6:.1f}M')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─────────────────────────────────────────────────────────────────────────────\n# 6.1  Faster R-CNN Training (torchvision handles the loss internally)\n# ─────────────────────────────────────────────────────────────────────────────\nparams = [p for p in faster_rcnn_model.parameters() if p.requires_grad]\noptimizer_faster = torch.optim.SGD(params, lr=CFG['LR'],\n                                    momentum=0.9,\n                                    weight_decay=CFG['WEIGHT_DECAY'])\nscheduler_faster = torch.optim.lr_scheduler.CosineAnnealingLR(\n    optimizer_faster, T_max=CFG['NUM_EPOCHS'])\n\nfaster_history = {'train_loss': []}\n\nprint('='*60)\nprint('          FASTER R-CNN TRAINING')\nprint('='*60)\n\nfor epoch in range(CFG['NUM_EPOCHS']):\n    faster_rcnn_model.train()\n    epoch_loss = 0.0\n\n    for batch_idx, (images, targets) in enumerate(train_loader):\n        images  = [img.to(DEVICE) for img in images]\n        targets = [{k: v.to(DEVICE) for k, v in t.items()} for t in targets]\n\n        loss_dict = faster_rcnn_model(images, targets)\n        losses    = sum(loss_dict.values())\n\n        optimizer_faster.zero_grad()\n        losses.backward()\n        nn.utils.clip_grad_norm_(faster_rcnn_model.parameters(), 10.0)\n        optimizer_faster.step()\n        epoch_loss += losses.item()\n\n        if (batch_idx + 1) % 50 == 0:\n            loss_str = '  '.join([f'{k}={v.item():.4f}'\n                                   for k, v in loss_dict.items()])\n            print(f'  Epoch {epoch+1} | Batch {batch_idx+1}/{len(train_loader)} | {loss_str}')\n\n    scheduler_faster.step()\n    avg = epoch_loss / len(train_loader)\n    faster_history['train_loss'].append(avg)\n    print(f'\\n>>> Epoch {epoch+1}/{CFG[\"NUM_EPOCHS\"]}  avg_loss={avg:.4f}\\n')\n\ntorch.save(faster_rcnn_model.state_dict(), '/kaggle/working/faster_rcnn.pth')\nprint('Faster R-CNN weights saved.')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Evaluation — mAP @ IoU 0.4","metadata":{}},{"cell_type":"code","source":"def box_iou_single(b1, b2):\n    \"\"\"IoU between two xyxy boxes.\"\"\"\n    ix1 = max(b1[0], b2[0]); iy1 = max(b1[1], b2[1])\n    ix2 = min(b1[2], b2[2]); iy2 = min(b1[3], b2[3])\n    inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n    a1 = (b1[2]-b1[0])*(b1[3]-b1[1])\n    a2 = (b2[2]-b2[0])*(b2[3]-b2[1])\n    return inter / (a1 + a2 - inter + 1e-6)\n\n\ndef compute_map(predictions, ground_truths, iou_threshold=0.4):\n    \"\"\"\n    predictions  : list of dicts {'boxes':(N,4), 'scores':(N,)}\n    ground_truths: list of dicts {'boxes':(M,4)}\n    Returns mAP (average precision for pneumonia class)\n    \"\"\"\n    all_scores, all_tp, all_fp = [], [], []\n    total_gt = 0\n\n    for pred, gt in zip(predictions, ground_truths):\n        gt_boxes   = gt['boxes'].tolist() if len(gt['boxes']) else []\n        pred_boxes = pred['boxes']\n        scores     = pred['scores']\n\n        total_gt  += len(gt_boxes)\n        matched    = [False] * len(gt_boxes)\n\n        # Sort by score descending\n        order = np.argsort(-scores)\n        for idx in order:\n            box   = pred_boxes[idx]\n            score = scores[idx]\n            all_scores.append(score)\n            best_iou, best_j = 0, -1\n            for j, gtb in enumerate(gt_boxes):\n                iou = box_iou_single(box, gtb)\n                if iou > best_iou:\n                    best_iou, best_j = iou, j\n            if best_j != -1 and best_iou >= iou_threshold and not matched[best_j]:\n                all_tp.append(1); all_fp.append(0)\n                matched[best_j] = True\n            else:\n                all_tp.append(0); all_fp.append(1)\n\n    if not all_scores:\n        return 0.0\n\n    order     = np.argsort(-np.array(all_scores))\n    tp_cumsum = np.cumsum(np.array(all_tp)[order])\n    fp_cumsum = np.cumsum(np.array(all_fp)[order])\n    recalls   = tp_cumsum / (total_gt + 1e-6)\n    precisions= tp_cumsum / (tp_cumsum + fp_cumsum + 1e-6)\n\n    # 11-point interpolated AP\n    ap = 0.0\n    for t in np.arange(0, 1.1, 0.1):\n        p = precisions[recalls >= t]\n        ap += (p.max() if len(p) else 0)\n    return ap / 11.0\n\n\ndef apply_delta(proposals, deltas, cls_idx):\n    \"\"\"Decode class-specific bbox deltas (Fast R-CNN format).\"\"\"\n    px = (proposals[:,0]+proposals[:,2])/2\n    py = (proposals[:,1]+proposals[:,3])/2\n    pw =  proposals[:,2]-proposals[:,0]\n    ph =  proposals[:,3]-proposals[:,1]\n    dx = deltas[:, cls_idx*4    ]\n    dy = deltas[:, cls_idx*4 + 1]\n    dw = deltas[:, cls_idx*4 + 2]\n    dh = deltas[:, cls_idx*4 + 3]\n    gx = px + dx*pw; gy = py + dy*ph\n    gw = pw * torch.exp(dw.clamp(max=4))\n    gh = ph * torch.exp(dh.clamp(max=4))\n    return torch.stack([gx-gw/2, gy-gh/2, gx+gw/2, gy+gh/2], dim=1)\n\n\ndef nms(boxes, scores, iou_threshold=0.3):\n    return torchvision.ops.nms(boxes, scores, iou_threshold)\n\n\n# ── Evaluate Fast R-CNN ───────────────────────────────────────────────────────\nfast_rcnn.eval()\npreds_fast, gts_fast = [], []\n\nwith torch.no_grad():\n    for images, targets in val_loader:\n        proposals_list = []\n        cum_sizes = [0]\n        for img_t in images:\n            img_np = (img_t.permute(1,2,0).numpy() * 255).astype(np.uint8)\n            props  = run_selective_search(img_np, max_rois=CFG['SS_MAX_ROIS'])\n            proposals_list.append(props)\n            cum_sizes.append(cum_sizes[-1] + len(props))\n\n        cls_logits, bbox_deltas, rois = fast_rcnn(images, proposals_list)\n        if cls_logits.shape[0] == 0:\n            for t in targets:\n                preds_fast.append({'boxes': [], 'scores': np.array([])})\n                gts_fast.append(t)\n            continue\n\n        probs = F.softmax(cls_logits, dim=1)  # (R, 2)\n\n        for i, (start, end) in enumerate(zip(cum_sizes[:-1], cum_sizes[1:])):\n            p      = probs[start:end]\n            bd     = bbox_deltas[start:end]\n            props_t= torch.tensor(proposals_list[i], dtype=torch.float32).to(DEVICE)\n            if len(props_t) == 0:\n                preds_fast.append({'boxes': [], 'scores': np.array([])})\n            else:\n                decoded = apply_delta(props_t, bd, cls_idx=1)\n                scores  = p[:, 1]\n                keep    = nms(decoded, scores, CFG['NMS_THRESH'])\n                preds_fast.append({\n                    'boxes':  decoded[keep].cpu().numpy(),\n                    'scores': scores[keep].cpu().numpy()\n                })\n            gts_fast.append(targets[i])\n\nmap_fast = compute_map(preds_fast, gts_fast, iou_threshold=CFG['EVAL_IOU'])\nprint(f'Fast R-CNN  mAP@{CFG[\"EVAL_IOU\"]} = {map_fast:.4f}')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Evaluate Faster R-CNN ─────────────────────────────────────────────────────\nfaster_rcnn_model.eval()\npreds_faster, gts_faster = [], []\n\nwith torch.no_grad():\n    for images, targets in val_loader:\n        images_d = [img.to(DEVICE) for img in images]\n        outputs  = faster_rcnn_model(images_d)\n\n        for out, tgt in zip(outputs, targets):\n            mask = out['labels'] == 1\n            preds_faster.append({\n                'boxes':  out['boxes'][mask].cpu().numpy(),\n                'scores': out['scores'][mask].cpu().numpy()\n            })\n            gts_faster.append(tgt)\n\nmap_faster = compute_map(preds_faster, gts_faster, iou_threshold=CFG['EVAL_IOU'])\nprint(f'Faster R-CNN mAP@{CFG[\"EVAL_IOU\"]} = {map_faster:.4f}')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Comparison Summary","metadata":{}},{"cell_type":"code","source":"print('='*50)\nprint(f'  Fast R-CNN  (Selective Search) mAP@0.4 : {map_fast:.4f}')\nprint(f'  Faster R-CNN (RPN)             mAP@0.4 : {map_faster:.4f}')\nprint('='*50)\n\nfig, axes = plt.subplots(1, 2, figsize=(10, 4))\naxes[0].bar(['Fast R-CNN', 'Faster R-CNN'], [map_fast, map_faster],\n            color=['steelblue', 'darkorange'])\naxes[0].set_title('mAP @ IoU 0.4'); axes[0].set_ylim(0, 1)\n\naxes[1].plot(fast_rcnn_history['train_loss'],   label='Fast R-CNN')\naxes[1].plot(faster_history['train_loss'],       label='Faster R-CNN')\naxes[1].set_title('Training Loss'); axes[1].set_xlabel('Epoch')\naxes[1].legend()\n\nplt.tight_layout(); plt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Visual Inference","metadata":{}},{"cell_type":"code","source":"def visualise_predictions(model_name, predictions, ground_truths, val_ds, n=6):\n    fig, axes = plt.subplots(2, 3, figsize=(15, 10))\n    pos_indices = [i for i, g in enumerate(ground_truths)\n                   if (g['boxes'].shape[0] if hasattr(g['boxes'], 'shape') else len(g['boxes'])) > 0][:n]\n\n    for ax, idx in zip(axes.flatten(), pos_indices):\n        img_t, tgt = val_ds[idx]\n        img_np = (img_t.permute(1,2,0).numpy() * 255).astype(np.uint8)\n        ax.imshow(img_np)\n\n        # Ground truth\n        for x1,y1,x2,y2 in tgt['boxes'].numpy():\n            ax.add_patch(patches.Rectangle((x1,y1),x2-x1,y2-y1,\n                         lw=2, ec='lime', fc='none', label='GT'))\n\n        # Predictions\n        pred = predictions[idx]\n        boxes  = pred['boxes']\n        scores = pred['scores']\n        for (x1,y1,x2,y2), sc in zip(boxes, scores):\n            if sc > 0.3:\n                ax.add_patch(patches.Rectangle((x1,y1),x2-x1,y2-y1,\n                             lw=2, ec='red', fc='none'))\n                ax.text(x1, y1-4, f'{sc:.2f}', color='red', fontsize=8)\n\n        ax.set_title(model_name); ax.axis('off')\n\n    # legend\n    from matplotlib.lines import Line2D\n    legend_elements = [Line2D([0],[0],color='lime',lw=2,label='Ground Truth'),\n                       Line2D([0],[0],color='red', lw=2,label='Prediction')]\n    axes.flatten()[-1].legend(handles=legend_elements, loc='lower right')\n    plt.suptitle(f'{model_name} — Inference on Validation Set', fontsize=14)\n    plt.tight_layout(); plt.show()\n\n\nvisualise_predictions('Fast R-CNN',  preds_fast,   gts_fast,   val_ds)\nvisualise_predictions('Faster R-CNN',preds_faster, gts_faster, val_ds)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Kaggle Submission (Faster R-CNN)","metadata":{}},{"cell_type":"code","source":"# ── Run inference on test set ─────────────────────────────────────────────────\ntest_pids = [os.path.splitext(os.path.basename(f))[0]\n             for f in glob.glob(os.path.join(TEST_IMG_DIR, '*.dcm'))]\n\n# Dummy annotation for test (no labels)\ntest_ann = {pid: {'boxes': [], 'label': 0} for pid in test_pids}\ntest_ds  = PneumoniaDataset(test_pids, test_ann, TEST_IMG_DIR,\n                            img_size=CFG['IMG_SIZE'], augment=False)\ntest_loader = DataLoader(test_ds, batch_size=4, shuffle=False,\n                         num_workers=2, collate_fn=collate_fn)\n\nfaster_rcnn_model.eval()\nsubmission_rows = []\n\ntest_idx = 0\nwith torch.no_grad():\n    for images, _ in test_loader:\n        images_d = [img.to(DEVICE) for img in images]\n        outputs  = faster_rcnn_model(images_d)\n\n        for out in outputs:\n            pid    = test_pids[test_idx]; test_idx += 1\n            mask   = out['labels'] == 1\n            boxes  = out['boxes'][mask].cpu().numpy()\n            scores = out['scores'][mask].cpu().numpy()\n\n            if len(boxes) == 0:\n                pred_str = ''\n            else:\n                parts = []\n                for (x1,y1,x2,y2), sc in zip(boxes, scores):\n                    w, h = x2-x1, y2-y1\n                    parts.append(f'{sc:.4f} {int(x1)} {int(y1)} {int(w)} {int(h)}')\n                pred_str = ' '.join(parts)\n\n            submission_rows.append({'patientId': pid, 'PredictionString': pred_str})\n\nsubmission_df = pd.DataFrame(submission_rows)\nsubmission_df.to_csv('/kaggle/working/submission.csv', index=False)\nprint('submission.csv saved.')\nprint(submission_df.head(10))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Architecture Comparison\n\n| | Fast R-CNN | Faster R-CNN |\n|---|---|---|\n| **Proposals** | Selective Search (CPU) | Region Proposal Network (GPU) |\n| **Backbone** | VGG-16 | ResNet-50 + FPN |\n| **ROI Op** | ROI Pool | ROI Align |\n| **Speed** | ~2 s/image (SS dominates) | ~0.2 s/image |\n| **End-to-end** | ✗ (proposals offline) | ✓ |\n| **RSNA mAP@0.4** | *see above* | *see above* |","metadata":{}}]}