{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.6.6","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":"# 🚀 RSNA Knee Abnormality Detection: PyTorch 3D ConvNeXt Pipeline\n\nThis notebook implements a **Full 3D ConvNeXt-Small Multi-View Architecture** for volumetric MRI classification:\n1. **3D Volume Sampling:** Loads 16 uniformly sampled DICOM slices across Sagittal, Coronal, and Axial planes `(Shape: [3, 16, 192, 192])`.\n2. **3D Spatial Augmentations:** Includes spatial flips and intensity variations on volumetric data.\n3. **Custom 3D ConvNeXt:** Processes depth, height, and width simultaneously using 3D depthwise separable convolutions.\n4. **Mixed Precision (AMP):** Optimized GPU training using `torch.cuda.amp`.","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19"}},{"cell_type":"code","source":"import os\nimport gc\nimport math\nimport glob\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport pydicom\nfrom pydicom.pixel_data_handlers.util import apply_voi_lut\n\ndef seed_everything(seed=42):\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(42)\n\nclass CFG:\n    data_dir = Path('/kaggle/input/competitions/rsna-knee-abnormality-detection')\n    if not data_dir.exists():\n        data_dir = Path('/kaggle/input/rsna-knee-abnormality-detection')\n        \n    model_name = 'convnext_small_3d'\n    img_size = (192, 192)\n    num_slices = 16         # Глибина 3D об'єму для кожного плану\n    batch_size = 4\n    epochs = 5\n    lr = 3e-4\n    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    num_workers = 2\n    use_amp = True          # Mixed Precision для прискорення 3D конволюцій\n\nprint(f\"Using Device: {CFG.device}\")\nprint(f\"Data Directory: {CFG.data_dir}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📁 1. Data Preparation & Splitting\nЗавантаження CSV-файлів, обробка пропущених міток та розділення на Train / Validation.","metadata":{}},{"cell_type":"code","source":"train_df = pd.read_csv(CFG.data_dir / 'train.csv')\ntrain_series_df = pd.read_csv(CFG.data_dir / 'train_series.csv')\ntest_df = pd.read_csv(CFG.data_dir / 'test.csv')\n\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\nlabeled_df = train_df.dropna(subset=TARGET_COLS, how='all').reset_index(drop=True)\nlabeled_df[TARGET_COLS] = labeled_df[TARGET_COLS].fillna(0).astype(np.float32)\n\nval_size = int(len(labeled_df) * 0.2)\ntrain_split = labeled_df.iloc[:-val_size].reset_index(drop=True)\nval_split = labeled_df.iloc[-val_size:].reset_index(drop=True)\n\nprint(f\"Train Split: {len(train_split):,} | Val Split: {len(val_split):,}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🩺 2. 3D Multi-View Dataset & Augmentations\nЗчитування серії DICOM-зрізів у 3D-тензор `(C, D, H, W)` та застосування 3D-аугментацій.","metadata":{}},{"cell_type":"code","source":"class Knee3DMRIDataset(Dataset):\n    def __init__(self, df, series_df, is_train=True, is_test=False):\n        self.df = df\n        self.series_df = series_df\n        self.is_train = is_train\n        self.is_test = is_test\n        self.series_dir_name = 'test_series' if is_test else 'train_series'\n\n    def __len__(self):\n        return len(self.df)\n\n    def _apply_3d_augmentations(self, volume):\n        # 3D аугментації: відображення та масштабування інтенсивності\n        if np.random.rand() > 0.5:\n            volume = np.flip(volume, axis=-1)  # Горизонтальний flip\n        if np.random.rand() > 0.5:\n            volume = np.flip(volume, axis=-2)  # Вертикальний flip\n        if np.random.rand() > 0.5:\n            scale = np.random.uniform(0.8, 1.2)\n            volume = np.clip(volume * scale, 0.0, 1.0)\n        return volume.copy()\n\n    def _load_3d_dicom_volume(self, study_uid, series_uid):\n        \"\"\"Зчитує 16 рівномірно розподілених зрізів -> (D, H, W)\"\"\"\n        path = CFG.data_dir / self.series_dir_name / str(study_uid) / str(series_uid)\n        dcm_files = sorted(list(path.glob('*.dcm')))\n        \n        if not dcm_files:\n            return np.zeros((CFG.num_slices, CFG.img_size[0], CFG.img_size[1]), dtype=np.float32)\n        \n        # Рівномірне вибіркове зчитування 16 зрізів по всій глибині MRI\n        indices = np.linspace(0, len(dcm_files) - 1, CFG.num_slices, dtype=int)\n        \n        slices = []\n        for idx in indices:\n            try:\n                dcm = pydicom.dcmread(dcm_files[idx])\n                img = dcm.pixel_array.astype(np.float32)\n                try:\n                    img = apply_voi_lut(img, dcm)\n                except Exception:\n                    pass\n                \n                img_min, img_max = img.min(), img.max()\n                if img_max > img_min:\n                    img = (img - img_min) / (img_max - img_min)\n                else:\n                    img = np.zeros_like(img)\n                \n                img_tensor = torch.tensor(img).unsqueeze(0).unsqueeze(0)\n                img_resized = torch.nn.functional.interpolate(\n                    img_tensor, size=CFG.img_size, mode='bilinear', align_corners=False\n                ).squeeze()\n                \n                slices.append(img_resized.numpy())\n            except Exception:\n                slices.append(np.zeros(CFG.img_size, dtype=np.float32))\n                \n        vol = np.stack(slices, axis=0) # (16, H, W)\n        if self.is_train:\n            vol = self._apply_3d_augmentations(vol)\n        return vol\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        study_uid = row['StudyInstanceUID']\n        study_series = self.series_df[self.series_df['StudyInstanceUID'] == study_uid]\n        \n        planes_data = []\n        for plane in ['Sagittal', 'Coronal', 'Axial']:\n            plane_series = study_series[study_series['Anatomical_Plane'] == plane]\n            if len(plane_series) > 0:\n                s_uid = plane_series.iloc[0]['SeriesInstanceUID']\n                vol = self._load_3d_dicom_volume(study_uid, s_uid)\n            else:\n                vol = np.zeros((CFG.num_slices, CFG.img_size[0], CFG.img_size[1]), dtype=np.float32)\n            \n            planes_data.append(torch.tensor(vol, dtype=torch.float32).unsqueeze(0))\n            \n        # Об'єднуємо 3 площини у канали -> (3, D, H, W)\n        multi_plane_3d = torch.cat(planes_data, dim=0)\n        \n        if self.is_test:\n            return multi_plane_3d, study_uid\n            \n        labels = torch.tensor(row[TARGET_COLS].values.astype(np.float32))\n        return multi_plane_3d, labels","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🏗️ 3. 3D ConvNeXt-Small Architecture\nРеалізація блоків `Conv3d` та `LayerNorm` для обробки об'ємних медичних сканів.","metadata":{}},{"cell_type":"code","source":"class ConvNeXtBlock3D(nn.Module):\n    def __init__(self, dim):\n        super().__init__()\n        self.dwconv = nn.Conv3d(dim, dim, kernel_size=7, padding=3, groups=dim)\n        self.norm = nn.LayerNorm(dim, eps=1e-6)\n        self.pwconv1 = nn.Linear(dim, 4 * dim)\n        self.act = nn.GELU()\n        self.pwconv2 = nn.Linear(4 * dim, dim)\n\n    def forward(self, x):\n        residual = x\n        x = self.dwconv(x)\n        x = x.permute(0, 2, 3, 4, 1) # (N, C, D, H, W) -> (N, D, H, W, C)\n        x = self.norm(x)\n        x = self.pwconv1(x)\n        x = self.act(x)\n        x = self.pwconv2(x)\n        x = x.permute(0, 4, 1, 2, 3) # Назад до (N, C, D, H, W)\n        return residual + x\n\nclass ConvNeXtSmall3D(nn.Module):\n    def __init__(self, in_channels=3, num_classes=len(TARGET_COLS)):\n        super().__init__()\n        # Stem Layer\n        self.stem = nn.Sequential(\n            nn.Conv3d(in_channels, 48, kernel_size=(3, 4, 4), stride=(2, 4, 4), padding=(1, 0, 0)),\n            nn.GroupNorm(1, 48)\n        )\n        \n        # Stages\n        self.stage1 = nn.Sequential(ConvNeXtBlock3D(48), ConvNeXtBlock3D(48))\n        self.downsample1 = nn.Sequential(nn.Conv3d(48, 96, kernel_size=2, stride=2), nn.GroupNorm(1, 96))\n        \n        self.stage2 = nn.Sequential(ConvNeXtBlock3D(96), ConvNeXtBlock3D(96))\n        self.downsample2 = nn.Sequential(nn.Sequential(nn.Conv3d(96, 192, kernel_size=2, stride=2), nn.GroupNorm(1, 192)))\n        \n        self.stage3 = nn.Sequential(ConvNeXtBlock3D(192), ConvNeXtBlock3D(192))\n        \n        # Global Pooling & Head\n        self.pool = nn.AdaptiveAvgPool3d((1, 1, 1))\n        self.classifier = nn.Sequential(\n            nn.Linear(192, 256),\n            nn.BatchNorm1d(256),\n            nn.GELU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x):\n        # x shape: (Batch, 3_planes, 16_slices, H, W)\n        x = self.stem(x)\n        x = self.stage1(x)\n        x = self.downsample1(x)\n        x = self.stage2(x)\n        x = self.downsample2(x)\n        x = self.stage3(x)\n        \n        feat = self.pool(x).flatten(1)\n        logits = self.classifier(feat)\n        return logits\n\n# Перевірка розмірностей тензора\nmodel_test = ConvNeXtSmall3D()\ndummy_input = torch.randn(2, 3, 16, 192, 192)\nout = model_test(dummy_input)\nprint(f\"✅ Output Logits Shape: {out.shape} (Batch Size x {len(TARGET_COLS)} Targets)\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🔁 4. Model Training with Mixed Precision (AMP)\nНавчання моделі з використанням `torch.cuda.amp.GradScaler` для збереження пам'яті GPU при 3D-конволюціях.","metadata":{}},{"cell_type":"code","source":"def train_epoch(model, dataloader, criterion, optimizer, scaler, device):\n    model.train()\n    running_loss = 0.0\n    \n    for inputs, labels in tqdm(dataloader, desc=\"Training\", leave=False):\n        inputs, labels = inputs.to(device), labels.to(device)\n        optimizer.zero_grad()\n        \n        # Сучасний синтаксис torch.amp.autocast\n        with torch.amp.autocast('cuda', enabled=CFG.use_amp):\n            outputs = model(inputs)\n            loss = criterion(outputs, labels)\n        \n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        \n        running_loss += loss.item() * inputs.size(0)\n        \n    return running_loss / len(dataloader.dataset)\n\ndef validate(model, dataloader, criterion, device):\n    model.eval()\n    running_loss = 0.0\n    all_preds, all_targets = [], []\n    \n    with torch.no_grad():\n        for inputs, labels in tqdm(dataloader, desc=\"Validation\", leave=False):\n            inputs, labels = inputs.to(device), labels.to(device)\n            \n            # Сучасний синтаксис torch.amp.autocast\n            with torch.amp.autocast('cuda', enabled=CFG.use_amp):\n                outputs = model(inputs)\n                loss = criterion(outputs, labels)\n            \n            running_loss += loss.item() * inputs.size(0)\n            preds = torch.sigmoid(outputs).cpu().numpy()\n            all_preds.append(preds)\n            all_targets.append(labels.cpu().numpy())\n            \n    return running_loss / len(dataloader.dataset), np.vstack(all_preds), np.vstack(all_targets)\n\n# Підготовка DataLoader\ntrain_dataset = Knee3DMRIDataset(train_split, train_series_df, is_train=True)\nval_dataset = Knee3DMRIDataset(val_split, train_series_df, is_train=False)\n\ntrain_loader = DataLoader(train_dataset, batch_size=CFG.batch_size, shuffle=True, num_workers=CFG.num_workers)\nval_loader = DataLoader(val_dataset, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n\nmodel = ConvNeXtSmall3D().to(CFG.device)\ncriterion = nn.BCEWithLogitsLoss()\noptimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=1e-4)\n\n# Сучасний синтаксис torch.amp.GradScaler\nscaler = torch.amp.GradScaler('cuda', enabled=CFG.use_amp)\n\nprint(\"🚀 Starting 3D ConvNeXt Training...\")\nbest_val_loss = float('inf')\n\nfor epoch in range(1, CFG.epochs + 1):\n    train_loss = train_epoch(model, train_loader, criterion, optimizer, scaler, CFG.device)\n    val_loss, val_preds, val_targets = validate(model, val_loader, criterion, CFG.device)\n    \n    print(f\"Epoch [{epoch}/{CFG.epochs}] | Train BCE: {train_loss:.4f} | Val BCE: {val_loss:.4f}\")\n    \n    if val_loss < best_val_loss:\n        best_val_loss = val_loss\n        torch.save(model.state_dict(), 'best_knee_3d_convnext.pth')\n        print(\"   -> Model Saved!\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🔮 5. Inference & Submission Generation\nЗавантаження кращих вагок, розрахунок передбачень для тестового набору та збереження `submission.csv`.","metadata":{}},{"cell_type":"code","source":"test_series_df = pd.read_csv(CFG.data_dir / 'test_series.csv')\ntest_dataset = Knee3DMRIDataset(test_df, test_series_df, is_train=False, is_test=True)\ntest_loader = DataLoader(test_dataset, batch_size=CFG.batch_size, shuffle=False, num_workers=CFG.num_workers)\n\n# Завантаження збережених вагок\nmodel.load_state_dict(torch.load('best_knee_3d_convnext.pth', map_location=CFG.device))\nmodel.eval()\n\nsubmission_preds, study_uids = [], []\n\nprint(\"📦 Running 3D Test Set Inference...\")\nwith torch.no_grad():\n    for inputs, uids in tqdm(test_loader, desc=\"Inference\"):\n        inputs = inputs.to(CFG.device)\n        with torch.amp.autocast('cuda', enabled=CFG.use_amp):\n            outputs = model(inputs)\n        \n        # Перетворимо в float32, щоб уникнути overflow warnings у pandas\n        probs = torch.sigmoid(outputs).to(torch.float32).cpu().numpy()\n        submission_preds.append(probs)\n        study_uids.extend(uids)\n\nsubmission_preds = np.vstack(submission_preds)\nsub_df = pd.DataFrame({'StudyInstanceUID': study_uids})\nfor i, col in enumerate(TARGET_COLS):\n    sub_df[col] = submission_preds[:, i]\n\nsub_df.to_csv('submission.csv', index=False)\nprint(\"\\n✅ Successfully generated submission.csv!\")\nprint(sub_df.head())","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}