{"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":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}],"dockerImageVersionId":31089,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ====================================================\n# CELL 1: IMPORTS & CONFIG\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-09-04T22:01:32.61189Z","iopub.execute_input":"2025-09-04T22:01:32.612648Z","iopub.status.idle":"2025-09-04T22:01:45.803583Z","shell.execute_reply.started":"2025-09-04T22:01:32.612622Z","shell.execute_reply":"2025-09-04T22:01:45.802812Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Competition Configuration\nclass Config:\n    # Paths\n    TRAIN_CSV_PATH = '/kaggle/input/rsna-intracranial-aneurysm-detection/train.csv'\n    SERIES_DIR = '/kaggle/input/rsna-intracranial-aneurysm-detection/series/'\n    SEGMENTATION_DIR = '/kaggle/input/rsna-intracranial-aneurysm-detection/segmentations/'\n    STAGE1_MODEL_PATH = '/kaggle/input/pytorch-aneurysmnet-intracranial-e15-nb153/pytorch/default/6/stage1_segmentation_best.pth'\n    \n    # Stage 2 Configuration\n    ROI_SIZE = (224, 224)\n    ROIS_PER_SERIES = 5\n    BATCH_SIZE = 32\n    EPOCHS = 5\n    LEARNING_RATE = 1e-4\n    N_FOLDS = 5\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 = True  # 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\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        \"\"\"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\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/pytorch-aneurysmnet-intracranial-e15-nb153/pytorch/default/6/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        \"\"\"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        \"\"\"Return both seg mask and the preprocessed 3D volume used for Stage 1.\"\"\"\n        if self.model is None:\n            zero = np.zeros((48, 112, 112), dtype=np.float32)\n            return zero, zero\n        try:\n            volume = self.processor.load_dicom_series(series_path)\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                seg_logits, _ = self.model(volume_tensor)\n                seg_mask = torch.sigmoid(seg_logits).cpu().numpy()[0, 0]\n            # One-time debug of mask stats to diagnose low-quality outputs\n            try:\n                if not hasattr(self, '_printed_stats'):\n                    print(f\"DEBUG: seg_mask stats -> min={float(seg_mask.min()):.4f}, max={float(seg_mask.max()):.4f}, mean={float(seg_mask.mean()):.4f}\")\n                    self._printed_stats = True\n            except Exception:\n                pass\n            return seg_mask, volume\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    def extract_top3_rois(self, series_path):\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    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\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    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        \"\"\"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 (lazy loading)\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            # Use trained ensemble\n            predictions = model_ensemble.predict_single(series_path)\n        else:\n            # Fallback: extract metadata and make informed dummy predictions\n            print(f\"Using fallback prediction for {series_id}\")\n            \n            # Load DICOM metadata\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                \n                # Slightly better informed predictions based on modality\n                if modality in ['CTA', 'MRA']:\n                    # Vascular imaging - slightly higher probability\n                    base_prob = 0.1\n                else:\n                    # Other modalities - lower baseline\n                    base_prob = 0.05\n                \n                # Add some noise to make predictions more realistic\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                # No DICOM files found\n                predictions = np.full(len(InferenceConfig.LABEL_COLS), 0.5)\n\n        # Ensure predictions is numpy array and convert to list safely\n        if not isinstance(predictions, np.ndarray):\n            predictions = np.array(predictions)\n        \n        # Create prediction DataFrame\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        # Return safe default predictions\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-09-04T22:01:54.377656Z","iopub.execute_input":"2025-09-04T22:01:54.378453Z","iopub.status.idle":"2025-09-04T22:01:54.582802Z","shell.execute_reply.started":"2025-09-04T22:01:54.37843Z","shell.execute_reply":"2025-09-04T22:01:54.582052Z"}},"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-09-04T22:02:09.633784Z","iopub.execute_input":"2025-09-04T22:02:09.634354Z","execution_failed":"2025-09-04T22:03:56.52Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}