{"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":"# 🦵 RSNA Abnormality Detection — EfficientNet 2.5D Baseline\n\n> **Competition:** [RSNA Knee Abnormality Detection](https://www.kaggle.com/competitions/rsna-knee-abnormality-detection)  \n> **Strategy:** 2.5D EfficientNetV2-S on middle slices of each series, study-level attention pooling, 12-label sigmoid head  \n> **Metric:** Macro-averaged AUC-ROC  \n\n### Architecture at a glance\n```\nFor each MRI series:\n  • Sample N_SLICES middle slices (2.5D: stack 3 adjacent → 3-channel \"RGB\")\n  • EfficientNetV2-S backbone → 1280-d embedding per slice group\n  • Average pool over slices → series embedding\nFor each study:\n  • Weighted average over series embeddings (learnable weights)\n  • 12-head linear → sigmoid → 12 probabilities\nLoss: weighted binary cross-entropy per label\n```\n","metadata":{}},{"cell_type":"code","source":"import os, gc, random, warnings\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import roc_auc_score\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport timm\n\nwarnings.filterwarnings('ignore')\n\n# ── Config ────────────────────────────────────────────────────────────────────\nclass CFG:\n    seed          = 42\n    debug         = False          # set True to run 1 batch per epoch for speed\n    base          = Path('/kaggle/input/competitions/rsna-knee-abnormality-detection')\n    model_name    = 'efficientnetv2_s'\n    batch_size    = 2      # was 8\n    img_size      = 224    # was 256\n    n_slices      = 3      # was 5\n    in_chans      = 3              # adjacent-slice stacking (2.5D)\n    epochs        = 8\n    lr            = 2e-4\n    wd            = 1e-4\n    fold          = 0\n    n_folds       = 3\n    amp           = True\n    device        = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\nTARGETS = ['ACL','MCL','Medial Meniscus','Lateral Meniscus',\n           'Medial OA','Lateral OA','PF OA','Effusion',\n           'Synovitis',\"Baker's\",'Contusion','Fracture']\n\ndef seed_everything(seed):\n    random.seed(seed); np.random.seed(seed)\n    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n\nseed_everything(CFG.seed)\nprint(f'Device: {CFG.device}')\nprint(f'AMP: {CFG.amp}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:58.822234Z","iopub.execute_input":"2026-08-05T17:01:58.822854Z","iopub.status.idle":"2026-08-05T17:01:58.833482Z","shell.execute_reply.started":"2026-08-05T17:01:58.822826Z","shell.execute_reply":"2026-08-05T17:01:58.832841Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Data Loading ──────────────────────────────────────────────────────────────\ntrain = pd.read_csv(CFG.base / 'train.csv')\ntrain_series = pd.read_csv(CFG.base / 'train_series.csv')\ntest  = pd.read_csv(CFG.base / 'test.csv')\ntest_series  = pd.read_csv(CFG.base / 'test_series.csv')\n\n# Only use studies with labels\nlabeled_mask = train[TARGETS].notna().all(axis=1)\ntrain_lab = train[labeled_mask].reset_index(drop=True)\nprint(f'Labeled studies: {len(train_lab):,}')\n\n# Merge series info\ntrain_merged = train_lab.merge(train_series, on='StudyInstanceUID', how='left')\ntest_merged  = test.merge(test_series, on='StudyInstanceUID', how='left')\n\n# Build DICOM file list\ndef get_dcm_paths(base_dir, study_id, series_id):\n    folder = Path(base_dir) / study_id / series_id\n    return sorted(folder.glob('*.dcm'))\n\nprint(train_merged.head(3))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:58.834692Z","iopub.execute_input":"2026-08-05T17:01:58.834989Z","iopub.status.idle":"2026-08-05T17:01:59.017149Z","shell.execute_reply.started":"2026-08-05T17:01:58.834962Z","shell.execute_reply":"2026-08-05T17:01:59.016255Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cross-validation split ────────────────────────────────────────────────────\n# Stratify on most common label (Medial Meniscus tends to be most frequent)\nsgkf = StratifiedGroupKFold(n_splits=CFG.n_folds, shuffle=True, random_state=CFG.seed)\ntrain_lab['fold'] = -1\nfor fold, (_, val_idx) in enumerate(\n    sgkf.split(train_lab, train_lab['ACL'].fillna(0), groups=train_lab['StudyInstanceUID'])\n):\n    train_lab.loc[val_idx, 'fold'] = fold\n\nprint(train_lab['fold'].value_counts().sort_index())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:59.018294Z","iopub.execute_input":"2026-08-05T17:01:59.019082Z","iopub.status.idle":"2026-08-05T17:01:59.039713Z","shell.execute_reply.started":"2026-08-05T17:01:59.019035Z","shell.execute_reply":"2026-08-05T17:01:59.03915Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Dataset ───────────────────────────────────────────────────────────────────\ntry:\n    import pydicom\n    from PIL import Image\n    import torchvision.transforms as T\nexcept ImportError as e:\n    raise RuntimeError(f'Missing dependency: {e}. Add pydicom and Pillow.')\n\ndef dcm_to_array(path):\n    \"\"\"Load a DICOM slice and return a normalised float32 array [H,W].\"\"\"\n    ds = pydicom.dcmread(str(path))\n    img = ds.pixel_array.astype(np.float32)\n    # Apply window-center / window-width if available\n    wc = getattr(ds, 'WindowCenter', None)\n    ww = getattr(ds, 'WindowWidth', None)\n    if wc is not None and ww is not None:\n        wc = float(wc[0]) if hasattr(wc, '__len__') else float(wc)\n        ww = float(ww[0]) if hasattr(ww, '__len__') else float(ww)\n        lo, hi = wc - ww / 2, wc + ww / 2\n        img = np.clip(img, lo, hi)\n        img = (img - lo) / (ww + 1e-6)\n    else:\n        img = (img - img.min()) / (img.max() - img.min() + 1e-6)\n    return img\n\n\ndef load_series_slices(dcm_paths, n_slices, in_chans, img_size):\n    \"\"\"\n    Load N slices from the middle of a series.\n    Stack adjacent slices to form 3-channel pseudo-RGB (2.5D).\n    Returns tensor of shape [n_slices, in_chans, H, W].\n    \"\"\"\n    n = len(dcm_paths)\n    mid = n // 2\n    half = n_slices // 2\n    indices = list(range(max(0, mid - half), min(n, mid + half + n_slices % 2)))\n    # Pad if needed\n    while len(indices) < n_slices:\n        indices.append(indices[-1])\n\n    arrays = []\n    for idx in indices:\n        arr = dcm_to_array(dcm_paths[min(idx, n - 1)])\n        arr = np.array(Image.fromarray(arr).resize((img_size, img_size), Image.BILINEAR))\n        arrays.append(arr)\n\n    # 2.5D stacking: each output channel = average of in_chans adjacent slices\n    result = []\n    for i in range(n_slices):\n        channels = []\n        for offset in range(-(in_chans // 2), in_chans // 2 + 1):\n            j = max(0, min(len(arrays) - 1, i + offset))\n            channels.append(arrays[j])\n        result.append(np.stack(channels[:in_chans], axis=0))  # [C, H, W]\n\n    return torch.tensor(np.stack(result, axis=0), dtype=torch.float32)  # [N, C, H, W]\n\n\nclass KneeDataset(Dataset):\n    def __init__(self, df, df_series, base_dir, split='train', targets=TARGETS):\n        self.df = df.reset_index(drop=True)\n        self.df_series = df_series\n        self.base_dir = Path(base_dir)\n        self.split = split\n        self.targets = targets\n        self.augment = T.Compose([\n            T.RandomHorizontalFlip(p=0.5),\n            T.RandomVerticalFlip(p=0.1),\n        ]) if split == 'train' else nn.Identity()\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        study_id = row['StudyInstanceUID']\n\n        # Get all series for this study\n        series_rows = self.df_series[self.df_series['StudyInstanceUID'] == study_id]\n\n        series_tensors = []\n        series_meta = []\n        for _, srow in series_rows.iterrows():\n            sid = srow['SeriesInstanceUID']\n            dcm_folder = self.base_dir / study_id / sid\n            paths = sorted(dcm_folder.glob('*.dcm'))\n            if not paths:\n                continue\n            try:\n                slices = load_series_slices(paths, CFG.n_slices, CFG.in_chans, CFG.img_size)\n                series_tensors.append(slices)  # [N_slices, C, H, W]\n                series_meta.append([\n                    float(srow.get('Fluid_Sensitive', 0)),\n                    float(srow.get('Fat_Suppression', 0)),\n                    float({'Sagittal': 0, 'Coronal': 1, 'Axial': 2}.get(\n                          srow.get('Anatomical_Plane', 'Sagittal'), 0)) / 2.0\n                ])\n            except Exception:\n                continue\n\n        if not series_tensors:\n            # Fallback: zeros\n            series_tensors = [torch.zeros(CFG.n_slices, CFG.in_chans,\n                                          CFG.img_size, CFG.img_size)]\n            series_meta = [[0., 0., 0.]]\n\n        # Pad / truncate to max_series = 6 series per study\n        MAX_SERIES = 6\n        series_tensors = series_tensors[:MAX_SERIES]\n        series_meta = series_meta[:MAX_SERIES]\n        while len(series_tensors) < MAX_SERIES:\n            series_tensors.append(torch.zeros_like(series_tensors[0]))\n            series_meta.append([0., 0., 0.])\n\n        x = torch.stack(series_tensors, dim=0)      # [S, N, C, H, W]\n        meta = torch.tensor(series_meta, dtype=torch.float32)  # [S, 3]\n        mask = torch.zeros(MAX_SERIES, dtype=torch.bool)       # [S]\n        mask[:len(series_rows.head(MAX_SERIES))] = True\n\n        if self.split in ('train', 'valid'):  # Return real labels for both train and validation\n            labels = torch.tensor(\n                row[self.targets].values.astype(np.float32), dtype=torch.float32\n            )\n        else:\n            labels = torch.zeros(len(self.targets))  # Test set gets zeros\n\n        return x, meta, mask, labels, study_id\n\nprint('Dataset class defined ✓')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:59.040859Z","iopub.execute_input":"2026-08-05T17:01:59.041251Z","iopub.status.idle":"2026-08-05T17:01:59.062692Z","shell.execute_reply.started":"2026-08-05T17:01:59.041229Z","shell.execute_reply":"2026-08-05T17:01:59.061892Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Model ─────────────────────────────────────────────────────────────────────\nclass KneeModel(nn.Module):\n    def __init__(self, backbone='efficientnetv2_s', n_targets=12, n_series=6, feat_dim=1280):\n        super().__init__()\n        self.n_series = n_series\n        self.backbone = timm.create_model(\n            backbone, pretrained=False, in_chans=CFG.in_chans,\n            num_classes=0, global_pool='avg'\n        )\n        feat_dim = self.backbone.num_features\n\n        # Series-level meta encoder\n        self.meta_enc = nn.Sequential(\n            nn.Linear(3, 32), nn.ReLU(), nn.Linear(32, feat_dim)\n        )\n\n        # Study-level attention over series\n        self.attn = nn.Sequential(\n            nn.Linear(feat_dim, 128), nn.Tanh(), nn.Linear(128, 1)\n        )\n\n        # Classification head\n        self.head = nn.Sequential(\n            nn.Dropout(0.3),\n            nn.Linear(feat_dim, n_targets)\n        )\n\n    def forward(self, x, meta, mask):\n        # x: [B, S, N, C, H, W]\n        B, S, N, C, H, W = x.shape\n\n        # Encode all slices across all series\n        x_flat = x.view(B * S * N, C, H, W)          # [B*S*N, C, H, W]\n        feats_flat = self.backbone(x_flat)              # [B*S*N, D]\n        feats = feats_flat.view(B, S, N, -1)           # [B, S, N, D]\n        D = feats.shape[-1]\n\n        # Pool over slices\n        series_feats = feats.mean(dim=2)               # [B, S, D]\n\n        # Add meta signal\n        meta_feats = self.meta_enc(meta)               # [B, S, D]\n        series_feats = series_feats + meta_feats\n\n        # Attention pooling over series (masked)\n        attn_logits = self.attn(series_feats).squeeze(-1)  # [B, S]\n        attn_logits = attn_logits.masked_fill(~mask, float('-inf'))\n        attn_weights = torch.softmax(attn_logits, dim=-1)  # [B, S]\n        study_feat = (attn_weights.unsqueeze(-1) * series_feats).sum(dim=1)  # [B, D]\n\n        return self.head(study_feat)  # [B, 12] logits\n\nmodel = KneeModel()\ntotal_params = sum(p.numel() for p in model.parameters())\nprint(f'Model parameters: {total_params/1e6:.1f}M')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:59.064483Z","iopub.execute_input":"2026-08-05T17:01:59.064782Z","iopub.status.idle":"2026-08-05T17:01:59.406882Z","shell.execute_reply.started":"2026-08-05T17:01:59.064761Z","shell.execute_reply":"2026-08-05T17:01:59.406233Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print([t for t in TARGETS if t in train.columns])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:59.407843Z","iopub.execute_input":"2026-08-05T17:01:59.40813Z","iopub.status.idle":"2026-08-05T17:01:59.412818Z","shell.execute_reply.started":"2026-08-05T17:01:59.408109Z","shell.execute_reply":"2026-08-05T17:01:59.411972Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Training ──────────────────────────────────────────────────────────────────\ndef compute_pos_weights(df, targets):\n    \"\"\"Compute positive class weights for weighted BCE.\"\"\"\n    weights = []\n    for t in targets:\n        pos = df[t].sum()\n        neg = len(df) - pos\n        weights.append(neg / (pos + 1e-6))\n    return torch.tensor(weights, dtype=torch.float32).to(CFG.device)\n\n\ndef train_epoch(model, loader, optimizer, scaler, pos_weights):\n    model.train()\n    total_loss = 0.\n    for step, (x, meta, mask, labels, _) in enumerate(loader):\n        x, meta, mask, labels = (\n            x.to(CFG.device), meta.to(CFG.device),\n            mask.to(CFG.device), labels.to(CFG.device)\n        )\n        optimizer.zero_grad()\n        with torch.cuda.amp.autocast(enabled=CFG.amp):\n            logits = model(x, meta, mask)\n            loss = F.binary_cross_entropy_with_logits(\n                logits, labels, pos_weight=pos_weights\n            )\n        scaler.scale(loss).backward()\n        scaler.unscale_(optimizer)\n        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n        scaler.step(optimizer)\n        scaler.update()\n        total_loss += loss.item()\n        if CFG.debug and step == 0:\n            break\n    return total_loss / (step + 1)\n\n\n@torch.no_grad()\ndef valid_epoch(model, loader):\n    model.eval()\n    all_logits, all_labels = [], []\n    for step, (x, meta, mask, labels, _) in enumerate(loader):\n        x, meta, mask = x.to(CFG.device), meta.to(CFG.device), mask.to(CFG.device)\n        with torch.cuda.amp.autocast(enabled=CFG.amp):\n            logits = model(x, meta, mask)\n        all_logits.append(logits.float().cpu())\n        all_labels.append(labels)\n        if CFG.debug and step == 0:\n            break\n    preds = torch.sigmoid(torch.cat(all_logits)).numpy()\n    labs  = torch.cat(all_labels).numpy()\n    aucs = []\n    for i, t in enumerate(TARGETS):\n        y = labs[:, i]\n        if y.sum() > 0 and y.sum() < len(y):   # needs both pos AND neg\n            aucs.append(roc_auc_score(y, preds[:, i]))\n    return (np.mean(aucs) if aucs else 0.0), preds\n\n\n\n# Create fold split\ntrn_idx = train_lab[train_lab['fold'] != CFG.fold].index.tolist()\nval_idx = train_lab[train_lab['fold'] == CFG.fold].index.tolist()\ntrn_df = train_lab.loc[trn_idx].reset_index(drop=True)\nval_df = train_lab.loc[val_idx].reset_index(drop=True)\n\ntrn_series_dir = CFG.base / 'train_series'\ntrn_ds = KneeDataset(trn_df, train_series, trn_series_dir, split='train')\nval_ds = KneeDataset(val_df, train_series, trn_series_dir, split='valid')\n\ntrn_loader = DataLoader(trn_ds, batch_size=CFG.batch_size, shuffle=True,\n                        num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_ds, batch_size=CFG.batch_size, shuffle=False,\n                        num_workers=2, pin_memory=True)\n\nmodel = KneeModel().to(CFG.device)\noptimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.wd)\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n    optimizer, T_max=CFG.epochs, eta_min=CFG.lr * 0.1\n)\nscaler = torch.cuda.amp.GradScaler(enabled=CFG.amp)\npos_weights = compute_pos_weights(trn_df, TARGETS)\n\nbest_auc = 0.\nfor epoch in range(1, CFG.epochs + 1):\n    trn_loss = train_epoch(model, trn_loader, optimizer, scaler, pos_weights)\n    val_auc, val_preds = valid_epoch(model, val_loader)\n    scheduler.step()\n    print(f'Epoch {epoch:02d} | loss={trn_loss:.4f} | val_AUC={val_auc:.4f}')\n    if val_auc > best_auc:\n        best_auc = val_auc\n        torch.save(model.state_dict(), 'best_model.pt')\n        best_preds = val_preds\n        best_val_df = val_df.copy()\n\nprint(f'\\nBest fold {CFG.fold} AUC: {best_auc:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:01:59.413727Z","iopub.execute_input":"2026-08-05T17:01:59.413988Z","iopub.status.idle":"2026-08-05T17:02:46.38019Z","shell.execute_reply.started":"2026-08-05T17:01:59.413957Z","shell.execute_reply":"2026-08-05T17:02:46.379223Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(f\"Labeled studies in Train: {len(trn_df)}\")\nprint(f\"Labeled studies in Val:   {len(val_df)}\")\nprint(\"Val positive counts per target:\")\nprint(val_df[TARGETS].sum())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:02:46.381594Z","iopub.execute_input":"2026-08-05T17:02:46.381988Z","iopub.status.idle":"2026-08-05T17:02:46.389547Z","shell.execute_reply.started":"2026-08-05T17:02:46.381953Z","shell.execute_reply":"2026-08-05T17:02:46.388976Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Inference & Submission ────────────────────────────────────────────────────\ntest_series_dir = CFG.base / 'test_series'\ntest_ds = KneeDataset(test, test_series, test_series_dir, split='test')\ntest_loader = DataLoader(test_ds, batch_size=CFG.batch_size, shuffle=False,\n                         num_workers=2, pin_memory=True)\n\nmodel.load_state_dict(torch.load('best_model.pt', map_location=CFG.device))\nmodel.eval()\n\nall_preds, all_ids = [], []\nwith torch.no_grad():\n    for x, meta, mask, _, study_ids in test_loader:\n        x, meta, mask = x.to(CFG.device), meta.to(CFG.device), mask.to(CFG.device)\n        with torch.cuda.amp.autocast(enabled=CFG.amp):\n            logits = model(x, meta, mask)\n        preds = torch.sigmoid(logits).float().cpu().numpy()\n        all_preds.append(preds)\n        all_ids.extend(list(study_ids))\n\npreds_arr = np.vstack(all_preds)\nsub = pd.DataFrame(preds_arr, columns=TARGETS)\nsub.insert(0, 'StudyInstanceUID', all_ids)\n\n# Fill missing test studies with 0.5\nsub_template = pd.read_csv(CFG.base / 'sample_submission.csv')\nsub = sub_template[['StudyInstanceUID']].merge(sub, on='StudyInstanceUID', how='left')\nfor col in TARGETS:\n    sub[col] = sub[col].fillna(0.5)\n\nsub.to_csv('submission.csv', index=False)\nprint(f'submission.csv shape: {sub.shape}')\nsub.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-05T17:02:46.390492Z","iopub.execute_input":"2026-08-05T17:02:46.390784Z","iopub.status.idle":"2026-08-05T17:02:47.77602Z","shell.execute_reply.started":"2026-08-05T17:02:46.390754Z","shell.execute_reply":"2026-08-05T17:02:47.775293Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Next Steps to Improve\n\n| Idea | Expected Gain |\n|------|---------------|\n| **Report NLP** — extract pseudo-labels from the radiology text (mDeBERTa / BiomedBERT) | +0.02–0.05 AUC |\n| **Full 3D volumes** — replace 2.5D with 3D UNet / SlowFast | +0.01–0.03 AUC |\n| **Larger backbone** — ConvNeXt-XL, ViT-L, or EVA-02 | +0.01–0.02 AUC |\n| **TTA** — horizontal flip, multi-scale | +0.005 AUC |\n| **Multi-fold ensemble** — average 5 folds | +0.005–0.01 AUC |\n| **Plane-aware heads** — separate linear head per anatomical plane | +0.005 AUC |\n","metadata":{}}]}