{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":99552,"databundleVersionId":13441085,"sourceType":"competition"},{"sourceId":12687919,"sourceType":"datasetVersion","datasetId":7976292},{"sourceId":12780021,"sourceType":"datasetVersion","datasetId":8079690}],"dockerImageVersionId":31090,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# 1. Setup and Imports","metadata":{}},{"cell_type":"code","source":"import os\nimport sys\nimport gc\nimport json\nimport shutil\nimport warnings\nfrom pathlib import Path\nfrom typing import List, Dict, Tuple\n\nimport numpy as np\nimport polars as pl\nimport pandas as pd\nimport pydicom\nimport cv2\nfrom scipy import ndimage\n\nimport torch\nimport torch.nn as nn\nfrom torch.cuda.amp import autocast\nimport timm\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nimport kaggle_evaluation.rsna_inference_server\n\nwarnings.filterwarnings('ignore')\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:11:40.974022Z","iopub.execute_input":"2025-09-08T11:11:40.974335Z","iopub.status.idle":"2025-09-08T11:11:40.979846Z","shell.execute_reply.started":"2025-09-08T11:11:40.974313Z","shell.execute_reply":"2025-09-08T11:11:40.979174Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 2. Configuration","metadata":{}},{"cell_type":"code","source":"class CFG:\n    # Competition constants\n    ID_COL = 'SeriesInstanceUID'\n    LABEL_COLS = [\n        'Left Infraclinoid Internal Carotid Artery', 'Right Infraclinoid Internal Carotid Artery',\n        'Left Supraclinoid Internal Carotid Artery', 'Right Supraclinoid Internal Carotid Artery',\n        'Left Middle Cerebral Artery', 'Right Middle Cerebral Artery',\n        'Anterior Communicating Artery', 'Left Anterior Cerebral Artery', 'Right Anterior Cerebral Artery',\n        'Left Posterior Communicating Artery', 'Right Posterior Communicating Artery',\n        'Basilar Tip', 'Other Posterior Circulation', 'Aneurysm Present',\n    ]\n\n    # --- Pipeline 1: 3D Voxel Model Config ---\n    P1_MODEL_DIR = '/kaggle/input/rsna2025-effnetv2-32ch/'\n    P1_MODEL_NAME = 'tf_efficientnetv2_s.in21k_ft_in1k'\n    P1_MODEL_FOLDS = [0, 1, 2, 3, 4]\n    P1_INPUT_SHAPE = (32, 256, 256)  # D, H, W\n    P1_IN_CHANS = 32\n\n    # --- Pipeline 2: 2.5D Projection Model Config ---\n    P2_MODEL_DIR = '/kaggle/input/rsna-iad-trained-models/models/'\n    P2_INPUT_SIZE = 512\n    P2_MODELS = {\n        'tf_efficientnetv2_s': 'tf_efficientnetv2_s_fold0_best.pth',\n        'convnext_small': 'convnext_small_fold0_best.pth',\n        'swin_small_patch4_window7_224': 'swin_small_patch4_window7_224_fold0_best.pth',\n    }\n    P2_ENSEMBLE_WEIGHTS = {\n        'tf_efficientnetv2_s': 0.4,\n        'convnext_small': 0.3,\n        'swin_small_patch4_window7_224': 0.3,\n    }\n\n    # --- Final Ensemble Weights ---\n    # Weight for combining the two pipeline predictions\n    PIPELINE_3D_WEIGHT = 0.5\n    PIPELINE_2D_WEIGHT = 0.5\n\n    # --- Inference Config ---\n    USE_TTA = True","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:11:54.794805Z","iopub.execute_input":"2025-09-08T11:11:54.795078Z","iopub.status.idle":"2025-09-08T11:11:54.800412Z","shell.execute_reply.started":"2025-09-08T11:11:54.795056Z","shell.execute_reply":"2025-09-08T11:11:54.799617Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 3. Unified Preprocessing","metadata":{}},{"cell_type":"code","source":"def load_and_sort_dicom_series(series_path: str) -> List[pydicom.Dataset]:\n    \"\"\"Loads and sorts DICOM files by slice position.\"\"\"\n    series_path = Path(series_path)\n    dicom_files = [os.path.join(r, f) for r, _, fs in os.walk(series_path) for f in fs if f.endswith('.dcm')]\n    \n    if not dicom_files:\n        raise ValueError(f\"No DICOM files found in {series_path}\")\n\n    datasets = []\n    for f in dicom_files:\n        try:\n            ds = pydicom.dcmread(f, force=True)\n            if hasattr(ds, 'pixel_array'):\n                datasets.append(ds)\n        except Exception:\n            continue\n    \n    if not datasets:\n        raise ValueError(f\"No valid DICOM files could be read from {series_path}\")\n\n    # Sort by slice location or instance number as a fallback\n    datasets.sort(key=lambda ds: float(getattr(ds, 'ImagePositionPatient', [0, 0, 0])[2] if hasattr(ds, 'ImagePositionPatient') else getattr(ds, 'InstanceNumber', 0)))\n    return datasets\n\ndef process_to_volume(datasets: List[pydicom.Dataset]) -> Tuple[np.ndarray, Dict]:\n    \"\"\"Processes sorted DICOMs into a 3D volume and extracts metadata.\"\"\"\n    slices = []\n    metadata = {}\n    \n    for i, ds in enumerate(datasets):\n        img = ds.pixel_array.astype(np.float32)\n        \n        slope = float(getattr(ds, 'RescaleSlope', 1))\n        intercept = float(getattr(ds, 'RescaleIntercept', 0))\n        img = img * slope + intercept\n\n        # Use CTA windowing as a robust default\n        center, width = 50, 350\n        img_min = center - width / 2\n        img_max = center + width / 2\n        img = np.clip(img, img_min, img_max)\n        \n        slices.append(img)\n\n        if i == 0: # Extract metadata from the first slice\n            try:\n                age_str = getattr(ds, 'PatientAge', '050Y')\n                metadata['age'] = int(''.join(filter(str.isdigit, age_str[:3])) or '50')\n            except:\n                metadata['age'] = 50\n            metadata['sex'] = 1 if getattr(ds, 'PatientSex', 'M') == 'M' else 0\n\n    if not slices:\n        raise ValueError(\"Could not extract any pixel arrays from the DICOM series.\")\n        \n    return np.stack(slices, axis=0), metadata\n\ndef unified_preprocessor(series_path: str) -> Tuple[np.ndarray, np.ndarray, Dict]:\n    \"\"\"\n    Main preprocessing function.\n    Returns:\n        - volume_3d (np.ndarray): (D, H, W) for the 3D pipeline.\n        - proj_image_2d (np.ndarray): (H, W, 3) for the 2.5D pipeline.\n        - metadata (Dict): Patient age and sex.\n    \"\"\"\n    datasets = load_and_sort_dicom_series(series_path)\n    full_volume, metadata = process_to_volume(datasets)\n    \n    # --- 1. Create 3D Voxel Volume ---\n    target_d, target_h, target_w = CFG.P1_INPUT_SHAPE\n    zoom_factors = [\n        target_d / full_volume.shape[0],\n        target_h / full_volume.shape[1],\n        target_w / full_volume.shape[2]\n    ]\n    volume_3d = ndimage.zoom(full_volume, zoom_factors, order=1, mode='nearest')\n    \n    # Normalize to [0, 255] uint8\n    vol_min, vol_max = volume_3d.min(), volume_3d.max()\n    if vol_max > vol_min:\n        volume_3d = ((volume_3d - vol_min) / (vol_max - vol_min) * 255).astype(np.uint8)\n    else:\n        volume_3d = np.zeros_like(volume_3d, dtype=np.uint8)\n    \n    # --- 2. Create 2.5D Projection Image ---\n    size = CFG.P2_INPUT_SIZE\n    \n    # Projections are created from the original full_volume for max quality\n    middle_slice = cv2.resize(full_volume[full_volume.shape[0] // 2], (size, size))\n    mip = cv2.resize(np.max(full_volume, axis=0), (size, size))\n    std_proj = cv2.resize(np.std(full_volume, axis=0), (size, size))\n\n    # Normalize each channel to [0, 255] uint8\n    def normalize_channel(ch):\n        ch_min, ch_max = ch.min(), ch.max()\n        if ch_max > ch_min:\n            return ((ch - ch_min) / (ch_max - ch_min) * 255).astype(np.uint8)\n        return np.zeros_like(ch, dtype=np.uint8)\n\n    proj_image_2d = np.stack([\n        normalize_channel(middle_slice),\n        normalize_channel(mip),\n        normalize_channel(std_proj)\n    ], axis=-1)\n\n    return volume_3d, proj_image_2d, metadata","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:12:12.882544Z","iopub.execute_input":"2025-09-08T11:12:12.883353Z","iopub.status.idle":"2025-09-08T11:12:12.896587Z","shell.execute_reply.started":"2025-09-08T11:12:12.883326Z","shell.execute_reply":"2025-09-08T11:12:12.895787Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 4. Model Definitions and Transforms","metadata":{}},{"cell_type":"code","source":"# --- Pipeline 1: 3D Model ---\nclass Timm3DModel(nn.Module):\n    def __init__(self, model_name, pretrained=False):\n        super().__init__()\n        self.model = timm.create_model(\n            model_name,\n            pretrained=pretrained,\n            in_chans=CFG.P1_IN_CHANS,\n            num_classes=len(CFG.LABEL_COLS)\n        )\n    def forward(self, x):\n        return self.model(x)\n\n# --- Pipeline 2: 2.5D Model ---\nclass MultiBackboneModel(nn.Module):\n    def __init__(self, model_name, num_classes=len(CFG.LABEL_COLS), pretrained=False):\n        super().__init__()\n        \n        # Conditionally add img_size only for Swin models\n        create_kwargs = {\n            'pretrained': pretrained,\n            'num_classes': 0,\n            'in_chans': 3,\n            'global_pool': '',\n        }\n        if 'swin' in model_name:\n            create_kwargs['img_size'] = CFG.P2_INPUT_SIZE\n            \n        self.backbone = timm.create_model(model_name, **create_kwargs)\n        \n        with torch.no_grad():\n            dummy_features = self.backbone(torch.randn(1, 3, CFG.P2_INPUT_SIZE, CFG.P2_INPUT_SIZE))\n            if len(dummy_features.shape) == 4: # Conv features\n                num_features = dummy_features.shape[1]\n                self.pool = nn.AdaptiveAvgPool2d(1)\n            else: # Transformer features\n                num_features = dummy_features.shape[-1]\n                self.pool = lambda x: x.mean(dim=1)\n\n        # This meta_fc matches the saved model, including a Dropout layer\n        self.meta_fc = nn.Sequential(\n            nn.Linear(2, 16),\n            nn.ReLU(),\n            nn.Dropout(0.2),\n            nn.Linear(16, 32),\n            nn.ReLU()\n        )\n\n        # This complex classifier matches the saved model\n        self.classifier = nn.Sequential(\n            nn.Linear(num_features + 32, 512),\n            nn.BatchNorm1d(512),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(512, 256),\n            nn.BatchNorm1d(256),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, image, meta):\n        img_features = self.backbone(image)\n        img_features = self.pool(img_features).flatten(1)\n        meta_features = self.meta_fc(meta)\n        combined = torch.cat([img_features, meta_features], dim=1)\n        return self.classifier(combined)\n\n# --- Transforms ---\ndef get_tta_transforms():\n    # Define safe augmentations. Avoid HorizontalFlip due to anatomical left/right.\n    return A.Compose([\n        A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n        ToTensorV2()\n    ])\n\nTRANSFORM_3D = A.Compose([A.Normalize(mean=0.5, std=0.5), ToTensorV2()])\nTRANSFORM_2D_INFERENCE = A.Compose([A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2()])\nTRANSFORM_2D_TTA = get_tta_transforms()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:12:29.494867Z","iopub.execute_input":"2025-09-08T11:12:29.495155Z","iopub.status.idle":"2025-09-08T11:12:29.508913Z","shell.execute_reply.started":"2025-09-08T11:12:29.495133Z","shell.execute_reply":"2025-09-08T11:12:29.508147Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 5. Prediction and Model Loading","metadata":{}},{"cell_type":"code","source":"# Global variables to hold the loaded models\nMODELS_3D = []\nMODELS_2D = {}\n\ndef load_all_models():\n    \"\"\"Loads all models for both pipelines into the global variables.\"\"\"\n    # Pipeline 1: 3D Models\n    for fold in CFG.P1_MODEL_FOLDS:\n        model_path = os.path.join(CFG.P1_MODEL_DIR, f\"{CFG.P1_MODEL_NAME}_fold{fold}_best.pth\")\n        model = Timm3DModel(CFG.P1_MODEL_NAME)\n        # Load the state_dict from the checkpoint file\n        sd = torch.load(model_path, map_location=device, weights_only=False)['model']\n        # Load the state_dict into the correct submodule (model.model)\n        model.model.load_state_dict(sd)\n        model.to(device).eval()\n        MODELS_3D.append(model)\n    print(f\"Loaded {len(MODELS_3D)} 3D models.\")\n\n    # Pipeline 2: 2.5D Models\n    for name, path in CFG.P2_MODELS.items():\n        model_path = os.path.join(CFG.P2_MODEL_DIR, path)\n        model = MultiBackboneModel(name)\n        sd = torch.load(model_path, map_location=device, weights_only=False)['model_state_dict']\n        model.load_state_dict(sd)\n        model.to(device).eval()\n        MODELS_2D[name] = model\n    print(f\"Loaded {len(MODELS_2D)} 2.5D models.\")\n\ndef predict_3d_pipeline(volume_3d: np.ndarray) -> np.ndarray:\n    \"\"\"Runs the 3D pipeline and returns averaged predictions.\"\"\"\n    with torch.no_grad(), autocast():\n        # Transpose (D, H, W) -> (H, W, D) for Albumentations\n        image_tensor = TRANSFORM_3D(image=volume_3d.transpose(1, 2, 0))['image']\n        image_tensor = image_tensor.unsqueeze(0).to(device) # (1, D, H, W)\n        \n        all_preds = []\n        for model in MODELS_3D:\n            output = model(image_tensor)\n            all_preds.append(torch.sigmoid(output).cpu().numpy())\n            \n    return np.mean(all_preds, axis=0).squeeze()\n\ndef predict_2d_pipeline(proj_image_2d: np.ndarray, metadata: Dict) -> np.ndarray:\n    \"\"\"Runs the 2.5D pipeline and returns weighted-average predictions.\"\"\"\n    with torch.no_grad(), autocast():\n        meta_tensor = torch.tensor([[metadata['age'] / 100.0, metadata['sex']]], dtype=torch.float32).to(device)\n        \n        all_model_preds = []\n        for name, model in MODELS_2D.items():\n            \n            # TTA for this model\n            tta_preds = []\n            if CFG.USE_TTA:\n                for _ in range(2): # TTA iterations\n                    image_tensor = TRANSFORM_2D_TTA(image=proj_image_2d)['image'].unsqueeze(0).to(device)\n                    output = model(image_tensor, meta_tensor)\n                    tta_preds.append(torch.sigmoid(output).cpu().numpy())\n            else:\n                 image_tensor = TRANSFORM_2D_INFERENCE(image=proj_image_2d)['image'].unsqueeze(0).to(device)\n                 output = model(image_tensor, meta_tensor)\n                 tta_preds.append(torch.sigmoid(output).cpu().numpy())\n            \n            # Average TTA predictions\n            model_pred = np.mean(tta_preds, axis=0)\n            all_model_preds.append(model_pred)\n\n    # Weighted average of model predictions\n    weights = np.array([CFG.P2_ENSEMBLE_WEIGHTS[name] for name in MODELS_2D.keys()])\n    predictions = np.array(all_model_preds).squeeze(axis=1)\n    \n    return np.average(predictions, weights=weights, axis=0)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:12:48.884505Z","iopub.execute_input":"2025-09-08T11:12:48.88519Z","iopub.status.idle":"2025-09-08T11:12:48.916252Z","shell.execute_reply.started":"2025-09-08T11:12:48.885164Z","shell.execute_reply":"2025-09-08T11:12:48.915704Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 6. Main Execution","metadata":{}},{"cell_type":"code","source":"def predict(series_path: str) -> pl.DataFrame:\n    \"\"\"Top-level prediction function for the Kaggle server.\"\"\"\n    try:\n        # 1. Unified preprocessing\n        volume_3d, proj_image_2d, metadata = unified_preprocessor(series_path)\n\n        # 2. Run Pipeline 1 (3D)\n        preds_3d = predict_3d_pipeline(volume_3d)\n\n        # 3. Run Pipeline 2 (2.5D)\n        preds_2d = predict_2d_pipeline(proj_image_2d, metadata)\n\n        # 4. Final weighted ensemble of both pipelines\n        final_preds = (CFG.PIPELINE_3D_WEIGHT * preds_3d) + (CFG.PIPELINE_2D_WEIGHT * preds_2d)\n\n        # 5. Post-processing: ensure 'Aneurysm Present' is at least the max of others\n        max_location_prob = np.max(final_preds[:-1])\n        final_preds[-1] = np.max([final_preds[-1], max_location_prob])\n        \n        # Create output dataframe in the required format\n        return pl.DataFrame([final_preds.tolist()], schema=CFG.LABEL_COLS)\n\n    except Exception as e:\n        print(f\"Error processing {os.path.basename(series_path)}: {e}. Returning fallback.\")\n        return pl.DataFrame([[0.1] * len(CFG.LABEL_COLS)], schema=CFG.LABEL_COLS)\n    finally:\n        # Crucial memory and disk space cleanup for Kaggle environment\n        shared_dir = '/kaggle/shared'\n        shutil.rmtree(shared_dir, ignore_errors=True)\n        os.makedirs(shared_dir, exist_ok=True)\n        if torch.cuda.is_available():\n            torch.cuda.empty_cache()\n        gc.collect()\n\n# Load all models at startup before the server begins processing\nload_all_models()\n\n# Initialize the inference server with our main `predict` function\ninference_server = kaggle_evaluation.rsna_inference_server.RSNAInferenceServer(predict)\n\n# This part automatically handles whether to run for a local test or the final submission\nif os.getenv('KAGGLE_IS_COMPETITION_RERUN'):\n    inference_server.serve()\nelse:\n    inference_server.run_local_gateway()\n    # Display submission dataframe for local testing and verification\n    submission_df = pl.read_parquet('/kaggle/working/submission.parquet')\n    display(submission_df)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-08T11:13:06.240599Z","iopub.execute_input":"2025-09-08T11:13:06.241096Z","iopub.status.idle":"2025-09-08T11:13:25.035064Z","shell.execute_reply.started":"2025-09-08T11:13:06.241074Z","shell.execute_reply":"2025-09-08T11:13:25.03442Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}