{"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":13851420,"sourceType":"competition"},{"sourceId":12780021,"sourceType":"datasetVersion","datasetId":8079690},{"sourceId":12937280,"sourceType":"datasetVersion","datasetId":8119423},{"sourceId":12977543,"sourceType":"datasetVersion","datasetId":8214002},{"sourceId":3732,"sourceType":"modelInstanceVersion","modelInstanceId":2659,"modelId":312},{"sourceId":556796,"sourceType":"modelInstanceVersion","modelInstanceId":413711,"modelId":431445},{"sourceId":623584,"sourceType":"modelInstanceVersion","isSourceIdPinned":true,"modelInstanceId":469189,"modelId":485063}],"dockerImageVersionId":31090,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ====================================================\n# CELL 1: IMPORTS & CONFIG\n# Importa librerías (PyTorch, pandas/polars, etc.), suprime warnings y setea configuración de backend.\n# ====================================================\n\nimport os\nimport shutil\nimport numpy as np\nimport pandas as pd\nimport polars as pl\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nimport torch.nn.functional as F\nfrom torchvision import transforms\nimport timm\nimport cv2\nimport pydicom\nimport nibabel as nib\nfrom scipy import ndimage\nfrom scipy.ndimage import label, center_of_mass\nfrom PIL import Image\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import roc_auc_score\nimport kaggle_evaluation.rsna_inference_server\nfrom collections import defaultdict\nfrom tqdm import tqdm\nimport warnings\nwarnings.filterwarnings('ignore')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-30T15:38:35.744258Z","iopub.execute_input":"2025-10-30T15:38:35.74455Z","iopub.status.idle":"2025-10-30T15:38:48.567305Z","shell.execute_reply.started":"2025-10-30T15:38:35.744499Z","shell.execute_reply":"2025-10-30T15:38:48.566745Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Competition Configuration\nclass Config:\n    # Paths\n    # Ruta al archivo CSV con las etiquetas de entrenamiento.\n    TRAIN_CSV_PATH = '/kaggle/input/rsna-intracranial-aneurysm-detection/train.csv'\n    # Carpeta con las series de imágenes médicas (DICOM)\n    SERIES_DIR = '/kaggle/input/rsna-intracranial-aneurysm-detection/series/'\n    # Carpeta con las máscaras de segmentación generadas en el Stage 1, \n    # Sirven como “mapas” que señalan las zonas candidatas donde podría haber aneurisma\n    SEGMENTATION_DIR = '/kaggle/input/rsna-intracranial-aneurysm-detection/segmentations/'\n    # Ruta del modelo entrenado en Stage 1\n    # Modelo segmentador (3D UNet) que encuentra posibles regiones sospechosas.\n    STAGE1_MODEL_PATH = '/kaggle/input/aneurisma/pytorch/default/1/stage1_segmentation_best.pth'\n    \n    # Stage 2 Configuration\n    # El tamaño en píxeles de cada ROI (región de interés) que se recorta de la tomografía. Tamaño fijo de las “fotos” de posibles aneurismas\n    ROI_SIZE = (320, 320)\n    # Máximo número de ROIs por estudio, máximo 5 recortes por paciente para entrenar\n    ROIS_PER_SERIES = 10\n    # Número de imágenes que se procesan en paralelo en la GPU durante entrenamiento, cuántas fotos pasan al mismo tiempo por la red\n    BATCH_SIZE = 16\n    # Número de veces que el modelo ve todo el dataset durante el entrenamiento, más epochs → más aprendizaje, pero riesgo de sobreajuste.\n    EPOCHS = 60\n    # La velocidad a la que el modelo ajusta sus parámetros al aprender, qué tan rápido “aprende” el modelo en cada paso.\n    LEARNING_RATE = 3e-4\n    # Número de divisiones para validación cruzada, se entrena 5 veces en combinaciones distintas para asegurarse de que el modelo generaliza bien.\n    N_FOLDS = 1\n    \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', 'Anterior Communicating Artery',\n        '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    # Device and training\n    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    MIXED_PRECISION = True\n    STAGE2_CACHE_DIR = '/kaggle/working/stage2_cache'\n    # Optional: reuse Stage 1 external cache volumes directly for exact preprocessing parity\n    STAGE1_EXTERNAL_CACHE_DIR = '/kaggle/input/rsna2025-v2-intracranial-aneurysm-detection-nb153/stage1_AneurysmNet_prebuilt_v2'  # e.g., '/kaggle/input/rsna2025aneurysmnetprebuildnb153/stage1_AneurysmNet_prebuilt'\n    \n    # Debug\n    DEBUG_MODE = False\n    DEBUG_SAMPLES = 0\n    # Cache/throughput\n    REUSE_EXISTING_ROIS = False  # if cached training_df exists, reuse to skip long ROI extraction\n\nprint(f\"✅ Configuration loaded - Device: {Config.DEVICE}\")\n\n# Speed-friendly backend settings\ntry:\n    torch.backends.cudnn.benchmark = True\n    torch.backends.cuda.matmul.allow_tf32 = True\n    torch.set_float32_matmul_precision('high')\nexcept Exception:\n    pass\n\n# ====================================================\n# CELL 1.5: CUSTOM 3D UNET (REPLACES MONAI BASICUNET)\n# ====================================================\n# Aplicaciones de convoluciones 3D (no imágenes planas, sino cubos de datos)\nclass ConvBlock3D(nn.Module):\n    def __init__(self, in_ch, out_ch):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Conv3d(in_ch, out_ch, 3, padding=1), nn.GroupNorm(8, out_ch), nn.ReLU(inplace=True),\n            nn.Conv3d(out_ch, out_ch, 3, padding=1), nn.GroupNorm(8, out_ch), nn.ReLU(inplace=True)\n        )\n    def forward(self, x):\n        return self.net(x)\n\nclass UNet3D(nn.Module):\n    def __init__(self, in_ch=1, base=24):\n        super().__init__()\n        b = base\n        self.enc1 = ConvBlock3D(in_ch, b)\n        self.pool1 = nn.MaxPool3d(2)\n        self.enc2 = ConvBlock3D(b, b*2)\n        self.pool2 = nn.MaxPool3d(2)\n        self.enc3 = ConvBlock3D(b*2, b*4)\n        self.pool3 = nn.MaxPool3d(2)\n        self.bott = ConvBlock3D(b*4, b*8)\n        self.up3  = nn.ConvTranspose3d(b*8, b*4, 2, stride=2); self.dec3 = ConvBlock3D(b*8, b*4)\n        self.up2  = nn.ConvTranspose3d(b*4, b*2, 2, stride=2); self.dec2 = ConvBlock3D(b*4, b*2)\n        self.up1  = nn.ConvTranspose3d(b*2, b, 2, stride=2);   self.dec1 = ConvBlock3D(b*2, b)\n        self.seg_head = nn.Conv3d(b, 1, 1)\n        self.cls_pool = nn.AdaptiveAvgPool3d(1); self.cls_head = nn.Linear(b*8, 1)\n    def forward(self, x):\n        e1 = self.enc1(x); e2 = self.enc2(self.pool1(e1)); e3 = self.enc3(self.pool2(e2)); b = self.bott(self.pool3(e3))\n        d3 = self.dec3(torch.cat([self.up3(b), e3], dim=1))\n        d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))\n        d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))\n        seg = self.seg_head(d1); cls = self.cls_head(self.cls_pool(b).flatten(1))\n        return seg, cls\n\nclass CustomTransforms:\n    \"\"\"Pure PyTorch transforms to replace MONAI transforms\"\"\"\n    \n    def __init__(self, keys=['volume']):\n        self.keys = keys\n        \n    def __call__(self, data_dict):\n        \"\"\"Apply transforms to data dictionary\"\"\"\n        result = {}\n        \n        for key in data_dict:\n            if key in self.keys:\n                # Convert numpy array to tensor if needed\n                if isinstance(data_dict[key], np.ndarray):\n                    result[key] = torch.from_numpy(data_dict[key]).float()\n                else:\n                    result[key] = data_dict[key]\n            else:\n                result[key] = data_dict[key]\n        \n        return result\n\nprint(\"✅ Custom 3D UNet and transforms loaded (MONAI-free!)\")\n\n\n# ====================================================\n# CELL 2: DATA LOADING & ROI EXTRACTION\n# ====================================================\n\nclass Simple3DSegmentationNet(nn.Module):\n    \"\"\"Wrapper to match Stage 1 UNet3D interface for Stage 3 loading.\"\"\"\n    def __init__(self, in_channels=1, out_channels=1):\n        super().__init__()\n        self.unet = UNet3D(in_ch=in_channels, base=24)\n    def forward(self, x):\n        return self.unet(x)\n        \nclass SimpleDICOMProcessor:\n    \"\"\"Simplified DICOM processor aligned with Stage 1\"\"\"\n    def __init__(self, target_size=(48, 112, 112)):\n        self.target_size = target_size\n        # Prefer working prebuilt if available, else input dataset path\n        working_prebuilt = '/kaggle/input/rsna2025-v2-intracranial-aneurysm-detection-nb153/stage1_AneurysmNet_prebuilt_v2'\n        default_prebuilt = getattr(Config, 'STAGE1_EXTERNAL_CACHE_DIR', '')\n        if os.path.isdir(working_prebuilt):\n            self.external_cache_dir = working_prebuilt\n        else:\n            self.external_cache_dir = default_prebuilt\n        # brainmask path (v2 prebuilt has brainmasks/ with npz files)\n        self.brainmask_key = 'm'\n        \n    def load_dicom_series(self, series_path):\n        \"\"\"Load DICOM series with Stage 1-style preprocessing (CT HU windowing + isotropic resample).\"\"\"\n        try:\n            # Prefer loading from Stage 1 external cache if available\n            if self.external_cache_dir:\n                sid = os.path.basename(series_path.rstrip('/'))\n                cand = [\n                    os.path.join(self.external_cache_dir, 'volumes', f'{sid}.npy'),\n                    os.path.join(self.external_cache_dir, 'volumes', f'{sid}.npy.tmp.npy'),\n                ]\n                for p in cand:\n                    if os.path.exists(p):\n                        try:\n                            vol = np.load(p, allow_pickle=False, mmap_mode='r')\n                            # Ensure correct target size\n                            if vol.shape != self.target_size:\n                                target_d, target_h, target_w = self.target_size\n                                D, H, W = vol.shape\n                                if D != target_d:\n                                    idx = np.linspace(0, max(D - 1, 0), num=target_d).astype(int) if D > 0 else np.zeros(target_d, dtype=int)\n                                    vol = vol[idx]\n                                if (H, W) != (target_h, target_w):\n                                    resized = np.empty((target_d, target_h, target_w), dtype=np.float32)\n                                    for i in range(target_d):\n                                        resized[i] = cv2.resize(vol[i].astype(np.float32), (target_w, target_h))\n                                    vol = resized\n                            # Optional brainmask gating (as in Stage 1 training)\n                            bm_path = os.path.join(self.external_cache_dir, 'brainmasks', f'{sid}_brainmask.npz')\n                            if os.path.exists(bm_path):\n                                try:\n                                    bm = np.load(bm_path)[self.brainmask_key].astype(np.float32)\n                                    if bm.shape == vol.shape and bm.sum() > 0:\n                                        vol = (np.asarray(vol, dtype=np.float32) * bm)\n                                except Exception:\n                                    pass\n                            if Config.DEBUG_MODE:\n                                print(f\"DEBUG: Loaded volume from external cache {p}, stats: min={float(np.min(vol)):.3f}, max={float(np.max(vol)):.3f}, mean={float(np.mean(vol)):.3f}\")\n                            return vol.astype(np.float32)\n                        except Exception:\n                            pass\n\n            # Collect DICOMs\n            dicoms = []\n            for root, _, files in os.walk(series_path):\n                for f in files:\n                    if f.endswith('.dcm'):\n                        try:\n                            ds = pydicom.dcmread(os.path.join(root, f), force=True)\n                            if hasattr(ds, 'PixelData'):\n                                dicoms.append(ds)\n                        except Exception:\n                            continue\n            if not dicoms:\n                return np.zeros(self.target_size, dtype=np.float32)\n\n            # Sort by orientation vector dot IPP; fallback InstanceNumber\n            try:\n                orient = np.array(dicoms[0].ImageOrientationPatient, dtype=np.float32)\n                row = orient[:3]; col = orient[3:]\n                normal = np.cross(row, col)\n                def sort_key(ds):\n                    ipp = np.array(getattr(ds, 'ImagePositionPatient', [0,0,0]), dtype=np.float32)\n                    return float(np.dot(ipp, normal))\n                dicoms = sorted(dicoms, key=sort_key)\n            except Exception:\n                dicoms = sorted(dicoms, key=lambda ds: getattr(ds, 'InstanceNumber', 0))\n\n            # Spacing\n            try:\n                dy, dx = map(float, dicoms[0].PixelSpacing)\n            except Exception:\n                ps = getattr(dicoms[0], 'PixelSpacing', [1.0, 1.0])\n                dy, dx = float(ps[0]), float(ps[1])\n            zs = []\n            for i in range(1, len(dicoms)):\n                p0 = np.array(getattr(dicoms[i-1], 'ImagePositionPatient', [0,0,0]), dtype=np.float32)\n                p1 = np.array(getattr(dicoms[i], 'ImagePositionPatient', [0,0,0]), dtype=np.float32)\n                d = np.linalg.norm(p1 - p0)\n                if d > 0:\n                    zs.append(d)\n            dz = float(np.median(zs)) if zs else float(getattr(dicoms[0], 'SliceThickness', 1.0))\n            dz = dz if (dz > 0 and np.isfinite(dz)) else 1.0\n            dy = dy if (dy > 0 and np.isfinite(dy)) else 1.0\n            dx = dx if (dx > 0 and np.isfinite(dx)) else 1.0\n\n            # Build volume with HU and CT windowing\n            base_h = int(getattr(dicoms[0], 'Rows', 256))\n            base_w = int(getattr(dicoms[0], 'Columns', 256))\n            vol_slices = []\n            modality = (getattr(dicoms[0], 'Modality', '') or '').upper()\n            c = 300.0; w = 700.0\n            lo, hi = c - w/2.0, c + w/2.0\n            for ds in dicoms:\n                try:\n                    arr = ds.pixel_array\n                except Exception:\n                    continue\n                if arr.ndim >= 3:\n                    h, w2 = arr.shape[-2], arr.shape[-1]\n                    frames = arr.reshape(int(np.prod(arr.shape[:-2])), h, w2)\n                else:\n                    frames = arr[np.newaxis, ...]\n                for sl in frames:\n                    sl = sl.astype(np.float32)\n                    if getattr(ds, 'PhotometricInterpretation', 'MONOCHROME2') == 'MONOCHROME1':\n                        sl = sl.max() - sl\n                    slope = float(getattr(ds, 'RescaleSlope', 1.0)); intercept = float(getattr(ds, 'RescaleIntercept', 0.0))\n                    sl = sl * slope + intercept\n                    if sl.shape != (base_h, base_w):\n                        sl = cv2.resize(sl, (base_w, base_h))\n                    if modality == 'CT':\n                        s = np.clip(sl, lo, hi)\n                        s = (s - lo) / (hi - lo + 1e-6)\n                    else:\n                        mean = float(sl.mean()); std = float(sl.std() + 1e-6)\n                        s = (sl - mean) / std; zc = 3.0\n                        s = np.clip(s, -zc, zc); s = (s + zc) / (2.0*zc)\n                    vol_slices.append(s.astype(np.float32))\n            if not vol_slices:\n                return np.zeros(self.target_size, dtype=np.float32)\n\n            volume = np.stack(vol_slices, axis=0).astype(np.float32)\n            # Isotropic resample to 1.0 mm\n            z, y, x = volume.shape\n            newD = max(1, int(round(z * dz / 1.0)))\n            newH = max(1, int(round(y * dy / 1.0)))\n            newW = max(1, int(round(x * dx / 1.0)))\n            volume = ndimage.zoom(volume, (newD / z, newH / y, newW / x), order=1)\n            # Resize to target grid\n            target_d, target_h, target_w = self.target_size\n            D, H, W = volume.shape\n            if D != target_d:\n                idx = np.linspace(0, max(D - 1, 0), num=target_d).astype(int) if D > 0 else np.zeros(target_d, dtype=int)\n                volume = volume[idx]\n            if (H, W) != (target_h, target_w):\n                resized = np.empty((target_d, target_h, target_w), dtype=np.float32)\n                for i in range(target_d):\n                    resized[i] = cv2.resize(volume[i].astype(np.float32), (target_w, target_h))\n                volume = resized\n            return volume.astype(np.float32)\n        except Exception as e:\n            print(f\"Failed to load {series_path}: {e}\")\n            return np.zeros(self.target_size, dtype=np.float32)\n    \n    def preprocess_volume(self, volume):\n        pass \n    # === CAMBIO: Normalización básica del volumen =======================\n    # Tipo de mejora: Predictibilidad\n    # Descripción del cambio: Normalización Z-score y opción de histogram matching para reducir variaciones entre escáneres/centros.\n    # Beneficio esperado: +2–4% AUC/Sensibilidad en presencia.\n    # ====================================================================\n\n    # [MODIFICACION] Normalización Z-score por estudio\n        #Eliminación de outliers (extremos)\n        print(\"Eliminación de outliers\")\n        p1, p99 = np.percentile(volume, [1, 99])\n        volume = np.clip(volume, p1, p99)\n        print(\"Normalizacion con z-score\")\n        eps = 1e-8\n        vol = (volume - np.mean(volume)) / (np.std(volume) + eps)\n    \n    # [OPCIONAL] Histogram Matching a una referencia precomputada para reducir variaciones entre escáneres/centros\n        from skimage.exposure import match_histograms\n        ref_hist = np.load('ref_histogram_template.npy') \n        vol = match_histograms(vol, ref_hist)\n    \n        return vol\n    # [ORIGINAL] --- inicio cuerpo anterior preprocess_volume ---\n    #         \"\"\"Simple preprocessing (match Stage 1)\"\"\"\n    #         p1, p99 = np.percentile(volume, [1, 99])\n    #         volume = np.clip(volume, p1, p99)\n    #         denom = (p99 - p1) if (p99 - p1) > 1e-6 else 1e-6\n    #         volume = (volume - p1) / denom\n    #         \n    #         if volume.shape != self.target_size:\n    #             target_d, target_h, target_w = self.target_size\n    #             D, H, W = volume.shape\n    #             if D != target_d:\n    #                 idx = np.linspace(0, max(D - 1, 0), num=target_d).astype(int) if D > 0 else np.zeros(target_d, dtype=int)\n    #                 volume = volume[idx]\n    #             if (H, W) != (target_h, target_w):\n    #                 resized = np.empty((target_d, target_h, target_w), dtype=np.float32)\n    #                 for i in range(target_d):\n    #                     resized[i] = cv2.resize(volume[i].astype(np.float32), (target_w, target_h))\n    #                 volume = resized\n    #         \n    #         return volume.astype(np.float32)\n    # \n    # [ORIGINAL]\nclass Stage1Predictor:\n    \"\"\"Load and use Stage 1 model for ROI extraction\"\"\"\n    def __init__(self, model_path):\n        self.device = Config.DEVICE\n        self.processor = SimpleDICOMProcessor()\n        \n        # Load Stage 1 model (exact UNet used in Stage 1 training)\n        print(\"Loading Stage 1 model...\")\n        self.model = UNet3D(in_ch=1, base=24).to(self.device)\n        \n        try:\n            preferred = '/kaggle/input/aneurisma/pytorch/default/1/stage1_segmentation_best.pth'\n            load_path = preferred if os.path.exists(preferred) else model_path\n            if load_path != model_path:\n                print(f\"Using Stage 1 checkpoint from working dir: {preferred}\")\n            checkpoint = torch.load(load_path, map_location=self.device, weights_only=False)\n            print(f\"Loaded Stage 1 checkpoint from: {load_path}\")\n            if 'model_state_dict' in checkpoint:\n                state_dict = checkpoint['model_state_dict']\n            else:\n                state_dict = checkpoint\n            \n            # Handle DataParallel wrapper\n            if any(key.startswith('module.') for key in state_dict.keys()):\n                state_dict = {key.replace('module.', ''): value for key, value in state_dict.items()}\n            \n            # Diagnostic: count matched keys and shapes\n            model_state = self.model.state_dict()\n            matched, total = 0, 0\n            for k, v in model_state.items():\n                total += 1\n                if k in state_dict and state_dict[k].shape == v.shape:\n                    matched += 1\n            match_ratio = matched / max(1, total)\n            print(f\"Stage 1 checkpoint match ratio: {match_ratio:.2%} ({matched}/{total})\")\n            if matched == 0:\n                # Try key remap: strip optional 'unet.' prefix from checkpoint or add if needed\n                remapped = {}\n                if any(k.startswith('unet.') for k in state_dict.keys()):\n                    remapped = {k.replace('unet.', '', 1): v for k, v in state_dict.items()}\n                else:\n                    remapped = state_dict\n                # Recompute match\n                matched = 0\n                for k, v in model_state.items():\n                    if k in remapped and remapped[k].shape == v.shape:\n                        matched += 1\n                print(f\"Remapped match: {matched}/{total}\")\n                state_dict = remapped\n            self.model.load_state_dict(state_dict, strict=False)\n            self.model.eval()\n            print(\"✅ Stage 1 model loaded successfully\")\n        except Exception as e:\n            print(f\"❌ Error loading Stage 1 model: {e}\")\n            self.model = None\n    \n    def predict_segmentation(self, series_path):\n        pass\n        \"\"\"Get segmentation mask from Stage 1 model\"\"\"\n        if self.model is None:\n            return np.zeros((48, 112, 112), dtype=np.float32)\n        \n        try:\n            # Load volume\n            volume = self.processor.load_dicom_series(series_path)\n            \n            # Predict\n            with torch.no_grad():\n                volume_tensor = torch.from_numpy(volume).unsqueeze(0).unsqueeze(0).to(self.device)\n                # channels_last_3d for throughput and parity\n                try:\n                    volume_tensor = volume_tensor.to(memory_format=torch.channels_last_3d)\n                except Exception:\n                    pass\n                seg_logits, _ = self.model(volume_tensor)\n                seg_mask = torch.sigmoid(seg_logits).cpu().numpy()[0, 0]\n            \n            return seg_mask\n        except Exception as e:\n            print(f\"Error predicting segmentation for {series_path}: {e}\")\n            return np.zeros((64, 128, 128), dtype=np.float32)\n\n    def predict_segmentation_with_volume(self, series_path):\n        # pass\n        # === CAMBIO: Ensemble por promedio simple ==========================\n        # Tipo de mejora: Predictibilidad\n        # Descripción del cambio: Reemplazar/facilitar promedio simple (todos los modelos aportan igual, aunque algunos eran más precisos) \n        # por ensamble ponderado (los modelos que demostraron mejor desempeño en validación tienen más peso en la predicción final)\n        # Beneficio esperado: +1–3% AUC global; mayor robustez del ensamble. \n        # Evita que un modelo “malo” arruine la predicción. Hace el sistema más robusto y estable en nuevos datos.\n        # ====================================================================\n\n        # [MODIFICACION] Ejemplo de combinación ponderada si 'model_preds' contiene N predicciones:\n        #weights = torch.tensor([0.5, 0.3, 0.2], device=model_preds[0].device)  # Ajustar con AUC de validación\n        #stack = torch.stack(model_preds, dim=0)\n        #final_pred = torch.sum(weights.view(-1, *([1]*(stack.ndim-1))) * stack, dim=0)\n        #return final_pred\n        # [ORIGINAL] Promedio simple (deja comentado aquí si quieres comparar)\n        # final_pred = torch.mean(torch.stack(model_preds, dim=0), dim=0)\n        #PARCHE INICIO\n        if self.model is None:\n            zero = np.zeros((48, 112, 112), dtype=np.float32)\n            return zero, zero\n        try:\n            # Cargar y preprocesar el volumen DICOM\n            volume = self.processor.load_dicom_series(series_path)\n    \n            with torch.no_grad():\n                volume_tensor = torch.from_numpy(volume).unsqueeze(0).unsqueeze(0).to(self.device)\n                try:\n                    volume_tensor = volume_tensor.to(memory_format=torch.channels_last_3d)\n                except Exception:\n                    pass\n    \n                # Ejecutar el modelo UNet3D\n                seg_logits, _ = self.model(volume_tensor)\n                seg_mask = torch.sigmoid(seg_logits).cpu().numpy()[0, 0]\n    \n            # Debug opcional de estadísticas de la máscara\n            try:\n                if not hasattr(self, '_printed_stats'):\n                    print(f\"DEBUG: seg_mask stats -> min={float(seg_mask.min()):.4f}, \"\n                          f\"max={float(seg_mask.max()):.4f}, mean={float(seg_mask.mean()):.4f}\")\n                    self._printed_stats = True\n            except Exception:\n                pass\n    \n            return seg_mask, volume\n    \n        except Exception as e:\n            print(f\"Error predicting segmentation (with volume) for {series_path}: {e}\")\n            zero = np.zeros((48, 112, 112), dtype=np.float32)\n            return zero, zero\n\nclass ROIExtractor:\n    \"\"\"Research-backed ROI extraction with adaptive count and quality filtering\"\"\"\n    def __init__(self, stage1_predictor, roi_size=(224, 224)):\n        self.stage1_predictor = stage1_predictor\n        self.roi_size = roi_size\n        self.processor = SimpleDICOMProcessor()\n\n        # Research-backed thresholds\n        # Relaxed thresholds to avoid over-pruning when Stage 1 is weak\n        self.min_confidence_threshold = 0.15\n        self.high_confidence_threshold = 0.5\n        self.max_rois_per_series = getattr(Config, 'ROIS_PER_SERIES', 3)\n        # Post-process controls\n        self.border_margin = 2            # suppress edge activations near skull\n        self.min_region_size = 6         # minimum connected component size (pixels)\n        self.morph_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))\n\n\n    def iou3d(b1, b2):\n        # b = (z1,y1,x1,z2,y2,x2)\n        z1 = max(b1[0], b2[0]); y1 = max(b1[1], b2[1]); x1 = max(b1[2], b2[2])\n        z2 = min(b1[3], b2[3]); y2 = min(b1[4], b2[4]); x2 = min(b1[5], b2[5])\n        dz = max(0, z2 - z1); dy = max(0, y2 - y1); dx = max(0, x2 - x1)\n        inter = dz * dy * dx\n        v1 = (b1[3]-b1[0])*(b1[4]-b1[1])*(b1[5]-b1[2])\n        v2 = (b2[3]-b2[0])*(b2[4]-b2[1])*(b2[5]-b2[2])\n        union = max(v1 + v2 - inter, 1e-8)\n        return inter / union\n\n    def extract_top3_rois(self, series_path):\n    #OPTIMIZACION DESPUES DE LA EJECUCION\n        try:\n            rois = self.extract_adaptive_rois(series_path)\n    \n            # Ordenar por confianza (ya está en cada ROI)\n            rois = sorted(rois, key=lambda r: r['confidence'], reverse=True)\n    \n            # Devolver top-3\n            return rois[:3]\n    \n        except Exception as e:\n            print(f\"❌ Error en extract_top3_rois({series_path}): {e}\")\n            return self._get_emergency_fallback_rois()        \n\n    \n    # === CAMBIO:Selección de sólo 3 ROIs top ==========\n    # Tipo de mejora: Predictibilidad\n    # Descripción del cambio: Aplicar NMS 3D para eliminar ROIs solapados y ampliar a top-5 para cubrir lesiones múltiples. No utiliza el cache.\n    # Beneficio esperado: +1–2% Sensibilidad; menor redundancia de .\n    # ===================================================\n\n  \n    # [MODIFICACION] NMS 3D y top-5\n    # SE DESCARTA LA MODIFICACION PORQUE YA SE MANEJA confidence para medir la fuerza del ROI\n    # Score aquí es redundante y los bounding boxes (bbox) tampoco se calculan en el pipeline \n    # rois = sorted(rois, key=lambda r: r['score'], reverse=True)\n    # selected = []\n    # for r in rois:\n        # if all(iou3d(r['bbox'], s['bbox']) < 0.3 for s in selected):\n            # selected.append(r)\n        #if len(selected) == 5:\n            # break\n    # return selected\n    # [ORIGINAL] \n    #         \"\"\"Extract 0-5 ROIs based on segmentation quality (research-backed)\"\"\"\n    #         # Cache ROI results per series to avoid recomputation\n    #         try:\n    #             os.makedirs(Config.STAGE2_CACHE_DIR, exist_ok=True)\n    #             sid = os.path.basename(series_path)\n    #             cache_path = os.path.join(Config.STAGE2_CACHE_DIR, f\"{sid}_rois.npy\")\n    #             if os.path.exists(cache_path):\n    #                 arr = np.load(cache_path, allow_pickle=True)\n    #                 return list(arr)\n    #         except Exception:\n    #             cache_path = None\n    #         rois = self.extract_adaptive_rois(series_path)\n    #         try:\n    #             if cache_path is not None:\n    #                 np.save(cache_path, np.array(rois, dtype=object), allow_pickle=True)\n    #         except Exception:\n    #             pass\n    #         return rois\n    # \n    # [ORIGINAL]\n    def extract_adaptive_rois(self, series_path):\n        \"\"\"Extract 0-5 ROIs based on segmentation quality (research-backed)\"\"\"\n        try:\n            print(f\"🔍 DEBUG: Quality-based ROI extraction for {os.path.basename(series_path)}\")\n            \n            # Get Stage 1 seg mask and the preprocessed volume (avoid reloading original DICOMs here)\n            seg_mask, original_volume = self.stage1_predictor.predict_segmentation_with_volume(series_path)\n            print(f\"🔍 DEBUG: Segmentation mask shape: {seg_mask.shape}; Volume shape: {original_volume.shape}\")\n            \n            # STEP 1: Assess overall segmentation quality\n            seg_quality = self._assess_segmentation_quality(seg_mask)\n            print(f\"🔍 DEBUG: Segmentation quality score: {seg_quality:.3f}\")\n            \n            # STEP 2: If segmentation is poor, still attempt candidate extraction; fallback only if none\n            low_quality = seg_quality < self.min_confidence_threshold\n            if low_quality:\n                print(f\"🔍 DEBUG: Low segmentation quality ({seg_quality:.3f} < {self.min_confidence_threshold}), attempting candidate extraction anyway\")\n            \n            # STEP 4: Extract ROIs with confidence-based filtering\n            roi_candidates = self._find_quality_based_rois(seg_mask, original_volume)\n            \n            if low_quality and not roi_candidates:\n                print(\"🔍 DEBUG: No candidates under low-quality mask, using volume-based fallback\")\n                return self._get_quality_fallback_rois_from_volume(original_volume, self.max_rois_per_series)\n\n            # STEP 5: Adaptive ROI count\n            selected_rois = self._select_adaptive_rois(roi_candidates, seg_quality, original_volume)\n            \n            print(f\"🔍 DEBUG: Selected {len(selected_rois)} ROIs based on quality assessment\")\n            return selected_rois\n            \n        except Exception as e:\n            print(f\"❌ Error in quality-based ROI extraction: {e}\")\n            return self._get_emergency_fallback_rois()\n    \n    def _assess_segmentation_quality(self, seg_mask):\n        \"\"\"Assess segmentation quality using connected components and border penalties.\"\"\"\n        try:\n            D, H, W = seg_mask.shape\n            largest_area_frac = 0.0\n            largest_mean_conf = 0.0\n            total_components = 0\n            border_touch_penalty = 0.0\n\n            for z in range(D):\n                sm = seg_mask[z]\n                # suppress borders\n                sm_proc = sm.copy()\n                sm_proc[:self.border_margin, :] = 0\n                sm_proc[-self.border_margin:, :] = 0\n                sm_proc[:, :self.border_margin] = 0\n                sm_proc[:, -self.border_margin:] = 0\n\n               # Adaptive thresholding based on actual max values\n                max_val = float(sm_proc.max())\n                if max_val > 0.3:\n                    thr = max(0.05, 0.3 * max_val)\n                elif max_val > 0.1:\n                    thr = max(0.03, 0.4 * max_val)\n                else:\n                    thr = max(0.02, 0.5 * max_val)\n                binmask = (sm_proc > thr).astype(np.uint8)\n                if binmask.max() == 0:\n                    continue\n                # small opening to remove speckle\n                binmask = cv2.morphologyEx(binmask, cv2.MORPH_OPEN, self.morph_kernel)\n\n                labeled, n = label(binmask)\n                if n == 0:\n                    continue\n                total_components += int(n)\n\n                # evaluate components\n                for comp_id in range(1, n + 1):\n                    comp = (labeled == comp_id)\n                    comp_size = int(comp.sum())\n                    if comp_size < self.min_region_size:\n                        continue\n                    mean_conf = float(sm[comp].mean())\n                    area_frac = comp_size / float(H * W)\n                    if area_frac > largest_area_frac:\n                        largest_area_frac = area_frac\n                    if mean_conf > largest_mean_conf:\n                        largest_mean_conf = mean_conf\n\n                    # simple border-touch penalty if component abuts image edge\n                    ys, xs = np.where(comp)\n                    if ys.size > 0:\n                        if (ys.min() <= self.border_margin or ys.max() >= H - self.border_margin - 1 or\n                            xs.min() <= self.border_margin or xs.max() >= W - self.border_margin - 1):\n                            border_touch_penalty += 0.02\n\n            # compose quality score\n            area_score = min(largest_area_frac / 0.02, 1.0)  # cap around ~2% of slice (aneurysm-sized)\n            comp_penalty = min(0.1, 0.0015 * total_components) + min(0.1, border_touch_penalty)\n            quality_score = max(0.0, 0.6 * largest_mean_conf + 0.4 * area_score - comp_penalty)\n\n            # robust floor based on global mask stats to avoid spurious 0.0 quality\n            max_val = float(seg_mask.max())\n            mean_val = float(seg_mask.mean())\n            if max_val >= 0.55:\n                quality_score = max(quality_score, 0.35)\n            elif max_val >= 0.45:\n                quality_score = max(quality_score, 0.25)\n            elif mean_val >= 0.25:\n                quality_score = max(quality_score, 0.22)\n\n            return float(quality_score)\n        except Exception:\n            return 0.1\n    \n    def _find_quality_based_rois(self, seg_mask, original_volume):\n        \"\"\"Find ROI candidates with confidence scores (no hardcoded count)\"\"\"\n        print(\"🔍 DEBUG: Finding quality-based ROI candidates...\")\n        \n        # Resize segmentation mask to match original volume\n        if seg_mask.shape != original_volume.shape:\n            print(\"🔍 DEBUG: Resizing segmentation mask with cv2...\")\n            seg_mask_resized = np.zeros(original_volume.shape, dtype=np.float32)\n            for i in range(min(seg_mask.shape[0], original_volume.shape[0])):\n                if i < seg_mask.shape[0]:\n                    resized_slice = cv2.resize(\n                        seg_mask[i],\n                        (original_volume.shape[2], original_volume.shape[1])\n                    )\n                    seg_mask_resized[i] = resized_slice\n        else:\n            seg_mask_resized = seg_mask\n        \n        # 3D peak proposals first (relative peak logic; does not lower thresholds)\n        roi_candidates = self._proposals_from_3d_peaks(seg_mask_resized)\n        if len(roi_candidates) == 0:\n            # Fall back to 2D slice-wise CC method\n            roi_candidates = []\n        \n        H, W = original_volume.shape[1], original_volume.shape[2]\n        for slice_idx in range(seg_mask_resized.shape[0]):\n            slice_mask = seg_mask_resized[slice_idx].copy()\n\n            # Suppress borders to avoid skull/edge activations\n            slice_mask[:self.border_margin, :] = 0\n            slice_mask[-self.border_margin:, :] = 0\n            slice_mask[:, :self.border_margin] = 0\n            slice_mask[:, -self.border_margin:] = 0\n\n            # Adaptive dynamic threshold tied to local max (aligned with quality assessment)\n            max_val = float(slice_mask.max())\n            if max_val > 0.2:\n                thr = max(self.min_confidence_threshold, 0.25 * max_val)\n            elif max_val > 0.1:\n                thr = max(0.03, 0.30 * max_val)\n            else:\n                thr = max(0.02, 0.25 * max_val)\n            high_conf_regions = (slice_mask > thr).astype(np.uint8)\n            if high_conf_regions.max() == 0:\n                # Percentile-based fallback with small dilation to form blobs\n                p90 = float(np.percentile(slice_mask, 90))\n                if p90 > 0:\n                    mask_peaks = (slice_mask >= p90).astype(np.uint8)\n                    # small dilation to merge nearby high pixels\n                    mask_peaks = cv2.dilate(mask_peaks, self.morph_kernel, iterations=1)\n                    labeled_regions, num_regions = label(mask_peaks)\n                    for region_id in range(1, num_regions + 1):\n                        region_mask = (labeled_regions == region_id)\n                        region_size = int(region_mask.sum())\n                        if region_size < 3:\n                            continue\n                        ys, xs = np.where(region_mask)\n                        if ys.size == 0:\n                            continue\n                        # Skip borders\n                        if (ys.min() <= self.border_margin or ys.max() >= H - self.border_margin - 1 or\n                            xs.min() <= self.border_margin or xs.max() >= W - self.border_margin - 1):\n                            continue\n                        com = center_of_mass(region_mask)\n                        y, x = int(com[0]), int(com[1])\n                        region_confidence = float(slice_mask[region_mask].mean())\n                        roi_candidates.append({\n                            'slice_idx': slice_idx,\n                            'y': y,\n                            'x': x,\n                            'confidence': region_confidence,\n                            'region_size': region_size\n                        })\n                continue\n            # Apply opening only if region is sufficiently large; avoid eroding tiny blobs\n            if int(high_conf_regions.sum()) > 50:\n                high_conf_regions = cv2.morphologyEx(high_conf_regions, cv2.MORPH_OPEN, self.morph_kernel)\n\n            labeled_regions, num_regions = label(high_conf_regions)\n            for region_id in range(1, num_regions + 1):\n                region_mask = (labeled_regions == region_id)\n                region_size = int(region_mask.sum())\n                if region_size < self.min_region_size:\n                    continue\n                ys, xs = np.where(region_mask)\n                if ys.size == 0:\n                    continue\n                # Skip border-touching components\n                if (ys.min() <= self.border_margin or ys.max() >= H - self.border_margin - 1 or\n                    xs.min() <= self.border_margin or xs.max() >= W - self.border_margin - 1):\n                    continue\n\n                com = center_of_mass(region_mask)\n                y, x = int(com[0]), int(com[1])\n                region_confidence = float(slice_mask[region_mask].mean())\n\n                roi_candidates.append({\n                    'slice_idx': slice_idx,\n                    'y': y,\n                    'x': x,\n                    'confidence': region_confidence,\n                    'region_size': region_size\n                })\n        \n        # Sort by confidence (descending)\n        if not roi_candidates:\n            # Volume-wise peak fallback: pick top maxima per slice (excluding borders)\n            print(\"🔍 DEBUG: No ROI components found; using volume-wise peak fallback\")\n            D = seg_mask_resized.shape[0]\n            peak_candidates = []\n            for z in range(D):\n                m = seg_mask_resized[z].copy()\n                # suppress borders\n                m[:self.border_margin, :] = 0\n                m[-self.border_margin:, :] = 0\n                m[:, :self.border_margin] = 0\n                m[:, -self.border_margin:] = 0\n                yx = np.unravel_index(np.argmax(m), m.shape)\n                y, x = int(yx[0]), int(yx[1])\n                conf = float(m[y, x])\n                if conf > 0:\n                    peak_candidates.append({\n                        'slice_idx': z,\n                        'y': y,\n                        'x': x,\n                        'confidence': conf,\n                        'region_size': 1\n                    })\n            # Keep strongest few peaks across volume\n            peak_candidates.sort(key=lambda c: c['confidence'], reverse=True)\n            roi_candidates.extend(peak_candidates[: max( self.max_rois_per_series * 3, 6)])\n\n        roi_candidates.sort(key=lambda x: x['confidence'], reverse=True)\n        \n        print(f\"🔍 DEBUG: Found {len(roi_candidates)} ROI candidates\")\n        return roi_candidates\n\n    def _proposals_from_3d_peaks(self, seg_mask_zyx: np.ndarray):\n        \"\"\"3D local-max proposals with seeded relative growth (no absolute threshold lowering).\"\"\"\n        try:\n            D, H, W = seg_mask_zyx.shape\n            # Light 3D smoothing to stabilize local maxima\n            try:\n                sm = ndimage.gaussian_filter(seg_mask_zyx.astype(np.float32), sigma=0.75)\n            except Exception:\n                sm = seg_mask_zyx.astype(np.float32)\n            # 3D local maxima via maximum filter\n            footprint = np.ones((3,3,3), dtype=np.uint8)\n            max_f = ndimage.maximum_filter(sm, footprint=footprint, mode='nearest')\n            peaks = (sm == max_f)\n            # Suppress borders\n            b = self.border_margin\n            if b > 0:\n                peaks[:, :b, :] = False; peaks[:, -b:, :] = False\n                peaks[:, :, :b] = False; peaks[:, :, -b:] = False\n            coords = np.argwhere(peaks)\n            if coords.shape[0] == 0:\n                return []\n            # Rank peaks by value and keep top-K to control cost\n            values = sm[peaks]\n            order = np.argsort(values)[::-1]\n            top_k = min(64, order.size)\n            selected = coords[order[:top_k]]\n            # Non-maximum suppression by 3D distance\n            kept = []\n            min_dist = 4.0\n            for (cz, cy, cx) in selected:\n                if any(((cz-kz)**2 + (cy-ky)**2 + (cx-kx)**2) ** 0.5 < min_dist for kz,ky,kx in kept):\n                    continue\n                kept.append((int(cz), int(cy), int(cx)))\n                if len(kept) >= 64:\n                    break\n            # Seeded relative growth\n            proposals = []\n            for cz, cy, cx in kept:\n                peak = float(sm[cz, cy, cx])\n                if peak <= 0:\n                    continue\n                rel_thr = max(0.6*peak, 1e-6)  # relative to each peak\n                # collect voxels that descend from the peak (thresholded region)\n                region = sm >= rel_thr\n                labeled, num = ndimage.label(region)\n                cid = int(labeled[cz, cy, cx])\n                if cid == 0:\n                    continue\n                comp = (labeled == cid)\n                size = int(comp.sum())\n                if size < self.min_region_size:\n                    continue\n                # score = peak * mean(comp)\n                conf = peak * float(sm[comp].mean() + 1e-6)\n                # project to a representative slice (peak slice)\n                ys, xs = np.where(comp[cz])\n                if ys.size == 0:\n                    # fallback to COM over full comp\n                    zc, yc, xc = ndimage.center_of_mass(comp)\n                    zc = int(round(zc)); yc = int(round(yc)); xc = int(round(xc))\n                    if yc <= self.border_margin or yc >= H - self.border_margin - 1 or xc <= self.border_margin or xc >= W - self.border_margin - 1:\n                        continue\n                    proposals.append({\n                        'slice_idx': int(zc),\n                        'y': int(yc),\n                        'x': int(xc),\n                        'confidence': float(conf),\n                        'region_size': size,\n                    })\n                else:\n                    y = int(ys.mean()); x = int(xs.mean())\n                    if y <= self.border_margin or y >= H - self.border_margin - 1 or x <= self.border_margin or x >= W - self.border_margin - 1:\n                        continue\n                    proposals.append({\n                        'slice_idx': int(cz),\n                        'y': y,\n                        'x': x,\n                        'confidence': float(conf),\n                        'region_size': size,\n                    })\n            proposals.sort(key=lambda c: c['confidence'], reverse=True)\n            return proposals\n        except Exception:\n            return []\n    \n    def _select_adaptive_rois(self, roi_candidates, seg_quality, original_volume):\n        \"\"\"Adaptively select ROIs based on segmentation quality (research-backed)\"\"\"\n        if not roi_candidates:\n            print(\"🔍 DEBUG: No candidates found, using fallback\")\n            return self._get_quality_fallback_rois_from_volume(original_volume)\n        \n        # Adaptive selection based on segmentation quality\n        if seg_quality >= self.high_confidence_threshold:\n            max_rois = self.max_rois_per_series\n            min_confidence = 0.3\n        elif seg_quality >= self.min_confidence_threshold + 0.2:\n            max_rois = self.max_rois_per_series\n            min_confidence = 0.2\n        else:\n            max_rois = self.max_rois_per_series\n            min_confidence = 0.05\n        \n        # Filter and select ROIs\n        filtered = [c for c in roi_candidates if c['confidence'] >= min_confidence]\n        selected_candidates = filtered[:max_rois]\n        # If not enough, top-off with next best candidates\n        if len(selected_candidates) < max_rois:\n            for c in roi_candidates:\n                if c in selected_candidates:\n                    continue\n                selected_candidates.append(c)\n                if len(selected_candidates) >= max_rois:\n                    break\n        \n        # Convert to ROI format\n        rois = []\n        for i, candidate in enumerate(selected_candidates):\n            roi_patch = self._extract_roi_patch(\n                original_volume,\n                candidate['slice_idx'], \n                candidate['y'], \n                candidate['x']\n            )\n            \n            rois.append({\n                'roi_image': roi_patch,\n                'slice_idx': candidate['slice_idx'],\n                'coordinates': (candidate['y'], candidate['x']),\n                'confidence': candidate['confidence'],\n                'roi_id': i\n            })\n        # Ensure at least max_rois via center-based fallback if still short\n        if len(rois) < self.max_rois_per_series:\n            needed = self.max_rois_per_series - len(rois)\n            center_fallbacks = self._get_quality_fallback_rois_from_volume(original_volume, needed)\n            rois.extend(center_fallbacks)\n        print(f\"🔍 DEBUG: Adaptively selected {len(rois)} ROIs (quality: {seg_quality:.3f})\")\n        return rois[: self.max_rois_per_series]\n    \n    def _get_quality_fallback_rois(self, series_path, seg_mask):\n        \"\"\"Fallback for poor segmentation quality: generate multiple center-based ROIs\"\"\"\n        print(\"🔍 DEBUG: Using quality-aware fallback (multi-center ROIs)\")\n        original_volume = self._load_efficient_volume(series_path)\n        return self._get_quality_fallback_rois_from_volume(original_volume, self.max_rois_per_series)\n\n    def _get_quality_fallback_rois_from_volume(self, original_volume, count: int = 3):\n        D, H, W = original_volume.shape\n        # Choose slice indices: center and quartiles\n        slices = sorted(set([D // 2, max(0, D // 4), min(D - 1, 3 * D // 4)]))\n        # Ensure desired count\n        while len(slices) < count:\n            # Add random slices if needed\n            slices.append(np.random.randint(0, D))\n            slices = list(dict.fromkeys(slices))\n        rois = []\n        cy, cx = H // 2, W // 2\n        for i, s in enumerate(slices[:count]):\n            roi_patch = self._extract_roi_patch(original_volume, s, cy, cx)\n            rois.append({\n                'roi_image': roi_patch,\n                'slice_idx': s,\n                'coordinates': (cy, cx),\n                'confidence': 0.2,\n                'roi_id': i\n            })\n        return rois\n    \n    def _get_simple_fallback_rois(self):\n        \"\"\"Simple fallback when no quality ROIs found\"\"\"\n        print(\"🔍 DEBUG: Using simple fallback (single center ROI)\")\n        dummy_roi = np.random.random((*Config.ROI_SIZE, 3)).astype(np.float32)\n        return [{\n            'roi_image': dummy_roi,\n            'slice_idx': 25,\n            'coordinates': (128, 128),\n            'confidence': 0.1,\n            'roi_id': 0\n        }]\n    \n    def _get_emergency_fallback_rois(self):\n        \"\"\"Emergency fallback when everything fails\"\"\"\n        print(\"🔍 DEBUG: Using emergency fallback ROI\")\n        dummy_roi = np.random.random((*Config.ROI_SIZE, 3)).astype(np.float32)\n        return [{\n            'roi_image': dummy_roi,\n            'slice_idx': 0,\n            'coordinates': (128, 128),\n            'confidence': 0.1,\n            'roi_id': 0\n        }]\n\n    \n    def _load_efficient_volume(self, series_path):\n        \"\"\"Load volume with smart distributed sampling to cover entire brain\"\"\"\n        try:\n            # Cache original volume slices to reduce repeated I/O\n            os.makedirs(Config.STAGE2_CACHE_DIR, exist_ok=True)\n            sid = os.path.basename(series_path)\n            vcache = os.path.join(Config.STAGE2_CACHE_DIR, f\"{sid}_vol.npy\")\n            if os.path.exists(vcache):\n                return np.load(vcache, allow_pickle=False)\n            dicom_files = [f for f in os.listdir(series_path) if f.endswith('.dcm')]\n            pixel_arrays = []\n            \n            # SMART SAMPLING: Distribute 50 slices across entire volume\n            total_files = len(dicom_files)\n            if total_files > 50:\n                # Calculate step size to distribute slices evenly\n                step = total_files / 50\n                selected_indices = [int(i * step) for i in range(50)]\n                selected_files = [dicom_files[i] for i in selected_indices]\n                print(f\"🔍 DEBUG: Smart sampling - selected {len(selected_files)} files from {total_files} total (every {step:.1f})\")\n            else:\n                selected_files = dicom_files\n                print(f\"🔍 DEBUG: Using all {len(selected_files)} files (less than 50)\")\n            \n            for f in selected_files:\n                try:\n                    ds = pydicom.dcmread(os.path.join(series_path, f), force=True)\n                    if hasattr(ds, 'pixel_array'):\n                        arr = ds.pixel_array\n                        if arr.ndim == 2:\n                            pixel_arrays.append(arr)\n                except:\n                    continue\n            \n            if pixel_arrays:\n                # SMALLER target shape to reduce memory usage\n                target_shape = (256, 256)  # Reduced from (512, 512)\n                \n                resized_arrays = []\n                for arr in pixel_arrays:\n                    # Use cv2.resize instead of ndimage.zoom (more reliable)\n                    if arr.shape != target_shape:\n                        resized_arr = cv2.resize(arr.astype(np.float32), target_shape)\n                        resized_arrays.append(resized_arr)\n                    else:\n                        resized_arrays.append(arr.astype(np.float32))\n                \n                volume = np.stack(resized_arrays, axis=0)\n                \n                # Simple normalization\n                p1, p99 = np.percentile(volume, [1, 99])\n                volume = np.clip(volume, p1, p99)\n                volume = (volume - p1) / (p99 - p1 + 1e-8)\n                \n                try:\n                    np.save(vcache, volume.astype(np.float32), allow_pickle=False)\n                except Exception:\n                    pass\n                return volume\n            \n        except Exception as e:\n            print(f\"Error loading efficient volume: {e}\")\n        \n        # Fallback volume (matches our smart sampling approach)\n        return np.random.random((50, 256, 256)).astype(np.float32)\n\n    \n    def _extract_roi_patch(self, volume, slice_idx, center_y, center_x):\n        \"\"\"Extract ROI with adjacent-slice context as RGB channels (s-1, s, s+1).\"\"\"\n        D, H, W = volume.shape\n        s_indices = [max(0, slice_idx - 1), slice_idx, min(D - 1, slice_idx + 1)]\n        channels = []\n        half_size = Config.ROI_SIZE[0] // 2\n        for s in s_indices:\n            slice_data = volume[s]\n            h, w = slice_data.shape\n            y1 = max(0, center_y - half_size)\n            y2 = min(h, center_y + half_size)\n            x1 = max(0, center_x - half_size)\n            x2 = min(w, center_x + half_size)\n            patch = slice_data[y1:y2, x1:x2]\n            patch_resized = cv2.resize(patch, Config.ROI_SIZE)\n            channels.append(patch_resized)\n        patch_3ch = np.stack(channels, axis=2)\n        return patch_3ch\n    \n\ndef create_training_data(df, stage1_predictor):\n    \"\"\"Create training data with 3 ROIs per series\"\"\"\n    print(\"🔄 Extracting ROIs for training data...\")\n    \n    # Reuse cached ROIs/training dataframe if available\n    cache_dir = 'rois'\n    os.makedirs(cache_dir, exist_ok=True)\n    cached_df_path_parquet = os.path.join(cache_dir, 'training_df.parquet')\n    external_cached_df_path = os.path.join(getattr(Config, 'ROIS_EXTERNAL_DIR', ''), 'training_df.parquet')\n    if Config.REUSE_EXISTING_ROIS:\n        # Prefer working cache\n        if os.path.exists(cached_df_path_parquet):\n            try:\n                cached = pl.read_parquet(cached_df_path_parquet).to_pandas()\n                if len(cached) > 0 and all(c in cached.columns for c in ['roi_path', 'roi_id', 'series_id'] + Config.LABEL_COLS):\n                    print(f\"✅ Reusing cached training ROIs (working): {len(cached)} samples from {cached['series_id'].nunique()} series\")\n                    return cached\n            except Exception:\n                pass\n        # Fallback to external cache\n        if isinstance(external_cached_df_path, str) and len(external_cached_df_path) and os.path.exists(external_cached_df_path):\n            try:\n                cached = pl.read_parquet(external_cached_df_path).to_pandas()\n                if len(cached) > 0 and all(c in cached.columns for c in ['roi_path', 'roi_id', 'series_id'] + Config.LABEL_COLS):\n                    print(f\"✅ Reusing cached training ROIs (external): {len(cached)} samples from {cached['series_id'].nunique()} series\")\n                    # Optionally copy into working for faster subsequent access\n                    try:\n                        pl.from_pandas(cached).write_parquet(cached_df_path_parquet)\n                    except Exception:\n                        pass\n                    return cached\n            except Exception:\n                pass\n    roi_extractor = ROIExtractor(stage1_predictor)\n    training_data = []\n    \n    os.makedirs('rois', exist_ok=True)\n    \n    for idx, row in tqdm(df.iterrows(), total=len(df), desc=\"Extracting ROIs\"):\n        series_id = row[Config.ID_COL]\n        series_path = os.path.join(Config.SERIES_DIR, series_id)\n        \n        if not os.path.exists(series_path):\n            continue\n        \n        # Extract ROIs, modificado para la prediccion de test.csv\n        rois = roi_extractor.extract_top3_rois(series_path)\n        \n        # Create training samples\n        for roi_data in rois:\n            roi_filename = f\"rois/{series_id}_roi_{roi_data['roi_id']}.png\"\n            \n            # Save ROI image\n            roi_image = (roi_data['roi_image'] * 255).astype(np.uint8)\n            Image.fromarray(roi_image).save(roi_filename)\n            \n            # Create training record\n            sample = {\n                'roi_id': f\"{series_id}_roi_{roi_data['roi_id']}\",\n                'roi_path': roi_filename,\n                'series_id': series_id,\n                'roi_confidence': roi_data['confidence'],\n                'slice_idx': roi_data['slice_idx']\n            }\n            \n            # Add all label columns\n            for col in Config.LABEL_COLS:\n                sample[col] = row[col]\n            \n            training_data.append(sample)\n    \n    training_df = pd.DataFrame(training_data)\n    print(f\"✅ Created {len(training_df)} training samples from {len(df)} series\")\n    # Save for reuse next runs\n    try:\n        pl.from_pandas(training_df).write_parquet(cached_df_path_parquet)\n        print(f\"💾 Saved training ROI dataframe → {cached_df_path_parquet}\")\n    except Exception:\n        pass\n    \n    return training_df\n\nprint(\"✅ Data loading and ROI extraction functions loaded\")\n\n# ====================================================\n# CELL 3: MODEL DEFINITION\n# ====================================================\n\nclass AneurysmClassificationDataset(Dataset):\n    \"\"\"Dataset for ROI-based classification\"\"\"\n    def __init__(self, df, mode='train'):\n        self.df = df\n        self.mode = mode\n        \n        # Data augmentation for training\n        if mode == 'train':\n            self.transform = transforms.Compose([\n                transforms.RandomHorizontalFlip(0.5),\n                transforms.RandomVerticalFlip(0.5),\n                transforms.RandomRotation(15),\n                transforms.ColorJitter(brightness=0.2, contrast=0.2),\n                transforms.ToTensor(),\n                transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n            ])\n        else:\n            self.transform = transforms.Compose([\n                transforms.ToTensor(),\n                transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n            ])\n    \n    def __len__(self):\n        return len(self.df)\n    \n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        \n        # Load ROI image\n        roi_path = row['roi_path']\n        try:\n            image = Image.open(roi_path).convert('RGB')\n        except:\n            # Fallback to dummy image\n            image = Image.fromarray(np.random.randint(0, 255, (*Config.ROI_SIZE, 3), dtype=np.uint8))\n        \n        # Apply transforms\n        image = self.transform(image)\n        \n        # Get labels\n        labels = torch.tensor([row[col] for col in Config.LABEL_COLS], dtype=torch.float32)\n        \n        return {\n            'image': image,\n            'labels': labels,\n            'roi_id': row['roi_id'],\n            'confidence': torch.tensor(row['roi_confidence'], dtype=torch.float32)\n        }\n\nclass AneurysmEfficientNet(nn.Module):\n    \"\"\"EfficientNet-B3 for aneurysm classification with offline weights\"\"\"\n    def __init__(self, num_classes=len(Config.LABEL_COLS)):\n        super().__init__()\n        \n        # Load EfficientNet-B3 with offline pre-trained weights\n        import timm\n        \n        # Path to the pre-trained weights you added\n        weights_path = '/kaggle/input/tf-efficientnet/pytorch/tf-efficientnet-b3/1/tf_efficientnet_b3_aa-84b4657e.pth'\n        \n        try:\n            # Create model without pre-trained weights first\n            self.backbone = timm.create_model('efficientnet_b3', pretrained=False, num_classes=0)\n            \n            # Load the offline weights\n            if os.path.exists(weights_path):\n                print(f\"🔄 Loading offline EfficientNet-B3 weights from: {weights_path}\")\n                state_dict = torch.load(weights_path, map_location='cpu', weights_only=False)\n                \n                # Load weights into the model (ignore classifier since we're using num_classes=0)\n                self.backbone.load_state_dict(state_dict, strict=False)\n                print(\"✅ Successfully loaded offline EfficientNet-B3 weights!\")\n            else:\n                print(f\"⚠️ Weights file not found at {weights_path}, using random initialization\")\n                \n        except Exception as e:\n            print(f\"❌ Error loading offline weights: {e}\")\n            print(\"🔄 Falling back to timm without pre-training...\")\n            self.backbone = timm.create_model('efficientnet_b3', pretrained=False, num_classes=0)\n        \n        # Get feature dimension\n        feature_dim = self.backbone.num_features\n        \n        # Classification head with dropout\n        self.classifier = nn.Sequential(\n            nn.Dropout(0.3),\n            nn.Linear(feature_dim, 512),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(512, num_classes)\n        )\n        \n    def forward(self, x):\n        features = self.backbone(x)\n        logits = self.classifier(features)\n        return logits # Initialize model\n\n# Using original EfficientNet approach\n\ndef calculate_class_weights(df):\n    \"\"\"Calculate class weights with 13x multiplier for Aneurysm Present\"\"\"\n    pos_counts = df[Config.LABEL_COLS].sum()\n    neg_counts = len(df) - pos_counts\n    \n    # Standard frequency-based weights\n    class_weights = neg_counts / (pos_counts + 1e-8)\n    class_weights = np.minimum(class_weights, 100.0)  # Cap at 100\n    \n    # Apply 13x multiplier to \"Aneurysm Present\" (matches competition metric)\n    class_weights.iloc[-1] = class_weights.iloc[-1] * 13.0\n    \n    return torch.tensor(class_weights.values, dtype=torch.float32)\n\nprint(\"✅ Model definition loaded\")\n\n# ====================================================\n# CELL 4: TRAINING PIPELINE\n# ====================================================\n\ndef train_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n    total_loss = 0\n    num_batches = 0\n    \n    for batch in tqdm(loader, desc=\"Training\"):\n        images = batch['image'].to(device, non_blocking=True)\n        labels = batch['labels'].to(device, non_blocking=True)\n        \n        optimizer.zero_grad()\n        \n        # Forward pass\n        with torch.cuda.amp.autocast(enabled=Config.MIXED_PRECISION):\n            logits = model(images)\n            loss = criterion(logits, labels)\n        \n        # Backward pass\n        if Config.MIXED_PRECISION:\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n        else:\n            loss.backward()\n            optimizer.step()\n        \n        total_loss += loss.item()\n        num_batches += 1\n    \n    return total_loss / num_batches\n\ndef validate_epoch(model, loader, criterion, device):\n    pass\n    model.eval()\n    total_loss = 0\n    all_preds = []\n    all_labels = []\n    num_batches = 0\n    \n    with torch.no_grad():\n        for batch in tqdm(loader, desc=\"Validating\"):\n            images = batch['image'].to(device, non_blocking=True)\n            labels = batch['labels'].to(device, non_blocking=True)\n            \n            with torch.cuda.amp.autocast(enabled=Config.MIXED_PRECISION):\n                logits = model(images)\n                loss = criterion(logits, labels)\n            \n            total_loss += loss.item()\n            num_batches += 1\n            \n            # Collect predictions for AUC\n            probs = torch.sigmoid(logits).cpu().numpy()\n            all_preds.append(probs)\n            all_labels.append(labels.cpu().numpy())\n    \n    # Calculate AUC\n    if len(all_preds) > 0:\n        all_preds = np.vstack(all_preds)\n        all_labels = np.vstack(all_labels)\n        \n        try:\n            auc_scores = []\n            for i in range(len(Config.LABEL_COLS)):\n                if len(np.unique(all_labels[:, i])) > 1:\n                    auc = roc_auc_score(all_labels[:, i], all_preds[:, i])\n                    auc_scores.append(auc)\n                else:\n                    auc_scores.append(0.5)\n            \n            # Weighted AUC (13x weight for Aneurysm Present)\n            weights = [1.0] * (len(Config.LABEL_COLS) - 1) + [13.0]\n            weighted_auc = np.average(auc_scores, weights=weights)\n        except:\n            weighted_auc = 0.5\n    else:\n        weighted_auc = 0.5\n    \n    return total_loss / num_batches, weighted_auc\n\ndef main_training():\n    print(\"🚀 STAGE 2: ANEURYSM CLASSIFICATION WITH EFFICIENTNET-B3\")\n    \n    # Load data\n    train_df = pd.read_csv(Config.TRAIN_CSV_PATH)\n    \n    if Config.DEBUG_MODE:\n        train_df = train_df.head(Config.DEBUG_SAMPLES)\n    \n    print(f\"Training samples: {len(train_df)}\")\n    print(f\"Aneurysm cases: {train_df['Aneurysm Present'].sum()}\")\n    \n    # Initialize Stage 1 predictor\n    stage1_predictor = Stage1Predictor(Config.STAGE1_MODEL_PATH)\n    \n    # Create training data with ROIs\n    training_df = create_training_data(train_df, stage1_predictor)\n    \n    # Calculate class weights\n    class_weights = calculate_class_weights(training_df)\n    print(f\"Class weights: {class_weights}\")\n    \n    # Create criterion with class weights\n    criterion = nn.BCEWithLogitsLoss(pos_weight=class_weights).to(Config.DEVICE)\n    \n    # Mixed precision scaler\n    global scaler\n    scaler = torch.cuda.amp.GradScaler(enabled=Config.MIXED_PRECISION)\n    \n    # Cross-validation or single split\n    # Use Aneurysm Present for stratification\n    fold_scores = []\n    if Config.N_FOLDS <= 1:\n        idx_all = np.arange(len(training_df))\n        train_idx, val_idx = train_test_split(\n            idx_all,\n            test_size=0.2,\n            stratify=training_df['Aneurysm Present'],\n            random_state=42,\n        )\n        fold_splits = [(train_idx, val_idx)]\n    else:\n        skf = StratifiedKFold(n_splits=Config.N_FOLDS, shuffle=True, random_state=42)\n        fold_splits = list(skf.split(training_df, training_df['Aneurysm Present']))\n    \n    for fold, (train_idx, val_idx) in enumerate(fold_splits):\n        print(f\"\\n{'='*50}\")\n        print(f\"FOLD {fold + 1}/{Config.N_FOLDS}\")\n        print(f\"{'='*50}\")\n        \n        # Split data\n        train_fold_df = training_df.iloc[train_idx].reset_index(drop=True)\n        val_fold_df = training_df.iloc[val_idx].reset_index(drop=True)\n        \n        print(f\"Train ROIs: {len(train_fold_df)}, Val ROIs: {len(val_fold_df)}\")\n        \n        # Create datasets\n        train_dataset = AneurysmClassificationDataset(train_fold_df, mode='train')\n        val_dataset = AneurysmClassificationDataset(val_fold_df, mode='val')\n        \n        # Create loaders (tuned for throughput)\n        train_loader = DataLoader(\n            train_dataset,\n            batch_size=Config.BATCH_SIZE,\n            shuffle=True,\n            num_workers=8,\n            pin_memory=True,\n            persistent_workers=True,\n            prefetch_factor=8,\n        )\n        val_loader = DataLoader(\n            val_dataset,\n            batch_size=Config.BATCH_SIZE,\n            shuffle=False,\n            num_workers=8,\n            pin_memory=True,\n            persistent_workers=True,\n            prefetch_factor=8,\n        )\n        \n        # Initialize model\n        model = AneurysmEfficientNet().to(Config.DEVICE)\n        \n        # Optimizer with different learning rates\n        optimizer = optim.AdamW([\n            {'params': model.backbone.parameters(), 'lr': Config.LEARNING_RATE * 0.1},  # Lower LR for backbone\n            {'params': model.classifier.parameters(), 'lr': Config.LEARNING_RATE}\n        ], weight_decay=1e-4)\n\n        # Multi-GPU if available\n        if torch.cuda.device_count() > 1:\n            model = nn.DataParallel(model)\n        \n        # Scheduler\n        scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=Config.EPOCHS)\n        \n        # Training loop\n        best_auc = 0\n        \n        for epoch in range(Config.EPOCHS):\n            print(f\"\\nEpoch {epoch+1}/{Config.EPOCHS}\")\n            \n            # Train\n            train_loss = train_epoch(model, train_loader, optimizer, criterion, Config.DEVICE)\n            \n            # Validate\n            val_loss, val_auc = validate_epoch(model, val_loader, criterion, Config.DEVICE)\n            \n            # Step scheduler\n            scheduler.step()\n            \n            print(f\"Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Val AUC: {val_auc:.4f}\")\n            \n            # Save best model\n            if val_auc > best_auc:\n                best_auc = val_auc\n                torch.save({\n                    'model_state_dict': model.state_dict(),\n                    'optimizer_state_dict': optimizer.state_dict(),\n                    'val_auc': val_auc,\n                    'epoch': epoch,\n                    'fold': fold\n                }, f'stage2_fold_{fold}_best.pth')\n                print(f\"💾 Saved best model (AUC: {val_auc:.4f})\")\n        \n        fold_scores.append(best_auc)\n        print(f\"Fold {fold + 1} best AUC: {best_auc:.4f}\")\n    \n    # Final results\n    mean_cv_score = np.mean(fold_scores)\n    print(f\"\\n✅ Cross-validation complete!\")\n    print(f\"Mean CV AUC: {mean_cv_score:.4f} ± {np.std(fold_scores):.4f}\")\n    print(f\"Individual fold scores: {fold_scores}\")\n\nprint(\"✅ Training pipeline loaded\")\n\n# ====================================================\n# CELL 5: INFERENCE & SUBMISSION\n# ====================================================\n\nclass InferenceConfig:\n    \"\"\"Configuration for inference server\"\"\"\n    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\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', 'Anterior Communicating Artery',\n        '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\nclass ModelEnsemble:\n    \"\"\"Ensemble of Stage 2 models for inference\"\"\"\n    def __init__(self, model_paths, device):\n        self.device = device\n        self.models = []\n        \n        for path in model_paths:\n            try:\n                model = AneurysmEfficientNet().to(device)\n                checkpoint = torch.load(path, map_location=device, weights_only=False)\n                \n                if 'model_state_dict' in checkpoint:\n                    state_dict = checkpoint['model_state_dict']\n                else:\n                    state_dict = checkpoint\n                \n                # Handle DataParallel wrapper\n                if any(key.startswith('module.') for key in state_dict.keys()):\n                    state_dict = {key.replace('module.', ''): value for key, value in state_dict.items()}\n                \n                model.load_state_dict(state_dict)\n                model.eval()\n                self.models.append(model)\n                print(f\"Loaded model: {path}\")\n            except Exception as e:\n                print(f\"Error loading {path}: {e}\")\n        \n        print(f\"Loaded {len(self.models)} models for ensemble\")\n    \n    def predict_single(self, series_path):\n        pass\n        \"\"\"Predict for a single series\"\"\"\n        # Initialize predictors once and reuse\n        global _shared_stage1_predictor\n        if '_shared_stage1_predictor' not in globals() or _shared_stage1_predictor is None:\n            _shared_stage1_predictor = Stage1Predictor(Config.STAGE1_MODEL_PATH)\n        roi_extractor = ROIExtractor(_shared_stage1_predictor)\n        \n        # Extract ROIs\n        rois = roi_extractor.extract_top3_rois(series_path)\n        \n        # Prepare images\n        transform = transforms.Compose([\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n        # Disable flips to preserve artery laterality (left/right)\n        tta_transforms = [\n            lambda img: img,\n        ]\n\n        # Keep PIL images for TTA\n        roi_pils = []\n        roi_confs = []\n        for roi_data in rois:\n            roi_image = roi_data['roi_image']\n            roi_pil = Image.fromarray((roi_image * 255).astype(np.uint8))\n            roi_pils.append(roi_pil)\n            roi_confs.append(float(roi_data.get('confidence', 0.0)))\n        roi_confs = np.array(roi_confs, dtype=np.float32) if len(roi_confs) else np.array([1.0], dtype=np.float32)\n        # normalize confidences to sum=1 (avoid division by zero)\n        conf_sum = float(roi_confs.sum())\n        if conf_sum <= 1e-6:\n            roi_weights = np.ones_like(roi_confs) / max(1, roi_confs.size)\n        else:\n            roi_weights = roi_confs / conf_sum\n        \n        # Ensemble predictions\n        all_predictions = []\n        \n        with torch.no_grad():\n            for model in self.models:\n                per_roi_preds = []\n                for roi_pil in roi_pils:\n                    tta_probs = []\n                    for t in tta_transforms:\n                        aug_pil = t(roi_pil)\n                        roi_tensor = transform(aug_pil).unsqueeze(0).to(self.device)\n                        logits = model(roi_tensor)\n                        probs = torch.sigmoid(logits).cpu().numpy()[0]\n                        tta_probs.append(probs)\n                    roi_avg = np.mean(tta_probs, axis=0)\n                    per_roi_preds.append(roi_avg)\n                per_roi_preds = np.stack(per_roi_preds, axis=0)  # [R, C]\n                # confidence-weighted mean and max-pool fusion\n                weighted_mean = (per_roi_preds * roi_weights[:, None]).sum(axis=0)\n                roi_max = per_roi_preds.max(axis=0)\n                fused = np.maximum(weighted_mean, roi_max)\n                all_predictions.append(fused)\n        \n        # Average ensemble predictions\n        ensemble_pred = np.mean(all_predictions, axis=0)\n        \n        return ensemble_pred\n\nclass InferenceDICOMProcessor:\n    \"\"\"DICOM processor for inference\"\"\"\n    def __init__(self):\n        pass\n\n# Global variables for model ensemble\nmodel_ensemble = None\nprocessor = None\n\ndef initialize_models():\n    \"\"\"Initialize models - called once at startup\"\"\"\n    global model_ensemble, processor\n    \n    print(\"Initializing models...\")\n    \n    # Model paths - adjust these to match your uploaded dataset structure\n    model_paths = [\n        'stage2_fold_0_best.pth',\n        'stage2_fold_1_best.pth',\n        'stage2_fold_2_best.pth',\n        'stage2_fold_3_best.pth',\n        'stage2_fold_4_best.pth',\n    ]\n    \n    # Check if models exist, use available ones\n    available_models = [path for path in model_paths if os.path.exists(path)]\n    \n    if not available_models:\n        print(\"Warning: No trained models found! Using dummy predictions.\")\n        model_ensemble = None\n    else:\n        try:\n            model_ensemble = ModelEnsemble(available_models, InferenceConfig.DEVICE)\n            print(\"Models initialized successfully!\")\n        except Exception as e:\n            print(f\"Error initializing models: {e}\")\n            model_ensemble = None\n    \n    processor = InferenceDICOMProcessor()\n\ndef predict(series_path: str) -> pl.DataFrame:\n    \"\"\"Make predictions for the competition API\"\"\"\n    global model_ensemble, processor\n\n    # Initialize models on first call\n    if model_ensemble is None and processor is None:\n        initialize_models()\n\n    series_id = os.path.basename(series_path)\n\n    try:\n        if model_ensemble is not None:\n            # === CAMBIO: Performance ===========================\n            # Tipo de mejora: Performance\n            # Descripción del cambio: Usar torch.no_grad() + torch.cuda.amp.autocast() para acelerar.\n            # Beneficio esperado: −30–40% tiempo de inferencia en GPU \n            # ===================================================\n            # [ORIGINAL]\n            # Hace las operaciones en precisión completa, es más lento y utiliza más memoria\n            # predictions = model_ensemble.predict_single(series_path)\n            # [MODIFICACIÓN]\n            \n            if Config.MIXED_PRECISION:\n                with torch.no_grad():\n                    with torch.cuda.amp.autocast():\n                        print(\"Mejora de performance, beneficio de mejora en inferencia en GPU\")\n                        predictions = model_ensemble.predict_single(series_path)\n            else:\n                with torch.no_grad():\n                    predictions = model_ensemble.predict_single(series_path)\n            # [MODIFICACIÓN]\n        else:\n            # Fallback: extract metadata and make informed dummy predictions\n            print(f\"Using fallback prediction for {series_id}\")\n            all_filepaths = []\n            for root, _, files in os.walk(series_path):\n                for file in files:\n                    if file.endswith('.dcm'):\n                        all_filepaths.append(os.path.join(root, file))\n\n            if all_filepaths:\n                ds = pydicom.dcmread(all_filepaths[0], force=True)\n                modality = getattr(ds, 'Modality', 'UNKNOWN')\n                base_prob = 0.1 if modality in ['CTA', 'MRA'] else 0.05\n                predictions = np.random.normal(base_prob, 0.02, len(InferenceConfig.LABEL_COLS))\n                predictions = np.clip(predictions, 0.001, 0.999)\n            else:\n                predictions = np.full(len(InferenceConfig.LABEL_COLS), 0.5)\n\n        # Ensure numpy\n        if not isinstance(predictions, np.ndarray):\n            predictions = np.array(predictions)\n\n        prediction_df = pl.DataFrame(\n            data=[[series_id] + predictions.tolist()],\n            schema=[InferenceConfig.ID_COL, *InferenceConfig.LABEL_COLS],\n            orient='row',\n        )\n\n    except Exception as e:\n        print(f\"Error processing {series_id}: {e}\")\n        prediction_df = pl.DataFrame(\n            data=[[series_id] + [0.5] * len(InferenceConfig.LABEL_COLS)],\n            schema=[InferenceConfig.ID_COL, *InferenceConfig.LABEL_COLS],\n            orient='row',\n        )\n\n    # IMPORTANT: Remove SeriesInstanceUID before returning (API requirement)\n    prediction_df = prediction_df.drop(InferenceConfig.ID_COL)\n\n    # IMPORTANT: Disk cleanup to prevent \"out of disk space\" errors\n    shutil.rmtree('/kaggle/shared', ignore_errors=True)\n\n    return prediction_df\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-10-30T15:38:54.456454Z","iopub.execute_input":"2025-10-30T15:38:54.457399Z","iopub.status.idle":"2025-10-30T15:38:54.676871Z","shell.execute_reply.started":"2025-10-30T15:38:54.457374Z","shell.execute_reply":"2025-10-30T15:38:54.676008Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ====================================================\n# SERVER EXECUTION\n# ====================================================\n\n# Initialize the inference server\ninference_server = kaggle_evaluation.rsna_inference_server.RSNAInferenceServer(predict)\n\nprint(\"✅ Inference and submission pipeline loaded\")\n\n# ====================================================\n# CELL 6: MAIN EXECUTION\n# ====================================================\n\nif __name__ == \"__main__\":\n    if os.getenv('KAGGLE_IS_COMPETITION_RERUN'):\n        # Production mode - serve the API\n        print(\"Starting inference server...\")\n        inference_server.serve()\n    else:\n        # Training mode\n        print(\"Ready for Stage 2 training!\")\n        print(\"Uncomment the line below to start training:\")\n        print(\"# main_training()\")\n        \n        # Uncomment to start training\n        main_training()\n        \n        # Or run local testing\n        print(\"Running local gateway for testing...\")\n        inference_server.run_local_gateway()\n        \n        # Display results if available\n        results_path = '/kaggle/working/submission.parquet'\n        if os.path.exists(results_path):\n            results_df = pl.read_parquet(results_path)\n            print(\"Submission preview:\")\n            print(results_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-30T15:39:24.489864Z","iopub.execute_input":"2025-10-30T15:39:24.49037Z","iopub.status.idle":"2025-10-31T02:02:00.021016Z","shell.execute_reply.started":"2025-10-30T15:39:24.490344Z","shell.execute_reply":"2025-10-31T02:02:00.020121Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}