{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"**Overview:** This notebook builds a complete, reproducible multi-label PyTorch baseline for predicting **12 knee pathology targets** from 3D MRI volumes. We utilize a 2.5D ResNet-34 architecture adapted for 24 spatial depth channels, training with Automatic Mixed Precision (AMP) on Fold 0 validation split.","metadata":{}},{"cell_type":"markdown","source":"## Datasets\n- **Metadata & Pseudo-Labels CSV:** [RSNA Knee Metadata & Pseudo-Labels](https://www.kaggle.com/datasets/barun2104/rsna-knee-stratified-folds-and-llm-soft-labels)\n- **Preprocessed 3D Volumes:** [RSNA Knee Processed 3D Volumes](https://www.kaggle.com/datasets/barun2104/rsna-knee-mri-processed-3d-volumes)","metadata":{}},{"cell_type":"markdown","source":"## 1. Environment Setup & Data Verification\nLet's verify GPU availability and confirm that our preprocessed `(24, 224, 224)` `.npz` volume files are properly mounted in `/kaggle/input/`.","metadata":{}},{"cell_type":"code","source":"import os\nimport time\nimport glob\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm.notebook import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.models as models\nfrom sklearn.metrics import roc_auc_score\n\n# Device Configuration\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"⚡ Using compute device: {device}\")\n\n# Path Specifications\nIMG_DIR = \"/kaggle/input/datasets/barun2104/rsna-knee-mri-processed-3d-volumes\"\nCSV_PATH = \"/kaggle/input/datasets/barun2104/rsna-knee-stratified-folds-and-llm-soft-labels/train_folds_with_pseudo.csv\"\nTRAIN_SERIES_PATH = \"/kaggle/input/competitions/rsna-knee-abnormality-detection/train_series.csv\"\n\n# Verify File Count in Input Directory\navailable_files = len([f for f in os.listdir(IMG_DIR) if f.endswith('.npz')])\nprint(f\"✅ Input directory mounted: {available_files:,} volume files (.npz) detected.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:04:05.056858Z","iopub.execute_input":"2026-08-11T18:04:05.057248Z","iopub.status.idle":"2026-08-11T18:04:16.290834Z","shell.execute_reply.started":"2026-08-11T18:04:05.057224Z","shell.execute_reply":"2026-08-11T18:04:16.290079Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Load Metadata & Generate Study-Series Mapping \nNow, let's load our cross-validation metadata file containing pre-assigned stratified folds and target annotations across **12 primary knee pathologies.** We will use the `train_series.csv` to generate mapping between Study and Series.","metadata":{}},{"cell_type":"code","source":"# 12 Multi-Label Target Pathologies\nTARGET_COLS = [\n    'ACL', 'MCL', 'Medial Meniscus', 'Lateral Meniscus', \n    'Medial OA', 'Lateral OA', 'PF OA', 'Effusion', \n    'Synovitis', \"Baker's\", 'Contusion', 'Fracture'\n]\n\n# Load Custom Metadata\ndf = pd.read_csv(CSV_PATH)\n\n# Load Competition train_series.csv\ntrain_series_df = pd.read_csv(TRAIN_SERIES_PATH)\n\n# Direct Mapping: StudyInstanceUID -> List of SeriesInstanceUIDs\nstudy_to_series_map = train_series_df.groupby('StudyInstanceUID')['SeriesInstanceUID'].apply(list).to_dict()\n\n# Attach series list directly to main dataframe\ndf['series_ids'] = df['StudyInstanceUID'].map(study_to_series_map)\n\n# Filter for manually annotated ground-truth studies (fold != -1)\nmanual_df = df[df['fold'] != -1].reset_index(drop=True)\n\nprint(f\"📊 Total Expert Ground-Truth Studies: {len(manual_df)}\")\nprint(f\"🔗 Total Mapped Studies in Series Mapping: {len(study_to_series_map):,}\")\nprint(f\"🎯 Target Count: {len(TARGET_COLS)} classes\")\nprint(\"\\nFold Distribution:\")\nprint(manual_df['fold'].value_counts().sort_index())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:04:37.604792Z","iopub.execute_input":"2026-08-11T18:04:37.605246Z","iopub.status.idle":"2026-08-11T18:04:37.950015Z","shell.execute_reply.started":"2026-08-11T18:04:37.605213Z","shell.execute_reply":"2026-08-11T18:04:37.949252Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. PyTorch Dataset & DataLoader\nOur dataset loader fetches each preprocessed `(24, 224, 224)` volume, normalizes pixel values from `uint8` (0-255) to `float32` (0.0-1.0), and returns the 12-element multi-label target vector.","metadata":{}},{"cell_type":"code","source":"class KneeMRIDataset(Dataset):\n    def __init__(self, df, series_map, img_dir=IMG_DIR, target_slices=24, is_train=True):\n        self.df = df.reset_index(drop=True)\n        self.series_map = series_map\n        self.img_dir = img_dir\n        self.target_slices = target_slices\n        self.is_train = is_train\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        # 1. Fetch mapped SeriesInstanceUIDs for this study\n        series_ids = self.series_map.get(study_id, [])\n        \n        # 2. Load all existing series .npz files for this study\n        loaded_vols = []\n        for s_id in series_ids:\n            series_path = os.path.join(self.img_dir, f\"{s_id}.npz\")\n            if os.path.exists(series_path):\n                with np.load(series_path) as npz:\n                    key = 'data' if 'data' in npz else npz.files[0]\n                    vol = npz[key]  # Expected shape: (slices, H, W)\n                    loaded_vols.append(vol)\n\n        # 3. Raise explicit error if no series files exist\n        if len(loaded_vols) == 0:\n            raise FileNotFoundError(\n                f\"No series .npz files found for StudyInstanceUID '{study_id}' in directory: {self.img_dir}\"\n            )\n\n        # 4. Concatenate loaded series along the depth (slice) axis\n        combined_vol = np.concatenate(loaded_vols, axis=0)  # Shape: (total_slices, H, W)\n        \n        # 5. Resample slice count to fixed target_slices (24) via uniform indexing\n        curr_slices = combined_vol.shape[0]\n        if curr_slices != self.target_slices:\n            slice_indices = np.linspace(0, curr_slices - 1, self.target_slices).astype(int)\n            combined_vol = combined_vol[slice_indices]\n\n        # 6. Convert to FloatTensor and scale uint8 [0, 255] -> float [0.0, 1.0]\n        tensor_vol = torch.from_numpy(combined_vol).float()\n        if tensor_vol.max() > 1.0:\n            tensor_vol = tensor_vol / 255.0\n\n        labels = torch.tensor(row[TARGET_COLS].values.astype(np.float32))\n        return tensor_vol, labels\n        \n# Reserve Fold 0 for Validation, Folds 1-4 for Training\ntrain_df = manual_df[manual_df['fold'] != 0].reset_index(drop=True)\nval_df = manual_df[manual_df['fold'] == 0].reset_index(drop=True)\n\ntrain_dataset = KneeMRIDataset(train_df, series_map=study_to_series_map, is_train=True)\nval_dataset = KneeMRIDataset(val_df, series_map=study_to_series_map, is_train=False)\n\n# DataLoaders (Batch size = 32 due to reduced memory footprint)\ntrain_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2, pin_memory=True)\n\nprint(f\"🏋️ Train samples (Folds 1-4): {len(train_dataset)}\")\nprint(f\"🧪 Validation samples (Fold 0): {len(val_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:05:55.390833Z","iopub.execute_input":"2026-08-11T18:05:55.391164Z","iopub.status.idle":"2026-08-11T18:05:55.405623Z","shell.execute_reply.started":"2026-08-11T18:05:55.391138Z","shell.execute_reply":"2026-08-11T18:05:55.404599Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. 2.5D ResNet-34 Architecture\nInstead of computationally expensive 3D convolutions, we adopt a **2.5D approach:**\n- We treat the **24 depth slices** as input feature channels into a standard 2D Convolutional Backbone.\n- The first layer (`conv1`) is adapted from 3 channels to 24 channels by repeating ResNet pre-trained weights across the depth axis, maintaining rich transfer-learning initialization.","metadata":{}},{"cell_type":"code","source":"class KneeResNet25D(nn.Module):\n    def __init__(self, num_classes=12, in_channels=24, pretrained=True):\n        super().__init__()\n        weights = models.ResNet34_Weights.DEFAULT if pretrained else None\n        self.backbone = models.resnet34(weights=weights)\n        \n        # Modify conv1 for 24 depth slices\n        old_conv = self.backbone.conv1\n        self.backbone.conv1 = nn.Conv2d(\n            in_channels, old_conv.out_channels,\n            kernel_size=old_conv.kernel_size,\n            stride=old_conv.stride,\n            padding=old_conv.padding,\n            bias=old_conv.bias\n        )\n        \n        # Proper weight expansion initialization\n        with torch.no_grad():\n            repeat_factor = (in_channels // 3) + 1\n            new_weight = old_conv.weight.repeat(1, repeat_factor, 1, 1)[:, :in_channels, :, :]\n            # Scale by sqrt ratio to maintain variance\n            self.backbone.conv1.weight = nn.Parameter(new_weight * np.sqrt(3.0 / in_channels))\n            \n        self.backbone.fc = nn.Linear(self.backbone.fc.in_features, num_classes)\n\n    def forward(self, x):\n        return self.backbone(x)\n\nmodel = KneeResNet25D(num_classes=len(TARGET_COLS), in_channels=24, pretrained=True).to(device)\nprint(f\"✅ Model initialized with {sum(p.numel() for p in model.parameters()):,} parameters.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:10:22.705752Z","iopub.execute_input":"2026-08-11T18:10:22.706268Z","iopub.status.idle":"2026-08-11T18:10:23.15247Z","shell.execute_reply.started":"2026-08-11T18:10:22.706198Z","shell.execute_reply":"2026-08-11T18:10:23.151756Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Metric Evaluation & Loss Setup\nWe evaluate validation performance using **Macro ROC-AUC** across all 12 targets, handling edge cases where a batch or split might have only single-class instances.","metadata":{}},{"cell_type":"code","source":"def compute_macro_auc(y_true, y_pred):\n    \"\"\"Computes mean ROC-AUC across targets with class check.\"\"\"\n    aucs = []\n    for i in range(y_true.shape[1]):\n        # Verify that both positive (1) and negative (0) samples exist in validation split\n        if len(np.unique(y_true[:, i])) > 1:\n            auc = roc_auc_score(y_true[:, i], y_pred[:, i])\n            aucs.append(auc)\n            \n    return float(np.mean(aucs)) if len(aucs) > 0 else 0.5\n\n# Hyperparameters\nEPOCHS = 10\nLR = 3e-4\ncriterion = nn.BCEWithLogitsLoss()\noptimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=1e-2)\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\nscaler = torch.amp.GradScaler('cuda')  # Automatic Mixed Precision\n\nhistory = {'train_loss': [], 'val_loss': [], 'val_auc': []}\nbest_val_auc = 0.0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:10:31.152432Z","iopub.execute_input":"2026-08-11T18:10:31.153212Z","iopub.status.idle":"2026-08-11T18:10:31.159776Z","shell.execute_reply.started":"2026-08-11T18:10:31.153183Z","shell.execute_reply":"2026-08-11T18:10:31.158913Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Training & Validation Loop","metadata":{}},{"cell_type":"code","source":"print(\"🚀 Starting Training Loop on Fold 0...\\n\")\n\nfor epoch in range(1, EPOCHS + 1):\n    # --- TRAIN PHASE ---\n    model.train()\n    running_train_loss = 0.0\n    \n    train_bar = tqdm(train_loader, desc=f\"Epoch {epoch}/{EPOCHS} [Train]\")\n    for x_batch, y_batch in train_bar:\n        x_batch, y_batch = x_batch.to(device), y_batch.to(device)\n        optimizer.zero_grad()\n        \n        # Mixed Precision Forward Pass\n        with torch.amp.autocast('cuda'):\n            logits = model(x_batch)\n            loss = criterion(logits, y_batch)\n            \n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        \n        running_train_loss += loss.item() * x_batch.size(0)\n        train_bar.set_postfix({'loss': f\"{loss.item():.4f}\"})\n        \n    epoch_train_loss = running_train_loss / len(train_dataset)\n    scheduler.step()\n\n    # --- VALIDATION PHASE ---\n    model.eval()\n    running_val_loss = 0.0\n    val_preds, val_targets = [], []\n    \n    with torch.no_grad():\n        for x_batch, y_batch in tqdm(val_loader, desc=f\"Epoch {epoch}/{EPOCHS} [Val]\"):\n            x_batch, y_batch = x_batch.to(device), y_batch.to(device)\n            \n            with torch.amp.autocast('cuda'):\n                logits = model(x_batch)\n                loss = criterion(logits, y_batch)\n                \n            running_val_loss += loss.item() * x_batch.size(0)\n            preds = torch.sigmoid(logits)\n            \n            val_preds.append(preds.cpu().numpy())\n            val_targets.append(y_batch.cpu().numpy())\n            \n    epoch_val_loss = running_val_loss / len(val_dataset)\n    val_preds = np.vstack(val_preds)\n    val_targets = np.vstack(val_targets)\n    \n    epoch_val_auc = compute_macro_auc(val_targets, val_preds)\n    \n    # Store Metrics\n    history['train_loss'].append(epoch_train_loss)\n    history['val_loss'].append(epoch_val_loss)\n    history['val_auc'].append(epoch_val_auc)\n    \n    print(f\"📊 Epoch {epoch:02d}/{EPOCHS:02d} | \"\n          f\"Train Loss: {epoch_train_loss:.4f} | \"\n          f\"Val Loss: {epoch_val_loss:.4f} | \"\n          f\"Val Macro ROC-AUC: {epoch_val_auc:.4f}\")\n    \n    # Checkpoint Best Model\n    if epoch_val_auc > best_val_auc:\n        best_val_auc = epoch_val_auc\n        torch.save(model.state_dict(), \"best_resnet34_fold0.pth\")\n        print(f\"💾 Saved new best checkpoint! (Val Macro ROC-AUC: {best_val_auc:.4f})\")\n    print(\"-\" * 65)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:10:36.551134Z","iopub.execute_input":"2026-08-11T18:10:36.551805Z","iopub.status.idle":"2026-08-11T18:11:13.748929Z","shell.execute_reply.started":"2026-08-11T18:10:36.551763Z","shell.execute_reply":"2026-08-11T18:11:13.748135Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Convergence Plotting","metadata":{}},{"cell_type":"code","source":"fig, ax1 = plt.subplots(figsize=(8, 4))\n\ncolor = 'tab:red'\nax1.set_xlabel('Epoch')\nax1.set_ylabel('BCE Loss', color=color)\nax1.plot(range(1, EPOCHS + 1), history['train_loss'], label='Train Loss', color='crimson', linestyle='--')\nax1.plot(range(1, EPOCHS + 1), history['val_loss'], label='Val Loss', color='darkred')\nax1.tick_params(axis='y', labelcolor=color)\n\nax2 = ax1.twinx()\ncolor = 'tab:blue'\nax2.set_ylabel('Val Macro ROC-AUC', color=color)\nax2.plot(range(1, EPOCHS + 1), history['val_auc'], label='Val ROC-AUC', color='dodgerblue', linewidth=2)\nax2.tick_params(axis='y', labelcolor=color)\n\nplt.title(\"RSNA Knee MRI — 2.5D ResNet34 Baseline Training Curves\")\nfig.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-11T18:11:49.838819Z","iopub.execute_input":"2026-08-11T18:11:49.839337Z","iopub.status.idle":"2026-08-11T18:11:50.391358Z","shell.execute_reply.started":"2026-08-11T18:11:49.839304Z","shell.execute_reply":"2026-08-11T18:11:50.390458Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Next Improvements & Roadmap\n- **Semi-Supervised Pre-Training:** Pre-train on the unannotated studies (`fold == -1`) using the soft probability targets (`pseudo_*`) provided in the metadata CSV before fine-tuning on ground-truth folds.\n- **Backbone Experimentation:** Test heavier backbones like EfficientNet-B4 or ConvNeXt.\n- **3D Data Augmentation:** Introduce spatial transforms such as random horizontal flipping, slight rotations, and intensity gamma shifts via `albumentations`.","metadata":{}}]}