{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":99552,"databundleVersionId":13851420,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":13694916,"sourceType":"datasetVersion","datasetId":8710901}],"dockerImageVersionId":31193,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==========================================\n# PHASE 1: ENSEMBLE 3-FOLD, 4-EPOCH TRAINING SCRIPT\n# ==========================================\n# This is the full, non-debug script.\n# It trains 3 models across 3 folds (9 models total)\n# with all bug fixes (PatientID, normalization, etc.)\n# ==========================================\n\n# -------------------------\n# 1. GLOBAL IMPORTS\n# -------------------------\nimport os\nimport glob\nimport random\nimport warnings\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport functools\nfrom pathlib import Path\nfrom tqdm import tqdm\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom typing import List, Tuple, Optional, Dict\nimport gc\nimport shutil\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.optim import AdamW\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\n\nimport timm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nfrom sklearn.model_selection import StratifiedKFold, GroupKFold\nfrom sklearn.metrics import roc_auc_score\nimport pydicom\n\nwarnings.filterwarnings('ignore')\nprint(f\"Imports successful. Numpy version: {np.__version__}\")\n\n# -------------------------\n# 2. GLOBAL CONFIGURATION\n# -------------------------\nclass Config:\n    # --- Set DEBUG = False for the full run ---\n    DEBUG = False\n    \n    # --- Path to your PNG dataset ---\n    DATA_DIR = \"/kaggle/input/another1\" \n    CVT_PNG_DIR = os.path.join(DATA_DIR, \"cvt_png\")\n    SERIES_MAPPING_PATH = os.path.join(DATA_DIR, \"series_index_mapping.csv\")\n    LOCALIZERS_PATH = os.path.join(DATA_DIR, \"train_localizers_with_relative.csv\")\n    \n    # --- Path to original competition data ---\n    ORIGINAL_DATA_DIR = \"/kaggle/input/rsna-intracranial-aneurysm-detection\"\n    TRAIN_CSV_PATH = os.path.join(ORIGINAL_DATA_DIR, \"train.csv\")\n    ORIGINAL_SERIES_DIR = os.path.join(ORIGINAL_DATA_DIR, \"series\")\n    \n    # --- Model Hyperparameters ---\n    NUM_FRAMES = 8\n    IMAGE_SIZE = 224\n    NUM_CLASSES = 14\n    BATCH_SIZE = 6 # Use 6 for full run\n    NUM_EPOCHS = 4 # <-- 4 EPOCHS for ~10-11 hour run\n    LEARNING_RATE = 5e-5\n    \n    # --- Train all 3 models ---\n    MODELS_TO_TRAIN = [\n        (\"effnetv2s\", \"tf_efficientnetv2_s.in1k\"),\n        (\"convnext_tiny\", \"convnext_tiny.fb_in1k\"),\n        (\"maxvit_tiny\", \"maxvit_tiny_tf_224.in1k\"),\n    ]\n    \n    # --- Feature Flags ---\n    USE_METADATA = True\n    USE_WINDOWING = True\n    USE_3CHANNEL_INPUT = True\n    USE_IMPROVED_LOSS = True\n    USE_CLAHE = True\n    USE_STRONG_AUGMENTATION = True\n    \n    # --- Dataloader & System ---\n    NUM_WORKERS = 2\n    PIN_MEMORY = True\n    PREFETCH_FACTOR = 2\n    PERSISTENT_WORKERS = True\n    \n    # --- CV & Training Loop ---\n    NUM_FOLDS = 3 # <-- 3 FOLDS for balance\n    ACCUMULATION_STEPS = 5\n    EARLY_STOPPING_PATIENCE = 5 # Patience of 5 on a 4-epoch run means it will likely run all 4\n    USE_GROUP_CV = True\n    CACHE_SIZE = 100 # Full cache size\n    OUTPUT_DIR = \"/kaggle/working\"\n\nconfig = Config()\n\n# -------------------------\n# 3. GLOBAL SEED & DEVICE\n# -------------------------\ndef set_seed(seed: int = 42, deterministic: bool = False):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    if deterministic:\n        torch.backends.cudnn.deterministic = True\n        torch.backends.cudnn.benchmark = False\n    else:\n        torch.backends.cudnn.deterministic = False\n        torch.backends.cudnn.benchmark = True\nset_seed(42, deterministic=False)\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Using device: {device}\")\nif not torch.cuda.is_available():\n    print(\"WARNING: CUDA not available. Training will be VERY slow.\")\n\n# -------------------------\n# 4. GLOBAL TARGETS\n# -------------------------\nTARGET_COLS = [\n    'Left Infraclinoid Internal Carotid Artery', 'Right Infraclinoid Internal Carotid Artery', \n    'Left Supraclinoid Internal Carotid Artery', 'Right Supraclinoid Internal Carotid Artery',\n    'Left Middle Cerebral Artery', 'Right Middle Cerebral Artery',\n    'Anterior Communicating Artery', 'Left Anterior Cerebral Artery',\n    'Right Anterior Cerebral Artery', 'Left Posterior Communicating Artery',\n    'Right Posterior Communicating Artery', 'Basilar Tip',\n    'Other Posterior Circulation', 'Aneurysm Present'\n]\n\n# -------------------------\n# 5. GLOBAL HELPER FUNCTIONS\n# -------------------------\ndef get_windowing_params(modality: str) -> Tuple[float, float]:\n    windows = {'CT': (40, 80), 'CTA': (50, 350), 'MRA': (600, 1200), 'MRI': (40, 80), 'MR': (40, 80)}\n    return windows.get(modality, (40, 80))\n\ndef apply_dicom_windowing(img: np.ndarray, window_center: float, window_width: float) -> np.ndarray:\n    img_min = window_center - window_width // 2\n    img_max = window_center + window_width // 2\n    img = np.clip(img, img_min, img_max)\n    img = (img - img_min) / (img_max - img_min + 1e-7)\n    return (img * 255).astype(np.uint8)\n\ndef apply_clahe_normalization(img: np.ndarray, modality: str) -> np.ndarray:\n    if not config.USE_CLAHE: return img.astype(np.uint8)\n    img = img.astype(np.uint8)\n    if modality in ['CTA', 'MRA']:\n        clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))\n        img_clahe = clahe.apply(img)\n        img_clahe = cv2.convertScaleAbs(img_clahe, alpha=1.1, beta=5)\n    elif modality in ['MRI', 'MR']:\n        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n        img_clahe = clahe.apply(img)\n        img_clahe = np.power(img_clahe / 255.0, 0.9) * 255\n        img_clahe = img_clahe.astype(np.uint8)\n    else:\n        clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))\n        img_clahe = clahe.apply(img)\n    return img_clahe\n\ndef robust_normalization(volume: np.ndarray) -> np.ndarray:\n    if volume.size == 0:\n        return np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8)\n    p1, p99 = np.percentile(volume.flatten(), [1, 99])\n    volume_norm = np.clip(volume, p1, p99)\n    if p99 > p1:\n        volume_norm = (volume_norm - p1) / (p99 - p1 + 1e-7)\n    else:\n        volume_norm = np.zeros_like(volume_norm)\n    return (volume_norm * 255).astype(np.uint8)\n\ndef create_3channel_input_8frame(volume: np.ndarray) -> np.ndarray:\n    if len(volume) == 0:\n        return np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE, 3), dtype=np.uint8)\n    middle_slice = volume[len(volume) // 2]\n    mip = np.max(volume, axis=0)\n    std_proj = np.std(volume, axis=0).astype(np.float32)\n    if std_proj.max() > std_proj.min():\n        p1, p99 = np.percentile(std_proj, [5, 95])\n        std_proj = np.clip(std_proj, p1, p99)\n        std_proj = ((std_proj - p1) / (p99 - p1 + 1e-7) * 255).astype(np.uint8)\n    else:\n        std_proj = np.zeros_like(std_proj, dtype=np.uint8)\n    return np.stack([middle_slice, mip, std_proj], axis=-1)\n\ndef smart_8_frame_sampling(volume_paths: List[str]) -> List[str]:\n    n = len(volume_paths)\n    if n == 0: return []\n    if n <= 8:\n        result = volume_paths[:]\n        while len(result) < 8:\n            result.extend(volume_paths[:8-len(result)])\n        return result[:8]\n    start_idx = max(0, int(n * 0.1))\n    available_frames = n - start_idx\n    step = max(1, available_frames // 8)\n    indices = [start_idx + i * step for i in range(8)]\n    indices = [min(i, n - 1) for i in indices]\n    if len(set(indices)) < 8:\n        indices = np.linspace(start_idx, n-1, 8).astype(int).tolist()\n    return [volume_paths[i] for i in indices]\n\ndef resolve_dicom_path(dicom_entry, series_uid):\n    if dicom_entry and os.path.exists(dicom_entry):\n        return dicom_entry\n    series_dir = os.path.join(config.ORIGINAL_SERIES_DIR, series_uid)\n    if dicom_entry:\n        possible = os.path.join(series_dir, os.path.basename(dicom_entry))\n        if os.path.exists(possible):\n            return possible\n    if os.path.exists(series_dir):\n        candidates = sorted(glob.glob(os.path.join(series_dir, \"*.dcm\")))\n        if candidates:\n            if dicom_entry:\n                sop_uid = os.path.splitext(os.path.basename(dicom_entry))[0]\n                for cand_path in candidates:\n                    if sop_uid in cand_path:\n                        return cand_path\n            return candidates[0] \n    raise FileNotFoundError(f\"No DICOM file for series {series_uid}. Entry='{dicom_entry}'\")\n\n# -------------------------\n# 6. LOAD DATA & EDA\n# -------------------------\nprint(\"Loading main CSVs...\")\ntry:\n    if not os.path.exists(config.TRAIN_CSV_PATH):\n        raise FileNotFoundError(f\"Original train.csv not found at {config.TRAIN_CSV_PATH}\")\n    if not os.path.exists(config.SERIES_MAPPING_PATH):\n        raise FileNotFoundError(f\"series_index_mapping.csv not found at {config.SERIES_MAPPING_PATH}\")\n            \n    train_df = pd.read_csv(config.TRAIN_CSV_PATH)\n    series_mapping_df = pd.read_csv(config.SERIES_MAPPING_PATH)\n    \n    print(f\"train.csv shape: {train_df.shape}\")\n    print(f\"series_index_mapping.csv shape: {series_mapping_df.shape}\")\n\n    # --- MERGE PatientID INTO train_df ---\n    if 'PatientID' in series_mapping_df.columns:\n        patient_id_map = series_mapping_df[['SeriesInstanceUID', 'PatientID']].drop_duplicates()\n        train_df = train_df.merge(patient_id_map, on='SeriesInstanceUID', how='left')\n        print(f\"train_df shape after merging PatientID: {train_df.shape}\")\n        \n        if train_df['PatientID'].isnull().any():\n            print(\"Warning: Some series in train.csv were not found in series_mapping_df.\")\n            train_df['PatientID'] = train_df['PatientID'].fillna(train_df['SeriesInstanceUID'])\n    else:\n        print(\"ERROR: 'PatientID' column not found in series_index_mapping.csv!\")\n    \n    # --- DEBUG SUBSETTING ---\n    if config.DEBUG:\n        print(f\"--- DEBUG MODE: Subsetting data to 100 samples ---\")\n        train_df = train_df.sample(100, random_state=42).reset_index(drop=True)\n        print(f\"New train_df shape: {train_df.shape}\")\n    \n    # --- Statistics ---\n    print(\"\\n--- Label Distribution ---\")\n    label_counts = train_df[TARGET_COLS].sum().sort_values(ascending=False)\n    plt.figure(figsize=(10, 6))\n    sns.barplot(x=label_counts.values, y=label_counts.index, palette=\"viridis\")\n    plt.title(\"Distribution of Aneurysm Locations (Positive Cases)\")\n    plt.xlabel(\"Number of Positive Cases\")\n    plt.ylabel(\"Aneurysm Location\")\n    plt.show()\n\n    # --- Image Visualization ---\n    print(\"\\n--- Visualizing Sample 2.5D Model Inputs (from DICOM fallback) ---\")\n    \n    def load_volume_for_viz(series_uid: str, row) -> np.ndarray:\n        series_data = series_mapping_df[\n            series_mapping_df['SeriesInstanceUID'] == series_uid\n        ].sort_values('relative_index')\n        \n        if series_data.empty: \n            return np.zeros((8, config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8)\n            \n        modality = row.get('Modality', 'CT')\n        all_indices = list(range(len(series_data)))\n        sampled_indices_str = smart_8_frame_sampling([str(i) for i in all_indices])\n        sampled_indices = [int(i) for i in sampled_indices_str]\n        sampled_data = series_data.iloc[sampled_indices]\n            \n        volume = []\n        for _, dicom_row in sampled_data.iterrows():\n            dicom_entry = dicom_row.get('dicom_filename', None)\n            try:\n                dicom_path = resolve_dicom_path(dicom_entry, series_uid)\n                ds = pydicom.dcmread(dicom_path, force=True)\n                img = ds.pixel_array.astype(np.float32)\n                if hasattr(ds, 'RescaleSlope') and hasattr(ds, 'RescaleIntercept'):\n                    img = img * float(ds.RescaleSlope) + float(ds.RescaleIntercept)\n                window_center, window_width = get_windowing_params(modality)\n                img = apply_dicom_windowing(img, window_center, window_width)\n                img = apply_clahe_normalization(img, modality)\n                img = cv2.resize(img, (config.IMAGE_SIZE, config.IMAGE_SIZE), interpolation=cv2.INTER_AREA)\n                volume.append(img)\n            except Exception as e:\n                pass \n        \n        if not volume:\n            return np.zeros((8, config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8)\n            \n        while len(volume) < 8:\n            volume.append(volume[-1]) \n        return np.array(volume)\n\n    positive_cases = train_df[train_df['Aneurysm Present'] == 1].sample(min(2, len(train_df[train_df['Aneurysm Present'] == 1])), random_state=42)\n    negative_cases = train_df[train_df['Aneurysm Present'] == 0].sample(min(2, len(train_df[train_df['Aneurysm Present'] == 0])), random_state=42)\n    sample_df = pd.concat([positive_cases, negative_cases])\n\n    if not sample_df.empty:\n        fig, axes = plt.subplots(len(sample_df), 3, figsize=(12, len(sample_df) * 4))\n        if len(sample_df) == 1: \n            axes = np.array([axes])\n        fig.suptitle(\"Model Input (Middle Slice, Max-IP, Std-IP)\", fontsize=16, y=1.02)\n\n        for i, (idx, row) in enumerate(sample_df.iterrows()):\n            volume = load_volume_for_viz(row['SeriesInstanceUID'], row)\n            volume_norm = robust_normalization(volume)\n            img_3channel = create_3channel_input_8frame(volume_norm)\n            \n            status = \"POSITIVE\" if row['Aneurysm Present'] == 1 else \"NEGATIVE\"\n            \n            axes[i, 0].imshow(img_3channel[:, :, 0], cmap='bone')\n            axes[i, 0].set_title(f\"Series (Aneurysm {status})\\nChannel 0: Middle Slice\")\n            axes[i, 0].axis('off')\n            \n            axes[i, 1].imshow(img_3channel[:, :, 1], cmap='bone')\n            axes[i, 1].set_title(f\"Series (Aneurysm {status})\\nChannel 1: Max-IP\")\n            axes[i, 1].axis('off')\n            \n            axes[i, 2].imshow(img_3channel[:, :, 2], cmap='bone')\n            axes[i, 2].set_title(f\"Series (Aneurysm {status})\\nChannel 2: Std-IP\")\n            axes[i, 2].axis('off')\n        \n        plt.tight_layout()\n        plt.show()\n    else:\n        print(\"No samples to display in EDA (DEBUG subset might be too small).\")\n    \nexcept FileNotFoundError as e:\n    print(f\"ERROR: {e}\")\n    print(\"Please make sure your PNG dataset ('another1') and the original\")\n    print(\"competition data ('rsna-intracranial-aneurysm-detection') are both added.\")\n    train_df = None\n\n# -------------------------\n# 7. MODEL & LOSS DEFINITION\n# -------------------------\nclass ImprovedMultiFrameModel(nn.Module):\n    def __init__(self, model_name_backbone: str, num_frames=8, num_classes=14, pretrained=True):\n        super(ImprovedMultiFrameModel, self).__init__()\n        self.model_name_backbone = model_name_backbone\n        self.num_frames = num_frames\n        self.num_classes = num_classes\n        self.use_metadata = config.USE_METADATA\n        print(f\"Loading backbone: {self.model_name_backbone}\")\n        \n        self.backbone = timm.create_model(\n            self.model_name_backbone,\n            pretrained=pretrained,\n            num_classes=0,\n            global_pool='avg'\n        )\n        self.feature_dim = self.backbone.num_features\n        print(f\"Backbone {self.model_name_backbone}: {self.feature_dim} features\")\n        \n        if self.use_metadata:\n            self.meta_fc = nn.Sequential(\n                nn.Linear(2, 16), nn.ReLU(), nn.Dropout(0.2),\n                nn.Linear(16, 32), nn.ReLU()\n            )\n            classifier_input_dim = self.feature_dim + 32\n        else:\n            classifier_input_dim = self.feature_dim\n            \n        self.classifier = nn.Sequential(\n            nn.Linear(classifier_input_dim, 512),\n            nn.BatchNorm1d(512), nn.ReLU(), nn.Dropout(0.3),\n            nn.Linear(512, 256),\n            nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x, meta=None):\n        features = self.backbone(x)\n        if self.use_metadata and meta is not None:\n            meta_features = self.meta_fc(meta)\n            features = torch.cat([features, meta_features], dim=1)\n        output = self.classifier(features)\n        return output\n\nclass FocalLoss(nn.Module):\n    def __init__(self, alpha=1, gamma=2):\n        super(FocalLoss, self).__init__()\n        self.alpha = alpha\n        self.gamma = gamma\n        \n    def forward(self, inputs, targets):\n        bce_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')\n        pt = torch.exp(-bce_loss)\n        focal_loss = self.alpha * (1-pt)**self.gamma * bce_loss\n        return focal_loss.mean()\n\nclass ImprovedLoss(nn.Module):\n    def __init__(self, aneurysm_weight=3.0, focal_weight=0.3):\n        super(ImprovedLoss, self).__init__()\n        weights = torch.ones(config.NUM_CLASSES)\n        weights[-1] = aneurysm_weight\n        self.register_buffer('weights', weights)\n        self.focal_loss = FocalLoss(alpha=1, gamma=2)\n        self.focal_weight = focal_weight\n        \n    def forward(self, outputs, targets):\n        bce_loss = F.binary_cross_entropy_with_logits(outputs, targets, reduction='none')\n        weighted_bce = (bce_loss * self.weights.to(outputs.device)).mean()\n        focal_loss_val = self.focal_loss(outputs, targets)\n        return (1 - self.focal_weight) * weighted_bce + self.focal_weight * focal_loss_val\n\n# -------------------------\n# 8. TRAINING: DATASET CLASS\n# -------------------------\ndef create_frame_paths_8frame_structured(train_df, series_mapping_df):\n    frame_paths = {}\n    print(\"Creating 8-frame optimized structured paths from PNG dataset...\")\n    \n    all_png_files = {}\n    print(f\"Scanning PNG directory: {config.CVT_PNG_DIR}\")\n    if not os.path.exists(config.CVT_PNG_DIR):\n        print(f\"WARNING: PNG Directory not found at {config.CVT_PNG_DIR}\")\n        print(\"The dataset will fall back to reading DICOMs, which will be much slower.\")\n    \n    for root, _, files in os.walk(config.CVT_PNG_DIR):\n        for file in files:\n            if file.endswith(\".png\"):\n                try:\n                    series_uid = os.path.basename(os.path.dirname(root))\n                    if series_uid not in all_png_files:\n                        all_png_files[series_uid] = []\n                    all_png_files[series_uid].append(os.path.join(root, file))\n                except:\n                    pass\n    print(f\"Found PNGs for {len(all_png_files)} series.\")\n\n    for series_uid in tqdm(train_df['SeriesInstanceUID'].unique(), desc=\"Processing series\"):\n        series_data = series_mapping_df[series_mapping_df['SeriesInstanceUID'] == series_uid]\n        if series_data.empty:\n            frame_paths[series_uid] = {'paths': [], 'is_dummy': True}\n            continue\n\n        found_paths = []\n        if series_uid in all_png_files:\n            png_files = sorted(list(set(all_png_files[series_uid])), key=lambda x: os.path.basename(x))\n            if png_files:\n                found_paths = png_files\n        \n        if not found_paths:\n            dicom_dir = os.path.join(config.ORIGINAL_SERIES_DIR, series_uid)\n            if os.path.exists(dicom_dir):\n                num_frames = len(series_data)\n                found_paths = [f\"dummy_dicom_path_{i:04d}.dcm\" for i in range(num_frames)]\n\n        if found_paths:\n            sampled = smart_8_frame_sampling(found_paths)\n            is_dummy = any(p.startswith('dummy_dicom_path') for p in sampled)\n            frame_paths[series_uid] = {'paths': sampled, 'is_dummy': is_dummy}\n        else:\n            frame_paths[series_uid] = {'paths': [], 'is_dummy': True}\n            \n    return frame_paths\n\nclass EightFrameDataset(Dataset):\n    def __init__(self, df, frame_paths_dict, series_mapping_df, num_frames=8, transform=None, is_training=True):\n        self.df = df.reset_index(drop=True)\n        self.frame_paths_dict = frame_paths_dict\n        self.series_mapping_df = series_mapping_df\n        self.num_frames = num_frames\n        self.transform = transform\n        self.is_training = is_training\n        self._cache = {}\n        self._cache_keys = []\n        self._max_cache_size = config.CACHE_SIZE\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        if idx in self._cache:\n            return self._cache[idx]\n        \n        row = self.df.iloc[idx]\n        series_uid = row['SeriesInstanceUID']\n        labels = torch.tensor(row[TARGET_COLS].values.astype(np.float32))\n        metadata = self._extract_metadata(row)\n        \n        try:\n            image = self._load_8frame_3channel_image(series_uid, row)\n        except Exception as e:\n            dummy_image = np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE, 3), dtype=np.uint8)\n            if self.transform:\n                image = self.transform(image=dummy_image)['image']\n            else:\n                image = torch.from_numpy(dummy_image).permute(2,0,1).float()\n\n        result = (image, labels, metadata)\n        self._update_cache(idx, result)\n        return result\n\n    def _update_cache(self, idx, data):\n        if len(self._cache) >= self._max_cache_size:\n            oldest_idx = self._cache_keys.pop(0)\n            if oldest_idx in self._cache:\n                del self._cache[oldest_idx]\n        self._cache[idx] = data\n        self._cache_keys.append(idx)\n\n    def _extract_metadata(self, row) -> torch.Tensor:\n        if not config.USE_METADATA:\n            return torch.tensor([0.0, 0.0], dtype=torch.float32)\n        \n        age = row.get('PatientAge', 50)\n        if pd.isna(age):\n            age = 50\n        elif isinstance(age, str):\n            age = int(''.join(filter(str.isdigit, age[:3])) or '50')\n        age = min(float(age), 100.0) / 100.0\n        \n        sex = row.get('PatientSex', 'M')\n        sex = 1.0 if sex == 'M' else 0.0\n        return torch.tensor([age, sex], dtype=torch.float32)\n\n    def _load_8frame_3channel_image(self, series_uid: str, row) -> torch.Tensor:\n        entry = self.frame_paths_dict.get(series_uid, {'paths': [], 'is_dummy': True})\n        paths = entry['paths']\n        is_dummy = entry.get('is_dummy', True)\n        \n        if len(paths) == 0:\n            raise FileNotFoundError(f\"No paths found for series {series_uid}\")\n            \n        if is_dummy:\n            volume = self._load_volume_from_dicom_8frame(series_uid, row)\n        else:\n            volume = self._load_volume_from_png_8frame(paths)\n            \n        volume_norm = robust_normalization(volume)\n        image = create_3channel_input_8frame(volume_norm)\n        \n        if self.transform:\n            transformed = self.transform(image=image)\n            image = transformed['image']\n            \n        return image\n\n    def _load_volume_from_png_8frame(self, paths: List[str]) -> np.ndarray:\n        volume = []\n        for path in paths:\n            try:\n                img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)\n                if img is not None:\n                    img = cv2.resize(img, (config.IMAGE_SIZE, config.IMAGE_SIZE), interpolation=cv2.INTER_AREA)\n                    volume.append(img)\n                else:\n                    volume.append(np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8))\n            except Exception:\n                volume.append(np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8))\n        \n        if not volume:\n            return np.zeros((8, config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8)\n        return np.array(volume)\n\n    def _load_volume_from_dicom_8frame(self, series_uid: str, row) -> np.ndarray:\n        series_data = self.series_mapping_df[self.series_mapping_df['SeriesInstanceUID'] == series_uid].sort_values('relative_index')\n        if series_data.empty:\n            return np.zeros((8, config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8)\n            \n        modality = row.get('Modality', 'CT')\n        \n        if len(series_data) <= 8:\n            sampled_data = series_data\n        else:\n            all_indices = list(range(len(series_data)))\n            sampled_indices_str = smart_8_frame_sampling([str(i) for i in all_indices])\n            sampled_indices = [int(i) for i in sampled_indices_str]\n            sampled_data = series_data.iloc[sampled_indices]\n            \n        volume = []\n        for _, dicom_row in sampled_data.iterrows():\n            dicom_entry = dicom_row.get('dicom_filename', None)\n            try:\n                dicom_path = resolve_dicom_path(dicom_entry, series_uid)\n                ds = pydicom.dcmread(dicom_path, force=True)\n                img = ds.pixel_array.astype(np.float32)\n                \n                if hasattr(ds, 'RescaleSlope') and hasattr(ds, 'RescaleIntercept'):\n                    img = img * float(ds.RescaleSlope) + float(ds.RescaleIntercept)\n                        \n                if config.USE_WINDOWING:\n                    window_center, window_width = get_windowing_params(modality)\n                    img = apply_dicom_windowing(img, window_center, window_width)\n                else:\n                    img_min, img_max = img.min(), img.max()\n                    if img_max > img_min:\n                        img = ((img - img_min) / (img_max - img_min) * 255).astype(np.uint8)\n                    else:\n                        img = np.zeros_like(img, dtype=np.uint8)\n                        \n                img = apply_clahe_normalization(img, modality)\n                img = cv2.resize(img, (config.IMAGE_SIZE, config.IMAGE_SIZE), interpolation=cv2.INTER_AREA)\n                volume.append(img)\n            except Exception as e:\n                volume.append(np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8))\n        \n        while len(volume) < 8:\n            if volume:\n                volume.append(volume[-1])\n            else:\n                volume.append(np.zeros((config.IMAGE_SIZE, config.IMAGE_SIZE), dtype=np.uint8))\n                \n        return np.array(volume[:8])\n\n# -------------------------\n# 9. TRAINING: TRANSFORMS\n# -------------------------\ndef get_transforms(model_backbone: str):\n    \"\"\"\n    Returns the correct train and validation transforms for a given model.\n    \"\"\"\n    if 'tf_' in model_backbone:\n        print(f\"Using TF normalization (0.5, 0.5) for: {model_backbone}\")\n        mean = [0.5, 0.5, 0.5]\n        std = [0.5, 0.5, 0.5]\n    else:\n        print(f\"Using PyTorch normalization (0.485...) for: {model_backbone}\")\n        mean = [0.485, 0.456, 0.406]\n        std = [0.229, 0.224, 0.225]\n\n    if config.USE_STRONG_AUGMENTATION:\n        train_transform = A.Compose([\n            A.Rotate(limit=15, p=0.7),\n            A.HorizontalFlip(p=0.5),\n            A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.1, rotate_limit=10, p=0.6),\n            A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.6),\n            A.RandomGamma(gamma_limit=(80, 120), p=0.4),\n            A.GaussNoise(var_limit=(10, 80), p=0.4),\n            A.Blur(blur_limit=3, p=0.2),\n            A.CoarseDropout(max_holes=8, max_height=32, max_width=32, p=0.3),\n            A.Normalize(mean=mean, std=std),\n            ToTensorV2()\n        ])\n    else:\n        train_transform = A.Compose([\n            A.Rotate(limit=10, p=0.5),\n            A.HorizontalFlip(p=0.5),\n            A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3),\n            A.GaussNoise(var_limit=(10, 50), p=0.2),\n            A.Normalize(mean=mean, std=std),\n            ToTensorV2()\n        ])\n\n    val_transform = A.Compose([\n        A.Normalize(mean=mean, std=std),\n        ToTensorV2()\n    ])\n    \n    return train_transform, val_transform\n\n# -------------------------\n# 10. TRAINING: LOSS & METRIC\n# -------------------------\ndef get_loss_function():\n    if config.USE_IMPROVED_LOSS:\n        return ImprovedLoss(aneurysm_weight=3.0, focal_weight=0.3).to(device)\n    else:\n        weights = torch.ones(config.NUM_CLASSES, device=device)\n        weights[-1] = 3.0\n        return nn.BCEWithLogitsLoss(pos_weight=weights)\n\ndef calculate_competition_metric(y_true, y_pred):\n    individual_aucs = []\n    for i in range(13):\n        try:\n            if len(np.unique(y_true[:, i])) > 1:\n                auc = roc_auc_score(y_true[:, i], y_pred[:, i])\n            else:\n                auc = 0.5\n            individual_aucs.append(auc)\n        except:\n            individual_aucs.append(0.5)\n            \n    try:\n        if len(np.unique(y_true[:, 13])) > 1:\n            aneurysm_present_auc = roc_auc_score(y_true[:, 13], y_pred[:, 13])\n        else:\n            aneurysm_present_auc = 0.5\n    except:\n        aneurysm_present_auc = 0.5\n        \n    avg_individual = np.mean(individual_aucs)\n    final_score = (aneurysm_present_auc + avg_individual) / 2\n    return final_score, aneurysm_present_auc, avg_individual\n\n# -------------------------\n# 11. TRAINING: HELPERS\n# -------------------------\ndef save_checkpoint(model, model_name_backbone, optimizer, scheduler, epoch, best_score, val_loss, out_dir, model_name_prefix, fold):\n    os.makedirs(out_dir, exist_ok=True)\n    model_path = os.path.join(\n        out_dir,\n        f\"{model_name_prefix}_fold{fold}_epoch{epoch}_score{best_score:.6f}.pth\"\n    )\n    torch.save({\n        'epoch': epoch,\n        'model_name_backbone': model_name_backbone,\n        'model_state_dict': model.state_dict(),\n        'optimizer_state_dict': optimizer.state_dict(),\n        'scheduler_state_dict': scheduler.state_dict(),\n        'best_score': best_score,\n        'val_loss': val_loss,\n    }, model_path)\n    return model_path\n\n# --- FAST CV SPLIT FUNCTION ---\ndef create_robust_cv_split(train_df_local, n_splits=5):\n    print(\"Creating patient-separated cross-validation split (from DataFrame)...\")\n    \n    if 'PatientID' not in train_df_local.columns:\n        print(\"ERROR: 'PatientID' column not in train_df! Check Section 6 merge.\")\n        print(\"Falling back to StratifiedKFold (NOT patient-aware).\")\n        skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)\n        return list(skf.split(train_df_local, train_df_local['Aneurysm Present']))\n        \n    groups = train_df_local['PatientID']\n    n_groups = train_df_local['PatientID'].nunique()\n    print(f\"Total unique patients found: {n_groups}\")\n    \n    if n_groups < n_splits:\n        print(f\"Warning: Only {n_groups} patients. Falling back to StratifiedKFold.\")\n        skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)\n        return list(skf.split(train_df_local, train_df_local['Aneurysm Present']))\n        \n    gkf = GroupKFold(n_splits=n_splits)\n    splits = list(gkf.split(train_df_local, groups=groups))\n    return splits\n\n# -------------------------\n# 12. TRAINING: TRAIN/VAL LOOPS\n# -------------------------\ndef train_epoch_optimized(model, train_loader, criterion, optimizer, scaler, device, accumulation_steps):\n    model.train()\n    running_loss = 0.0\n    optimizer.zero_grad()\n    \n    for batch_idx, (images, targets, metadata) in enumerate(tqdm(train_loader, desc=\"Training\")):\n        images = images.to(device, non_blocking=True)\n        targets = targets.to(device, non_blocking=True)\n        metadata = metadata.to(device, non_blocking=True)\n        \n        with torch.cuda.amp.autocast():\n            outputs = model(images, metadata)\n            loss = criterion(outputs, targets)\n            loss = loss / accumulation_steps\n            \n        scaler.scale(loss).backward()\n        \n        if (batch_idx + 1) % accumulation_steps == 0 or (batch_idx + 1) == len(train_loader):\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad()\n            \n        running_loss += loss.item() * accumulation_steps\n        \n    return running_loss / len(train_loader)\n\ndef validate_epoch_optimized(model, val_loader, criterion, device):\n    model.eval()\n    running_loss = 0.0\n    all_outputs = []\n    all_targets = []\n    \n    with torch.no_grad():\n        for images, targets, metadata in tqdm(val_loader, desc=\"Validating\"):\n            images = images.to(device, non_blocking=True)\n            targets = targets.to(device, non_blocking=True)\n            metadata = metadata.to(device, non_blocking=True)\n            \n            with torch.cuda.amp.autocast():\n                logits = model(images, metadata)\n                loss = criterion(logits, targets)\n                \n            outputs = torch.sigmoid(logits)\n            running_loss += loss.item()\n            all_outputs.append(outputs.cpu().numpy())\n            all_targets.append(targets.cpu().numpy())\n            \n    all_outputs = np.concatenate(all_outputs)\n    all_targets = np.concatenate(all_targets)\n    \n    final_score, aneurysm_auc, avg_individual = calculate_competition_metric(all_targets, all_outputs)\n    return running_loss / len(val_loader), final_score, aneurysm_auc, avg_individual\n\n# -------------------------\n# 13. TRAINING: MAIN EXECUTION\n# -------------------------\nif train_df is not None:\n    try:\n        # Build frame paths dict ONCE\n        frame_paths_dict = create_frame_paths_8frame_structured(train_df, series_mapping_df)\n        \n        # Filter train_df to valid series ONCE\n        valid_series = [uid for uid, entry in frame_paths_dict.items() if len(entry['paths']) > 0]\n        train_df_filtered = train_df[train_df['SeriesInstanceUID'].isin(valid_series)].copy()\n        print(f\"Filtered train data shape (series with paths): {train_df_filtered.shape}\")\n        \n        # Create CV splits ONCE\n        cv_splits = create_robust_cv_split(train_df_filtered, config.NUM_FOLDS)\n        \n        # --- Outer loop for Models ---\n        for model_name_prefix, model_backbone in config.MODELS_TO_TRAIN:\n            print(f\"\\n\\n{'='*50}\")\n            print(f\"STARTING {config.NUM_FOLDS}-FOLD TRAINING FOR MODEL: {model_name_prefix} (Backbone: {model_backbone})\")\n            print(f\"{'='*50}\")\n            \n            # --- GET MODEL-SPECIFIC TRANSFORMS ---\n            train_transform, val_transform = get_transforms(model_backbone)\n            \n            all_fold_scores = []\n            \n            # --- Inner loop for Folds ---\n            for fold in range(config.NUM_FOLDS):\n                print(f\"\\n--- Fold {fold}/{config.NUM_FOLDS - 1} ---\")\n                \n                # --- 1. Get Fold Data ---\n                train_indices, val_indices = cv_splits[fold]\n                train_fold_df = train_df_filtered.iloc[train_indices]\n                val_fold_df = train_df_filtered.iloc[val_indices]\n                print(f\"Train size: {len(train_fold_df)}, Val size: {len(val_fold_df)}\")\n\n                # --- 2. Create Datasets & Loaders (with correct transforms) ---\n                train_dataset = EightFrameDataset(train_fold_df, frame_paths_dict, series_mapping_df, \n                                                  num_frames=config.NUM_FRAMES, transform=train_transform, is_training=True)\n                val_dataset = EightFrameDataset(val_fold_df, frame_paths_dict, series_mapping_df, \n                                                num_frames=config.NUM_FRAMES, transform=val_transform, is_training=False)\n                \n                train_loader = DataLoader(train_dataset, batch_size=config.BATCH_SIZE, shuffle=True, \n                                          num_workers=config.NUM_WORKERS, pin_memory=config.PIN_MEMORY, \n                                          drop_last=True, prefetch_factor=config.PREFETCH_FACTOR, \n                                          persistent_workers=config.PERSISTENT_WORKERS)\n                val_loader = DataLoader(val_dataset, batch_size=config.BATCH_SIZE, shuffle=False, \n                                        num_workers=config.NUM_WORKERS, pin_memory=config.PIN_MEMORY, \n                                        prefetch_factor=config.PREFETCH_FACTOR, \n                                        persistent_workers=config.PERSISTENT_WORKERS)\n                print(f\"Train batches: {len(train_loader)}, Val batches: {len(val_loader)}\")\n\n                # --- 3. Initialize Model, Optimizer, etc. (Fresh for each fold) ---\n                model = ImprovedMultiFrameModel(model_name_backbone=model_backbone, \n                                                num_frames=config.NUM_FRAMES, \n                                                num_classes=config.NUM_CLASSES, \n                                                pretrained=True)\n                model = model.to(device)\n                \n                criterion = get_loss_function()\n                optimizer = AdamW(model.parameters(), lr=config.LEARNING_RATE, weight_decay=1e-4)\n                scheduler = CosineAnnealingLR(optimizer, T_max=config.NUM_EPOCHS, eta_min=1e-6)\n                scaler = torch.cuda.amp.GradScaler()\n\n                # --- 4. Run Training Loop for this fold ---\n                best_score = 0.0\n                best_epoch = 0\n                patience_counter = 0\n                \n                for epoch in range(config.NUM_EPOCHS):\n                    print(f\"\\nEpoch {epoch+1}/{config.NUM_EPOCHS}\")\n                    \n                    train_loss = train_epoch_optimized(model, train_loader, criterion, optimizer, scaler, device, config.ACCUMULATION_STEPS)\n                    val_loss, val_score, aneurysm_auc, avg_individual = validate_epoch_optimized(model, val_loader, criterion, device)\n                    scheduler.step()\n                    \n                    print(f\"Model {model_name_prefix} Fold {fold} Epoch {epoch+1} - Train Loss: {train_loss:.6f}, Val Loss: {val_loss:.6f}, Val Score: {val_score:.6f}\")\n                    print(f\"Aneurysm AUC: {aneurysm_auc:.4f}, Avg Location AUC: {avg_individual:.4f}\")\n                    \n                    if val_score > best_score:\n                        best_score = val_score\n                        best_epoch = epoch + 1\n                        patience_counter = 0\n                        ckpt_path = save_checkpoint(model, model_backbone, optimizer, scheduler, epoch+1, best_score, val_loss, \n                                                    config.OUTPUT_DIR, model_name_prefix, fold)\n                        print(f\"Saved checkpoint (Best Score): {os.path.basename(ckpt_path)}\")\n                    else:\n                        patience_counter += 1\n                        print(f\"No improvement. Patience: {patience_counter}/{config.EARLY_STOPPING_PATIENCE}\")\n                        if patience_counter >= config.EARLY_STOPPING_PATIENCE:\n                            print(f\"Early stopping at epoch {epoch+1}\")\n                            break\n                    \n                    torch.cuda.empty_cache()\n                    gc.collect()\n\n                print(f\"\\nModel {model_name_prefix} Fold {fold} finished. Best Score: {best_score:.6f} at epoch {best_epoch}\")\n                all_fold_scores.append(best_score)\n                \n                # Clean up memory before next fold\n                del model, train_dataset, val_dataset, train_loader, val_loader, optimizer, scheduler, scaler\n                gc.collect()\n                torch.cuda.empty_cache()\n                \n            print(f\"\\n--- 3-Fold Training Finished for {model_name_prefix} ---\")\n            print(f\"Fold Scores: {all_fold_scores}\")\n            print(f\"Average CV Score: {np.mean(all_fold_scores):.6f}\")\n\n        print(\"\\n\\n===== ALL 9 MODELS (3 Models x 3 Folds) TRAINING COMPLETE =====\")\n        print(\"Your models are saved in the '/kaggle/working' directory.\")\n        print(\"Please download them, upload as a new dataset, and use the inference notebook.\")\n\n    except Exception as e:\n        print(f\"An unexpected error occurred during the main loop: {e}\")\n        import traceback\n        traceback.print_exc()\n\nelse:\n    print(\"ERROR: 'train_df' was not loaded in Section 6. Halting script.\")\n\nprint(\"\\nScript finished.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-16T08:22:29.729623Z","iopub.execute_input":"2025-11-16T08:22:29.730256Z","iopub.status.idle":"2025-11-16T15:52:02.422166Z","shell.execute_reply.started":"2025-11-16T08:22:29.730229Z","shell.execute_reply":"2025-11-16T15:52:02.421393Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}