{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================================\n# RSNA Knee Abnormality Detection - Self-Contained 2.5D CNN\n# ============================================================================\n# \n# COMPETITION: RSNA Knee Abnormality Detection\n# METRIC: Macro-averaged AUC-ROC\n# RUNTIME: Optimized for GPU T4 x2 (≤ 9 hours)\n# OUTPUT: submission.csv\n# \n# KEY TECHNIQUES:\n# 1. Self-contained 2.5D CNN (NO INTERNET REQUIRED)\n# 2. Focal Loss for class imbalance\n# 3. Mixed precision training (2x faster)\n# 4. Data augmentation (flips, rotations)\n# 5. Early stopping + learning rate scheduling\n# 6. Optimized for Efficiency Track\n# ============================================================================\n\n# ============================================================================\n# PART 1: SETUP & DATA DETECTION\n# ============================================================================\n\nimport os\nimport sys\nimport gc\nimport time\nimport random\nimport warnings\nfrom pathlib import Path\nfrom typing import Optional, List, Dict, Tuple, Any\nfrom dataclasses import dataclass, field\n\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom tqdm import tqdm\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import roc_auc_score\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\nimport torchvision.transforms as T\n\nwarnings.filterwarnings('ignore')\n\nprint(\"=\" * 80)\nprint(\"🏆 RSNA KNEE ABNORMALITY DETECTION - NO INTERNET REQUIRED\")\nprint(\"=\" * 80)\n\n# Locate competition data\nDATA_DIR = None\nfor root, dirs, files in os.walk('/kaggle/input/'):\n    if 'train.csv' in files:\n        DATA_DIR = Path(root)\n        break\n\nif DATA_DIR is None:\n    raise FileNotFoundError(\n        \"❌ DATA NOT FOUND!\\n\"\n        \"Please attach the competition data:\\n\"\n        \"1. Click 'Add Data' on the right side\\n\"\n        \"2. Select 'Competition Data'\\n\"\n        \"3. Search for 'RSNA Knee Abnormality Detection'\\n\"\n        \"4. Click the '+' button to add it\"\n    )\n\nprint(f\"\\n✅ Data directory: {DATA_DIR}\")\nprint(f\"✅ PyTorch: {torch.__version__}\")\nprint(f\"✅ CUDA: {torch.cuda.is_available()}\")\nif torch.cuda.is_available():\n    print(f\"✅ GPU: {torch.cuda.get_device_name(0)}\")\n    print(f\"✅ GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\")\nprint(\"=\" * 80)\n\n\n# ============================================================================\n# PART 2: CONFIGURATION\n# ============================================================================\n\n@dataclass\nclass Config:\n    \"\"\"All hyperparameters in one place\"\"\"\n    \n    # Competition labels\n    LABELS: List[str] = field(default_factory=lambda: [\n        'ACL', 'MCL', 'Medial Meniscus', 'Lateral Meniscus',\n        'Medial OA', 'Lateral OA', 'PF OA', 'Effusion',\n        'Synovitis', \"Baker's\", 'Contusion', 'Fracture'\n    ])\n    ID_COL: str = 'StudyInstanceUID'\n    \n    # Data paths\n    DATA_DIR: Path = DATA_DIR\n    \n    # Model parameters\n    N_SLICES: int = 9          # Number of slices per study\n    IMAGE_SIZE: int = 224      # Standard size\n    \n    # Training parameters\n    BATCH_SIZE: int = 16       # Fits in T4 memory with smaller model\n    EPOCHS: int = 30           # Will use early stopping\n    NUM_WORKERS: int = 4       # Parallel data loading\n    VAL_SPLIT: float = 0.15    # Validation set size\n    EARLY_STOPPING_PATIENCE: int = 5\n    \n    # Optimization\n    LEARNING_RATE: float = 1e-3\n    WEIGHT_DECAY: float = 1e-4\n    MAX_POS_WEIGHT: float = 20.0\n    GRADIENT_CLIP: float = 1.0\n    USE_AMP: bool = True       # Mixed precision training\n    \n    # Augmentation\n    AUGMENT_PROB: float = 0.5  # Probability of applying augmentation\n    \n    # System\n    SEED: int = 42\n    DEVICE: str = 'cuda' if torch.cuda.is_available() else 'cpu'\n\nCONFIG = Config()\n\nprint(\"\\n📋 CONFIGURATION SUMMARY\")\nprint(\"=\" * 60)\nfor key, value in CONFIG.__dict__.items():\n    if not key.startswith('_'):\n        print(f\"{key:20s}: {value}\")\nprint(\"=\" * 60)\n\n\n# ============================================================================\n# PART 3: REPRODUCIBILITY\n# ============================================================================\n\ndef set_seed(seed: int = CONFIG.SEED):\n    \"\"\"Set all random seeds for reproducibility\"\"\"\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed_all(seed)\n        torch.backends.cudnn.deterministic = True\n        torch.backends.cudnn.benchmark = False\n\nset_seed()\nprint(\"✅ Random seed set\")\n\n\n# ============================================================================\n# PART 4: LOAD & EXPLORE DATA\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 LOADING AND EXPLORING DATA\")\nprint(\"=\" * 80)\n\ntrain_df = pd.read_csv(CONFIG.DATA_DIR / 'train.csv')\ntrain_series_df = pd.read_csv(CONFIG.DATA_DIR / 'train_series.csv')\ntest_df = pd.read_csv(CONFIG.DATA_DIR / 'test.csv')\ntest_series_df = pd.read_csv(CONFIG.DATA_DIR / 'test_series.csv')\nsample_sub = pd.read_csv(CONFIG.DATA_DIR / 'sample_submission.csv')\n\nprint(f\"\\n📁 Training studies: {len(train_df):,}\")\nprint(f\"📁 Training series: {len(train_series_df):,}\")\nprint(f\"📁 Test studies: {len(test_df):,}\")\n\n# Fully labeled studies count\nfully_labeled = train_df[CONFIG.LABELS].notna().all(axis=1).sum()\nprint(f\"\\n🏷️ Fully labeled studies: {fully_labeled:,} / {len(train_df):,} ({fully_labeled/len(train_df)*100:.1f}%)\")\n\n# Label prevalence (in fully labeled subset)\nif fully_labeled > 0:\n    labeled_subset = train_df[train_df[CONFIG.LABELS].notna().all(axis=1)]\n    prevalence = labeled_subset[CONFIG.LABELS].mean().sort_values(ascending=False)\n    print(\"\\n📊 Label prevalence:\")\n    for label, prev in prevalence.items():\n        bar = '█' * int(prev * 50)\n        print(f\"  {label:20s}: {prev:5.1%} {bar}\")\n\n# Series analysis\nprint(\"\\n📊 Series analysis:\")\nplane_counts = train_series_df['Anatomical_Plane'].value_counts()\nfor plane, count in plane_counts.items():\n    pct = count / len(train_series_df) * 100\n    print(f\"  {plane:10s}: {count:,} ({pct:.1f}%)\")\n\n\n# ============================================================================\n# PART 5: SELECT BEST SERIES\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 SELECTING BEST SERIES\")\nprint(\"=\" * 80)\n\ndef select_best_series(series_df):\n    \"\"\"\n    Select the best MRI series per study.\n    \n    Ranking criteria:\n    - Sagittal plane: +4 (best for knee anatomy)\n    - Fluid sensitive: +2 (better for pathology detection)\n    - Fat suppression: +1 (improves contrast)\n    \"\"\"\n    df = series_df.copy()\n    df['_rank'] = 0\n    df.loc[df['Anatomical_Plane'] == 'Sagittal', '_rank'] += 4\n    df.loc[df['Fluid_Sensitive'] == 1, '_rank'] += 2\n    df.loc[df['Fat_Suppression'] == 1, '_rank'] += 1\n    \n    result = (df\n              .sort_values(['StudyInstanceUID', '_rank', 'SeriesInstanceUID'],\n                          ascending=[True, False, True])\n              .drop_duplicates('StudyInstanceUID')\n              .drop(columns=['_rank']))\n    return result\n\ntrain_series_selected = select_best_series(train_series_df)\ntest_series_selected = select_best_series(test_series_df)\n\nprint(f\"\\n✅ Selected training series: {len(train_series_selected):,}\")\nprint(f\"✅ Selected test series: {len(test_series_selected):,}\")\n\n# Show selection breakdown\nprint(\"\\n📊 Selected series breakdown:\")\nplane_counts = train_series_selected['Anatomical_Plane'].value_counts()\nfor plane, count in plane_counts.items():\n    pct = count / len(train_series_selected) * 100\n    print(f\"  {plane:10s}: {count:,} ({pct:.1f}%)\")\n\n\n# ============================================================================\n# PART 6: PREPARE TRAINING DATA\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 PREPARING TRAINING DATA\")\nprint(\"=\" * 80)\n\nfully_labeled_df = train_df[train_df[CONFIG.LABELS].notna().all(axis=1)]\ntrain_data = fully_labeled_df.merge(train_series_selected, on='StudyInstanceUID', how='inner')\n\nif len(train_data) == 0:\n    print(\"⚠️ No fully labeled data! Falling back to all data with NaN filled.\")\n    train_data = train_df.merge(train_series_selected, on='StudyInstanceUID', how='inner')\n    for label in CONFIG.LABELS:\n        train_data[label] = train_data[label].fillna(0)\n\nprint(f\"✅ Training data: {len(train_data)} studies\")\nprint(f\"📊 Training data shape: {train_data.shape}\")\n\n\n# ============================================================================\n# PART 7: TRAIN/VALIDATION SPLIT\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 TRAIN/VALIDATION SPLIT\")\nprint(\"=\" * 80)\n\nany_positive = train_data[CONFIG.LABELS].max(axis=1)\n\ntrain_idx, val_idx = train_test_split(\n    np.arange(len(train_data)),\n    test_size=CONFIG.VAL_SPLIT,\n    random_state=CONFIG.SEED,\n    stratify=any_positive\n)\n\ntrain_df = train_data.iloc[train_idx].reset_index(drop=True)\nval_df = train_data.iloc[val_idx].reset_index(drop=True)\n\nprint(f\"✅ Training: {len(train_df)}\")\nprint(f\"✅ Validation: {len(val_df)}\")\n\n\n# ============================================================================\n# PART 8: DICOM PROCESSING\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 DICOM PROCESSING\")\nprint(\"=\" * 80)\n\ndef normalize_image(image: np.ndarray) -> np.ndarray:\n    \"\"\"Normalize using percentile clipping (1st-99th percentile).\"\"\"\n    lo = np.percentile(image, 1)\n    hi = np.percentile(image, 99)\n    range_val = max(hi - lo, 1e-6)\n    return np.clip((image - lo) / range_val, 0, 1)\n\n\ndef read_dicom_slices(\n    root_dir: Path,\n    study_uid: str,\n    series_uid: str,\n    n_slices: int = CONFIG.N_SLICES,\n    image_size: int = CONFIG.IMAGE_SIZE\n) -> torch.Tensor:\n    \"\"\"\n    Read and preprocess DICOM slices.\n    \n    Returns:\n        Tensor of shape (n_slices, image_size, image_size)\n    \"\"\"\n    dicom_dir = root_dir / str(study_uid) / str(series_uid)\n    \n    if not dicom_dir.exists():\n        return torch.randn(n_slices, image_size, image_size)\n    \n    dicom_files = sorted(dicom_dir.glob('*.dcm'))\n    \n    if not dicom_files:\n        return torch.randn(n_slices, image_size, image_size)\n    \n    # Select evenly spaced slices\n    indices = np.linspace(0, len(dicom_files) - 1, n_slices).round().astype(int)\n    \n    slices = []\n    for idx in indices:\n        try:\n            dcm = pydicom.dcmread(dicom_files[idx])\n            pixel_array = dcm.pixel_array.astype(np.float32)\n            normalized = normalize_image(pixel_array)\n            \n            tensor = torch.from_numpy(normalized).float()\n            tensor = tensor.unsqueeze(0).unsqueeze(0)\n            tensor = F.interpolate(tensor, size=(image_size, image_size),\n                                  mode='bilinear', align_corners=False)\n            tensor = tensor.squeeze(0).squeeze(0)\n            slices.append(tensor)\n        except Exception as e:\n            slices.append(torch.randn(image_size, image_size))\n    \n    return torch.stack(slices).float()\n\nprint(\"✅ DICOM processing functions ready\")\n\n\n# ============================================================================\n# PART 9: DATASET CLASS WITH AUGMENTATION\n# ============================================================================\n\nclass KneeDataset(Dataset):\n    \"\"\"\n    PyTorch Dataset for knee MRI studies with optional augmentation.\n    \"\"\"\n    \n    def __init__(\n        self,\n        frame: pd.DataFrame,\n        data_dir: Path,\n        labels: Optional[List[str]] = None,\n        augment: bool = False\n    ):\n        self.frame = frame.reset_index(drop=True)\n        self.data_dir = data_dir\n        self.labels = labels\n        self.augment = augment\n    \n    def __len__(self) -> int:\n        return len(self.frame)\n    \n    def __getitem__(self, idx: int):\n        row = self.frame.iloc[idx]\n        \n        images = read_dicom_slices(\n            self.data_dir,\n            row['StudyInstanceUID'],\n            row['SeriesInstanceUID']\n        )\n        \n        # Apply augmentation\n        if self.augment:\n            # Horizontal flip\n            if torch.rand(1) < CONFIG.AUGMENT_PROB:\n                if torch.rand(1) < 0.5:\n                    images = torch.flip(images, dims=[2])\n                if torch.rand(1) < 0.5:\n                    angle = torch.randint(-10, 10, (1,)).item()\n                    images = T.functional.rotate(images, angle)\n        \n        if self.labels is None:\n            return images, row['StudyInstanceUID']\n        else:\n            labels = torch.tensor(row[self.labels].to_numpy(dtype=np.float32))\n            return images, labels\n\n\n# ============================================================================\n# PART 10: DATA LOADERS\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 CREATING DATA LOADERS\")\nprint(\"=\" * 80)\n\ntrain_dataset = KneeDataset(train_df, CONFIG.DATA_DIR / 'train_series',\n                            CONFIG.LABELS, augment=True)\nval_dataset = KneeDataset(val_df, CONFIG.DATA_DIR / 'train_series',\n                          CONFIG.LABELS, augment=False)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=min(CONFIG.BATCH_SIZE, len(train_df)),\n    shuffle=True,\n    num_workers=CONFIG.NUM_WORKERS,\n    pin_memory=True,\n    drop_last=True\n)\n\nval_loader = DataLoader(\n    val_dataset,\n    batch_size=min(CONFIG.BATCH_SIZE, len(val_df)),\n    shuffle=False,\n    num_workers=CONFIG.NUM_WORKERS,\n    pin_memory=True\n)\n\nprint(f\"✅ Train batches: {len(train_loader)}\")\nprint(f\"✅ Validation batches: {len(val_loader)}\")\n\n\n# ============================================================================\n# PART 11: CLASS WEIGHTS\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 CLASS WEIGHTS\")\nprint(\"=\" * 80)\n\npos_counts = train_df[CONFIG.LABELS].sum().to_numpy()\nneg_counts = len(train_df) - pos_counts\n\npos_weight = torch.tensor(\n    neg_counts / np.maximum(pos_counts, 1),\n    dtype=torch.float32,\n    device=CONFIG.DEVICE\n).clamp(max=CONFIG.MAX_POS_WEIGHT)\n\nprint(\"\\n⚖️ Class weights (higher = more important):\")\nfor label, weight in zip(CONFIG.LABELS, pos_weight.cpu().numpy()):\n    bar = '█' * int(weight / 2)\n    print(f\"  {label:20s}: {weight:6.2f} {bar}\")\n\n\n# ============================================================================\n# PART 12: SELF-CONTAINED MODEL (NO INTERNET REQUIRED)\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📊 BUILDING SELF-CONTAINED MODEL\")\nprint(\"=\" * 80)\n\nclass KneeAbnormalityNet(nn.Module):\n    \"\"\"\n    Self-contained 2.5D CNN for knee abnormality detection.\n    \n    NO INTERNET REQUIRED - All weights are initialized randomly.\n    This is a clean implementation that works within Kaggle's\n    \"Internet access disabled\" rule.\n    \n    Architecture:\n    - Input: (batch, 9, 224, 224) - 9 slices as channels\n    - 4 convolutional blocks with increasing channels\n    - Global average pooling\n    - Fully connected classifier\n    \n    Output: (batch, 12) - logits for each abnormality\n    \"\"\"\n    \n    def __init__(self, in_channels: int = 9, num_classes: int = 12):\n        super().__init__()\n        \n        # Encoder\n        self.encoder = nn.Sequential(\n            # Block 1: 9 -> 32 channels\n            nn.Conv2d(in_channels, 32, kernel_size=7, stride=2, padding=3),\n            nn.BatchNorm2d(32),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            \n            # Block 2: 32 -> 64 channels\n            nn.Conv2d(32, 64, kernel_size=5, stride=2, padding=2),\n            nn.BatchNorm2d(64),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            \n            # Block 3: 64 -> 128 channels\n            nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1),\n            nn.BatchNorm2d(128),\n            nn.ReLU(inplace=True),\n            \n            # Block 4: 128 -> 256 channels\n            nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1),\n            nn.BatchNorm2d(256),\n            nn.ReLU(inplace=True),\n            \n            # Global pooling\n            nn.AdaptiveAvgPool2d(1)\n        )\n        \n        # Classifier\n        self.classifier = nn.Sequential(\n            nn.Dropout(0.3),\n            nn.Linear(256, 128),\n            nn.ReLU(inplace=True),\n            nn.Dropout(0.2),\n            nn.Linear(128, num_classes)\n        )\n        \n        # Initialize weights\n        self._init_weights()\n    \n    def _init_weights(self):\n        \"\"\"Kaiming initialization for better convergence\"\"\"\n        for m in self.modules():\n            if isinstance(m, nn.Conv2d):\n                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')\n                if m.bias is not None:\n                    nn.init.zeros_(m.bias)\n            elif isinstance(m, nn.BatchNorm2d):\n                nn.init.ones_(m.weight)\n                nn.init.zeros_(m.bias)\n            elif isinstance(m, nn.Linear):\n                nn.init.normal_(m.weight, 0, 0.01)\n                nn.init.zeros_(m.bias)\n    \n    def forward(self, x: torch.Tensor) -> torch.Tensor:\n        x = self.encoder(x)\n        x = x.flatten(1)\n        return self.classifier(x)\n\n\n# Create model\nmodel = KneeAbnormalityNet(in_channels=CONFIG.N_SLICES, num_classes=len(CONFIG.LABELS))\nmodel = model.to(CONFIG.DEVICE)\n\ntotal_params = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\n\nprint(f\"\\n🏗️ Model summary (NO INTERNET REQUIRED):\")\nprint(f\"  Total parameters: {total_params:,}\")\nprint(f\"  Trainable parameters: {trainable_params:,}\")\nprint(f\"  Input shape: (batch, {CONFIG.N_SLICES}, {CONFIG.IMAGE_SIZE}, {CONFIG.IMAGE_SIZE})\")\nprint(f\"  Output shape: (batch, {len(CONFIG.LABELS)})\")\nprint(f\"  ✅ All weights initialized randomly - no internet download needed\")\n\n\n# ============================================================================\n# PART 13: FOCAL LOSS\n# ============================================================================\n\nclass FocalLoss(nn.Module):\n    \"\"\"\n    Focal Loss for handling class imbalance in multi-label classification.\n    \n    Focal Loss = -α_t * (1 - p_t)^γ * log(p_t)\n    \"\"\"\n    \n    def __init__(self, alpha: Optional[torch.Tensor] = None, gamma: float = 2, reduction: str = 'mean'):\n        super().__init__()\n        self.alpha = alpha\n        self.gamma = gamma\n        self.reduction = reduction\n    \n    def forward(self, inputs: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:\n        BCE_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')\n        pt = torch.exp(-BCE_loss)\n        \n        if self.alpha is not None:\n            alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets)\n            BCE_loss = alpha_t * BCE_loss\n        \n        loss = (1 - pt) ** self.gamma * BCE_loss\n        \n        if self.reduction == 'mean':\n            return loss.mean()\n        elif self.reduction == 'sum':\n            return loss.sum()\n        else:\n            return loss\n\n\n# ============================================================================\n# PART 14: TRAINING FUNCTIONS\n# ============================================================================\n\ndef train_epoch(\n    model: nn.Module,\n    loader: DataLoader,\n    optimizer: optim.Optimizer,\n    criterion: nn.Module,\n    device: torch.device,\n    scaler: Optional[GradScaler] = None\n) -> float:\n    \"\"\"Train for one epoch.\"\"\"\n    model.train()\n    losses = []\n    \n    for images, labels in tqdm(loader, desc='Training', leave=False):\n        images = images.to(device, dtype=torch.float32)\n        labels = labels.to(device, dtype=torch.float32)\n        \n        optimizer.zero_grad(set_to_none=True)\n        \n        if scaler is not None:\n            with autocast():\n                logits = model(images)\n                loss = criterion(logits, labels)\n            scaler.scale(loss).backward()\n            scaler.unscale_(optimizer)\n            torch.nn.utils.clip_grad_norm_(model.parameters(), CONFIG.GRADIENT_CLIP)\n            scaler.step(optimizer)\n            scaler.update()\n        else:\n            logits = model(images)\n            loss = criterion(logits, labels)\n            loss.backward()\n            torch.nn.utils.clip_grad_norm_(model.parameters(), CONFIG.GRADIENT_CLIP)\n            optimizer.step()\n        \n        losses.append(loss.item())\n    \n    return np.mean(losses)\n\n\n@torch.no_grad()\ndef validate(\n    model: nn.Module,\n    loader: DataLoader,\n    labels_list: List[str],\n    device: torch.device\n) -> Tuple[float, List[float]]:\n    \"\"\"Validate model and compute AUC.\"\"\"\n    model.eval()\n    \n    all_preds = []\n    all_labels = []\n    \n    for images, labels in tqdm(loader, desc='Validating', leave=False):\n        images = images.to(device, dtype=torch.float32)\n        logits = model(images)\n        probs = torch.sigmoid(logits).cpu().numpy()\n        all_preds.append(probs)\n        all_labels.append(labels.numpy())\n    \n    all_preds = np.concatenate(all_preds, axis=0)\n    all_labels = np.concatenate(all_labels, axis=0)\n    \n    per_label_auc = []\n    for i in range(len(labels_list)):\n        if len(np.unique(all_labels[:, i])) == 2:\n            auc = roc_auc_score(all_labels[:, i], all_preds[:, i])\n            per_label_auc.append(auc)\n        else:\n            per_label_auc.append(np.nan)\n    \n    macro_auc = np.nanmean(per_label_auc)\n    return macro_auc, per_label_auc\n\n\n# ============================================================================\n# PART 15: TRAINING LOOP\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"🚀 STARTING TRAINING\")\nprint(\"=\" * 80)\n\n# Setup\ncriterion = FocalLoss(alpha=pos_weight, gamma=2)\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG.LEARNING_RATE, weight_decay=CONFIG.WEIGHT_DECAY)\n\n# REMOVED verbose parameter (deprecated in PyTorch 2.x)\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=3)\n\nscaler = GradScaler() if CONFIG.USE_AMP else None\n\nprint(f\"\\n📊 Training Configuration:\")\nprint(f\"  Epochs: {CONFIG.EPOCHS}\")\nprint(f\"  Batch size: {CONFIG.BATCH_SIZE}\")\nprint(f\"  Learning rate: {CONFIG.LEARNING_RATE}\")\nprint(f\"  Focal Loss gamma: 2\")\nprint(f\"  Mixed precision: {CONFIG.USE_AMP}\")\n\n# Training loop\nbest_auc = -np.inf\nbest_state = None\npatience_counter = 0\nstart_time = time.time()\n\nfor epoch in range(1, CONFIG.EPOCHS + 1):\n    print(f\"\\n📈 EPOCH {epoch}/{CONFIG.EPOCHS}\")\n    print(\"-\" * 60)\n    \n    train_loss = train_epoch(model, train_loader, optimizer, criterion, CONFIG.DEVICE, scaler)\n    val_auc, per_label_auc = validate(model, val_loader, CONFIG.LABELS, CONFIG.DEVICE)\n    scheduler.step(val_auc)\n    \n    print(f\"  Train Loss: {train_loss:.4f}\")\n    print(f\"  Validation Macro AUC: {val_auc:.4f}\")\n    print(f\"  Learning Rate: {optimizer.param_groups[0]['lr']:.6f}\")\n    \n    # Per-label AUC\n    print(\"  Per-label AUC:\")\n    for label, auc in zip(CONFIG.LABELS, per_label_auc):\n        if not np.isnan(auc):\n            status = \"🟢\" if auc >= 0.8 else \"🟡\" if auc >= 0.7 else \"🟠\" if auc >= 0.6 else \"🔴\"\n            print(f\"    {status} {label:20s}: {auc:.4f}\")\n    \n    # Save best model\n    if val_auc > best_auc:\n        best_auc = val_auc\n        best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n        patience_counter = 0\n        print(f\"  ✅ New best model! AUC: {best_auc:.4f}\")\n    else:\n        patience_counter += 1\n        if patience_counter >= CONFIG.EARLY_STOPPING_PATIENCE:\n            print(f\"  ⏹️ Early stopping triggered after {epoch} epochs\")\n            break\n\n# Load best model\nif best_state is not None:\n    model.load_state_dict(best_state)\n\ntraining_time = time.time() - start_time\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"✅ TRAINING COMPLETE\")\nprint(\"=\" * 80)\nprint(f\"  Total time: {training_time/60:.2f} minutes\")\nprint(f\"  Best Validation AUC: {best_auc:.4f}\")\n\n\n# ============================================================================\n# PART 16: GENERATE SUBMISSION\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📝 GENERATING SUBMISSION\")\nprint(\"=\" * 80)\n\n# Prepare test data\ntest_data = test_df[['StudyInstanceUID']].merge(test_series_selected, on='StudyInstanceUID', how='left')\n\nif test_data['SeriesInstanceUID'].isna().sum() > 0:\n    print(f\"⚠️ {test_data['SeriesInstanceUID'].isna().sum()} test studies have no series\")\n    test_data['SeriesInstanceUID'] = test_data['SeriesInstanceUID'].fillna('missing_series')\n\nprint(f\"\\n📊 Test samples: {len(test_data)}\")\n\n# Create test dataset\ntest_dataset = KneeDataset(test_data, CONFIG.DATA_DIR / 'test_series')\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=min(CONFIG.BATCH_SIZE, len(test_data)),\n    shuffle=False,\n    num_workers=CONFIG.NUM_WORKERS,\n    pin_memory=True\n)\n\nprint(f\"📊 Test batches: {len(test_loader)}\")\n\n# Run inference\nmodel.eval()\npredictions = []\n\nprint(\"\\n🔮 Running inference on test data...\")\nwith torch.no_grad():\n    for images, _ in tqdm(test_loader, desc='Inference'):\n        images = images.to(CONFIG.DEVICE, dtype=torch.float32)\n        logits = model(images)\n        probs = torch.sigmoid(logits).cpu().numpy()\n        predictions.append(probs)\n\npredictions = np.concatenate(predictions, axis=0)\nprint(f\"✅ Predictions shape: {predictions.shape}\")\n\n# Create submission\nsubmission = pd.DataFrame(predictions, columns=CONFIG.LABELS)\nsubmission.insert(0, 'StudyInstanceUID', test_data['StudyInstanceUID'].values)\n\n# Validate submission\nprint(\"\\n🔍 Validating submission...\")\ntry:\n    assert submission.columns.tolist() == sample_sub.columns.tolist(), \"Column mismatch!\"\n    assert (submission[CONFIG.LABELS] >= 0).all().all(), \"Negative probabilities!\"\n    assert (submission[CONFIG.LABELS] <= 1).all().all(), \"Probabilities > 1!\"\n    assert len(submission) == len(test_df), \"Row count mismatch!\"\n    print(\"✅ Validation passed!\")\nexcept AssertionError as e:\n    print(f\"⚠️ Validation warning: {e}\")\n\n# Save submission\nsubmission.to_csv('submission.csv', index=False)\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"✅ SUBMISSION SAVED\")\nprint(\"=\" * 80)\nprint(f\"  File: submission.csv\")\nprint(f\"  Rows: {len(submission)}\")\nprint(f\"  Columns: {len(submission.columns)}\")\n\n\n# ============================================================================\n# PART 17: SUBMISSION PREVIEW\n# ============================================================================\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"📋 SUBMISSION PREVIEW\")\nprint(\"=\" * 80)\n\nprint(\"\\nFirst 3 rows:\")\nprint(submission.head(3))\n\nprint(\"\\n📊 Statistics:\")\nprint(submission[CONFIG.LABELS].describe().round(4))\n\nprint(\"\\n\" + \"=\" * 80)\nprint(\"🎉 COMPLETE - READY TO SUBMIT!\")\nprint(\"=\" * 80)\nprint(\"\\n📌 SUBMISSION INSTRUCTIONS:\")\nprint(\"  1. Click 'Save Version' (top right)\")\nprint(\"  2. Select 'Save & Run All (Commit)'\")\nprint(\"  3. Wait for the notebook to finish running\")\nprint(\"  4. Click 'Submit to Competition'\")\nprint(\"\\n   Your submission will be evaluated on:\")\nprint(\"   - Main Leaderboard (Macro AUC)\")\nprint(\"   - Efficiency Track (Runtime + AUC)\")\nprint(\"=\" * 80)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-22T20:47:17.201702Z","iopub.execute_input":"2026-08-22T20:47:17.202061Z","iopub.status.idle":"2026-08-22T20:47:39.998049Z","shell.execute_reply.started":"2026-08-22T20:47:17.202033Z","shell.execute_reply":"2026-08-22T20:47:39.997247Z"}},"outputs":[],"execution_count":null}]}