{"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"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# RSNA Knee Abnormality Detection — Submission Notebook\n**Author:** Erlin Torres  \n**Approach:** 2.5D MRNet-style image model trained on weak labels derived from radiology reports  \n**Target:** 12-label macro-AUC, Efficiency Track  \n\n## Pipeline Overview\n1. Load weak labels (pre-generated from NLP labeling notebook)\n2. Cross-validation split (study-level, stratified)\n3. DICOM DataLoader (slice sampling + augmentation)\n4. Model: EfficientNet-B0 slice encoder + attention pooling across slices and series\n5. Training loop with weighted BCE loss (compensates for label imbalance)\n6. Inference on test set → submission.csv","metadata":{}},{"cell_type":"code","source":"# ── Standard library ───────────────────────────────────────────────────────────\nimport os, gc, re, time, warnings\nwarnings.filterwarnings(\"ignore\")\n\n# ── Data & numerics ────────────────────────────────────────────────────────────\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\n\n# ── Imaging ────────────────────────────────────────────────────────────────────\nimport pydicom\nimport cv2\n\n# ── ML ────────────────────────────────────────────────────────────────────────\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport timm\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import roc_auc_score\n\n# ── Install missing packages if needed ────────────────────────────────────────\n# pydicom and timm may not be pre-installed on all Kaggle kernels\nimport subprocess\nsubprocess.run([\"pip\", \"install\", \"-q\", \"pydicom\", \"timm\", \"opencv-python-headless\"],\n               capture_output=True)\n\n# ── Paths ──────────────────────────────────────────────────────────────────────\nCOMP_DIR   = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\nLABELS_DIR = Path(\"/kaggle/input/datasets/amilcartorres/rsna-knee-weak-labels\")   # your uploaded dataset\nWORK_DIR   = Path(\"/kaggle/working\")\n\n# ── Constants ──────────────────────────────────────────────────────────────────\nLABEL_COLS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\",\n    \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"\n]\nN_LABELS   = len(LABEL_COLS)   # 12\nPLANE_MAP  = {\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}\nSEED       = 42\n\n# ── Reproducibility ────────────────────────────────────────────────────────────\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\n\n# ── Device ─────────────────────────────────────────────────────────────────────\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Device: {DEVICE}\")\nprint(f\"PyTorch: {torch.__version__}\")\nprint(f\"timm: {timm.__version__}\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:04:59.029115Z","iopub.execute_input":"2026-08-27T22:04:59.029591Z","iopub.status.idle":"2026-08-27T22:05:02.274819Z","shell.execute_reply.started":"2026-08-27T22:04:59.029561Z","shell.execute_reply":"2026-08-27T22:05:02.274036Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 1 — Load Data\n\nWe load three things:\n- `weak_labels.csv` — our NLP-derived labels for 4,089 usable studies\n- `train_series.csv` — metadata per series (plane, fluid_sensitive, fat_suppression)\n- `test_series.csv` — same metadata for test studies (no labels, inference only)\n\n**Key design decision:** We trained the labeler separately and saved the result.\nThe submission notebook never runs NLP or translation — it only loads the CSV.\nThis keeps inference fast and well within the 9-hour runtime limit.","metadata":{}},{"cell_type":"code","source":"# ── Load weak labels ───────────────────────────────────────────────────────────\nweak_df = pd.read_csv(LABELS_DIR / \"weak_labels.csv\")\n\n# Keep only usable studies (exclude Greek, confidence > 0)\nweak_df = weak_df[weak_df[\"label_confidence\"] > 0].reset_index(drop=True)\n\nprint(f\"Usable training studies: {len(weak_df)}\")\nprint(f\"Label columns: {LABEL_COLS}\")\n\n# ── Load series metadata ───────────────────────────────────────────────────────\ntrain_series = pd.read_csv(COMP_DIR / \"train_series.csv\")\ntest_series  = pd.read_csv(COMP_DIR / \"test_series.csv\")\ntest_df      = pd.read_csv(COMP_DIR / \"test.csv\")\n\nprint(f\"\\nTrain series: {len(train_series)}\")\nprint(f\"Test series:  {len(test_series)}\")\nprint(f\"Test studies: {len(test_df)}\")\n\n# ── Quick sanity check ─────────────────────────────────────────────────────────\n# Every study in weak_df should have at least one series in train_series\nstudy_ids_with_series = set(train_series[\"StudyInstanceUID\"].unique())\nstudy_ids_in_labels   = set(weak_df[\"StudyInstanceUID\"].unique())\nmissing = study_ids_in_labels - study_ids_with_series\nprint(f\"\\nStudies in labels but missing series metadata: {len(missing)}\")\n# Should be 0 — if not, we drop them\n\nif missing:\n    weak_df = weak_df[~weak_df[\"StudyInstanceUID\"].isin(missing)].reset_index(drop=True)\n    print(f\"  Dropped {len(missing)} studies. Remaining: {len(weak_df)}\")\n\n# ── Build per-study series lookup ──────────────────────────────────────────────\n# Group series metadata by study — we'll use this in the DataLoader\nstudy_to_series = (\n    train_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\ntest_study_to_series = (\n    test_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\nprint(f\"\\nExample series list for first training study:\")\nfirst_study = weak_df[\"StudyInstanceUID\"].iloc[0]\nfor s in study_to_series[first_study]:\n    print(f\"  {s}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:05:06.978341Z","iopub.execute_input":"2026-08-27T22:05:06.978781Z","iopub.status.idle":"2026-08-27T22:05:10.47753Z","shell.execute_reply.started":"2026-08-27T22:05:06.978755Z","shell.execute_reply":"2026-08-27T22:05:10.476838Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 2 — Cross-Validation Split\n\n**Why this matters more than almost anything else in medical imaging ML:**\n\nA naive random split would put slices or series from the same study into both\ntrain and validation — the model memorizes patient anatomy, not pathology,\nand you get falsely optimistic CV scores that collapse on the private leaderboard.\n\n**Our approach: StratifiedGroupKFold**\n- `groups` = StudyInstanceUID → ensures one study is entirely in train OR val, never both\n- `stratify` = a composite label column → ensures each fold has similar positive rates\n  for the rarest labels (Fracture, MCL, Lateral OA), which matter most for macro-AUC\n\n**Why 5 folds:**\n- With 4,089 usable studies, 5-fold gives ~820 validation studies per fold\n- Enough to get a stable AUC estimate on rare labels (Fracture: ~30% prevalence\n  in gold set, so ~250 expected positives per val fold if weak labels were perfect)\n- We'll train fold 0 only for the first submission (faster), then ensemble\n  all 5 folds if time permits before the Oct 22 deadline\n\n**Important caveat:**\nOur weak labels have ~3-10x lower positive rates than reality. This means\nour CV AUC will be measured against noisy labels — it's a relative signal\n(is fold A better than fold B?) not an absolute one. The 58 gold-labeled\nstudies are our only true ground truth for absolute performance.","metadata":{}},{"cell_type":"code","source":"# ── Build stratification label ─────────────────────────────────────────────────\n# Problem: StratifiedGroupKFold needs a single stratification column.\n# With 12 binary labels, we can't stratify on all simultaneously.\n# Solution: create a composite \"label signature\" that captures the most\n# important label combinations for rare-class balance.\n\n# Step 1: identify the 3 rarest labels (most important to balance)\nlabel_prevalence = weak_df[LABEL_COLS].mean()\nrarest_3 = label_prevalence.nsmallest(3).index.tolist()\nprint(f\"3 rarest labels (prioritized for stratification): {rarest_3}\")\n\n# Step 2: also always include Effusion (most common — anchors the distribution)\nstratify_cols = rarest_3 + [\"Effusion\"]\nstratify_cols = list(dict.fromkeys(stratify_cols))  # deduplicate, preserve order\nprint(f\"Stratification columns: {stratify_cols}\")\n\n# Step 3: encode as integer (binary string → int)\n# e.g. [MCL=0, Lateral OA=1, Medial OA=0, Effusion=1] → \"0101\" → 5\nweak_df[\"strat_label\"] = (\n    weak_df[stratify_cols]\n    .fillna(0)\n    .astype(int)\n    .apply(lambda row: int(\"\".join(row.astype(str)), 2), axis=1)\n)\nprint(f\"\\nUnique stratification groups: {weak_df['strat_label'].nunique()}\")\nprint(f\"Distribution (top 10):\")\nprint(weak_df[\"strat_label\"].value_counts().head(10))\n\n# ── Create 5-fold split ────────────────────────────────────────────────────────\n# StratifiedGroupKFold: stratifies on strat_label, groups on StudyInstanceUID\n# This guarantees: no study leaks across folds, rare labels are balanced\n\nsgkf = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=SEED)\n\nweak_df[\"fold\"] = -1  # initialize\n\nfor fold_idx, (train_idx, val_idx) in enumerate(\n    sgkf.split(\n        weak_df,\n        y=weak_df[\"strat_label\"],\n        groups=weak_df[\"StudyInstanceUID\"]\n    )\n):\n    weak_df.loc[val_idx, \"fold\"] = fold_idx\n\nprint(f\"\\nFold distribution:\")\nprint(weak_df[\"fold\"].value_counts().sort_index())\n\n# ── Verify: no study appears in multiple folds ─────────────────────────────────\n# Each StudyInstanceUID should map to exactly one fold\nstudy_fold_counts = weak_df.groupby(\"StudyInstanceUID\")[\"fold\"].nunique()\nassert (study_fold_counts == 1).all(), \"LEAKAGE: some studies appear in multiple folds!\"\nprint(f\"\\n✓ No study leakage across folds\")\n\n# ── Check label balance per fold ───────────────────────────────────────────────\nprint(f\"\\nPositive rate per fold (Effusion + rarest labels):\")\ncheck_cols = [\"Effusion\"] + rarest_3\nfold_rates = weak_df.groupby(\"fold\")[check_cols].mean()\nprint(fold_rates.round(3))\n\n# ── Save the fold assignment for reproducibility ───────────────────────────────\nweak_df[[\"StudyInstanceUID\", \"fold\"]].to_csv(\n    WORK_DIR / \"fold_assignments.csv\", index=False\n)\nprint(f\"\\n✓ Fold assignments saved to fold_assignments.csv\")\n\n# ── For this first submission: use fold 0 only ─────────────────────────────────\nTRAIN_FOLD = 0   # change to \"all\" later for full 5-fold ensemble\ntrain_df = weak_df[weak_df[\"fold\"] != TRAIN_FOLD].reset_index(drop=True)\nval_df   = weak_df[weak_df[\"fold\"] == TRAIN_FOLD].reset_index(drop=True)\n\nprint(f\"\\nFold {TRAIN_FOLD} split:\")\nprint(f\"  Train studies: {len(train_df)}\")\nprint(f\"  Val   studies: {len(val_df)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:05:16.761476Z","iopub.execute_input":"2026-08-27T22:05:16.762186Z","iopub.status.idle":"2026-08-27T22:05:18.171333Z","shell.execute_reply.started":"2026-08-27T22:05:16.76216Z","shell.execute_reply":"2026-08-27T22:05:18.170606Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"CFG = {\n    \"img_size\":          224,\n    \"n_slices_train\":    6,\n    \"n_slices_val\":      10,\n    \"max_series\":        4,\n    \"backbone\":          \"efficientnet_b0\",\n    \"feat_dim\":          256,\n    \"dropout\":           0.3,\n    \"batch_size\":        4,\n    \"lr\":                3e-4,\n    \"n_epochs\":          6,\n    \"warmup_epochs\":     1,\n    \"grad_clip\":         1.0,\n    \"use_amp\":           True,\n    \"num_workers\":       2,\n    \"n_folds\":           5,\n    \"train_fold\":        0,\n    \"seed\":              42,\n}\nprint(\"CFG ready:\", CFG)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:05:27.475332Z","iopub.execute_input":"2026-08-27T22:05:27.476229Z","iopub.status.idle":"2026-08-27T22:05:27.482374Z","shell.execute_reply.started":"2026-08-27T22:05:27.476186Z","shell.execute_reply":"2026-08-27T22:05:27.48166Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 3 — DICOM DataLoader\n\n**The core challenge:** Each study is ~5 series × ~30 slices = ~150 DICOM files.\nLoading all 150 files per study at training time is too slow (DICOM parsing is expensive).\nWe need a sampling strategy that:\n1. Loads a manageable number of slices per series\n2. Normalizes intensity across scanners/sites (huge variation in raw pixel values)\n3. Applies augmentation to prevent overfitting on 3,272 studies\n\n**Slice sampling strategy:**\n- During training: randomly sample N_SLICES_TRAIN=12 slices per series\n  (random subset forces the model to not rely on any single slice)\n- During validation/inference: take the center N_SLICES_VAL=16 slices\n  (deterministic, covers the anatomically relevant region)\n- Why center slices for val? Knee anatomy is concentrated in the middle\n  of the volume — peripheral slices are often just soft tissue or air\n\n**Intensity normalization:**\nDICOM pixel values vary wildly across scanners (some store raw ADC values,\nsome store window-leveled uint8, some store 12-bit integers).\nWe normalize each slice independently: clip to [1st, 99th] percentile,\nthen scale to [0, 1]. This is more robust than global statistics.\n\n**Series cap:**\nStudies can have up to 14 series (mean 5.5). We cap at MAX_SERIES=6\nto bound memory and runtime. If a study has >6 series, we prioritize:\nfluid-sensitive series first (most informative for pathology), then by plane.","metadata":{}},{"cell_type":"code","source":"# ── Configuration ──────────────────────────────────────────────────────────────\nCFG = {\n    \"img_size\":          224,    # resize each slice to 224×224\n    \"n_slices_train\":    12,     # slices sampled per series during training\n    \"n_slices_val\":      16,     # slices used per series during val/inference\n    \"max_series\":        6,      # max series per study (cap for memory)\n    \"backbone\":          \"efficientnet_b0\",\n    \"feat_dim\":          256,\n    \"dropout\":           0.3,\n    \"batch_size\":        4,      # studies per batch (not slices — studies are large)\n    \"lr\":                3e-4,\n    \"n_epochs\":          8,\n    \"warmup_epochs\":     1,\n    \"grad_clip\":         1.0,\n    \"n_folds\":           5,\n    \"train_fold\":        0,\n    \"seed\":              42,\n}\n\n# ── DICOM loading utilities ────────────────────────────────────────────────────\ndef load_dicom_slice(dcm_path: str, img_size: int) -> np.ndarray:\n    \"\"\"\n    Load one DICOM slice → normalized float32 numpy array (H, W).\n    Handles mixed transfer syntaxes (uncompressed, JPEG lossless, JPEG2000).\n    \"\"\"\n    dcm = pydicom.dcmread(dcm_path)\n    img = dcm.pixel_array.astype(np.float32)\n\n    # Robust percentile normalization — handles 12-bit, 16-bit, uint8 equally\n    p1, p99 = np.percentile(img, 1), np.percentile(img, 99)\n    if p99 > p1:\n        img = (img - p1) / (p99 - p1)\n    img = np.clip(img, 0.0, 1.0)\n\n    # Resize to fixed spatial size\n    img = cv2.resize(img, (img_size, img_size), interpolation=cv2.INTER_LINEAR)\n    return img  # (H, W), float32, range [0,1]\n\n\ndef load_series_slices(\n    series_dir: str,\n    n_slices: int,\n    img_size: int,\n    is_train: bool\n) -> np.ndarray:\n    \"\"\"\n    Load N slices from a series directory.\n    Training: random subset. Val/inference: center subset.\n    Returns: (n_slices, H, W) float32 array.\n    \"\"\"\n    dcm_files = sorted(Path(series_dir).glob(\"*.dcm\"))\n\n    if len(dcm_files) == 0:\n        # Empty series — return zeros (model must handle gracefully)\n        return np.zeros((n_slices, img_size, img_size), dtype=np.float32)\n\n    total = len(dcm_files)\n\n    if total <= n_slices:\n        # Fewer slices than requested — use all, repeat last to pad\n        chosen = dcm_files\n    elif is_train:\n        # Random contiguous window of n_slices (preserves spatial context)\n        max_start = total - n_slices\n        start = np.random.randint(0, max_start + 1)\n        chosen = dcm_files[start:start + n_slices]\n    else:\n        # Center window — anatomically most informative region\n        center = total // 2\n        half = n_slices // 2\n        start = max(0, center - half)\n        chosen = dcm_files[start:start + n_slices]\n\n    slices = []\n    for f in chosen:\n        try:\n            s = load_dicom_slice(str(f), img_size)\n            slices.append(s)\n        except Exception:\n            slices.append(np.zeros((img_size, img_size), dtype=np.float32))\n\n    # Pad if needed (shouldn't happen often)\n    while len(slices) < n_slices:\n        slices.append(slices[-1].copy())\n\n    return np.stack(slices[:n_slices], axis=0)  # (n_slices, H, W)\n\n\ndef select_series(series_list: list, max_series: int) -> list:\n    \"\"\"\n    Cap series count at max_series.\n    Priority: fluid-sensitive first (most informative for pathology detection),\n    then fill remaining slots with non-fluid series.\n    \"\"\"\n    fluid     = [s for s in series_list if s[\"Fluid_Sensitive\"] == 1]\n    non_fluid = [s for s in series_list if s[\"Fluid_Sensitive\"] == 0]\n    selected  = (fluid + non_fluid)[:max_series]\n    return selected\n\n\n# ── Dataset ────────────────────────────────────────────────────────────────────\nclass KneeDataset(Dataset):\n    \"\"\"\n    One item = one study.\n    Returns:\n      - series_tensors: list of (n_slices, 3, H, W) tensors — one per series\n      - meta_list:      list of (plane_onehot, fluid_sensitive, fat_suppression)\n      - labels:         (12,) float tensor — weak labels\n      - study_id:       string (for inference output)\n    \"\"\"\n\n    def __init__(\n        self,\n        df: pd.DataFrame,\n        study_to_series: dict,\n        series_base_dir: str,\n        cfg: dict,\n        is_train: bool = True,\n        label_cols: list = LABEL_COLS,\n    ):\n        self.df               = df.reset_index(drop=True)\n        self.study_to_series  = study_to_series\n        self.series_base_dir  = Path(series_base_dir)\n        self.cfg              = cfg\n        self.is_train         = is_train\n        self.label_cols       = label_cols\n        self.n_slices = (cfg[\"n_slices_train\"] if is_train\n                         else cfg[\"n_slices_val\"])\n\n    def __len__(self):\n        return len(self.df)\n\n    def _augment_slice(self, img: np.ndarray) -> np.ndarray:\n        \"\"\"\n        Simple augmentation on a single slice (H, W) float32.\n        Only applied during training.\n        \"\"\"\n        # Horizontal flip (anatomically valid for knees — left/right symmetric)\n        if np.random.random() < 0.5:\n            img = np.fliplr(img).copy()\n\n        # Slight brightness jitter\n        brightness = np.random.uniform(0.85, 1.15)\n        img = np.clip(img * brightness, 0.0, 1.0)\n\n        return img\n\n    def __getitem__(self, idx):\n        row      = self.df.iloc[idx]\n        study_id = row[\"StudyInstanceUID\"]\n\n        # ── Labels ────────────────────────────────────────────────────────────\n        if self.label_cols[0] in row.index:\n            labels = torch.tensor(\n                row[self.label_cols].fillna(0).values.astype(np.float32)\n            )\n        else:\n            labels = torch.zeros(len(self.label_cols))\n\n        # ── Select series ─────────────────────────────────────────────────────\n        all_series = self.study_to_series.get(study_id, [])\n        selected   = select_series(all_series, self.cfg[\"max_series\"])\n\n        series_tensors, meta_list = [], []\n\n        for s in selected:\n            series_dir = (self.series_base_dir /\n                          study_id / s[\"SeriesInstanceUID\"])\n\n            # Load slices\n            slices_np = load_series_slices(\n                str(series_dir),\n                self.n_slices,\n                self.cfg[\"img_size\"],\n                self.is_train\n            )  # (n_slices, H, W)\n\n            # Augment during training\n            if self.is_train:\n                slices_np = np.stack(\n                    [self._augment_slice(sl) for sl in slices_np], axis=0\n                )\n\n            # Convert grayscale (H,W) → 3-channel (3,H,W) for ImageNet backbone\n            # Stack same channel 3 times — standard practice for grayscale MRI\n            slices_np = np.stack(\n                [slices_np, slices_np, slices_np], axis=1\n            )  # (n_slices, 3, H, W)\n\n            series_tensor = torch.from_numpy(slices_np)  # float32\n            series_tensors.append(series_tensor)\n\n            # ── Metadata ──────────────────────────────────────────────────────\n            plane_idx = PLANE_MAP.get(s[\"Anatomical_Plane\"], 0)\n            plane_oh  = F.one_hot(\n                torch.tensor(plane_idx), num_classes=3\n            ).float()\n            fluid = torch.tensor(float(s[\"Fluid_Sensitive\"]))\n            fat   = torch.tensor(float(s[\"Fat_Suppression\"]))\n            meta_list.append((plane_oh, fluid, fat))\n\n        return series_tensors, meta_list, labels, study_id\n\n\n# ── Collate function ───────────────────────────────────────────────────────────\n# Default PyTorch collation can't handle variable-length lists of tensors.\n# We write a custom collate that keeps studies as a list (not stacked).\ndef knee_collate(batch):\n    \"\"\"\n    batch: list of (series_tensors, meta_list, labels, study_id)\n    Returns: (list_of_study_data, labels_batch, study_ids)\n    where list_of_study_data[i] = (series_tensors_i, meta_list_i)\n    \"\"\"\n    study_data = [(item[0], item[1]) for item in batch]\n    labels     = torch.stack([item[2] for item in batch], dim=0)\n    study_ids  = [item[3] for item in batch]\n    return study_data, labels, study_ids\n\n\n# ── Quick DataLoader smoke test ────────────────────────────────────────────────\nprint(\"Building DataLoader smoke test...\")\nTRAIN_SERIES_DIR = COMP_DIR / \"train_series\"\n\n# Use just 8 studies for the smoke test\nsmoke_df = train_df.head(8).copy()\n\nsmoke_dataset = KneeDataset(\n    df               = smoke_df,\n    study_to_series  = study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n    is_train         = True,\n)\n\nsmoke_loader = DataLoader(\n    smoke_dataset,\n    batch_size  = 2,\n    shuffle     = False,\n    num_workers = 0,      # 0 for smoke test — avoids multiprocessing issues\n    collate_fn  = knee_collate,\n)\n\nprint(\"Loading first batch...\")\nt0 = time.time()\nstudy_data, labels, study_ids = next(iter(smoke_loader))\nelapsed = time.time() - t0\n\nprint(f\"  Batch loaded in {elapsed:.1f}s\")\nprint(f\"  Studies in batch: {len(study_data)}\")\nprint(f\"  Labels shape: {labels.shape}\")          # should be (2, 12)\nprint(f\"  Study IDs: {study_ids}\")\nprint(f\"  Series count in study 0: {len(study_data[0][0])}\")\nprint(f\"  First series tensor shape: {study_data[0][0][0].shape}\")  # (n_slices, 3, 224, 224)\nprint(f\"  Labels study 0: {labels[0].numpy().round(2)}\")\n\n# Estimate full epoch time\nsecs_per_study = elapsed / 2\ntotal_train    = len(train_df)\nepoch_minutes  = (secs_per_study * total_train) / 60\nprint(f\"\\nEstimated time per epoch: {epoch_minutes:.0f} minutes \"\n      f\"({secs_per_study:.1f}s/study × {total_train} studies)\")\nprint(f\"Estimated 8-epoch training: {epoch_minutes*8/60:.1f} hours\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:05:30.838049Z","iopub.execute_input":"2026-08-27T22:05:30.838492Z","iopub.status.idle":"2026-08-27T22:05:31.736546Z","shell.execute_reply.started":"2026-08-27T22:05:30.838464Z","shell.execute_reply":"2026-08-27T22:05:31.73568Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 3b — Runtime Optimization\n\n**Problem:** 1.1s/study × 3,272 studies × 8 epochs = 8.3 hours training alone.\nTest inference (~1,300 studies) adds another ~1.4 hours. Total: ~9.7 hours. Over limit.\n\n**Three fixes, applied together:**\n\n1. **Reduce n_slices_train: 12 → 8**\n   Each series loads 8 slices instead of 12. 33% fewer forward passes through\n   the backbone per study. Quality impact: minimal — the attention pooling\n   learns from whichever slices it sees; fewer slices just means more\n   randomness across epochs (which is actually a mild regularizer).\n\n2. **num_workers: 0 → 2**\n   DataLoader prefetches the next batch while GPU processes the current one.\n   On Kaggle T4 with 2 CPU cores available, num_workers=2 is safe and typically\n   gives 30-40% wall-clock speedup on I/O-bound workloads like DICOM loading.\n\n3. **Reduce n_epochs: 8 → 6**\n   With weak labels and a small gold set for validation, epochs 7-8 rarely\n   improve AUC — they mostly overfit to label noise. 6 epochs is sufficient\n   for a first submission.\n\n**Expected result:** ~62min × (8/12) × 0.65 × (6/8) ≈ 20 minutes per epoch × 6 = ~2 hours training.\nLeaves ~7 hours buffer for inference + overhead. Well within limit.","metadata":{}},{"cell_type":"code","source":"# ── Update config with runtime-safe values ─────────────────────────────────────\nCFG.update({\n    \"n_slices_train\": 8,    # was 12\n    \"n_slices_val\":   12,   # was 16\n    \"n_epochs\":       6,    # was 8\n    \"num_workers\":    2,    # was 0\n})\n\n# ── Rebuild DataLoader with updated config ─────────────────────────────────────\nsmoke_dataset2 = KneeDataset(\n    df               = train_df.head(8).copy(),\n    study_to_series  = study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n    is_train         = True,\n)\n\nsmoke_loader2 = DataLoader(\n    smoke_dataset2,\n    batch_size  = 2,\n    shuffle     = False,\n    num_workers = CFG[\"num_workers\"],\n    collate_fn  = knee_collate,\n)\n\nprint(\"Re-timing with optimized config...\")\nt0 = time.time()\nstudy_data, labels, study_ids = next(iter(smoke_loader2))\nelapsed = time.time() - t0\n\n# First batch is always slower (worker startup) — time a second batch too\nt1 = time.time()\nstudy_data2, labels2, study_ids2 = next(iter(smoke_loader2))\nelapsed2 = time.time() - t1\n\nprint(f\"Batch 1 load time: {elapsed:.1f}s  (includes worker startup)\")\nprint(f\"Batch 2 load time: {elapsed2:.1f}s  (steady state)\")\nprint(f\"First series shape: {study_data[0][0][0].shape}\")  # should be (8, 3, 224, 224)\n\n# Use batch 2 for the estimate (more realistic)\nsecs_per_study   = elapsed2 / 2\nepoch_train_min  = (secs_per_study * len(train_df)) / 60\nepoch_val_min    = (secs_per_study * len(val_df))   / 60\ninfer_min        = (secs_per_study * 1300)           / 60  # ~test set size\ntotal_hours      = (epoch_train_min * CFG[\"n_epochs\"] + \n                    epoch_val_min   * CFG[\"n_epochs\"] + \n                    infer_min) / 60\n\nprint(f\"\\nOptimized runtime estimate:\")\nprint(f\"  Per epoch train:  {epoch_train_min:.0f} min\")\nprint(f\"  Per epoch val:    {epoch_val_min:.0f} min\")\nprint(f\"  Total training:   {epoch_train_min * CFG['n_epochs']:.0f} min  \"\n      f\"({CFG['n_epochs']} epochs)\")\nprint(f\"  Inference:        {infer_min:.0f} min  (~1300 test studies)\")\nprint(f\"  ─────────────────────────────────\")\nprint(f\"  TOTAL ESTIMATE:   {total_hours:.1f} hours\")\n\nif total_hours < 8.0:\n    print(f\"  ✓ Within 9-hour limit (1h buffer remaining)\")\nelif total_hours < 9.0:\n    print(f\"  ⚠ Tight — consider reducing n_epochs to {CFG['n_epochs']-1}\")\nelse:\n    print(f\"  ✗ Still over limit — reduce max_series or n_slices_train further\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:07:25.467259Z","iopub.execute_input":"2026-08-27T22:07:25.467698Z","iopub.status.idle":"2026-08-27T22:07:28.831019Z","shell.execute_reply.started":"2026-08-27T22:07:25.467672Z","shell.execute_reply":"2026-08-27T22:07:28.830219Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Fixed","metadata":{}},{"cell_type":"code","source":"# ── Update config ──────────────────────────────────────────────────────────────\nCFG.update({\n    \"n_slices_train\": 6,\n    \"n_slices_val\":   10,\n    \"max_series\":     4,\n    \"use_amp\":        True,   # mixed precision — free ~30-40% GPU speedup\n})\n\n# ── Re-time ────────────────────────────────────────────────────────────────────\nsmoke_dataset3 = KneeDataset(\n    df               = train_df.head(8).copy(),\n    study_to_series  = study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n    is_train         = True,\n)\n\nsmoke_loader3 = DataLoader(\n    smoke_dataset3,\n    batch_size  = 2,\n    shuffle     = False,\n    num_workers = CFG[\"num_workers\"],\n    collate_fn  = knee_collate,\n)\n\n# Discard first batch (worker startup)\n_ = next(iter(smoke_loader3))\n\n# Time second batch\nt1 = time.time()\nstudy_data3, labels3, _ = next(iter(smoke_loader3))\nelapsed3 = time.time() - t1\n\nsecs_per_study  = elapsed3 / 2\nepoch_train_min = (secs_per_study * len(train_df)) / 60\nepoch_val_min   = (secs_per_study * len(val_df))   / 60\ninfer_min       = (secs_per_study * 1300)           / 60\n\n# AMP reduces GPU compute time ~35% — I/O stays same\n# Realistic combined speedup on mixed I/O+compute workload: ~20%\namp_factor      = 0.80\ntotal_hours     = (\n    (epoch_train_min * CFG[\"n_epochs\"] +\n     epoch_val_min   * CFG[\"n_epochs\"] +\n     infer_min) * amp_factor\n) / 60\n\nprint(f\"Steady-state batch time: {elapsed3:.1f}s ({secs_per_study:.2f}s/study)\")\nprint(f\"Series shape check: {study_data3[0][0][0].shape}\")  # expect (6,3,224,224)\nprint(f\"\\nRuntime estimate (with AMP):\")\nprint(f\"  Per epoch train:  {epoch_train_min * amp_factor:.0f} min\")\nprint(f\"  Per epoch val:    {epoch_val_min   * amp_factor:.0f} min\")\nprint(f\"  Inference:        {infer_min       * amp_factor:.0f} min\")\nprint(f\"  TOTAL:            {total_hours:.1f} hours\")\n\nif total_hours < 7.5:\n    print(f\"  ✓ Safe — good headroom\")\nelif total_hours < 9.0:\n    print(f\"  ✓ Within limit\")\nelse:\n    print(f\"  ✗ Still over — will reduce n_epochs to 5\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:07:31.519959Z","iopub.execute_input":"2026-08-27T22:07:31.520285Z","iopub.status.idle":"2026-08-27T22:07:33.450533Z","shell.execute_reply.started":"2026-08-27T22:07:31.520253Z","shell.execute_reply":"2026-08-27T22:07:33.44976Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 4 — Model Architecture","metadata":{}},{"cell_type":"code","source":"'''\n**The three-level hierarchy:**\n    \nStudy Input\n│\n├── Series 1 (Sagittal, fluid-sensitive)\n│   ├── Slice 1 ──> [256-dim vector] ┐\n│   ├── Slice 2 ──> [256-dim vector] ┼── Attention Pooling ──> [256-dim] + Metadata Offset\n│   └── Slice 6 ──> [256-dim vector] ┘\n│\n├── Series 2 (Sagittal, T1)          ─────────> [256-dim] + Metadata Offset\n├── Series 3 (Coronal, fluid-sens)   ─────────> [256-dim] + Metadata Offset\n└── Series 4 (Axial, fluid-sens)     ─────────> [256-dim] + Metadata Offset\n│\n▼\nStudy Attention Aggregation\n│\n▼\n[256-dim Vector]\n│\n▼\nLinear Layer ──> [12 Logits] ──> Sigmoid ──> 12 Probabilities\n'''","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T14:49:19.643071Z","iopub.execute_input":"2026-08-27T14:49:19.643528Z","iopub.status.idle":"2026-08-27T14:49:19.652635Z","shell.execute_reply.started":"2026-08-27T14:49:19.643489Z","shell.execute_reply":"2026-08-27T14:49:19.651665Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"\n**Key design choices:**\n- EfficientNet-B0: 5M parameters, fast, strong ImageNet pretraining\n- Attention pooling (not max/mean): model learns which slices matter per label\n- Metadata conditioning: plane + fluid_sensitive + fat_suppression added to\n  each series embedding — lets model weight sagittal fluid-sensitive series\n  more heavily for meniscus labels without us hardcoding that rule\n- Shared backbone across all slices and series: weight sharing forces\n  generalization, reduces parameters, fits in 16GB VRAM","metadata":{}},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport timm\n\nclass SliceEncoder(nn.Module):\n    \"\"\"2D CNN backbone — applied independently to each slice.\"\"\"\n    def __init__(self, backbone_name=\"efficientnet_b0\", pretrained=True, out_dim=256):\n        super().__init__()\n        self.backbone = timm.create_model(\n            backbone_name,\n            pretrained=pretrained,\n            num_classes=0,\n            global_pool=\"avg\"\n        )\n        backbone_dim = self.backbone.num_features\n        self.proj = nn.Sequential(\n            nn.Linear(backbone_dim, out_dim),\n            nn.LayerNorm(out_dim),\n            nn.GELU()\n        )\n\n    def forward(self, x):\n        # x: (N_slices, 3, H, W)\n        return self.proj(self.backbone(x))  # (N_slices, out_dim)\n\n\nclass SliceAttentionPool(nn.Module):\n    \"\"\"Attention pooling across slices — learns which slices show pathology.\"\"\"\n    def __init__(self, dim=256):\n        super().__init__()\n        self.attn = nn.Sequential(\n            nn.Linear(dim, 64),\n            nn.Tanh(),\n            nn.Linear(64, 1)\n        )\n\n    def forward(self, slice_feats):\n        # slice_feats: (N_slices, dim)\n        scores  = self.attn(slice_feats)          # (N_slices, 1)\n        weights = F.softmax(scores, dim=0)         # (N_slices, 1)\n        return (weights * slice_feats).sum(0)      # (dim,)\n\n\nclass SeriesMetaEncoder(nn.Module):\n    \"\"\"\n    Encodes per-series metadata into the same feature space as slice embeddings.\n    Output is ADDED to the series embedding — shifts the representation\n    based on what kind of sequence this series is.\n    \"\"\"\n    def __init__(self, dim=256):\n        super().__init__()\n        # Input: 3 (plane one-hot) + 1 (fluid_sensitive) + 1 (fat_suppression) = 5\n        self.net = nn.Sequential(\n            nn.Linear(5, 32),\n            nn.ReLU(),\n            nn.Linear(32, dim)\n        )\n\n    def forward(self, plane_onehot, fluid_sensitive, fat_suppression):\n        meta = torch.cat([\n            plane_onehot,\n            fluid_sensitive.unsqueeze(-1).float(),\n            fat_suppression.unsqueeze(-1).float()\n        ], dim=-1)          # (5,)\n        return self.net(meta)   # (dim,)\n\n\nclass StudyAggregator(nn.Module):\n    \"\"\"Attention pooling across series — learns which series matter most.\"\"\"\n    def __init__(self, dim=256):\n        super().__init__()\n        self.attn = nn.Sequential(\n            nn.Linear(dim, 64),\n            nn.Tanh(),\n            nn.Linear(64, 1)\n        )\n\n    def forward(self, series_feats):\n        # series_feats: (N_series, dim)\n        scores  = self.attn(series_feats)\n        weights = F.softmax(scores, dim=0)\n        return (weights * series_feats).sum(0)   # (dim,)\n\n\nclass KneeAbnormalityModel(nn.Module):\n    \"\"\"\n    Full 2.5D model.\n    At inference: takes a list of series tensors + metadata → 12 probabilities.\n    Text/reports are NOT used here — text was only used to generate weak labels.\n    \"\"\"\n    def __init__(self,\n                 backbone_name=\"efficientnet_b0\",\n                 pretrained=True,\n                 feat_dim=256,\n                 n_labels=12,\n                 dropout=0.3):\n        super().__init__()\n        self.slice_encoder = SliceEncoder(backbone_name, pretrained, feat_dim)\n        self.slice_pool    = SliceAttentionPool(feat_dim)\n        self.meta_encoder  = SeriesMetaEncoder(feat_dim)\n        self.study_agg     = StudyAggregator(feat_dim)\n        self.dropout       = nn.Dropout(dropout)\n        self.classifier    = nn.Linear(feat_dim, n_labels)\n\n    def encode_series(self, slices, plane_oh, fluid, fat):\n        \"\"\"slices: (N_slices, 3, H, W) → (feat_dim,)\"\"\"\n        slice_feats = self.slice_encoder(slices)      # (N_slices, feat_dim)\n        series_feat = self.slice_pool(slice_feats)    # (feat_dim,)\n        meta_feat   = self.meta_encoder(plane_oh, fluid, fat)\n        return series_feat + meta_feat                # (feat_dim,)\n\n    def forward(self, series_list, meta_list):\n        \"\"\"\n        series_list: list of (N_slices, 3, H, W) tensors\n        meta_list:   list of (plane_oh, fluid, fat) tuples\n        → (n_labels,) logits\n        \"\"\"\n        series_feats = torch.stack([\n            self.encode_series(slices.to(DEVICE), plane_oh.to(DEVICE),\n                               fluid.to(DEVICE), fat.to(DEVICE))\n            for slices, (plane_oh, fluid, fat) in zip(series_list, meta_list)\n        ], dim=0)                                     # (N_series, feat_dim)\n\n        study_feat = self.study_agg(series_feats)     # (feat_dim,)\n        study_feat = self.dropout(study_feat)\n        return self.classifier(study_feat)            # (n_labels,) logits\n\n\n# ── Instantiate and verify ─────────────────────────────────────────────────────\nmodel = KneeAbnormalityModel(\n    backbone_name = CFG[\"backbone\"],\n    pretrained    = True,\n    feat_dim      = CFG[\"feat_dim\"],\n    n_labels      = N_LABELS,\n    dropout       = CFG[\"dropout\"],\n).to(DEVICE)\n\n# Parameter count\ntotal_params     = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f\"Total parameters:     {total_params:,}\")\nprint(f\"Trainable parameters: {trainable_params:,}\")\n\n# Quick forward pass check\nwith torch.no_grad():\n    test_series = [torch.randn(6, 3, 224, 224).to(DEVICE) for _ in range(4)]\n    test_meta   = [\n        (F.one_hot(torch.tensor(i % 3), 3).float().to(DEVICE),\n         torch.tensor(float(i % 2)).to(DEVICE),\n         torch.tensor(float(i % 2)).to(DEVICE))\n        for i in range(4)\n    ]\n    test_logits = model(test_series, test_meta)\n\nprint(f\"Forward pass output:  {test_logits.shape}\")   # expect torch.Size([12])\nprint(f\"Sample probs: {torch.sigmoid(test_logits).cpu().numpy().round(3)}\")\nprint(\"✓ Model ready\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:09:04.629956Z","iopub.execute_input":"2026-08-27T22:09:04.630592Z","iopub.status.idle":"2026-08-27T22:09:10.007083Z","shell.execute_reply.started":"2026-08-27T22:09:04.630563Z","shell.execute_reply":"2026-08-27T22:09:10.006018Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 5 — Loss Function\n\n**Why BCEWithLogitsLoss and not CrossEntropy?**\nThis is a multi-label problem — each study can have multiple findings simultaneously\n(e.g., ACL tear + Effusion + Synovitis all = 1 for the same study).\nCrossEntropy assumes exactly one class is true. BCE treats each label independently\nas a binary yes/no question, which is correct here.\n\n**Why pos_weight?**\nOur weak labels severely under-detect positives (ACL: 5.8% predicted vs ~41% true).\nWithout weighting, BCE loss is dominated by true negatives — the model learns\n\"predict zero for everything\" which minimizes loss but gives AUC ≈ 0.5.\n\npos_weight tells BCE: \"a false negative on label X is pos_weight[X] times more\ncostly than a false positive.\" This forces the model to take positives seriously\neven when they're rare in the training labels.\n\n**Critical: compute pos_weight on train_df only.**\nUsing weak_df (full dataset including validation fold) would leak validation\nlabel statistics into the loss function — a subtle but real form of data leakage.","metadata":{}},{"cell_type":"code","source":"# ── Compute pos_weight from TRAINING FOLD ONLY ────────────────────────────────\n# This is the data-leakage-safe version — val fold stats never touch this\n\ntrain_labels = train_df[LABEL_COLS].fillna(0).values.astype(np.float32)\n\npos_counts = train_labels.sum(axis=0)          # positives per label\nneg_counts = len(train_df) - pos_counts        # negatives per label\n\n# Raw imbalance ratio\nraw_pos_weight = neg_counts / np.maximum(pos_counts, 1)\n\n# Gold prevalence prior — we know true prevalence from the 58 gold studies\n# Use this to correct for our labeler's under-detection\nGOLD_PREVALENCE = np.array([\n    0.414,   # ACL\n    0.155,   # MCL\n    0.448,   # Medial Meniscus\n    0.397,   # Lateral Meniscus\n    0.259,   # Medial OA\n    0.190,   # Lateral OA\n    0.362,   # PF OA\n    0.603,   # Effusion\n    0.466,   # Synovitis\n    0.207,   # Baker's\n    0.328,   # Contusion\n    0.310,   # Fracture\n])\n\nweak_prevalence = pos_counts / len(train_df)\n\n# Recall estimate: how much of the true signal did we capture?\nrecall_est = np.clip(weak_prevalence / GOLD_PREVALENCE, 0.05, 1.0)\n\n# Corrected weight: scale down raw weight by recall\n# Intuition: if we only captured 14% of true ACL positives,\n# many \"negatives\" are actually false negatives from our labeler —\n# so the true imbalance is less severe than raw_pos_weight suggests\ncorrected = raw_pos_weight * recall_est\n\n# Clip to [1, 15] — prevent extreme weights from destabilizing training\npos_weight = np.clip(corrected, 1.0, 15.0)\npos_weight_tensor = torch.tensor(pos_weight, dtype=torch.float32).to(DEVICE)\n\nprint(\"Loss weights per label (higher = model penalized more for missing positives):\")\nprint(f\"{'Label':20s} {'weak_prev':>10} {'recall_est':>10} {'pos_weight':>10}\")\nprint(\"-\" * 55)\nfor i, lbl in enumerate(LABEL_COLS):\n    print(f\"{lbl:20s} {weak_prevalence[i]:>10.3f} \"\n          f\"{recall_est[i]:>10.3f} {pos_weight[i]:>10.2f}\")\n\n# ── Loss function ──────────────────────────────────────────────────────────────\ncriterion = nn.BCEWithLogitsLoss(\n    pos_weight=pos_weight_tensor,\n    reduction=\"mean\"\n)\n\n# ── Optimizer ─────────────────────────────────────────────────────────────────\n# AdamW with weight decay — standard for fine-tuning pretrained backbones\n# Two parameter groups: backbone gets lower LR (already pretrained),\n# new heads get full LR (randomly initialized, need to learn fast)\nbackbone_params = list(model.slice_encoder.backbone.parameters())\nhead_params     = (\n    list(model.slice_encoder.proj.parameters()) +\n    list(model.slice_pool.parameters()) +\n    list(model.meta_encoder.parameters()) +\n    list(model.study_agg.parameters()) +\n    list(model.classifier.parameters())\n)\n\noptimizer = torch.optim.AdamW([\n    {\"params\": backbone_params, \"lr\": CFG[\"lr\"] * 0.1},  # 3e-5 for backbone\n    {\"params\": head_params,     \"lr\": CFG[\"lr\"]},         # 3e-4 for heads\n], weight_decay=1e-2)\n\n# ── LR Scheduler: cosine decay with linear warmup ─────────────────────────────\n# Warmup: LR ramps up for first epoch (prevents large gradient steps\n# when backbone weights are first exposed to medical MRI — far from ImageNet)\n# Cosine decay: smoothly reduces LR over remaining epochs\ntotal_steps   = CFG[\"n_epochs\"] * (len(train_df) // CFG[\"batch_size\"] + 1)\nwarmup_steps  = 1 * (len(train_df) // CFG[\"batch_size\"] + 1)\n\ndef lr_lambda(current_step):\n    if current_step < warmup_steps:\n        return current_step / max(1, warmup_steps)\n    progress = (current_step - warmup_steps) / max(1, total_steps - warmup_steps)\n    return 0.5 * (1.0 + np.cos(np.pi * progress))\n\nscheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n# ── AMP scaler ────────────────────────────────────────────────────────────────\nscaler = torch.cuda.amp.GradScaler(enabled=CFG[\"use_amp\"])\n\nprint(f\"\\nOptimizer: AdamW\")\nprint(f\"  Backbone LR: {CFG['lr'] * 0.1:.1e}\")\nprint(f\"  Head LR:     {CFG['lr']:.1e}\")\nprint(f\"  Weight decay: 1e-2\")\nprint(f\"Scheduler: cosine with {warmup_steps} warmup steps\")\nprint(f\"AMP: {'enabled' if CFG['use_amp'] else 'disabled'}\")\nprint(f\"Total training steps: {total_steps}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:10:26.992719Z","iopub.execute_input":"2026-08-27T22:10:26.993191Z","iopub.status.idle":"2026-08-27T22:10:27.013171Z","shell.execute_reply.started":"2026-08-27T22:10:26.993163Z","shell.execute_reply":"2026-08-27T22:10:27.012485Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 6 — Training Loop\n\n**What happens each epoch:**\n1. Train loop: forward pass → loss → backward → optimizer step → scheduler step\n2. Val loop: forward pass only (no gradients) → collect predictions and true labels\n3. Compute macro-AUC across all 12 labels on validation fold\n4. Save checkpoint if val AUC improved (best model only — saves disk space)\n\n**AMP (Automatic Mixed Precision):**\nUses float16 for most operations, float32 only where numerical precision matters.\nThe GradScaler handles the float16 → float32 gradient scaling automatically.\nNet effect: ~30-40% faster on T4, same model quality.\n\n**Why we evaluate on weak labels (and what that means):**\nOur val AUC is measured against noisy weak labels, not gold truth.\nThis means: use it as a RELATIVE signal (is epoch 4 better than epoch 3?)\nnot an ABSOLUTE one (0.72 AUC does not mean 72% diagnostic accuracy).\nThe 58 gold studies are our only absolute reference — we'll check those\nseparately after training.\n\n**Gradient clipping:**\nClips gradient norm to 1.0 before each optimizer step.\nPrevents exploding gradients when the loss spikes on hard examples\n(common with noisy labels in early epochs).","metadata":{}},{"cell_type":"code","source":"from torch.utils.data import DataLoader\nimport time, os\n\n# ── Build real DataLoaders ─────────────────────────────────────────────────────\ntrain_dataset = KneeDataset(\n    df               = train_df,\n    study_to_series  = study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n    is_train         = True,\n)\n\nval_dataset = KneeDataset(\n    df               = val_df,\n    study_to_series  = study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n    is_train         = False,   # no augmentation, center slices\n)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size  = CFG[\"batch_size\"],\n    shuffle     = True,\n    num_workers = CFG[\"num_workers\"],\n    collate_fn  = knee_collate,\n    pin_memory  = True,\n)\n\nval_loader = DataLoader(\n    val_dataset,\n    batch_size  = CFG[\"batch_size\"],\n    shuffle     = False,\n    num_workers = CFG[\"num_workers\"],\n    collate_fn  = knee_collate,\n    pin_memory  = True,\n)\n\nprint(f\"Train batches: {len(train_loader)}\")\nprint(f\"Val batches:   {len(val_loader)}\")\n\n\n# ── Evaluation function ────────────────────────────────────────────────────────\ndef evaluate(model, loader, criterion, device):\n    \"\"\"\n    Run one full pass over loader without gradients.\n    Returns: avg_loss, macro_auc, per_label_auc dict\n    \"\"\"\n    model.eval()\n    all_preds  = []   # (N_studies, 12)\n    all_labels = []   # (N_studies, 12)\n    total_loss = 0.0\n    n_batches  = 0\n\n    with torch.no_grad():\n        for study_data, labels, study_ids in loader:\n            labels = labels.to(device)\n            batch_logits = []\n\n            for series_list, meta_list in study_data:\n                # Forward pass for one study\n                with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                    logits = model(series_list, meta_list)  # (12,)\n                batch_logits.append(logits)\n\n            batch_logits = torch.stack(batch_logits, dim=0)  # (B, 12)\n\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                loss = criterion(batch_logits, labels)\n\n            total_loss += loss.item()\n            n_batches  += 1\n\n            probs = torch.sigmoid(batch_logits).cpu().numpy()\n            all_preds.append(probs)\n            all_labels.append(labels.cpu().numpy())\n\n    all_preds  = np.concatenate(all_preds,  axis=0)  # (N, 12)\n    all_labels = np.concatenate(all_labels, axis=0)  # (N, 12)\n\n    # Per-label AUC — skip labels with no positives in val fold\n    per_label_auc = {}\n    valid_aucs    = []\n    for i, lbl in enumerate(LABEL_COLS):\n        y_true = all_labels[:, i]\n        y_pred = all_preds[:, i]\n        if y_true.sum() > 0 and (1 - y_true).sum() > 0:\n            auc = roc_auc_score(y_true, y_pred)\n            per_label_auc[lbl] = auc\n            valid_aucs.append(auc)\n        else:\n            per_label_auc[lbl] = float(\"nan\")\n\n    macro_auc = np.nanmean(valid_aucs)\n    avg_loss  = total_loss / max(n_batches, 1)\n\n    return avg_loss, macro_auc, per_label_auc\n\n\n# ── Training loop ──────────────────────────────────────────────────────────────\nbest_auc       = 0.0\nbest_epoch     = 0\ncheckpoint_path = WORK_DIR / \"best_model.pth\"\n\nprint(f\"\\nStarting training — {CFG['n_epochs']} epochs\")\nprint(f\"Train: {len(train_df)} studies | Val: {len(val_df)} studies\")\nprint(\"=\" * 70)\n\nepoch_log = []   # for plotting later\n\nfor epoch in range(CFG[\"n_epochs\"]):\n    # ── Train ──────────────────────────────────────────────────────────────────\n    model.train()\n    train_loss  = 0.0\n    n_batches   = 0\n    t_epoch     = time.time()\n\n    for batch_idx, (study_data, labels, study_ids) in enumerate(train_loader):\n        labels = labels.to(DEVICE)\n        batch_logits = []\n\n        for series_list, meta_list in study_data:\n            # Forward pass: one study at a time through the model\n            # (studies have variable series counts — can't stack across studies)\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)  # (12,)\n            batch_logits.append(logits)\n\n        batch_logits = torch.stack(batch_logits, dim=0)  # (B, 12)\n\n        # Loss\n        with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n            loss = criterion(batch_logits, labels)\n\n        # Backward\n        optimizer.zero_grad()\n        scaler.scale(loss).backward()\n        scaler.unscale_(optimizer)\n        torch.nn.utils.clip_grad_norm_(model.parameters(), CFG[\"grad_clip\"])\n        scaler.step(optimizer)\n        scaler.update()\n        scheduler.step()\n\n        train_loss += loss.item()\n        n_batches  += 1\n\n        # Progress print every 100 batches\n        if (batch_idx + 1) % 100 == 0:\n            elapsed = time.time() - t_epoch\n            print(f\"  Epoch {epoch+1} | Batch {batch_idx+1}/{len(train_loader)} \"\n                  f\"| Loss: {train_loss/n_batches:.4f} \"\n                  f\"| {elapsed:.0f}s elapsed\")\n\n    avg_train_loss = train_loss / max(n_batches, 1)\n\n    # ── Validate ───────────────────────────────────────────────────────────────\n    val_loss, val_auc, per_label_auc = evaluate(\n        model, val_loader, criterion, DEVICE\n    )\n\n    epoch_time = time.time() - t_epoch\n\n    # ── Log ────────────────────────────────────────────────────────────────────\n    epoch_log.append({\n        \"epoch\":      epoch + 1,\n        \"train_loss\": avg_train_loss,\n        \"val_loss\":   val_loss,\n        \"val_auc\":    val_auc,\n        \"epoch_time\": epoch_time,\n    })\n\n    print(f\"\\nEpoch {epoch+1}/{CFG['n_epochs']} — {epoch_time/60:.1f} min\")\n    print(f\"  Train loss: {avg_train_loss:.4f}\")\n    print(f\"  Val   loss: {val_loss:.4f}\")\n    print(f\"  Val   AUC:  {val_auc:.4f}  (macro, weak labels)\")\n    print(f\"  Per-label AUC:\")\n    for lbl, auc in per_label_auc.items():\n        marker = \" ←\" if not np.isnan(auc) and auc < 0.55 else \"\"\n        print(f\"    {lbl:20s}: {auc:.4f}{marker}\")\n\n    # ── Checkpoint ─────────────────────────────────────────────────────────────\n    if val_auc > best_auc:\n        best_auc   = val_auc\n        best_epoch = epoch + 1\n        torch.save({\n            \"epoch\":      epoch + 1,\n            \"model_state\": model.state_dict(),\n            \"val_auc\":    val_auc,\n            \"cfg\":        CFG,\n        }, checkpoint_path)\n        print(f\"  ✓ New best — checkpoint saved (AUC: {val_auc:.4f})\")\n\n    print(\"=\" * 70)\n\nprint(f\"\\nTraining complete.\")\nprint(f\"Best val AUC: {best_auc:.4f} at epoch {best_epoch}\")\nprint(f\"Checkpoint: {checkpoint_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:10:30.25107Z","iopub.execute_input":"2026-08-27T22:10:30.251333Z","iopub.status.idle":"2026-08-27T22:10:36.314577Z","shell.execute_reply.started":"2026-08-27T22:10:30.251311Z","shell.execute_reply":"2026-08-27T22:10:36.313268Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── CELL: Platt Scaling Calibration ───────────────────────────────────────────\n# Uses the 58 gold-labeled studies to learn a per-label temperature scaling\n# that maps compressed predictions [0.02-0.45] → calibrated [0.1-0.8]\n# No retraining needed — pure post-processing\n\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.isotonic import IsotonicRegression\nimport numpy as np\n\nBASE   = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\ndf_all = pd.read_csv(f\"{BASE}/train.csv\")\n\nlabel_cols = LABEL_COLS\n\n# ── Get the 58 gold-labeled studies ───────────────────────────────────────────\ngold_mask = df_all[label_cols].notnull().all(axis=1)\ngold_df   = df_all[gold_mask].reset_index(drop=True)\nprint(f\"Gold studies for calibration: {len(gold_df)}\")\n\n# ── Run inference on gold studies to get raw model predictions ─────────────────\ngold_series = pd.read_csv(f\"{BASE}/train_series.csv\")\ngold_study_to_series = (\n    gold_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\ngold_dataset = KneeTestDataset(\n    df               = gold_df[[\"StudyInstanceUID\"]],\n    study_to_series  = gold_study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n)\n\ngold_loader = DataLoader(\n    gold_dataset,\n    batch_size  = 4,\n    shuffle     = False,\n    num_workers = 2,\n    collate_fn  = test_collate,\n)\n\nmodel.eval()\ngold_preds_raw = []\ngold_ids       = []\n\nwith torch.no_grad():\n    for study_data, study_ids in gold_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        gold_preds_raw.append(probs)\n        gold_ids.extend(study_ids)\n\ngold_preds_raw = np.concatenate(gold_preds_raw, axis=0)  # (58, 12)\ngold_df_indexed = gold_df.set_index(\"StudyInstanceUID\")\n\nprint(f\"Raw predictions on gold set: {gold_preds_raw.shape}\")\n\n# ── Fit per-label isotonic calibrator ─────────────────────────────────────────\n# Isotonic regression: monotonic mapping from raw scores → calibrated scores\n# Better than Platt scaling for small datasets with non-normal distributions\n# Requires at least 1 positive and 1 negative per label\n\ncalibrators = {}\nprint(\"\\nFitting calibrators:\")\nprint(f\"{'Label':20s} {'n_pos':>6} {'n_neg':>6} {'status':>12}\")\nprint(\"-\" * 48)\n\nfor i, lbl in enumerate(label_cols):\n    y_true = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    y_raw  = gold_preds_raw[:, i]\n    \n    n_pos = int(y_true.sum())\n    n_neg = int((1-y_true).sum())\n    \n    if n_pos >= 2 and n_neg >= 2:\n        # Fit isotonic regression calibrator\n        cal = IsotonicRegression(out_of_bounds=\"clip\")\n        cal.fit(y_raw, y_true)\n        calibrators[lbl] = cal\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'fitted':>12}\")\n    else:\n        # Not enough data — use prior-based scaling instead\n        # Scale predictions so their mean matches gold prevalence\n        gold_prev = GOLD_PREVALENCE[i]\n        raw_mean  = y_raw.mean()\n        scale     = gold_prev / max(raw_mean, 1e-6)\n        calibrators[lbl] = (\"scale\", scale)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} \"\n              f\"{'prior_scale':>12} (×{scale:.1f})\")\n\n\ndef calibrate_predictions(raw_preds: np.ndarray) -> np.ndarray:\n    \"\"\"\n    Apply per-label calibration to raw sigmoid outputs.\n    raw_preds: (N, 12) array\n    Returns: (N, 12) calibrated array, values in [0,1]\n    \"\"\"\n    calibrated = np.zeros_like(raw_preds)\n    for i, lbl in enumerate(label_cols):\n        cal = calibrators[lbl]\n        if isinstance(cal, tuple):\n            # Prior scaling fallback\n            _, scale = cal\n            calibrated[:, i] = np.clip(raw_preds[:, i] * scale, 0, 1)\n        else:\n            # Isotonic regression\n            calibrated[:, i] = cal.predict(raw_preds[:, i])\n    return calibrated\n\n# ── Validate calibration on gold set ──────────────────────────────────────────\ngold_preds_cal = calibrate_predictions(gold_preds_raw)\n\nprint(\"\\nCalibration effect on gold set:\")\nprint(f\"{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(label_cols):\n    y_true   = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    raw_mean = gold_preds_raw[:, i].mean()\n    cal_mean = gold_preds_cal[:, i].mean()\n    gold_p   = GOLD_PREVALENCE[i]\n    print(f\"{lbl:20s} {raw_mean:>10.3f} {cal_mean:>10.3f} {gold_p:>10.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T17:38:06.441842Z","iopub.execute_input":"2026-08-27T17:38:06.442872Z","iopub.status.idle":"2026-08-27T17:38:23.483422Z","shell.execute_reply.started":"2026-08-27T17:38:06.44283Z","shell.execute_reply":"2026-08-27T17:38:23.482358Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── CELL: Calibrated Inference ─────────────────────────────────────────────────\nmodel.eval()\nall_preds_raw = []\nall_ids       = []\n\nwith torch.no_grad():\n    for study_data, study_ids in test_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        all_preds_raw.append(probs)\n        all_ids.extend(study_ids)\n\nall_preds_raw = np.concatenate(all_preds_raw, axis=0)\n\n# Apply calibration\nall_preds_cal = calibrate_predictions(all_preds_raw)\n\nprint(\"Raw vs calibrated predictions:\")\nprint(f\"{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(label_cols):\n    print(f\"{lbl:20s} \"\n          f\"{all_preds_raw[:,i].mean():>10.3f} \"\n          f\"{all_preds_cal[:,i].mean():>10.3f} \"\n          f\"{GOLD_PREVALENCE[i]:>10.3f}\")\n\n# Build calibrated submission\nsubmission_cal = pd.DataFrame(all_preds_cal, columns=label_cols)\nsubmission_cal.insert(0, \"StudyInstanceUID\", all_ids)\nsubmission_cal.to_csv(WORK_DIR / \"submission_calibrated.csv\", index=False)\nprint(f\"\\n✓ Calibrated submission saved\")\nprint(submission_cal.to_string())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T17:39:20.019294Z","iopub.execute_input":"2026-08-27T17:39:20.020069Z","iopub.status.idle":"2026-08-27T17:39:21.857382Z","shell.execute_reply.started":"2026-08-27T17:39:20.019993Z","shell.execute_reply":"2026-08-27T17:39:21.856325Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── CELL: Platt Scaling Calibration ───────────────────────────────────────────\n# Uses the 58 gold-labeled studies to learn a per-label temperature scaling\n# that maps compressed predictions [0.02-0.45] → calibrated [0.1-0.8]\n# No retraining needed — pure post-processing\n\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.isotonic import IsotonicRegression\nimport numpy as np\n\nBASE   = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\ndf_all = pd.read_csv(f\"{BASE}/train.csv\")\n\nlabel_cols = LABEL_COLS\n\n# ── Get the 58 gold-labeled studies ───────────────────────────────────────────\ngold_mask = df_all[label_cols].notnull().all(axis=1)\ngold_df   = df_all[gold_mask].reset_index(drop=True)\nprint(f\"Gold studies for calibration: {len(gold_df)}\")\n\n# ── Run inference on gold studies to get raw model predictions ─────────────────\ngold_series = pd.read_csv(f\"{BASE}/train_series.csv\")\ngold_study_to_series = (\n    gold_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\ngold_dataset = KneeTestDataset(\n    df               = gold_df[[\"StudyInstanceUID\"]],\n    study_to_series  = gold_study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n)\n\ngold_loader = DataLoader(\n    gold_dataset,\n    batch_size  = 4,\n    shuffle     = False,\n    num_workers = 2,\n    collate_fn  = test_collate,\n)\n\nmodel.eval()\ngold_preds_raw = []\ngold_ids       = []\n\nwith torch.no_grad():\n    for study_data, study_ids in gold_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        gold_preds_raw.append(probs)\n        gold_ids.extend(study_ids)\n\ngold_preds_raw = np.concatenate(gold_preds_raw, axis=0)  # (58, 12)\ngold_df_indexed = gold_df.set_index(\"StudyInstanceUID\")\n\nprint(f\"Raw predictions on gold set: {gold_preds_raw.shape}\")\n\n# ── Fit per-label isotonic calibrator ─────────────────────────────────────────\n# Isotonic regression: monotonic mapping from raw scores → calibrated scores\n# Better than Platt scaling for small datasets with non-normal distributions\n# Requires at least 1 positive and 1 negative per label\n\ncalibrators = {}\nprint(\"\\nFitting calibrators:\")\nprint(f\"{'Label':20s} {'n_pos':>6} {'n_neg':>6} {'status':>12}\")\nprint(\"-\" * 48)\n\nfor i, lbl in enumerate(label_cols):\n    y_true = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    y_raw  = gold_preds_raw[:, i]\n    \n    n_pos = int(y_true.sum())\n    n_neg = int((1-y_true).sum())\n    \n    if n_pos >= 2 and n_neg >= 2:\n        # Fit isotonic regression calibrator\n        cal = IsotonicRegression(out_of_bounds=\"clip\")\n        cal.fit(y_raw, y_true)\n        calibrators[lbl] = cal\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'fitted':>12}\")\n    else:\n        # Not enough data — use prior-based scaling instead\n        # Scale predictions so their mean matches gold prevalence\n        gold_prev = GOLD_PREVALENCE[i]\n        raw_mean  = y_raw.mean()\n        scale     = gold_prev / max(raw_mean, 1e-6)\n        calibrators[lbl] = (\"scale\", scale)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} \"\n              f\"{'prior_scale':>12} (×{scale:.1f})\")\n\n\ndef calibrate_predictions(raw_preds: np.ndarray) -> np.ndarray:\n    \"\"\"\n    Apply per-label calibration to raw sigmoid outputs.\n    raw_preds: (N, 12) array\n    Returns: (N, 12) calibrated array, values in [0,1]\n    \"\"\"\n    calibrated = np.zeros_like(raw_preds)\n    for i, lbl in enumerate(label_cols):\n        cal = calibrators[lbl]\n        if isinstance(cal, tuple):\n            # Prior scaling fallback\n            _, scale = cal\n            calibrated[:, i] = np.clip(raw_preds[:, i] * scale, 0, 1)\n        else:\n            # Isotonic regression\n            calibrated[:, i] = cal.predict(raw_preds[:, i])\n    return calibrated\n\n# ── Validate calibration on gold set ──────────────────────────────────────────\ngold_preds_cal = calibrate_predictions(gold_preds_raw)\n\nprint(\"\\nCalibration effect on gold set:\")\nprint(f\"{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(label_cols):\n    y_true   = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    raw_mean = gold_preds_raw[:, i].mean()\n    cal_mean = gold_preds_cal[:, i].mean()\n    gold_p   = GOLD_PREVALENCE[i]\n    print(f\"{lbl:20s} {raw_mean:>10.3f} {cal_mean:>10.3f} {gold_p:>10.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T17:32:27.264659Z","iopub.execute_input":"2026-08-27T17:32:27.265693Z","iopub.status.idle":"2026-08-27T17:32:47.790954Z","shell.execute_reply.started":"2026-08-27T17:32:27.265654Z","shell.execute_reply":"2026-08-27T17:32:47.790113Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Check-point","metadata":{}},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport timm\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom torch.utils.data import Dataset, DataLoader\nimport pydicom\nimport cv2\nimport warnings\nwarnings.filterwarnings(\"ignore\")\n\n# ── Paths ──────────────────────────────────────────────────────────────────────\nCOMP_DIR  = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\nWORK_DIR  = Path(\"/kaggle/working\")\nDEVICE    = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nLABEL_COLS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\",\n    \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"\n]\nPLANE_MAP = {\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}\n\nCFG = {\n    \"img_size\":       224,\n    \"n_slices_val\":   10,\n    \"max_series\":     4,\n    \"backbone\":       \"efficientnet_b0\",\n    \"feat_dim\":       256,\n    \"dropout\":        0.3,\n    \"use_amp\":        True,\n    \"num_workers\":    2,\n}\n\ncheckpoint_path = WORK_DIR / \"best_model.pth\"\n\n# ── Verify checkpoint exists ───────────────────────────────────────────────────\nimport os\nif os.path.exists(checkpoint_path):\n    size_mb = os.path.getsize(checkpoint_path) / 1e6\n    print(f\"✓ Checkpoint found: {checkpoint_path} ({size_mb:.1f} MB)\")\nelse:\n    print(\"✗ Checkpoint NOT found — you need to retrain\")\n\nprint(f\"Device: {DEVICE}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T21:59:09.20458Z","iopub.execute_input":"2026-08-27T21:59:09.205144Z","iopub.status.idle":"2026-08-27T21:59:17.5806Z","shell.execute_reply.started":"2026-08-27T21:59:09.205117Z","shell.execute_reply":"2026-08-27T21:59:17.579831Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 7 — Inference & Submission\n\nLoad the best checkpoint, run inference on the test set, save submission.csv.\n\n**Key difference from training:**\n- No labels available — we only have StudyInstanceUID + series metadata\n- Use center slices (n_slices_val=10) for deterministic, reproducible predictions\n- No augmentation\n- Test set has ~1300 studies at scoring time (only 3 in the public example)","metadata":{}},{"cell_type":"code","source":"# ── CELL: Calibrated Inference ─────────────────────────────────────────────────\nmodel.eval()\nall_preds_raw = []\nall_ids       = []\n\nwith torch.no_grad():\n    for study_data, study_ids in test_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        all_preds_raw.append(probs)\n        all_ids.extend(study_ids)\n\nall_preds_raw = np.concatenate(all_preds_raw, axis=0)\n\n# Apply calibration\nall_preds_cal = calibrate_predictions(all_preds_raw)\n\nprint(\"Raw vs calibrated predictions:\")\nprint(f\"{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(label_cols):\n    print(f\"{lbl:20s} \"\n          f\"{all_preds_raw[:,i].mean():>10.3f} \"\n          f\"{all_preds_cal[:,i].mean():>10.3f} \"\n          f\"{GOLD_PREVALENCE[i]:>10.3f}\")\n\n# Build calibrated submission\nsubmission_cal = pd.DataFrame(all_preds_cal, columns=label_cols)\nsubmission_cal.insert(0, \"StudyInstanceUID\", all_ids)\nsubmission_cal.to_csv(WORK_DIR / \"submission_calibrated.csv\", index=False)\nprint(f\"\\n✓ Calibrated submission saved\")\nprint(submission_cal.to_string())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T21:59:41.462721Z","iopub.execute_input":"2026-08-27T21:59:41.463137Z","iopub.status.idle":"2026-08-27T21:59:41.473269Z","shell.execute_reply.started":"2026-08-27T21:59:41.46311Z","shell.execute_reply":"2026-08-27T21:59:41.472184Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Load best checkpoint ───────────────────────────────────────────────────────\ncheckpoint = torch.load(checkpoint_path, map_location=DEVICE,weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state\"])\nprint(f\"Loaded checkpoint from epoch {checkpoint['epoch']} \"\n      f\"(val AUC: {checkpoint['val_auc']:.4f})\")\n\n# ── Build test dataset ─────────────────────────────────────────────────────────\n# test_df has StudyInstanceUID but NO labels — inference only\n# We reuse KneeDataset with is_train=False and no label_cols\n\nclass KneeTestDataset(Dataset):\n    \"\"\"Same as KneeDataset but no labels — for inference only.\"\"\"\n    def __init__(self, df, study_to_series, series_base_dir, cfg):\n        self.df              = df.reset_index(drop=True)\n        self.study_to_series = study_to_series\n        self.series_base_dir = Path(series_base_dir)\n        self.cfg             = cfg\n        self.n_slices        = cfg[\"n_slices_val\"]\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row      = self.df.iloc[idx]\n        study_id = row[\"StudyInstanceUID\"]\n\n        all_series = self.study_to_series.get(study_id, [])\n        selected   = select_series(all_series, self.cfg[\"max_series\"])\n\n        series_tensors, meta_list = [], []\n\n        for s in selected:\n            series_dir = (self.series_base_dir /\n                          study_id / s[\"SeriesInstanceUID\"])\n\n            slices_np = load_series_slices(\n                str(series_dir),\n                self.n_slices,\n                self.cfg[\"img_size\"],\n                is_train=False      # center slices, no augmentation\n            )\n\n            slices_np = np.stack(\n                [slices_np, slices_np, slices_np], axis=1\n            )  # (n_slices, 3, H, W)\n\n            series_tensors.append(torch.from_numpy(slices_np))\n\n            plane_idx = PLANE_MAP.get(s[\"Anatomical_Plane\"], 0)\n            plane_oh  = F.one_hot(\n                torch.tensor(plane_idx), num_classes=3\n            ).float()\n            meta_list.append((\n                plane_oh,\n                torch.tensor(float(s[\"Fluid_Sensitive\"])),\n                torch.tensor(float(s[\"Fat_Suppression\"]))\n            ))\n\n        # Fallback: if study has no series (shouldn't happen), return zeros\n        if len(series_tensors) == 0:\n            series_tensors = [torch.zeros(self.n_slices, 3,\n                                          self.cfg[\"img_size\"],\n                                          self.cfg[\"img_size\"])]\n            meta_list = [(F.one_hot(torch.tensor(0), 3).float(),\n                          torch.tensor(0.0), torch.tensor(0.0))]\n\n        return series_tensors, meta_list, study_id\n\n\ndef test_collate(batch):\n    study_data = [(item[0], item[1]) for item in batch]\n    study_ids  = [item[2] for item in batch]\n    return study_data, study_ids\n\n\nTEST_SERIES_DIR = COMP_DIR / \"test_series\"\n\ntest_dataset = KneeTestDataset(\n    df               = test_df,\n    study_to_series  = test_study_to_series,\n    series_base_dir  = str(TEST_SERIES_DIR),\n    cfg              = CFG,\n)\n\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size  = 4,\n    shuffle     = False,\n    num_workers = CFG[\"num_workers\"],\n    collate_fn  = test_collate,\n)\n\nprint(f\"Test studies: {len(test_df)}\")\nprint(f\"Test batches: {len(test_loader)}\")\n\n# ── Run inference ──────────────────────────────────────────────────────────────\nmodel.eval()\nall_preds   = []\nall_ids     = []\n\nwith torch.no_grad():\n    for study_data, study_ids in test_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n\n        batch_logits = torch.stack(batch_logits, dim=0)   # (B, 12)\n        probs        = torch.sigmoid(batch_logits)         # (B, 12)\n        all_preds.append(probs.cpu().numpy())\n        all_ids.extend(study_ids)\n\nall_preds = np.concatenate(all_preds, axis=0)  # (N_test, 12)\n\nprint(f\"Inference complete. Predictions shape: {all_preds.shape}\")\n\n# ── Build submission.csv ───────────────────────────────────────────────────────\nsubmission = pd.DataFrame(all_preds, columns=LABEL_COLS)\nsubmission.insert(0, \"StudyInstanceUID\", all_ids)\n\n# Sanity checks\nassert len(submission) == len(test_df), \"Row count mismatch!\"\nassert list(submission.columns) == [\"StudyInstanceUID\"] + LABEL_COLS\nassert submission[LABEL_COLS].apply(lambda col: col.between(0, 1).all()).all(), \\\n    \"Probs out of [0,1]!\"\n\nprint(f\"\\nSubmission shape: {submission.shape}\")\nprint(f\"Columns: {list(submission.columns)}\")\nprint(f\"\\nSample predictions:\")\nprint(submission.head(3).to_string())\n\n# Save\nsubmission.to_csv(WORK_DIR / \"submission.csv\", index=False)\nprint(f\"\\n✓ submission.csv saved\")\n\n# Compare format against sample_submission.csv\nsample = pd.read_csv(COMP_DIR / \"sample_submission.csv\")\nprint(f\"\\nFormat check vs sample_submission:\")\nprint(f\"  Same columns: {list(sample.columns) == list(submission.columns)}\")\nprint(f\"  Row count — ours: {len(submission)}, sample: {len(sample)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T21:59:30.853635Z","iopub.execute_input":"2026-08-27T21:59:30.854202Z","iopub.status.idle":"2026-08-27T21:59:30.878475Z","shell.execute_reply.started":"2026-08-27T21:59:30.854175Z","shell.execute_reply":"2026-08-27T21:59:30.87754Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"------------------","metadata":{}},{"cell_type":"code","source":"# ── CELL: Platt Scaling Calibration ───────────────────────────────────────────\n# Uses the 58 gold-labeled studies to learn a per-label temperature scaling\n# that maps compressed predictions [0.02-0.45] → calibrated [0.1-0.8]\n# No retraining needed — pure post-processing\n\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.isotonic import IsotonicRegression\nimport numpy as np\n\nBASE   = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\ndf_all = pd.read_csv(f\"{BASE}/train.csv\")\n\nlabel_cols = LABEL_COLS\n\n# ── Get the 58 gold-labeled studies ───────────────────────────────────────────\ngold_mask = df_all[label_cols].notnull().all(axis=1)\ngold_df   = df_all[gold_mask].reset_index(drop=True)\nprint(f\"Gold studies for calibration: {len(gold_df)}\")\n\n# ── Run inference on gold studies to get raw model predictions ─────────────────\ngold_series = pd.read_csv(f\"{BASE}/train_series.csv\")\ngold_study_to_series = (\n    gold_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\ngold_dataset = KneeTestDataset(\n    df               = gold_df[[\"StudyInstanceUID\"]],\n    study_to_series  = gold_study_to_series,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n)\n\ngold_loader = DataLoader(\n    gold_dataset,\n    batch_size  = 4,\n    shuffle     = False,\n    num_workers = 2,\n    collate_fn  = test_collate,\n)\n\nmodel.eval()\ngold_preds_raw = []\ngold_ids       = []\n\nwith torch.no_grad():\n    for study_data, study_ids in gold_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        gold_preds_raw.append(probs)\n        gold_ids.extend(study_ids)\n\ngold_preds_raw = np.concatenate(gold_preds_raw, axis=0)  # (58, 12)\ngold_df_indexed = gold_df.set_index(\"StudyInstanceUID\")\n\nprint(f\"Raw predictions on gold set: {gold_preds_raw.shape}\")\n\n# ── Fit per-label isotonic calibrator ─────────────────────────────────────────\n# Isotonic regression: monotonic mapping from raw scores → calibrated scores\n# Better than Platt scaling for small datasets with non-normal distributions\n# Requires at least 1 positive and 1 negative per label\n\ncalibrators = {}\nprint(\"\\nFitting calibrators:\")\nprint(f\"{'Label':20s} {'n_pos':>6} {'n_neg':>6} {'status':>12}\")\nprint(\"-\" * 48)\n\nfor i, lbl in enumerate(label_cols):\n    y_true = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    y_raw  = gold_preds_raw[:, i]\n    \n    n_pos = int(y_true.sum())\n    n_neg = int((1-y_true).sum())\n    \n    if n_pos >= 2 and n_neg >= 2:\n        # Fit isotonic regression calibrator\n        cal = IsotonicRegression(out_of_bounds=\"clip\")\n        cal.fit(y_raw, y_true)\n        calibrators[lbl] = cal\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'fitted':>12}\")\n    else:\n        # Not enough data — use prior-based scaling instead\n        # Scale predictions so their mean matches gold prevalence\n        gold_prev = GOLD_PREVALENCE[i]\n        raw_mean  = y_raw.mean()\n        scale     = gold_prev / max(raw_mean, 1e-6)\n        calibrators[lbl] = (\"scale\", scale)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} \"\n              f\"{'prior_scale':>12} (×{scale:.1f})\")\n\n\ndef calibrate_predictions(raw_preds: np.ndarray) -> np.ndarray:\n    \"\"\"\n    Apply per-label calibration to raw sigmoid outputs.\n    raw_preds: (N, 12) array\n    Returns: (N, 12) calibrated array, values in [0,1]\n    \"\"\"\n    calibrated = np.zeros_like(raw_preds)\n    for i, lbl in enumerate(label_cols):\n        cal = calibrators[lbl]\n        if isinstance(cal, tuple):\n            # Prior scaling fallback\n            _, scale = cal\n            calibrated[:, i] = np.clip(raw_preds[:, i] * scale, 0, 1)\n        else:\n            # Isotonic regression\n            calibrated[:, i] = cal.predict(raw_preds[:, i])\n    return calibrated\n\n# ── Validate calibration on gold set ──────────────────────────────────────────\ngold_preds_cal = calibrate_predictions(gold_preds_raw)\n\nprint(\"\\nCalibration effect on gold set:\")\nprint(f\"{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(label_cols):\n    y_true   = gold_df_indexed.loc[gold_ids, lbl].values.astype(float)\n    raw_mean = gold_preds_raw[:, i].mean()\n    cal_mean = gold_preds_cal[:, i].mean()\n    gold_p   = GOLD_PREVALENCE[i]\n    print(f\"{lbl:20s} {raw_mean:>10.3f} {cal_mean:>10.3f} {gold_p:>10.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:11:36.169082Z","iopub.execute_input":"2026-08-27T22:11:36.169884Z","iopub.status.idle":"2026-08-27T22:11:39.875379Z","shell.execute_reply.started":"2026-08-27T22:11:36.169855Z","shell.execute_reply":"2026-08-27T22:11:39.87445Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Section 6b — Training Diagnostics\n\nVisualizing the training curve and per-label AUC helps us understand:\n1. When the model peaked (overfitting point)\n2. Which labels the model learned well vs. poorly\n3. Whether predictions are calibrated (spread across [0,1] or collapsed near 0)","metadata":{}},{"cell_type":"code","source":"from pathlib import Path\nWORK_DIR = Path(\"/kaggle/working\")\nCOMP_DIR = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:11:45.065324Z","iopub.execute_input":"2026-08-27T22:11:45.065825Z","iopub.status.idle":"2026-08-27T22:11:45.06986Z","shell.execute_reply.started":"2026-08-27T22:11:45.065796Z","shell.execute_reply":"2026-08-27T22:11:45.069051Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport matplotlib.patches as mpatches\nimport numpy as np\n\n# ── Data from our training run (paste your actual values) ─────────────────────\nepoch_log = [\n    {\"epoch\": 1, \"train_loss\": 0.4696, \"val_loss\": 0.5266, \"val_auc\": 0.4980},\n    {\"epoch\": 2, \"train_loss\": 0.4248, \"val_loss\": 0.4834, \"val_auc\": 0.5822},\n    {\"epoch\": 3, \"train_loss\": 0.4109, \"val_loss\": 0.5271, \"val_auc\": 0.6101},\n    {\"epoch\": 4, \"train_loss\": 0.3929, \"val_loss\": 0.4969, \"val_auc\": 0.5941},\n    {\"epoch\": 5, \"train_loss\": 0.3818, \"val_loss\": 0.5292, \"val_auc\": 0.5538},\n    {\"epoch\": 6, \"train_loss\": 0.3818, \"val_loss\": 0.5292, \"val_auc\": 0.5538},\n]\n\nper_label_auc_epoch3 = {\n    \"ACL\":              0.6506,\n    \"MCL\":              0.6507,\n    \"Medial Meniscus\":  0.5787,\n    \"Lateral Meniscus\": 0.6059,\n    \"Medial OA\":        0.6331,\n    \"Lateral OA\":       0.6536,\n    \"PF OA\":            0.6567,\n    \"Effusion\":         0.5991,\n    \"Synovitis\":        0.6591,\n    \"Baker's\":          0.5520,\n    \"Contusion\":        0.5283,\n    \"Fracture\":         0.5534,\n}\n\nepochs      = [d[\"epoch\"]      for d in epoch_log]\ntrain_loss  = [d[\"train_loss\"] for d in epoch_log]\nval_loss    = [d[\"val_loss\"]   for d in epoch_log]\nval_auc     = [d[\"val_auc\"]    for d in epoch_log]\n\nfig, axes = plt.subplots(1, 3, figsize=(18, 5))\nfig.suptitle(\"RSNA Knee — Training Diagnostics (Fold 0, EfficientNet-B0)\",\n             fontsize=13, fontweight=\"bold\")\n\n# ── Plot 1: Loss curves ────────────────────────────────────────────────────────\nax = axes[0]\nax.plot(epochs, train_loss, \"b-o\", label=\"Train loss\", linewidth=2)\nax.plot(epochs, val_loss,   \"r-o\", label=\"Val loss\",   linewidth=2)\nax.axvline(x=3, color=\"green\", linestyle=\"--\", alpha=0.7, label=\"Best epoch (3)\")\nax.set_xlabel(\"Epoch\")\nax.set_ylabel(\"BCE Loss\")\nax.set_title(\"Loss Curves\")\nax.legend()\nax.grid(True, alpha=0.3)\nax.set_xticks(epochs)\n\n# ── Plot 2: Val AUC curve ──────────────────────────────────────────────────────\nax = axes[1]\nax.plot(epochs, val_auc, \"g-o\", linewidth=2)\nax.axvline(x=3, color=\"green\", linestyle=\"--\", alpha=0.7, label=\"Best epoch (3)\")\nax.axhline(y=0.5, color=\"gray\", linestyle=\":\", alpha=0.5, label=\"Random baseline\")\nax.fill_between(epochs, 0.5, val_auc,\n                where=[v > 0.5 for v in val_auc],\n                alpha=0.15, color=\"green\", label=\"Above random\")\nax.set_xlabel(\"Epoch\")\nax.set_ylabel(\"Macro AUC (weak labels)\")\nax.set_title(\"Validation AUC\")\nax.legend(fontsize=8)\nax.grid(True, alpha=0.3)\nax.set_xticks(epochs)\nax.set_ylim(0.45, 0.65)\n\n# ── Plot 3: Per-label AUC bar chart ───────────────────────────────────────────\nax = axes[2]\nlabels_sorted = sorted(per_label_auc_epoch3.items(), key=lambda x: x[1], reverse=True)\nnames  = [x[0] for x in labels_sorted]\nvalues = [x[1] for x in labels_sorted]\ncolors = [\"#2ecc71\" if v >= 0.62 else\n          \"#f39c12\" if v >= 0.55 else\n          \"#e74c3c\" for v in values]\n\nbars = ax.barh(names, values, color=colors, edgecolor=\"white\", height=0.6)\nax.axvline(x=0.5, color=\"gray\", linestyle=\":\", alpha=0.7, label=\"Random\")\nax.axvline(x=0.62, color=\"green\", linestyle=\"--\", alpha=0.5, label=\"Good (≥0.62)\")\nax.set_xlabel(\"AUC ROC\")\nax.set_title(\"Per-Label AUC @ Best Epoch\")\nax.set_xlim(0.45, 0.72)\nax.legend(fontsize=8)\nax.grid(True, alpha=0.3, axis=\"x\")\n\n# Color legend\npatches = [\n    mpatches.Patch(color=\"#2ecc71\", label=\"Strong (≥0.62)\"),\n    mpatches.Patch(color=\"#f39c12\", label=\"Moderate (0.55–0.62)\"),\n    mpatches.Patch(color=\"#e74c3c\", label=\"Weak (<0.55)\"),\n]\nax.legend(handles=patches, fontsize=7, loc=\"lower right\")\n\nplt.tight_layout()\nplt.savefig(WORK_DIR / \"training_diagnostics.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\nprint(\"✓ Plot saved to training_diagnostics.png\")\n\n# ── Plot 4: Prediction distribution (calibration check) ───────────────────────\nfig, axes = plt.subplots(3, 4, figsize=(16, 10))\nfig.suptitle(\"Prediction Distribution per Label\\n\"\n             \"(Healthy = spread across [0,1], Broken = all near 0)\",\n             fontsize=12, fontweight=\"bold\")\n\n# Re-run inference on val set to get full prediction distribution\n# (3 test studies is too few to visualize — use val set)\nmodel.eval()\nval_preds_all  = []\nval_labels_all = []\n\nval_loader_vis = DataLoader(\n    KneeDataset(val_df, study_to_series,\n                str(TRAIN_SERIES_DIR), CFG, is_train=False),\n    batch_size=4, shuffle=False,\n    num_workers=2, collate_fn=knee_collate\n)\n\nwith torch.no_grad():\n    for study_data, labels, _ in val_loader_vis:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        batch_logits = torch.stack(batch_logits, dim=0)\n        probs = torch.sigmoid(batch_logits).cpu().numpy()\n        val_preds_all.append(probs)\n        val_labels_all.append(labels.numpy())\n\nval_preds_all  = np.concatenate(val_preds_all,  axis=0)  # (817, 12)\nval_labels_all = np.concatenate(val_labels_all, axis=0)  # (817, 12)\n\nfor i, (ax, lbl) in enumerate(zip(axes.flat, LABEL_COLS)):\n    preds  = val_preds_all[:, i]\n    labels = val_labels_all[:, i]\n\n    # Separate positive and negative weak labels\n    pos_preds = preds[labels == 1]\n    neg_preds = preds[labels == 0]\n\n    ax.hist(neg_preds, bins=30, alpha=0.6, color=\"steelblue\",\n            label=f\"Neg (n={len(neg_preds)})\", density=True)\n    if len(pos_preds) > 0:\n        ax.hist(pos_preds, bins=30, alpha=0.6, color=\"tomato\",\n                label=f\"Pos (n={len(pos_preds)})\", density=True)\n\n    ax.set_title(f\"{lbl}\", fontsize=9, fontweight=\"bold\")\n    ax.set_xlabel(\"Predicted prob\", fontsize=7)\n    ax.legend(fontsize=6)\n    ax.set_xlim(0, 1)\n\nplt.tight_layout()\nplt.savefig(WORK_DIR / \"prediction_distributions.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\nprint(\"✓ Prediction distribution plot saved\")\nprint(f\"\\nVal prediction stats:\")\nfor i, lbl in enumerate(LABEL_COLS):\n    preds = val_preds_all[:, i]\n    print(f\"  {lbl:20s}: mean={preds.mean():.3f}  \"\n          f\"min={preds.min():.3f}  max={preds.max():.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:11:48.581065Z","iopub.execute_input":"2026-08-27T22:11:48.581751Z","iopub.status.idle":"2026-08-27T22:16:07.489864Z","shell.execute_reply.started":"2026-08-27T22:11:48.581724Z","shell.execute_reply":"2026-08-27T22:16:07.489078Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport torch, numpy as np, pandas as pd\n\nWORK_DIR = Path(\"/kaggle/working\")\nCOMP_DIR = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\n# Quick prediction stats from the 3 test studies we already ran\ntest_preds = np.array([\n    [0.026352, 0.035400, 0.055420, 0.033478, 0.038757,\n     0.037323, 0.027954, 0.104492, 0.019089, 0.041077, 0.117798, 0.025040],\n    [0.036987, 0.044189, 0.071838, 0.044861, 0.041321,\n     0.044861, 0.036011, 0.112000, 0.034363, 0.039429, 0.078491, 0.023911],\n    [0.029144, 0.039124, 0.060425, 0.034424, 0.036072,\n     0.036072, 0.028214, 0.120972, 0.026459, 0.043365, 0.075745, 0.024521],\n])\n\nLABEL_COLS = [\"ACL\",\"MCL\",\"Medial Meniscus\",\"Lateral Meniscus\",\"Medial OA\",\n              \"Lateral OA\",\"PF OA\",\"Effusion\",\"Synovitis\",\"Baker's\",\"Contusion\",\"Fracture\"]\n\nprint(\"Prediction stats (3 public test studies):\")\nprint(f\"{'Label':20s} {'mean':>8} {'min':>8} {'max':>8}\")\nprint(\"-\" * 48)\nfor i, lbl in enumerate(LABEL_COLS):\n    col = test_preds[:, i]\n    print(f\"{lbl:20s} {col.mean():>8.3f} {col.min():>8.3f} {col.max():>8.3f}\")\n\nprint(f\"\\nOverall prediction range: [{test_preds.min():.3f}, {test_preds.max():.3f}]\")\nprint(f\"Grand mean: {test_preds.mean():.3f}\")\nprint(f\"\\nExpected mean if calibrated (from gold prevalence):\")\ngold_prev = [0.414,0.155,0.448,0.397,0.259,0.190,0.362,0.603,0.466,0.207,0.328,0.310]\nfor i, lbl in enumerate(LABEL_COLS):\n    ratio = test_preds[:,i].mean() / gold_prev[i]\n    print(f\"  {lbl:20s}: predicted={test_preds[:,i].mean():.3f} \"\n          f\"vs gold_prev={gold_prev[i]:.3f}  ratio={ratio:.2f}x\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:26:23.13764Z","iopub.execute_input":"2026-08-27T22:26:23.138045Z","iopub.status.idle":"2026-08-27T22:26:23.148659Z","shell.execute_reply.started":"2026-08-27T22:26:23.138017Z","shell.execute_reply":"2026-08-27T22:26:23.147962Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, gc, re, time, warnings\nwarnings.filterwarnings(\"ignore\")\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nimport pydicom, cv2\nimport torch, torch.nn as nn, torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport timm\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.isotonic import IsotonicRegression\nimport subprocess\nsubprocess.run([\"pip\",\"install\",\"-q\",\"pydicom\",\"timm\",\"opencv-python-headless\"],\n               capture_output=True)\n\n# ── All paths in one place ─────────────────────────────────────────────────────\nCOMP_DIR         = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\nLABELS_DIR       = Path(\"/kaggle/input/rsna-knee-weak-labels\")  # no /datasets/\nWORK_DIR         = Path(\"/kaggle/working\")\nTRAIN_SERIES_DIR = COMP_DIR / \"train_series\"\nTEST_SERIES_DIR  = COMP_DIR / \"test_series\"\ncheckpoint_path  = WORK_DIR / \"best_model.pth\"\n\n# ── All config in one place ────────────────────────────────────────────────────\nCFG = {\n    \"img_size\": 224, \"n_slices_train\": 6, \"n_slices_val\": 10,\n    \"max_series\": 4, \"backbone\": \"efficientnet_b0\", \"feat_dim\": 256,\n    \"dropout\": 0.3, \"batch_size\": 4, \"lr\": 3e-4, \"n_epochs\": 6,\n    \"warmup_epochs\": 1, \"grad_clip\": 1.0, \"use_amp\": True,\n    \"num_workers\": 2, \"n_folds\": 5, \"train_fold\": 0, \"seed\": 42,\n}\n\nLABEL_COLS = [\n    \"ACL\",\"MCL\",\"Medial Meniscus\",\"Lateral Meniscus\",\"Medial OA\",\n    \"Lateral OA\",\"PF OA\",\"Effusion\",\"Synovitis\",\"Baker's\",\"Contusion\",\"Fracture\"\n]\nN_LABELS  = 12\nPLANE_MAP = {\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}\nSEED      = 42\n\nGOLD_PREVALENCE = {\n    \"ACL\": 0.414, \"MCL\": 0.155, \"Medial Meniscus\": 0.448,\n    \"Lateral Meniscus\": 0.397, \"Medial OA\": 0.259, \"Lateral OA\": 0.190,\n    \"PF OA\": 0.362, \"Effusion\": 0.603, \"Synovitis\": 0.466,\n    \"Baker's\": 0.207, \"Contusion\": 0.328, \"Fracture\": 0.310,\n}\n\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"✓ Setup complete | Device: {DEVICE} | PyTorch: {torch.__version__}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:26:39.697031Z","iopub.execute_input":"2026-08-27T22:26:39.697699Z","iopub.status.idle":"2026-08-27T22:26:42.946453Z","shell.execute_reply.started":"2026-08-27T22:26:39.697671Z","shell.execute_reply":"2026-08-27T22:26:42.945533Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Platt Scaling Calibration ──────────────────────────────────────────────────\nfrom sklearn.isotonic import IsotonicRegression\nimport numpy as np, pandas as pd\nfrom pathlib import Path\nfrom torch.utils.data import DataLoader\n\nBASE   = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\ndf_all = pd.read_csv(f\"{BASE}/train.csv\")\n\nGOLD_PREVALENCE = {\n    \"ACL\": 0.414, \"MCL\": 0.155, \"Medial Meniscus\": 0.448,\n    \"Lateral Meniscus\": 0.397, \"Medial OA\": 0.259, \"Lateral OA\": 0.190,\n    \"PF OA\": 0.362, \"Effusion\": 0.603, \"Synovitis\": 0.466,\n    \"Baker's\": 0.207, \"Contusion\": 0.328, \"Fracture\": 0.310,\n}\nGOLD_PREV_ARRAY = np.array([GOLD_PREVALENCE[l] for l in LABEL_COLS])\n\n# ── Gold studies ───────────────────────────────────────────────────────────────\ngold_mask = df_all[LABEL_COLS].notnull().all(axis=1)\ngold_df   = df_all[gold_mask].reset_index(drop=True)\nprint(f\"Gold studies: {len(gold_df)}\")\n\ngold_series_df = pd.read_csv(f\"{BASE}/train_series.csv\")\ngold_s2s = (\n    gold_series_df\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[[\"SeriesInstanceUID\",\"Anatomical_Plane\",\n                         \"Fluid_Sensitive\",\"Fat_Suppression\"]].to_dict(\"records\"))\n    .to_dict()\n)\n\ngold_dataset = KneeTestDataset(\n    df               = gold_df[[\"StudyInstanceUID\"]],\n    study_to_series  = gold_s2s,\n    series_base_dir  = str(TRAIN_SERIES_DIR),\n    cfg              = CFG,\n)\ngold_loader = DataLoader(\n    gold_dataset, batch_size=4, shuffle=False,\n    num_workers=2, collate_fn=test_collate,\n)\n\n# ── Inference on gold set ──────────────────────────────────────────────────────\nmodel.eval()\ngold_preds_raw, gold_ids = [], []\n\nwith torch.no_grad():\n    for study_data, study_ids in gold_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                logits = model(series_list, meta_list)\n            batch_logits.append(logits)\n        probs = torch.sigmoid(\n            torch.stack(batch_logits, dim=0)\n        ).cpu().numpy()\n        gold_preds_raw.append(probs)\n        gold_ids.extend(study_ids)\n\ngold_preds_raw = np.concatenate(gold_preds_raw, axis=0)  # (58, 12)\ngold_df_idx    = gold_df.set_index(\"StudyInstanceUID\")\nprint(f\"Gold predictions shape: {gold_preds_raw.shape}\")\n\n# ── Fit calibrators ────────────────────────────────────────────────────────────\ncalibrators = {}\nprint(f\"\\n{'Label':20s} {'n_pos':>6} {'n_neg':>6} {'method':>14}\")\nprint(\"-\" * 50)\n\nfor i, lbl in enumerate(LABEL_COLS):\n    y_true = gold_df_idx.loc[gold_ids, lbl].values.astype(float)\n    y_raw  = gold_preds_raw[:, i]\n    n_pos  = int(y_true.sum())\n    n_neg  = int((1 - y_true).sum())\n\n    if n_pos >= 2 and n_neg >= 2:\n        cal = IsotonicRegression(out_of_bounds=\"clip\")\n        cal.fit(y_raw, y_true)\n        calibrators[lbl] = (\"isotonic\", cal)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'isotonic':>14}\")\n    else:\n        # Fallback: simple prior scaling\n        scale = GOLD_PREVALENCE[lbl] / max(y_raw.mean(), 1e-6)\n        calibrators[lbl] = (\"scale\", scale)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} \"\n              f\"{'prior_scale':>14} (×{scale:.1f})\")\n\n\ndef calibrate(raw: np.ndarray) -> np.ndarray:\n    \"\"\"(N,12) raw sigmoid → (N,12) calibrated, clipped to [0,1]\"\"\"\n    out = np.zeros_like(raw)\n    for i, lbl in enumerate(LABEL_COLS):\n        method, obj = calibrators[lbl]\n        if method == \"isotonic\":\n            out[:, i] = obj.predict(raw[:, i])\n        else:\n            out[:, i] = np.clip(raw[:, i] * obj, 0, 1)\n    return out\n\n# ── Validate on gold set ───────────────────────────────────────────────────────\ncal_gold = calibrate(gold_preds_raw)\nprint(f\"\\n{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(LABEL_COLS):\n    print(f\"{lbl:20s} \"\n          f\"{gold_preds_raw[:,i].mean():>10.3f} \"\n          f\"{cal_gold[:,i].mean():>10.3f} \"\n          f\"{GOLD_PREVALENCE[lbl]:>10.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T22:26:47.225818Z","iopub.execute_input":"2026-08-27T22:26:47.226087Z","iopub.status.idle":"2026-08-27T22:26:50.874781Z","shell.execute_reply.started":"2026-08-27T22:26:47.226067Z","shell.execute_reply":"2026-08-27T22:26:50.87391Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"New Model ~~","metadata":{}},{"cell_type":"markdown","source":"# SECTION 0 — SETUP: Imports, Paths, Config, Constants","metadata":{}},{"cell_type":"code","source":"import os, gc, time, warnings\nwarnings.filterwarnings(\"ignore\")\n \nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\n \nimport pydicom\nimport cv2\n \nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport timm\nfrom sklearn.model_selection import StratifiedGroupKFold\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.isotonic import IsotonicRegression\n \nimport subprocess\nsubprocess.run(\n    [\"pip\", \"install\", \"-q\", \"pydicom\", \"timm\", \"opencv-python-headless\"],\n    capture_output=True\n)\n \n# ── Paths ──────────────────────────────────────────────────────────────────────\nCOMP_DIR         = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\nLABELS_DIR       = Path(\"/kaggle/input/datasets/amilcartorres/rsna-knee-weak-labels\")\nWORK_DIR         = Path(\"/kaggle/working\")\nTRAIN_SERIES_DIR = COMP_DIR / \"train_series\"\nTEST_SERIES_DIR  = COMP_DIR / \"test_series\"\ncheckpoint_path  = WORK_DIR / \"best_model.pth\"\n \n# ── Config — one place, never split across cells ───────────────────────────────\nCFG = {\n    # Image\n    \"img_size\":          224,\n    \"n_slices_train\":    6,     # slices sampled per series during training\n    \"n_slices_val\":      10,    # slices used during val/inference (center window)\n    \"max_series\":        4,     # max series per study (fluid-sensitive prioritized)\n    # Model\n    \"backbone\":          \"efficientnet_b0\",\n    \"feat_dim\":          256,\n    \"dropout\":           0.3,\n    # Training\n    \"batch_size\":        4,\n    \"lr\":                3e-4,\n    \"n_epochs\":          6,\n    \"warmup_epochs\":     1,\n    \"grad_clip\":         1.0,\n    \"use_amp\":           True,  # mixed precision — ~30-40% faster on T4\n    # DataLoader\n    \"num_workers\":       2,\n    # CV\n    \"n_folds\":           5,\n    \"train_fold\":        0,     # train on folds 1-4, validate on fold 0\n    \"seed\":              42,\n}\n \n# ── Constants ──────────────────────────────────────────────────────────────────\nLABEL_COLS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\",\n    \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"\n]\nN_LABELS  = len(LABEL_COLS)   # 12\nPLANE_MAP = {\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}\nSEED      = CFG[\"seed\"]\n \n# Gold prevalence from the 58 gold-labeled studies — used for loss weighting\n# and calibration. These are the only reliable label statistics we have.\nGOLD_PREVALENCE = {\n    \"ACL\": 0.414, \"MCL\": 0.155, \"Medial Meniscus\": 0.448,\n    \"Lateral Meniscus\": 0.397, \"Medial OA\": 0.259, \"Lateral OA\": 0.190,\n    \"PF OA\": 0.362, \"Effusion\": 0.603, \"Synovitis\": 0.466,\n    \"Baker's\": 0.207, \"Contusion\": 0.328, \"Fracture\": 0.310,\n}\nGOLD_PREV_ARRAY = np.array([GOLD_PREVALENCE[l] for l in LABEL_COLS])\n \n# ── Reproducibility ────────────────────────────────────────────────────────────\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\n \n# ── Device ─────────────────────────────────────────────────────────────────────\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"✓ Setup complete\")\nprint(f\"  Device:  {DEVICE}\")\nprint(f\"  PyTorch: {torch.__version__}\")\nprint(f\"  timm:    {timm.__version__}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:03:33.647168Z","iopub.execute_input":"2026-08-28T01:03:33.648038Z","iopub.status.idle":"2026-08-28T01:03:51.238311Z","shell.execute_reply.started":"2026-08-28T01:03:33.647995Z","shell.execute_reply":"2026-08-28T01:03:51.237461Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 1 — LOAD DATA","metadata":{}},{"cell_type":"code","source":"# ── Load weak labels ───────────────────────────────────────────────────────────\nweak_df = pd.read_csv(LABELS_DIR / \"weak_labels.csv\")\nweak_df = weak_df[weak_df[\"label_confidence\"] > 0].reset_index(drop=True)\nprint(f\"Usable training studies: {len(weak_df)}\")\n \n# ── Load series metadata ───────────────────────────────────────────────────────\ntrain_series = pd.read_csv(COMP_DIR / \"train_series.csv\")\ntest_series  = pd.read_csv(COMP_DIR / \"test_series.csv\")\ntest_df      = pd.read_csv(COMP_DIR / \"test.csv\")\n \nprint(f\"Train series: {len(train_series)}\")\nprint(f\"Test series:  {len(test_series)}\")\nprint(f\"Test studies: {len(test_df)}\")\n \n# ── Sanity check: every labeled study must have series metadata ────────────────\nstudy_ids_with_series = set(train_series[\"StudyInstanceUID\"].unique())\nstudy_ids_in_labels   = set(weak_df[\"StudyInstanceUID\"].unique())\nmissing = study_ids_in_labels - study_ids_with_series\nif missing:\n    weak_df = weak_df[~weak_df[\"StudyInstanceUID\"].isin(missing)].reset_index(drop=True)\n    print(f\"  Dropped {len(missing)} studies missing series metadata\")\nprint(f\"✓ Missing series check: {len(missing)} missing\")\n \n# ── Build per-study series lookup dicts ───────────────────────────────────────\n# Maps StudyInstanceUID → list of series dicts with plane/fluid/fat metadata\n_cols = [\"SeriesInstanceUID\", \"Anatomical_Plane\", \"Fluid_Sensitive\", \"Fat_Suppression\"]\n \nstudy_to_series = (\n    train_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[_cols].to_dict(\"records\"))\n    .to_dict()\n)\n \ntest_study_to_series = (\n    test_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[_cols].to_dict(\"records\"))\n    .to_dict()\n)\n \nprint(f\"\\nExample series for first training study:\")\nfirst_study = weak_df[\"StudyInstanceUID\"].iloc[0]\nfor s in study_to_series[first_study]:\n    print(f\"  {s}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:03:54.897611Z","iopub.execute_input":"2026-08-28T01:03:54.898354Z","iopub.status.idle":"2026-08-28T01:03:58.633346Z","shell.execute_reply.started":"2026-08-28T01:03:54.898322Z","shell.execute_reply":"2026-08-28T01:03:58.632569Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"SECTION 2 — CROSS-VALIDATION SPLIT","metadata":{}},{"cell_type":"code","source":"# ── Build stratification label ─────────────────────────────────────────────────\n# Encode the 3 rarest labels + Effusion as a single integer per study\nlabel_prevalence = weak_df[LABEL_COLS].mean()\nrarest_3         = label_prevalence.nsmallest(3).index.tolist()\nstratify_cols    = list(dict.fromkeys(rarest_3 + [\"Effusion\"]))  # deduplicated\nprint(f\"Stratification columns: {stratify_cols}\")\n \nweak_df[\"strat_label\"] = (\n    weak_df[stratify_cols]\n    .fillna(0).astype(int)\n    .apply(lambda row: int(\"\".join(row.astype(str)), 2), axis=1)\n)\n \n# ── Create 5-fold split ────────────────────────────────────────────────────────\nsgkf = StratifiedGroupKFold(n_splits=CFG[\"n_folds\"], shuffle=True, random_state=SEED)\nweak_df[\"fold\"] = -1\n \nfor fold_idx, (train_idx, val_idx) in enumerate(\n    sgkf.split(weak_df, y=weak_df[\"strat_label\"], groups=weak_df[\"StudyInstanceUID\"])\n):\n    weak_df.loc[val_idx, \"fold\"] = fold_idx\n \n# ── Leakage check ─────────────────────────────────────────────────────────────\nstudy_fold_counts = weak_df.groupby(\"StudyInstanceUID\")[\"fold\"].nunique()\nassert (study_fold_counts == 1).all(), \"LEAKAGE: studies appear in multiple folds!\"\nprint(\"✓ No study leakage across folds\")\n \n# ── Fold 0 split ───────────────────────────────────────────────────────────────\nTRAIN_FOLD = CFG[\"train_fold\"]\ntrain_df   = weak_df[weak_df[\"fold\"] != TRAIN_FOLD].reset_index(drop=True)\nval_df     = weak_df[weak_df[\"fold\"] == TRAIN_FOLD].reset_index(drop=True)\nprint(f\"Train studies: {len(train_df)} | Val studies: {len(val_df)}\")\n \n# ── Save fold assignments for reproducibility ─────────────────────────────────\nweak_df[[\"StudyInstanceUID\", \"fold\"]].to_csv(WORK_DIR / \"fold_assignments.csv\", index=False)\nprint(\"✓ Fold assignments saved\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:04:02.65742Z","iopub.execute_input":"2026-08-28T01:04:02.657992Z","iopub.status.idle":"2026-08-28T01:04:04.245253Z","shell.execute_reply.started":"2026-08-28T01:04:02.657954Z","shell.execute_reply":"2026-08-28T01:04:04.244376Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 3 — DICOM DATALOADER","metadata":{}},{"cell_type":"code","source":"def load_dicom_slice(dcm_path: str, img_size: int) -> np.ndarray:\n    dcm = pydicom.dcmread(dcm_path)\n    img = dcm.pixel_array.astype(np.float32)\n    \n    # ── NaN guard ─────────────────────────────────────────────────────────────\n    img = np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0)\n    \n    p1, p99 = np.percentile(img, 1), np.percentile(img, 99)\n    if p99 > p1:\n        img = (img - p1) / (p99 - p1)\n    img = np.clip(img, 0.0, 1.0)\n    img = cv2.resize(img, (img_size, img_size), interpolation=cv2.INTER_LINEAR)\n    return img\n \n \ndef load_series_slices(series_dir: str, n_slices: int,\n                       img_size: int, is_train: bool) -> np.ndarray:\n    \"\"\"Load N slices from a series. Random window for train, center for val.\"\"\"\n    dcm_files = sorted(Path(series_dir).glob(\"*.dcm\"))\n    if not dcm_files:\n        return np.zeros((n_slices, img_size, img_size), dtype=np.float32)\n \n    total = len(dcm_files)\n    if total <= n_slices:\n        chosen = dcm_files\n    elif is_train:\n        start  = np.random.randint(0, total - n_slices + 1)\n        chosen = dcm_files[start:start + n_slices]\n    else:\n        center = total // 2\n        start  = max(0, center - n_slices // 2)\n        chosen = dcm_files[start:start + n_slices]\n \n    slices = []\n    for f in chosen:\n        try:\n            slices.append(load_dicom_slice(str(f), img_size))\n        except Exception:\n            slices.append(np.zeros((img_size, img_size), dtype=np.float32))\n \n    while len(slices) < n_slices:\n        slices.append(slices[-1].copy())\n \n    return np.stack(slices[:n_slices], axis=0)  # (n_slices, H, W)\n \n \ndef select_series(series_list: list, max_series: int) -> list:\n    \"\"\"Cap series count. Fluid-sensitive first, then non-fluid.\"\"\"\n    fluid     = [s for s in series_list if s[\"Fluid_Sensitive\"] == 1]\n    non_fluid = [s for s in series_list if s[\"Fluid_Sensitive\"] == 0]\n    return (fluid + non_fluid)[:max_series]\n \n \nclass KneeDataset(Dataset):\n    \"\"\"Training/validation dataset. Returns series tensors + metadata + labels.\"\"\"\n \n    def __init__(self, df, study_to_series, series_base_dir, cfg,\n                 is_train=True, label_cols=LABEL_COLS):\n        self.df              = df.reset_index(drop=True)\n        self.study_to_series = study_to_series\n        self.series_base_dir = Path(series_base_dir)\n        self.cfg             = cfg\n        self.is_train        = is_train\n        self.label_cols      = label_cols\n        self.n_slices        = cfg[\"n_slices_train\"] if is_train else cfg[\"n_slices_val\"]\n \n    def __len__(self):\n        return len(self.df)\n \n    def _augment(self, img: np.ndarray) -> np.ndarray:\n        if np.random.random() < 0.5:\n            img = np.fliplr(img).copy()\n        img = np.clip(img * np.random.uniform(0.85, 1.15), 0.0, 1.0)\n        return img\n \n    def __getitem__(self, idx):\n        row      = self.df.iloc[idx]\n        study_id = row[\"StudyInstanceUID\"]\n \n        # Labels\n        labels = torch.tensor(\n            row[self.label_cols].fillna(0).values.astype(np.float32)\n        )\n \n        # Series\n        all_series = self.study_to_series.get(study_id, [])\n        selected   = select_series(all_series, self.cfg[\"max_series\"])\n \n        series_tensors, meta_list = [], []\n        for s in selected:\n            series_dir = self.series_base_dir / study_id / s[\"SeriesInstanceUID\"]\n            slices_np  = load_series_slices(\n                str(series_dir), self.n_slices, self.cfg[\"img_size\"], self.is_train\n            )\n            if self.is_train:\n                slices_np = np.stack([self._augment(sl) for sl in slices_np])\n            # Grayscale → 3-channel for ImageNet backbone\n            slices_np = np.stack([slices_np, slices_np, slices_np], axis=1)\n            series_tensors.append(torch.from_numpy(slices_np))\n \n            plane_oh = F.one_hot(\n                torch.tensor(PLANE_MAP.get(s[\"Anatomical_Plane\"], 0)), num_classes=3\n            ).float()\n            meta_list.append((\n                plane_oh,\n                torch.tensor(float(s[\"Fluid_Sensitive\"])),\n                torch.tensor(float(s[\"Fat_Suppression\"]))\n            ))\n \n        return series_tensors, meta_list, labels, study_id\n \n \nclass KneeTestDataset(Dataset):\n    \"\"\"Inference-only dataset. No labels.\"\"\"\n \n    def __init__(self, df, study_to_series, series_base_dir, cfg):\n        self.df              = df.reset_index(drop=True)\n        self.study_to_series = study_to_series\n        self.series_base_dir = Path(series_base_dir)\n        self.cfg             = cfg\n        self.n_slices        = cfg[\"n_slices_val\"]\n \n    def __len__(self):\n        return len(self.df)\n \n    def __getitem__(self, idx):\n        row      = self.df.iloc[idx]\n        study_id = row[\"StudyInstanceUID\"]\n \n        all_series = self.study_to_series.get(study_id, [])\n        selected   = select_series(all_series, self.cfg[\"max_series\"])\n \n        if not selected:\n            selected = [{\"SeriesInstanceUID\": \"\", \"Anatomical_Plane\": \"Sagittal\",\n                         \"Fluid_Sensitive\": 0, \"Fat_Suppression\": 0}]\n \n        series_tensors, meta_list = [], []\n        for s in selected:\n            series_dir = self.series_base_dir / study_id / s[\"SeriesInstanceUID\"]\n            slices_np  = load_series_slices(\n                str(series_dir), self.n_slices, self.cfg[\"img_size\"], is_train=False\n            )\n            slices_np = np.stack([slices_np, slices_np, slices_np], axis=1)\n            series_tensors.append(torch.from_numpy(slices_np))\n \n            plane_oh = F.one_hot(\n                torch.tensor(PLANE_MAP.get(s[\"Anatomical_Plane\"], 0)), num_classes=3\n            ).float()\n            meta_list.append((\n                plane_oh,\n                torch.tensor(float(s[\"Fluid_Sensitive\"])),\n                torch.tensor(float(s[\"Fat_Suppression\"]))\n            ))\n \n        return series_tensors, meta_list, study_id\n \n \ndef knee_collate(batch):\n    \"\"\"Custom collate for variable-length study data.\"\"\"\n    study_data = [(item[0], item[1]) for item in batch]\n    labels     = torch.stack([item[2] for item in batch], dim=0)\n    study_ids  = [item[3] for item in batch]\n    return study_data, labels, study_ids\n \n \ndef test_collate(batch):\n    study_data = [(item[0], item[1]) for item in batch]\n    study_ids  = [item[2] for item in batch]\n    return study_data, study_ids\n \n \nprint(\"✓ Dataset classes defined\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:04:10.149952Z","iopub.execute_input":"2026-08-28T01:04:10.150658Z","iopub.status.idle":"2026-08-28T01:04:10.172458Z","shell.execute_reply.started":"2026-08-28T01:04:10.150629Z","shell.execute_reply":"2026-08-28T01:04:10.171613Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 4 — MODEL ARCHITECTURE","metadata":{}},{"cell_type":"code","source":"class SliceEncoder(nn.Module):\n    def __init__(self, backbone_name=\"efficientnet_b0\", pretrained=True, out_dim=256):\n        super().__init__()\n        self.backbone = timm.create_model(\n            backbone_name, pretrained=pretrained, num_classes=0, global_pool=\"avg\"\n        )\n        self.proj = nn.Sequential(\n            nn.Linear(self.backbone.num_features, out_dim),\n            nn.LayerNorm(out_dim),\n            nn.GELU()\n        )\n \n    def forward(self, x):   # x: (N_slices, 3, H, W)\n        return self.proj(self.backbone(x))  # (N_slices, out_dim)\n \n \nclass SliceAttentionPool(nn.Module):\n    def __init__(self, dim=256):\n        super().__init__()\n        self.attn = nn.Sequential(nn.Linear(dim, 64), nn.Tanh(), nn.Linear(64, 1))\n \n    def forward(self, x):   # x: (N_slices, dim)\n        w = F.softmax(self.attn(x), dim=0)  # (N_slices, 1)\n        return (w * x).sum(0)               # (dim,)\n \n \nclass SeriesMetaEncoder(nn.Module):\n    def __init__(self, dim=256):\n        super().__init__()\n        # 3 (plane one-hot) + 1 (fluid_sensitive) + 1 (fat_suppression) = 5 inputs\n        self.net = nn.Sequential(nn.Linear(5, 32), nn.ReLU(), nn.Linear(32, dim))\n \n    def forward(self, plane_oh, fluid, fat):\n        meta = torch.cat([plane_oh, fluid.unsqueeze(-1).float(),\n                          fat.unsqueeze(-1).float()], dim=-1)  # (5,)\n        return self.net(meta)  # (dim,)\n \n \nclass StudyAggregator(nn.Module):\n    def __init__(self, dim=256):\n        super().__init__()\n        self.attn = nn.Sequential(nn.Linear(dim, 64), nn.Tanh(), nn.Linear(64, 1))\n \n    def forward(self, x):   # x: (N_series, dim)\n        w = F.softmax(self.attn(x), dim=0)\n        return (w * x).sum(0)  # (dim,)\n \n \nclass KneeAbnormalityModel(nn.Module):\n    \"\"\"\n    2.5D model for 12-label knee abnormality detection.\n    Image-only at inference — text/reports used only during weak label generation.\n    \"\"\"\n    def __init__(self, backbone_name=\"efficientnet_b0\", pretrained=True,\n                 feat_dim=256, n_labels=12, dropout=0.3):\n        super().__init__()\n        self.slice_encoder = SliceEncoder(backbone_name, pretrained, feat_dim)\n        self.slice_pool    = SliceAttentionPool(feat_dim)\n        self.meta_encoder  = SeriesMetaEncoder(feat_dim)\n        self.study_agg     = StudyAggregator(feat_dim)\n        self.dropout       = nn.Dropout(dropout)\n        self.classifier    = nn.Linear(feat_dim, n_labels)\n \n    def encode_series(self, slices, plane_oh, fluid, fat):\n        feat = self.slice_encoder(slices.to(DEVICE))\n        feat = self.slice_pool(feat)\n        meta = self.meta_encoder(plane_oh.to(DEVICE), fluid.to(DEVICE), fat.to(DEVICE))\n        return feat + meta  # (feat_dim,)\n \n    def forward(self, series_list, meta_list):\n        series_feats = torch.stack([\n            self.encode_series(sl, *meta)\n            for sl, meta in zip(series_list, meta_list)\n        ], dim=0)                                # (N_series, feat_dim)\n        study_feat = self.study_agg(series_feats)\n        study_feat = self.dropout(study_feat)\n        return self.classifier(study_feat)       # (n_labels,) logits\n \n \n# ── Instantiate ────────────────────────────────────────────────────────────────\nmodel = KneeAbnormalityModel(\n    backbone_name=CFG[\"backbone\"], pretrained=True,\n    feat_dim=CFG[\"feat_dim\"], n_labels=N_LABELS, dropout=CFG[\"dropout\"]\n).to(DEVICE)\n \nn_params = sum(p.numel() for p in model.parameters())\nprint(f\"✓ Model ready | Parameters: {n_params:,}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:04:16.540067Z","iopub.execute_input":"2026-08-28T01:04:16.540436Z","iopub.status.idle":"2026-08-28T01:04:19.946872Z","shell.execute_reply.started":"2026-08-28T01:04:16.540403Z","shell.execute_reply":"2026-08-28T01:04:19.946173Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 5 — LOSS FUNCTION + OPTIMIZER","metadata":{}},{"cell_type":"code","source":"# ── pos_weight from training fold only ────────────────────────────────────────\ntrain_labels    = train_df[LABEL_COLS].fillna(0).values.astype(np.float32)\npos_counts      = train_labels.sum(axis=0)\nneg_counts      = len(train_df) - pos_counts\nraw_pos_weight  = neg_counts / np.maximum(pos_counts, 1)\nweak_prevalence = pos_counts / len(train_df)\nrecall_est      = np.clip(weak_prevalence / GOLD_PREV_ARRAY, 0.05, 1.0)\npos_weight      = np.clip(raw_pos_weight * recall_est, 1.0, 15.0)\npos_weight_tensor = torch.tensor(pos_weight, dtype=torch.float32).to(DEVICE)\n \nprint(\"pos_weight per label:\")\nfor i, lbl in enumerate(LABEL_COLS):\n    print(f\"  {lbl:20s}: {pos_weight[i]:.2f}\")\n \ncriterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight_tensor, reduction=\"mean\")\n \n# ── Two-group optimizer ────────────────────────────────────────────────────────\nbackbone_params = list(model.slice_encoder.backbone.parameters())\nhead_params = (\n    list(model.slice_encoder.proj.parameters()) +\n    list(model.slice_pool.parameters()) +\n    list(model.meta_encoder.parameters()) +\n    list(model.study_agg.parameters()) +\n    list(model.classifier.parameters())\n)\n \noptimizer = torch.optim.AdamW([\n    {\"params\": backbone_params, \"lr\": CFG[\"lr\"] * 0.1},  # 3e-5\n    {\"params\": head_params,     \"lr\": CFG[\"lr\"]},         # 3e-4\n], weight_decay=1e-2)\n \n# ── Cosine LR schedule with linear warmup ─────────────────────────────────────\ntotal_steps  = CFG[\"n_epochs\"] * (len(train_df) // CFG[\"batch_size\"] + 1)\nwarmup_steps = 1 * (len(train_df) // CFG[\"batch_size\"] + 1)\n \ndef lr_lambda(step):\n    if step < warmup_steps:\n        return step / max(1, warmup_steps)\n    progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)\n    return 0.5 * (1.0 + np.cos(np.pi * progress))\n \nscheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\nscaler    = torch.cuda.amp.GradScaler(enabled=CFG[\"use_amp\"])\n \nprint(f\"✓ Optimizer ready | Backbone LR: {CFG['lr']*0.1:.1e} | Head LR: {CFG['lr']:.1e}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:04:25.497404Z","iopub.execute_input":"2026-08-28T01:04:25.498158Z","iopub.status.idle":"2026-08-28T01:04:25.514792Z","shell.execute_reply.started":"2026-08-28T01:04:25.498109Z","shell.execute_reply":"2026-08-28T01:04:25.514107Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 6 — TRAINING LOOP","metadata":{}},{"cell_type":"code","source":"# ── DataLoaders ────────────────────────────────────────────────────────────────\ntrain_dataset = KneeDataset(train_df, study_to_series, str(TRAIN_SERIES_DIR), CFG, is_train=True)\nval_dataset   = KneeDataset(val_df,   study_to_series, str(TRAIN_SERIES_DIR), CFG, is_train=False)\n \ntrain_loader = DataLoader(train_dataset, batch_size=CFG[\"batch_size\"], shuffle=True,\n                          num_workers=CFG[\"num_workers\"], collate_fn=knee_collate, pin_memory=True)\nval_loader   = DataLoader(val_dataset,   batch_size=CFG[\"batch_size\"], shuffle=False,\n                          num_workers=CFG[\"num_workers\"], collate_fn=knee_collate, pin_memory=True)\n \nprint(f\"Train batches: {len(train_loader)} | Val batches: {len(val_loader)}\")\n \n \ndef evaluate(model, loader, criterion, device):\n    model.eval()\n    all_preds, all_labels, total_loss, n_batches = [], [], 0.0, 0\n\n    with torch.no_grad():\n        for study_data, labels, _ in loader:\n            labels = labels.to(device)\n            batch_logits = []\n            for series_list, meta_list in study_data:\n                with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                    batch_logits.append(model(series_list, meta_list))\n            batch_logits = torch.stack(batch_logits, dim=0)\n\n            # ── NaN guard on logits ────────────────────────────────────────────\n            if torch.isnan(batch_logits).any():\n                batch_logits = torch.nan_to_num(batch_logits, nan=0.0)\n\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                total_loss += criterion(batch_logits, labels).item()\n            n_batches += 1\n\n            probs = torch.sigmoid(batch_logits).cpu().numpy()\n            all_preds.append(probs)\n            all_labels.append(labels.cpu().numpy())\n\n    all_preds  = np.concatenate(all_preds,  axis=0)\n    all_labels = np.concatenate(all_labels, axis=0)\n\n    # ── NaN guard on arrays ────────────────────────────────────────────────────\n    all_preds  = np.nan_to_num(all_preds,  nan=0.5)  # 0.5 = uninformative\n    all_labels = np.nan_to_num(all_labels, nan=0.0)  # 0.0 = assume negative\n\n    per_label_auc, valid_aucs = {}, []\n    for i, lbl in enumerate(LABEL_COLS):\n        y_true = all_labels[:, i]\n        y_pred = all_preds[:, i]\n        if y_true.sum() > 0 and (1 - y_true).sum() > 0:\n            try:\n                auc = roc_auc_score(y_true, y_pred)\n                per_label_auc[lbl] = auc\n                valid_aucs.append(auc)\n            except Exception as e:\n                print(f\"  AUC failed for {lbl}: {e}\")\n                per_label_auc[lbl] = float(\"nan\")\n        else:\n            per_label_auc[lbl] = float(\"nan\")\n\n    return total_loss / max(n_batches, 1), np.nanmean(valid_aucs), per_label_auc\n \n \n# ── Training loop ──────────────────────────────────────────────────────────────\nbest_auc, best_epoch, epoch_log = 0.0, 0, []\n \nprint(f\"\\nStarting training — {CFG['n_epochs']} epochs\")\nprint(f\"Train: {len(train_df)} | Val: {len(val_df)}\")\nprint(\"=\" * 70)\n \nfor epoch in range(CFG[\"n_epochs\"]):\n    model.train()\n    train_loss, n_batches, t0 = 0.0, 0, time.time()\n \n    for batch_idx, (study_data, labels, _) in enumerate(train_loader):\n        labels = labels.to(DEVICE)\n        batch_logits = []\n \n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                batch_logits.append(model(series_list, meta_list))\n \n        batch_logits = torch.stack(batch_logits, dim=0)\n        with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n            loss = criterion(batch_logits, labels)\n \n        optimizer.zero_grad()\n        scaler.scale(loss).backward()\n        scaler.unscale_(optimizer)\n        torch.nn.utils.clip_grad_norm_(model.parameters(), CFG[\"grad_clip\"])\n        scaler.step(optimizer)\n        scaler.update()\n        scheduler.step()\n \n        train_loss += loss.item()\n        n_batches  += 1\n \n        if (batch_idx + 1) % 100 == 0:\n            print(f\"  Epoch {epoch+1} | Batch {batch_idx+1}/{len(train_loader)} \"\n                  f\"| Loss: {train_loss/n_batches:.4f} | {time.time()-t0:.0f}s\")\n \n    val_loss, val_auc, per_label_auc = evaluate(model, val_loader, criterion, DEVICE)\n    epoch_time = time.time() - t0\n \n    epoch_log.append({\"epoch\": epoch+1, \"train_loss\": train_loss/n_batches,\n                       \"val_loss\": val_loss, \"val_auc\": val_auc})\n \n    print(f\"\\nEpoch {epoch+1}/{CFG['n_epochs']} — {epoch_time/60:.1f} min\")\n    print(f\"  Train loss: {train_loss/n_batches:.4f} | Val loss: {val_loss:.4f}\")\n    print(f\"  Val AUC: {val_auc:.4f}  (macro, weak labels)\")\n    for lbl, auc in per_label_auc.items():\n        flag = \" ←\" if not np.isnan(auc) and auc < 0.55 else \"\"\n        print(f\"    {lbl:20s}: {auc:.4f}{flag}\")\n \n    if val_auc > best_auc:\n        best_auc, best_epoch = val_auc, epoch + 1\n        torch.save({\n            \"epoch\": epoch + 1,\n            \"model_state\": model.state_dict(),\n            \"val_auc\": val_auc,\n            \"cfg\": CFG,\n        }, checkpoint_path)\n        print(f\"  ✓ New best — checkpoint saved (AUC: {val_auc:.4f})\")\n \n    print(\"=\" * 70)\n \nprint(f\"\\nTraining complete.\")\nprint(f\"Best val AUC: {best_auc:.4f} at epoch {best_epoch}\")\nprint(f\"Checkpoint: {checkpoint_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T01:04:28.298517Z","iopub.execute_input":"2026-08-28T01:04:28.299473Z","iopub.status.idle":"2026-08-28T03:01:29.698109Z","shell.execute_reply.started":"2026-08-28T01:04:28.299429Z","shell.execute_reply":"2026-08-28T03:01:29.696522Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 7 — TRAINING DIAGNOSTICS (optional, runs after training)","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport matplotlib.patches as mpatches\n \nepochs_x   = [d[\"epoch\"]      for d in epoch_log]\ntrain_loss = [d[\"train_loss\"] for d in epoch_log]\nval_loss   = [d[\"val_loss\"]   for d in epoch_log]\nval_auc    = [d[\"val_auc\"]    for d in epoch_log]\n \nfig, axes = plt.subplots(1, 2, figsize=(12, 4))\nfig.suptitle(\"RSNA Knee — Training Diagnostics\", fontsize=12, fontweight=\"bold\")\n \nax = axes[0]\nax.plot(epochs_x, train_loss, \"b-o\", label=\"Train loss\")\nax.plot(epochs_x, val_loss,   \"r-o\", label=\"Val loss\")\nax.axvline(x=best_epoch, color=\"green\", linestyle=\"--\", label=f\"Best epoch ({best_epoch})\")\nax.set_xlabel(\"Epoch\"); ax.set_ylabel(\"BCE Loss\"); ax.set_title(\"Loss Curves\")\nax.legend(); ax.grid(True, alpha=0.3)\n \nax = axes[1]SSS\nax.plot(epochs_x, val_auc, \"g-o\")\nax.axvline(x=best_epoch, color=\"green\", linestyle=\"--\", label=f\"Best epoch ({best_epoch})\")\nax.axhline(y=0.5, color=\"gray\", linestyle=\":\", label=\"Random baseline\")\nax.set_xlabel(\"Epoch\"); ax.set_ylabel(\"Macro AUC\"); ax.set_title(\"Validation AUC\")\nax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim(0.45, 0.70)\n \nplt.tight_layout()\nplt.savefig(WORK_DIR / \"training_curves.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\nprint(\"✓ Training curves saved\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T03:16:38.913433Z","iopub.execute_input":"2026-08-28T03:16:38.914045Z","iopub.status.idle":"2026-08-28T03:16:39.681777Z","shell.execute_reply.started":"2026-08-28T03:16:38.914006Z","shell.execute_reply":"2026-08-28T03:16:39.680986Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 8 — CALIBRATION","metadata":{}},{"cell_type":"code","source":"# ── Load best checkpoint ───────────────────────────────────────────────────────\ncheckpoint = torch.load(checkpoint_path, map_location=DEVICE, weights_only=False)\nmodel.load_state_dict(checkpoint[\"model_state\"])\nprint(f\"✓ Loaded checkpoint: epoch {checkpoint['epoch']} (AUC: {checkpoint['val_auc']:.4f})\")\n \n# ── Get gold-labeled studies ───────────────────────────────────────────────────\ndf_all    = pd.read_csv(COMP_DIR / \"train.csv\")\ngold_mask = df_all[LABEL_COLS].notnull().all(axis=1)\ngold_df   = df_all[gold_mask].reset_index(drop=True)\nprint(f\"Gold studies for calibration: {len(gold_df)}\")\n \ngold_s2s = (\n    train_series\n    .groupby(\"StudyInstanceUID\")\n    .apply(lambda g: g[_cols].to_dict(\"records\"))\n    .to_dict()\n)\n \ngold_dataset = KneeTestDataset(\n    df=gold_df[[\"StudyInstanceUID\"]], study_to_series=gold_s2s,\n    series_base_dir=str(TRAIN_SERIES_DIR), cfg=CFG\n)\ngold_loader = DataLoader(gold_dataset, batch_size=4, shuffle=False,\n                         num_workers=2, collate_fn=test_collate)\n \n# ── Inference on gold set ──────────────────────────────────────────────────────\nmodel.eval()\ngold_preds_raw, gold_ids = [], []\n \nwith torch.no_grad():\n    for study_data, study_ids in gold_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                batch_logits.append(model(series_list, meta_list))\n        probs = torch.sigmoid(torch.stack(batch_logits, dim=0)).cpu().numpy()\n        gold_preds_raw.append(probs)\n        gold_ids.extend(study_ids)\n \ngold_preds_raw = np.concatenate(gold_preds_raw, axis=0)  # (58, 12)\ngold_df_idx    = gold_df.set_index(\"StudyInstanceUID\")\nprint(f\"Gold predictions shape: {gold_preds_raw.shape}\")\n \n# ── Fit per-label isotonic calibrators ────────────────────────────────────────\ncalibrators = {}\nprint(f\"\\n{'Label':20s} {'n_pos':>6} {'n_neg':>6} {'method':>14}\")\nprint(\"-\" * 50)\n \nfor i, lbl in enumerate(LABEL_COLS):\n    y_true = gold_df_idx.loc[gold_ids, lbl].values.astype(float)\n    y_raw  = gold_preds_raw[:, i]\n    n_pos  = int(y_true.sum())\n    n_neg  = int((1 - y_true).sum())\n \n    if n_pos >= 2 and n_neg >= 2:\n        cal = IsotonicRegression(out_of_bounds=\"clip\")\n        cal.fit(y_raw, y_true)\n        calibrators[lbl] = (\"isotonic\", cal)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'isotonic':>14}\")\n    else:\n        scale = GOLD_PREVALENCE[lbl] / max(y_raw.mean(), 1e-6)\n        calibrators[lbl] = (\"scale\", scale)\n        print(f\"{lbl:20s} {n_pos:>6} {n_neg:>6} {'prior_scale':>14} (×{scale:.1f})\")\n \n \ndef calibrate(raw: np.ndarray) -> np.ndarray:\n    \"\"\"Apply per-label calibration. Input/output: (N, 12) in [0, 1].\"\"\"\n    out = np.zeros_like(raw)\n    for i, lbl in enumerate(LABEL_COLS):\n        method, obj = calibrators[lbl]\n        if method == \"isotonic\":\n            out[:, i] = obj.predict(raw[:, i])\n        else:\n            out[:, i] = np.clip(raw[:, i] * obj, 0, 1)\n    return out\n \n \n# ── Validate calibration ───────────────────────────────────────────────────────\ncal_gold = calibrate(gold_preds_raw)\nprint(f\"\\n{'Label':20s} {'raw_mean':>10} {'cal_mean':>10} {'gold_prev':>10}\")\nprint(\"-\" * 54)\nfor i, lbl in enumerate(LABEL_COLS):\n    print(f\"{lbl:20s} {gold_preds_raw[:,i].mean():>10.3f} \"\n          f\"{cal_gold[:,i].mean():>10.3f} {GOLD_PREVALENCE[lbl]:>10.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T03:17:41.427173Z","iopub.execute_input":"2026-08-28T03:17:41.427967Z","iopub.status.idle":"2026-08-28T03:18:07.736641Z","shell.execute_reply.started":"2026-08-28T03:17:41.42789Z","shell.execute_reply":"2026-08-28T03:18:07.735466Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# SECTION 9 — INFERENCE + SAVE SUBMISSION","metadata":{}},{"cell_type":"code","source":"# ── Build test DataLoader ──────────────────────────────────────────────────────\ntest_dataset = KneeTestDataset(\n    df=test_df, study_to_series=test_study_to_series,\n    series_base_dir=str(TEST_SERIES_DIR), cfg=CFG\n)\ntest_loader = DataLoader(test_dataset, batch_size=4, shuffle=False,\n                         num_workers=CFG[\"num_workers\"], collate_fn=test_collate)\n \nprint(f\"Test studies: {len(test_df)} | Test batches: {len(test_loader)}\")\n \n# ── Inference ──────────────────────────────────────────────────────────────────\nmodel.eval()\nall_raw, all_ids = [], []\n \nwith torch.no_grad():\n    for study_data, study_ids in test_loader:\n        batch_logits = []\n        for series_list, meta_list in study_data:\n            with torch.cuda.amp.autocast(enabled=CFG[\"use_amp\"]):\n                batch_logits.append(model(series_list, meta_list))\n        probs = torch.sigmoid(torch.stack(batch_logits, dim=0)).cpu().numpy()\n        all_raw.append(probs)\n        all_ids.extend(study_ids)\n \nall_raw = np.concatenate(all_raw, axis=0)\nall_cal = calibrate(all_raw)\n \nprint(\"\\nRaw vs Calibrated predictions (test set):\")\nprint(f\"{'Label':20s} {'raw':>8} {'cal':>8} {'target':>8}\")\nprint(\"-\" * 48)\nfor i, lbl in enumerate(LABEL_COLS):\n    print(f\"{lbl:20s} {all_raw[:,i].mean():>8.3f} \"\n          f\"{all_cal[:,i].mean():>8.3f} {GOLD_PREVALENCE[lbl]:>8.3f}\")\n \n# ── Save calibrated submission ─────────────────────────────────────────────────\nsubmission = pd.DataFrame(all_cal, columns=LABEL_COLS)\nsubmission.insert(0, \"StudyInstanceUID\", all_ids)\n \n# Sanity checks\nassert len(submission) == len(test_df), \"Row count mismatch!\"\nassert list(submission.columns) == [\"StudyInstanceUID\"] + LABEL_COLS, \"Column mismatch!\"\nassert submission[LABEL_COLS].apply(lambda c: c.between(0, 1).all()).all(), \"Values out of [0,1]!\"\n \nsubmission.to_csv(WORK_DIR / \"submission_calibrated.csv\", index=False)\nprint(f\"\\n✓ submission_calibrated.csv saved ({len(submission)} rows)\")\nprint(submission.head(3).to_string())\n \n# ── Format check against sample ───────────────────────────────────────────────\nsample = pd.read_csv(COMP_DIR / \"sample_submission.csv\")\nprint(f\"\\nFormat check vs sample_submission:\")\nprint(f\"  Same columns: {list(sample.columns) == list(submission.columns)}\")\nprint(f\"  ✓ Ready to submit\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-28T03:18:57.426216Z","iopub.execute_input":"2026-08-28T03:18:57.42707Z","iopub.status.idle":"2026-08-28T03:18:59.149684Z","shell.execute_reply.started":"2026-08-28T03:18:57.427031Z","shell.execute_reply":"2026-08-28T03:18:59.148688Z"}},"outputs":[],"execution_count":null}]}