{"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":13190393,"sourceType":"competition"},{"sourceId":12687919,"sourceType":"datasetVersion","datasetId":7976292}],"dockerImageVersionId":31090,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Imports ","metadata":{}},{"cell_type":"code","source":"import os\nimport gc\nimport cv2\nimport json\nimport random\nimport warnings\nfrom glob import glob\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom joblib import Parallel, delayed\nfrom tqdm.auto import tqdm\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import roc_auc_score\n\nimport torch\nimport torch.nn as nn\nfrom torch.optim import AdamW\nfrom torch.optim.lr_scheduler import SequentialLR, LinearLR, CosineAnnealingLR\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import GradScaler, autocast\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport timm\nfrom timm.utils import ModelEmaV2\n\n# Suppress warnings\nwarnings.filterwarnings('ignore')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T09:13:39.742392Z","iopub.execute_input":"2025-08-18T09:13:39.742669Z","iopub.status.idle":"2025-08-18T09:13:55.742481Z","shell.execute_reply.started":"2025-08-18T09:13:39.74264Z","shell.execute_reply":"2025-08-18T09:13:55.741581Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Configuration","metadata":{}},{"cell_type":"code","source":"class CFG:\n    # General\n    seed = 42\n    debug = False  # Set to True for a small subset run\n    \n    # Paths\n    dicom_dir = \"/kaggle/input/rsna-intracranial-aneurysm-detection/series\"\n    train_csv_path = \"/kaggle/input/rsna-intracranial-aneurysm-detection/train.csv\"\n    output_dir = \"/kaggle/working/\"\n    preprocessed_image_dir = os.path.join(output_dir, \"preprocessed_images/\")\n\n    # Pre-processing\n    num_slices = 32\n    use_windowing = True\n    preprocessing_workers = max(2, os.cpu_count() - 2)\n\n    # Model\n    backbone = \"maxvit_base_tf_384.in21k\"\n    image_size = 384\n    num_labels = 14\n\n    # Training\n    n_folds = 5\n    n_epochs = 8\n    train_batch_size = 12\n    valid_batch_size = 24\n    \n    # Optimizer & Scheduler\n    head_lr = 1e-4\n    backbone_lr = 1e-5\n    weight_decay = 0.01\n    warmup_pct = 0.1\n    \n    # Loss & Metrics\n    label_weights = {\n        'Aneurysm Present': 13.0,\n    }\n\n    # Training Control\n    use_amp = True\n    gradient_clipping = 1.0\n    early_stopping_patience = 3\n    \n    # Label Information\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', 'Left Posterior Communicating Artery',\n        'Right Posterior Communicating Artery', 'Basilar Tip', 'Other Posterior Circulation',\n        'Aneurysm Present',\n    ]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T09:13:55.749253Z","iopub.execute_input":"2025-08-18T09:13:55.749482Z","iopub.status.idle":"2025-08-18T09:13:55.756386Z","shell.execute_reply.started":"2025-08-18T09:13:55.749465Z","shell.execute_reply":"2025-08-18T09:13:55.755314Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Reproducibility","metadata":{}},{"cell_type":"code","source":"def seed_everything(seed):\n    \"\"\"Seeds basic parameters for reproducibility.\"\"\"\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.292195Z","iopub.execute_input":"2025-08-17T15:52:31.292451Z","iopub.status.idle":"2025-08-17T15:52:31.316265Z","shell.execute_reply.started":"2025-08-17T15:52:31.292428Z","shell.execute_reply":"2025-08-17T15:52:31.31566Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# DICOM Pre-processing","metadata":{}},{"cell_type":"code","source":"def sort_slices(filepaths):\n    \"\"\"Sorts DICOM slices by z-position.\"\"\"\n    metas = []\n    for fp in filepaths:\n        try:\n            ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True)\n            z_pos = float(ds.ImagePositionPatient[2]) if hasattr(ds, \"ImagePositionPatient\") else 0.0\n            instance_num = float(ds.InstanceNumber) if hasattr(ds, \"InstanceNumber\") else 0.0\n            metas.append((fp, z_pos, instance_num))\n        except Exception:\n            continue\n    # Primary sort by z-position, secondary by instance number as fallback\n    metas.sort(key=lambda x: (x[1], x[2]))\n    return [m[0] for m in metas]\n\ndef apply_windowing(img, center, width, y_min=0, y_max=255):\n    \"\"\"Applies windowing to a single image.\"\"\"\n    img = np.clip(img, center - (width / 2), center + (width / 2))\n    img = (img - (center - width / 2)) / width\n    img = img * (y_max - y_min) + y_min\n    return img.astype(np.uint8)\n\ndef process_and_save_series(series_uid, dicom_dir, output_dir):\n    \"\"\"Processes a single DICOM series and saves it as a 2.5D PNG.\"\"\"\n    series_path = Path(dicom_dir) / series_uid\n    output_path = Path(output_dir) / f\"{series_uid}.png\"\n\n    if output_path.exists():\n        return series_uid, \"skipped_exists\"\n\n    try:\n        filepaths = glob(os.path.join(series_path, \"**/*.dcm\"), recursive=True)\n        if len(filepaths) < 2:\n            return series_uid, \"error_too_few_slices\"\n        \n        sorted_filepaths = sort_slices(filepaths)\n\n        slices, modalities = [], []\n        for fp in sorted_filepaths:\n            ds = pydicom.dcmread(fp, force=True)\n            if ds.pixel_array is None: continue\n            img = ds.pixel_array.astype(np.float32)\n            if hasattr(ds, 'RescaleSlope') and hasattr(ds, 'RescaleIntercept'):\n                img = img * ds.RescaleSlope + ds.RescaleIntercept\n            slices.append(img)\n            modalities.append(getattr(ds, 'Modality', 'Unknown'))\n        \n        if not slices: return series_uid, \"error_no_valid_slices\"\n        \n        volume = np.stack(slices, axis=0)\n        modality = modalities[0] if modalities else \"Unknown\"\n\n        # Resample slices\n        if len(volume) != CFG.num_slices:\n            indices = np.linspace(0, len(volume) - 1, CFG.num_slices).astype(int)\n            volume = volume[indices]\n        \n        # Create 2.5D image\n        if \"CT\" in modality:\n            brain_window = apply_windowing(volume, center=40, width=80)\n            subdural_window = apply_windowing(volume, center=50, width=130)\n            bone_window = apply_windowing(volume, center=600, width=2000)\n            \n            ch1 = cv2.resize(np.max(brain_window, axis=0), (CFG.image_size, CFG.image_size))\n            ch2 = cv2.resize(np.max(subdural_window, axis=0), (CFG.image_size, CFG.image_size))\n            ch3 = cv2.resize(np.max(bone_window, axis=0), (CFG.image_size, CFG.image_size))\n        else: # Fallback for MRI/MRA or Unknown\n            p_min, p_max = np.percentile(volume, [1, 99])\n            volume = np.clip(volume, p_min, p_max)\n            volume = ((volume - p_min) / (p_max - p_min + 1e-6) * 255).astype(np.uint8)\n            \n            ch1 = cv2.resize(volume[CFG.num_slices // 2], (CFG.image_size, CFG.image_size))\n            ch2 = cv2.resize(np.max(volume, axis=0), (CFG.image_size, CFG.image_size))\n            ch3 = cv2.resize(np.std(volume, axis=0).astype(np.uint8), (CFG.image_size, CFG.image_size))\n            \n        image_2_5d = np.stack([ch1, ch2, ch3], axis=-1)\n        \n        cv2.imwrite(str(output_path), image_2_5d, [cv2.IMWRITE_PNG_COMPRESSION, 3])\n        if not output_path.exists():\n            return series_uid, \"error_write_failed\"\n\n        return series_uid, \"success\"\n\n    except Exception as e:\n        return series_uid, f\"error_{str(e)}\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.31787Z","iopub.execute_input":"2025-08-17T15:52:31.318554Z","iopub.status.idle":"2025-08-17T15:52:31.332836Z","shell.execute_reply.started":"2025-08-17T15:52:31.318532Z","shell.execute_reply":"2025-08-17T15:52:31.332293Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Dataset & Transforms","metadata":{}},{"cell_type":"code","source":"def get_transforms(image_size):\n    return {\n        \"train\": A.Compose([\n            A.Resize(image_size, image_size),\n            A.HorizontalFlip(p=0.5),\n            A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5),\n            A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2(),\n        ]),\n        \"valid\": A.Compose([\n            A.Resize(image_size, image_size),\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2(),\n        ])\n    }\n\nclass AneurysmDataset(Dataset):\n    def __init__(self, df, transforms, image_dir, label_cols):\n        self.df = df.reset_index(drop=True)\n        self.transforms = transforms\n        self.image_dir = image_dir\n        self.label_cols = label_cols\n        self.image_paths = self.df['image_path'].values\n        self.labels = self.df[self.label_cols].values.astype(np.float32)\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        image_path = os.path.join(self.image_dir, self.image_paths[idx])\n        image = cv2.imread(image_path, cv2.IMREAD_COLOR)\n        if image is None:\n            raise FileNotFoundError(f\"Image not found at {image_path}\")\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        if self.transforms:\n            image = self.transforms(image=image)['image']\n        \n        labels = torch.tensor(self.labels[idx], dtype=torch.float32)\n        return {\"image\": image, \"labels\": labels}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.33354Z","iopub.execute_input":"2025-08-17T15:52:31.333851Z","iopub.status.idle":"2025-08-17T15:52:31.3504Z","shell.execute_reply.started":"2025-08-17T15:52:31.333835Z","shell.execute_reply":"2025-08-17T15:52:31.349823Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Model","metadata":{}},{"cell_type":"code","source":"class AneurysmModel(nn.Module):\n    def __init__(self, backbone_name, num_labels, pretrained=True):\n        super().__init__()\n        self.backbone = timm.create_model(\n            backbone_name, pretrained=pretrained, num_classes=0, global_pool=''\n        )\n        in_feats = self.backbone.num_features\n        self.global_pool = nn.AdaptiveAvgPool2d(1)\n        self.head = nn.Sequential(\n            nn.Flatten(),\n            nn.LayerNorm(in_feats),\n            nn.Dropout(0.3),\n            nn.Linear(in_feats, num_labels)\n        )\n\n    def forward(self, x):\n        feats = self.backbone(x)\n        pooled_feats = self.global_pool(feats)\n        return self.head(pooled_feats)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.351036Z","iopub.execute_input":"2025-08-17T15:52:31.351242Z","iopub.status.idle":"2025-08-17T15:52:31.365968Z","shell.execute_reply.started":"2025-08-17T15:52:31.351225Z","shell.execute_reply":"2025-08-17T15:52:31.365502Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Metric & Loss","metadata":{}},{"cell_type":"code","source":"def calculate_weighted_auc(y_true, y_pred, label_names, label_weights):\n    per_label_aucs = []\n    for i, name in enumerate(label_names):\n        if len(np.unique(y_true[:, i])) > 1:\n            auc = roc_auc_score(y_true[:, i], y_pred[:, i])\n        else:\n            auc = np.nan\n        per_label_aucs.append(auc)\n    \n    aucs = np.array(per_label_aucs, dtype=float)\n    weights = np.array([label_weights.get(name, 1.0) for name in label_names], dtype=float)\n    \n    valid = ~np.isnan(aucs)\n    if valid.sum() == 0:\n        return 0.0, per_label_aucs\n        \n    weighted_auc = (aucs[valid] * weights[valid]).sum() / weights[valid].sum()\n    return weighted_auc, per_label_aucs\n\ndef compute_pos_weight(df, label_cols):\n    pos_counts = df[label_cols].sum()\n    neg_counts = len(df) - pos_counts\n    pos_weight = neg_counts / (pos_counts + 1e-6)\n    return torch.tensor(pos_weight.values, dtype=torch.float32)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.366695Z","iopub.execute_input":"2025-08-17T15:52:31.366965Z","iopub.status.idle":"2025-08-17T15:52:31.383326Z","shell.execute_reply.started":"2025-08-17T15:52:31.36694Z","shell.execute_reply":"2025-08-17T15:52:31.38274Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Training & Validation","metadata":{}},{"cell_type":"code","source":"def train_fn(model, dataloader, optimizer, scheduler, loss_fn, scaler, device):\n    model.train()\n    total_loss = 0\n    bf16_available = torch.cuda.is_available() and torch.cuda.is_bf16_supported()\n    \n    for batch in tqdm(dataloader, desc=\"Training\", leave=False):\n        images = batch['image'].to(device, non_blocking=True)\n        labels = batch['labels'].to(device, non_blocking=True)\n        \n        optimizer.zero_grad(set_to_none=True)\n        \n        with autocast(enabled=CFG.use_amp, dtype=torch.bfloat16 if bf16_available else torch.float16):\n            outputs = model(images)\n            loss = loss_fn(outputs, labels)\n        \n        scaler.scale(loss).backward()\n        \n        if CFG.gradient_clipping:\n            scaler.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), CFG.gradient_clipping)\n            \n        scaler.step(optimizer)\n        scaler.update()\n        \n        if scheduler:\n            scheduler.step()\n        \n        total_loss += loss.item()\n        \n    return total_loss / len(dataloader)\n\ndef valid_fn(model, ema_model, dataloader, loss_fn, device, label_weights):\n    model.eval()\n    if ema_model: ema_model.eval()\n    \n    total_loss = 0\n    all_preds, all_labels = [], []\n\n    with torch.no_grad():\n        for batch in tqdm(dataloader, desc=\"Validation\", leave=False):\n            images = batch['image'].to(device, non_blocking=True)\n            labels = batch['labels'].to(device, non_blocking=True)\n            \n            with autocast(enabled=CFG.use_amp):\n                outputs = ema_model.module(images) if ema_model else model(images)\n                loss = loss_fn(outputs, labels) # Can report weighted loss here\n            \n            total_loss += loss.item()\n            all_preds.append(torch.sigmoid(outputs).cpu().numpy())\n            all_labels.append(labels.cpu().numpy())\n            \n    all_preds = np.concatenate(all_preds)\n    all_labels = np.concatenate(all_labels)\n    \n    avg_loss = total_loss / len(dataloader)\n    weighted_auc, per_label_aucs = calculate_weighted_auc(\n        all_labels, all_preds, CFG.LABEL_COLS, label_weights\n    )\n    \n    return avg_loss, weighted_auc, per_label_aucs, all_preds","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.383938Z","iopub.execute_input":"2025-08-17T15:52:31.384108Z","iopub.status.idle":"2025-08-17T15:52:31.397576Z","shell.execute_reply.started":"2025-08-17T15:52:31.384094Z","shell.execute_reply":"2025-08-17T15:52:31.397025Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Main Execution","metadata":{}},{"cell_type":"code","source":"def main():\n    seed_everything(CFG.seed)\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    \n    # Assertions for correctness\n    assert CFG.num_labels == len(CFG.LABEL_COLS)\n\n    df = pd.read_csv(CFG.train_csv_path)\n    df['PatientID'] = df['SeriesInstanceUID'].apply(lambda x: x.split('.')[4])\n    if CFG.debug:\n        df = df.sample(n=200, random_state=CFG.seed).reset_index(drop=True)\n\n    # --- Step 1: Pre-processing ---\n    print(\"--- Starting DICOM Pre-processing ---\")\n    os.makedirs(CFG.preprocessed_image_dir, exist_ok=True)\n    series_uids = df['SeriesInstanceUID'].unique()\n    \n    results = Parallel(n_jobs=CFG.preprocessing_workers)(\n        delayed(process_and_save_series)(uid, CFG.dicom_dir, CFG.preprocessed_image_dir)\n        for uid in tqdm(series_uids, desc=\"Dispatching jobs\")\n    )\n    \n    skipped_series = [res for res in results if res[1] != \"success\" and res[1] != \"skipped_exists\"]\n    if skipped_series:\n        print(f\"Warning: Skipped {len(skipped_series)} series due to errors.\")\n        pd.DataFrame(skipped_series, columns=[\"SeriesUID\", \"Reason\"]).to_csv(\n            os.path.join(CFG.output_dir, \"skipped_series.csv\"), index=False\n        )\n    print(\"--- Pre-processing Complete ---\")\n\n    df['image_path'] = df['SeriesInstanceUID'] + '.png'\n    \n    # --- Step 2: Training ---\n    oof_preds, oof_labels = [], []\n    final_fold_metrics = []\n    \n    skf = StratifiedGroupKFold(n_splits=CFG.n_folds, shuffle=True, random_state=CFG.seed)\n    y_stratify = df[CFG.LABEL_COLS[-1]]\n    groups = df['PatientID']\n    \n    for fold, (train_idx, valid_idx) in enumerate(skf.split(df, y_stratify, groups)):\n        print(f\"\\n========== FOLD {fold} ==========\")\n        train_data, valid_data = df.iloc[train_idx], df.iloc[valid_idx]\n        oof_labels.append(valid_data[CFG.LABEL_COLS].values)\n\n        transforms = get_transforms(CFG.image_size)\n        \n        train_dataset = AneurysmDataset(train_data, transforms['train'], CFG.preprocessed_image_dir, CFG.LABEL_COLS)\n        valid_dataset = AneurysmDataset(valid_data, transforms['valid'], CFG.preprocessed_image_dir, CFG.LABEL_COLS)\n        \n        train_loader = DataLoader(train_dataset, batch_size=CFG.train_batch_size, shuffle=True, num_workers=CFG.num_workers, pin_memory=True, drop_last=True)\n        valid_loader = DataLoader(valid_dataset, batch_size=CFG.valid_batch_size, shuffle=False, num_workers=CFG.num_workers, pin_memory=True)\n        \n        model = AneurysmModel(CFG.backbone, CFG.num_labels).to(device)\n        ema_model = ModelEmaV2(model, decay=0.9998)\n        \n        # Optimizer with param groups\n        head_params = list(model.head.parameters())\n        backbone_params = [p for n, p in model.named_parameters() if not n.startswith(\"head.\")]\n        optimizer = AdamW(\n            [{\"params\": backbone_params, \"lr\": CFG.backbone_lr},\n             {\"params\": head_params, \"lr\": CFG.head_lr}],\n            weight_decay=CFG.weight_decay, fused=True if device.type == 'cuda' else False\n        )\n        \n        # Scheduler with warmup\n        total_steps = len(train_loader) * CFG.n_epochs\n        warmup_steps = int(CFG.warmup_pct * total_steps)\n        main_steps = total_steps - warmup_steps\n        scheduler = SequentialLR(optimizer, schedulers=[\n            LinearLR(optimizer, start_factor=0.01, total_iters=warmup_steps),\n            CosineAnnealingLR(optimizer, T_max=main_steps, eta_min=1e-6)\n        ], milestones=[warmup_steps])\n        \n        # Loss function\n        pos_weight = compute_pos_weight(train_data, CFG.LABEL_COLS).to(device)\n        loss_fn = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\n        \n        scaler = GradScaler(enabled=CFG.use_amp)\n        best_auc = 0\n        patience_counter = 0\n        \n        for epoch in range(CFG.n_epochs):\n            train_loss = train_fn(model, train_loader, optimizer, scheduler, loss_fn, scaler, device)\n            ema_model.update(model)\n            \n            val_loss, weighted_auc, _, fold_oof = valid_fn(model, ema_model, valid_loader, loss_fn, device, CFG.label_weights)\n            \n            print(f\"Epoch {epoch+1}/{CFG.n_epochs} -> Train Loss: {train_loss:.4f} | Valid Loss: {val_loss:.4f} | Valid AUC: {weighted_auc:.4f}\")\n            \n            if weighted_auc > best_auc:\n                best_auc = weighted_auc\n                print(f\"🎉 New best score: {best_auc:.4f}. Saving model and OOF...\")\n                torch.save(ema_model.module.state_dict(), f\"{CFG.output_dir}/model_fold_{fold}_best.pth\")\n                np.save(f\"{CFG.output_dir}/oof_preds_fold_{fold}.npy\", fold_oof)\n                patience_counter = 0\n            else:\n                patience_counter += 1\n                if patience_counter >= CFG.early_stopping_patience:\n                    print(\"Early stopping triggered.\")\n                    break\n        \n        # Load best OOF for this fold\n        best_fold_oof = np.load(f\"{CFG.output_dir}/oof_preds_fold_{fold}.npy\")\n        oof_preds.append(best_fold_oof)\n        final_fold_metrics.append(best_auc)\n\n        del model, ema_model, train_loader, valid_loader; gc.collect(); torch.cuda.empty_cache()\n\n    # --- Step 3: Final Evaluation ---\n    print(\"\\n--- Cross-Validation Summary ---\")\n    for i, auc in enumerate(final_fold_metrics):\n        print(f\"Fold {i} Best AUC: {auc:.4f}\")\n    \n    oof_preds = np.concatenate(oof_preds)\n    oof_labels = np.concatenate(oof_labels)\n    \n    overall_auc, per_label_aucs_final = calculate_weighted_auc(oof_labels, oof_preds, CFG.LABEL_COLS, CFG.label_weights)\n    print(f\"\\nOverall CV Weighted AUC: {overall_auc:.4f}\")\n\n    # Save final results\n    oof_df = pd.DataFrame(oof_preds, columns=[f\"{col}_pred\" for col in CFG.LABEL_COLS])\n    oof_df.to_csv(os.path.join(CFG.output_dir, \"oof_predictions.csv\"), index=False)\n    \n    final_metrics = {\n        \"overall_cv_auc\": overall_auc,\n        \"per_fold_auc\": final_fold_metrics,\n        \"per_label_final_auc\": {name: auc for name, auc in zip(CFG.LABEL_COLS, per_label_aucs_final)}\n    }\n    with open(os.path.join(CFG.output_dir, \"run_metrics.json\"), \"w\") as f:\n        json.dump(final_metrics, f, indent=4)\n        \nif __name__ == \"__main__\":\n    main()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-17T15:52:31.398382Z","iopub.execute_input":"2025-08-17T15:52:31.398603Z"}},"outputs":[],"execution_count":null}]}