{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"e3e97fae","cell_type":"markdown","source":"\n# RSNA Knee MRI — 12-Finding Multi-Label Classification\n\n**Goal:** predict a per-study probability for 12 knee MRI findings (ACL, MCL, Medial Meniscus,\nLateral Meniscus, Medial OA, Lateral OA, PF OA, Effusion, Synovitis, Baker's, Contusion, Fracture),\nscored by **macro-averaged AUC-ROC**.\n\n### What this notebook does, top to bottom\n1. **EDA** — label prevalence, series-per-study structure, imaging-plane / sequence-type mix, report\n   language mix, and *visual* inspection of actual slices (including CLAHE before/after).\n2. **OpenCV preprocessing pipeline** — normalize → denoise → CLAHE → resize/pad — with visualizations\n   at every step so you can see exactly what each operation changes.\n3. **Weak supervision from reports** — a lightweight keyword labeler (baseline) plus a slot for a\n   multilingual transformer labeler, used ONLY to expand the training set. Reports are **not**\n   available at test time, so nothing text-based ends up in the inference path.\n4. **Handcrafted OpenCV features** — joint-space width (OA), fluid-region area (effusion/Baker's),\n   bone-marrow intensity stats (contusion) — engineered signals that are cheap, interpretable, and\n   complementary to deep features.\n5. **Deep model** — a slice → series → study Multiple-Instance-Learning (MIL) architecture built on\n   a 2D CNN backbone, with attention pooling at both aggregation levels.\n6. **Training loop** — per-label BCE loss with class weighting, multilabel-stratified CV.\n7. **Ensembling** — CNN + handcrafted-feature GBM, blended per label.\n8. **Runtime-safe inference / submission** — respects the Kaggle Notebooks-only, ≤9-hour,\n   no-internet constraints of this competition.\n\n\n","metadata":{}},{"id":"5fd4c8cd","cell_type":"markdown","source":"\n## Pipeline Architecture\n\n```\n┌──────────────────────────────────────────────────────────────────────────┐\n│                              TRAINING TIME ONLY                          │\n│                                                                            │\n│   Radiology Report (text) ──► Keyword / Transformer Labeler              │\n│                                        │                                  │\n│                                        ▼                                  │\n│                          Pseudo-labels for UNLABELED studies             │\n│                          (confidence-weighted, merged with               │\n│                           ground-truth labels for labeled studies)       │\n└──────────────────────────────────────────────────────────────────────────┘\n                                        │\n                                        ▼\n┌──────────────────────────────────────────────────────────────────────────┐\n│                         TRAINING + INFERENCE (shared)                    │\n│                                                                            │\n│  DICOM slice ─► OpenCV preprocessing ─► 2.5D triplet (slice, prev, next)  │\n│       (per slice, per series)                                            │\n│                     │                                                    │\n│                     ▼                                                    │\n│           ┌─────────────────────┐        ┌───────────────────────────┐  │\n│           │   CNN slice encoder │        │  Handcrafted OpenCV        │  │\n│           │ (EfficientNet, etc.)│        │  features per series:      │  │\n│           └─────────┬───────────┘        │  joint-space width,        │  │\n│                     │ slice embeddings   │  fluid-area, bone-marrow   │  │\n│                     ▼                     │  intensity stats           │  │\n│         Attention MIL pooling            └─────────────┬───────────────┘ │\n│         (slice ─► series embedding)                    │                 │\n│                     │                                   │                 │\n│                     ▼                                   │                 │\n│   series embedding ⊕ series metadata                    │                 │\n│   (plane, fluid-sensitive, fat-sat)                      │                 │\n│                     │                                   │                 │\n│                     ▼                                   │                 │\n│         Attention MIL pooling                            │                │\n│         (series ─► study embedding)                      │                │\n│                     │                                    │                │\n│                     ▼                                    ▼                │\n│              12-way sigmoid head                 Per-label LightGBM       │\n│              (deep model prediction)               (handcrafted feats)    │\n│                     │                                    │                │\n│                     └──────────────┬─────────────────────┘                │\n│                                    ▼                                      │\n│                    Per-label weighted blend (Nelder–Mead                 │\n│                    optimized on held-out fold, per label)                 │\n│                                    │                                      │\n│                                    ▼                                      │\n│                         submission.csv (12 probabilities/study)          │\n└──────────────────────────────────────────────────────────────────────────┘\n```\n\n**Why two model families feeding one blend?** The CNN+MIL path learns whatever visual pattern\ncorrelates with a finding, including subtle texture the human eye/rules might miss. The handcrafted\npath directly measures things radiologists measure (e.g. joint-space width for OA) — it's weaker\nalone but fails in *different* cases than the CNN, so blending typically buys real AUC, the same way\nCatBoost/LightGBM/XGBoost blending helps in tabular competitions.\n","metadata":{}},{"id":"a49851cb","cell_type":"markdown","source":"## 1. Setup: Imports & Config","metadata":{}},{"id":"7cad928b-7361-415b-b6a1-2fa9593f2e04","cell_type":"code","source":"!pip install pydicom pylibjpeg pylibjpeg-libjpeg python-gdcm timm lightgbm iterative-stratification --quiet","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:53.363436Z","iopub.execute_input":"2026-08-27T05:57:53.363765Z","iopub.status.idle":"2026-08-27T05:57:54.462642Z","shell.execute_reply.started":"2026-08-27T05:57:53.363746Z","shell.execute_reply":"2026-08-27T05:57:54.461669Z"}},"outputs":[],"execution_count":null},{"id":"eff9206b","cell_type":"code","source":"\nimport os\nimport re\nimport json\nimport random\nimport warnings\nfrom pathlib import Path\nfrom collections import Counter, defaultdict\n\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport pydicom\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\n\nwarnings.filterwarnings(\"ignore\")\n\n# ---- Reproducibility ----\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\n\n# ---- Paths (EDIT THESE to point at the competition data) ----\nDATA_DIR = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")                       # root folder of the competition dataset\nTRAIN_CSV = DATA_DIR / \"train.csv\"\nTRAIN_SERIES_CSV = DATA_DIR / \"train_series.csv\"\nTRAIN_SERIES_DIR = DATA_DIR / \"train_series\"\nTEST_CSV = DATA_DIR / \"test.csv\"\nTEST_SERIES_CSV = DATA_DIR / \"test_series.csv\"\nTEST_SERIES_DIR = DATA_DIR / \"test_series\"\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]\n\nIMG_SIZE = 384          # model input resolution after resize/pad\nMAX_SLICES_PER_SERIES = 32   # cap used both for training speed and inference runtime safety\n\n\ndef _exists(p):\n    return Path(p).exists()\n\n\nprint(\"Data dir configured at:\", DATA_DIR.resolve())\nfor p, name in [(TRAIN_CSV, \"train.csv\"), (TRAIN_SERIES_CSV, \"train_series.csv\"),\n                (TRAIN_SERIES_DIR, \"train_series/\"), (TEST_CSV, \"test.csv\")]:\n    print(f\"  {'FOUND   ' if _exists(p) else 'MISSING '} {name}  ->  {p}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:54.463487Z","iopub.execute_input":"2026-08-27T05:57:54.463671Z","iopub.status.idle":"2026-08-27T05:57:54.474272Z","shell.execute_reply.started":"2026-08-27T05:57:54.463652Z","shell.execute_reply":"2026-08-27T05:57:54.473461Z"}},"outputs":[],"execution_count":null},{"id":"9630b6f6","cell_type":"markdown","source":"\n## 2. Exploratory Data Analysis\n\n### 2.1 Label prevalence\n\nUnderstanding class balance drives two later decisions: (a) per-label `pos_weight` in the loss\nfunction, and (b) how much we should lean on pseudo-labeling to get more positive examples for the\nrarer findings (fracture, contusion, Baker's cyst are typically the rarest in knee MRI cohorts).\n","metadata":{}},{"id":"e0a3d70d","cell_type":"code","source":"\nif _exists(TRAIN_CSV):\n    train_df = pd.read_csv(TRAIN_CSV)\n    print(f\"train.csv: {len(train_df)} studies\")\n    print(train_df.head())\n\n    labeled_mask = train_df[LABEL_COLS].notna().all(axis=1)\n    print(f\"\\nStudies with full 12-label annotations: {labeled_mask.sum()} / {len(train_df)} \"\n          f\"({labeled_mask.mean()*100:.1f}%)\")\n\n    prevalence = train_df.loc[labeled_mask, LABEL_COLS].mean().sort_values(ascending=False)\n\n    fig, ax = plt.subplots(figsize=(9, 5))\n    bars = ax.barh(prevalence.index[::-1], prevalence.values[::-1], color=\"#4C72B0\")\n    ax.set_xlabel(\"Positive rate among labeled studies\")\n    ax.set_title(\"Label prevalence (labeled subset)\")\n    for bar, val in zip(bars, prevalence.values[::-1]):\n        ax.text(val + 0.005, bar.get_y() + bar.get_height()/2, f\"{val:.1%}\",\n                va=\"center\", fontsize=9)\n    plt.tight_layout()\n    plt.show()\nelse:\n    print(\"train.csv not found yet — point DATA_DIR at the competition data to run EDA.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:54.474715Z","iopub.execute_input":"2026-08-27T05:57:54.474862Z","iopub.status.idle":"2026-08-27T05:57:54.762196Z","shell.execute_reply.started":"2026-08-27T05:57:54.474849Z","shell.execute_reply":"2026-08-27T05:57:54.761366Z"}},"outputs":[],"execution_count":null},{"id":"85db85fc","cell_type":"markdown","source":"\n### 2.2 Report language mix\n\nReports come from a diverse set of international sites, so before building any text-based\npseudo-labeler we need to know which languages we're actually dealing with — this determines\nwhether a single multilingual model suffices or per-language handling is needed.\n","metadata":{}},{"id":"e94fa65b","cell_type":"code","source":"\ndef guess_language_signal(text):\n    '''Cheap heuristic language signal (NOT a real detector) — counts characteristic\n    stopwords per language so we get a quick distribution without adding a new dependency.\n    Swap this for `langdetect` or `fasttext` lid.176 for a real breakdown.'''\n    if not isinstance(text, str) or not text.strip():\n        return \"unknown\"\n    t = text.lower()\n    signals = {\n        \"en\": [\" the \", \" and \", \" no \", \" with \", \" tear \"],\n        \"es\": [\" el \", \" la \", \" sin \", \" con \", \" rotura \"],\n        \"fr\": [\" le \", \" la \", \" sans \", \" avec \", \" déchirure \"],\n        \"de\": [\" der \", \" die \", \" und \", \" ohne \", \" riss \"],\n        \"pt\": [\" o \", \" a \", \" sem \", \" com \", \" ruptura \"],\n    }\n    scores = {lang: sum(t.count(w) for w in words) for lang, words in signals.items()}\n    best = max(scores, key=scores.get)\n    return best if scores[best] > 0 else \"unknown\"\n\n\nif _exists(TRAIN_CSV) and \"Report\" in train_df.columns:\n    train_df[\"lang_guess\"] = train_df[\"Report\"].apply(guess_language_signal)\n    lang_counts = train_df[\"lang_guess\"].value_counts()\n    print(lang_counts)\n\n    fig, ax = plt.subplots(figsize=(6, 4))\n    lang_counts.plot(kind=\"bar\", ax=ax, color=\"#55A868\")\n    ax.set_title(\"Approx. report language mix (heuristic — verify with langdetect/fasttext)\")\n    ax.set_ylabel(\"# studies\")\n    plt.tight_layout()\n    plt.show()\n\n    print(\"\\nSample report (first 300 chars):\")\n    print(train_df[\"Report\"].dropna().iloc[0][:300])\nelse:\n    print(\"Skipping — train.csv / Report column not available yet.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:54.762966Z","iopub.execute_input":"2026-08-27T05:57:54.763128Z","iopub.status.idle":"2026-08-27T05:57:55.042731Z","shell.execute_reply.started":"2026-08-27T05:57:54.763113Z","shell.execute_reply":"2026-08-27T05:57:55.041858Z"}},"outputs":[],"execution_count":null},{"id":"b1668d4e","cell_type":"markdown","source":"\n### 2.3 Series-level structure\n\nEach study has multiple series varying in imaging plane and sequence type. This distribution\ndirectly informs how we route series to the model (e.g. fluid-sensitive sagittal/coronal series\nmatter most for ligament/meniscus tears; non-fluid-sensitive series matter more for OA/cartilage).\n","metadata":{}},{"id":"1f7622e6","cell_type":"code","source":"\nif _exists(TRAIN_SERIES_CSV):\n    series_df = pd.read_csv(TRAIN_SERIES_CSV)\n    print(f\"train_series.csv: {len(series_df)} series across \"\n          f\"{series_df['StudyInstanceUID'].nunique()} studies\")\n    print(series_df.head())\n\n    series_per_study = series_df.groupby(\"StudyInstanceUID\").size()\n\n    fig, axes = plt.subplots(1, 3, figsize=(16, 4))\n\n    axes[0].hist(series_per_study, bins=range(1, series_per_study.max() + 2), color=\"#C44E52\")\n    axes[0].set_title(\"Series per study\")\n    axes[0].set_xlabel(\"# series\")\n\n    plane_counts = series_df[\"Anatomical_Plane\"].value_counts()\n    axes[1].bar(plane_counts.index, plane_counts.values, color=\"#8172B2\")\n    axes[1].set_title(\"Series by anatomical plane\")\n\n    combo = series_df.groupby([\"Fluid_Sensitive\", \"Fat_Suppression\"]).size()\n    axes[2].bar([str(k) for k in combo.index], combo.values, color=\"#CCB974\")\n    axes[2].set_title(\"Fluid-sensitive x Fat-sat combos\")\n    axes[2].set_xlabel(\"(Fluid_Sensitive, Fat_Suppression)\")\n    axes[2].tick_params(axis=\"x\", rotation=30)\n\n    plt.tight_layout()\n    plt.show()\nelse:\n    print(\"train_series.csv not found yet.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:55.043247Z","iopub.execute_input":"2026-08-27T05:57:55.043425Z","iopub.status.idle":"2026-08-27T05:57:55.385046Z","shell.execute_reply.started":"2026-08-27T05:57:55.043411Z","shell.execute_reply":"2026-08-27T05:57:55.384016Z"}},"outputs":[],"execution_count":null},{"id":"6ac39c4d","cell_type":"markdown","source":"\n### 2.4 Slice-count distribution (why we cap `MAX_SLICES_PER_SERIES`)\n\nSeries typically hold 20–45 slices but the tail runs to a few hundred. Uncapped, that long tail\nwould dominate both training time and the 9-hour inference budget for a handful of outlier series\nwithout adding proportional signal — hence the uniform-subsampling cap defined in Config.\n","metadata":{}},{"id":"a42ec307","cell_type":"code","source":"\ndef count_slices(series_dir):\n    try:\n        return len(list(Path(series_dir).glob(\"*.dcm\")))\n    except Exception:\n        return 0\n\nif _exists(TRAIN_SERIES_DIR) and _exists(TRAIN_SERIES_CSV):\n    sample_rows = series_df.sample(min(200, len(series_df)), random_state=SEED)\n    slice_counts = []\n    for _, row in sample_rows.iterrows():\n        d = TRAIN_SERIES_DIR / row[\"StudyInstanceUID\"] / row[\"SeriesInstanceUID\"]\n        slice_counts.append(count_slices(d))\n\n    fig, ax = plt.subplots(figsize=(7, 4))\n    ax.hist(slice_counts, bins=30, color=\"#64B5CD\")\n    ax.axvline(MAX_SLICES_PER_SERIES, color=\"red\", linestyle=\"--\",\n               label=f\"MAX_SLICES_PER_SERIES = {MAX_SLICES_PER_SERIES}\")\n    ax.set_title(f\"Slice count per series (sample of {len(sample_rows)})\")\n    ax.set_xlabel(\"# slices\")\n    ax.legend()\n    plt.tight_layout()\n    plt.show()\nelse:\n    print(\"Skipping — need train_series/ on disk to count actual slice files.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:55.385604Z","iopub.execute_input":"2026-08-27T05:57:55.385774Z","iopub.status.idle":"2026-08-27T05:57:58.457667Z","shell.execute_reply.started":"2026-08-27T05:57:55.385758Z","shell.execute_reply":"2026-08-27T05:57:58.456613Z"}},"outputs":[],"execution_count":null},{"id":"da53ed93","cell_type":"markdown","source":"\n## 3. OpenCV Preprocessing Pipeline\n\nEach function below does **one job**. We visualize the effect of each step on a real slice so it's\nobvious what's changing and why, before any of this feeds a model.\n\n| Step | OpenCV op | Why |\n|---|---|---|\n| Percentile-clip normalize | `np.percentile` + linear rescale | MRI intensities are arbitrary/relative (no HU scale); a few hot pixels wreck naive min-max |\n| Denoise | `cv2.fastNlMeansDenoising` | MRI has more speckle/thermal noise than CT |\n| CLAHE | `cv2.createCLAHE` | Local contrast enhancement — brings out subtle low-contrast findings (small osteophytes, thin ligament fiber discontinuity) without blowing out the rest of the image |\n| Resize + pad | `cv2.resize` (`INTER_AREA`) + `cv2.copyMakeBorder` | Standardizes size while preserving aspect ratio, so anatomy/shape cues used for ligament & meniscus assessment aren't distorted |\n","metadata":{}},{"id":"f8c2032c","cell_type":"code","source":"\ndef load_dicom_slice(path):\n    '''Read a single .dcm, return raw pixel array (float32) + the pydicom dataset.'''\n    ds = pydicom.dcmread(path, force=True)\n    arr = ds.pixel_array.astype(np.float32)\n    slope = float(getattr(ds, \"RescaleSlope\", 1.0))\n    intercept = float(getattr(ds, \"RescaleIntercept\", 0.0))\n    return arr * slope + intercept, ds\n\n\ndef normalize_mri_slice(arr, clip_pct=(0.5, 99.5)):\n    '''Percentile-clip then rescale to 0-255 uint8.'''\n    lo, hi = np.percentile(arr, clip_pct)\n    hi = max(hi, lo + 1e-5)\n    arr = np.clip(arr, lo, hi)\n    return ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)\n\n\ndef denoise(img_u8, h=6, template_window=7, search_window=21):\n    '''Non-local-means denoise. Lower `h` (e.g. 3-4) if it blurs fine fiber detail too much.'''\n    return cv2.fastNlMeansDenoising(img_u8, None, h, template_window, search_window)\n\n\ndef enhance_contrast(img_u8, clip_limit=2.0, tile_grid=(8, 8)):\n    '''CLAHE local contrast enhancement.'''\n    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid)\n    return clahe.apply(img_u8)\n\n\ndef resize_and_pad(img_u8, target=IMG_SIZE):\n    '''Aspect-ratio-preserving resize + zero-pad to a square of side `target`.'''\n    h, w = img_u8.shape\n    scale = target / max(h, w)\n    nh, nw = max(1, int(round(h * scale))), max(1, int(round(w * scale)))\n    resized = cv2.resize(img_u8, (nw, nh), interpolation=cv2.INTER_AREA)\n    top = (target - nh) // 2\n    bottom = target - nh - top\n    left = (target - nw) // 2\n    right = target - nw - left\n    return cv2.copyMakeBorder(resized, top, bottom, left, right,\n                               borderType=cv2.BORDER_CONSTANT, value=0)\n\n\ndef preprocess_slice(path, target=IMG_SIZE, denoise_flag=True, clahe_flag=True):\n    '''Full per-slice pipeline: DICOM path -> model-ready uint8 image.'''\n    arr, ds = load_dicom_slice(path)\n    img = normalize_mri_slice(arr)\n    if denoise_flag:\n        img = denoise(img)\n    if clahe_flag:\n        img = enhance_contrast(img)\n    return resize_and_pad(img, target=target), ds\n\n\nprint(\"Preprocessing functions defined: normalize_mri_slice, denoise, enhance_contrast, \"\n      \"resize_and_pad, preprocess_slice\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:58.458305Z","iopub.execute_input":"2026-08-27T05:57:58.458485Z","iopub.status.idle":"2026-08-27T05:57:58.465894Z","shell.execute_reply.started":"2026-08-27T05:57:58.45847Z","shell.execute_reply":"2026-08-27T05:57:58.464861Z"}},"outputs":[],"execution_count":null},{"id":"4f4e7bdb","cell_type":"markdown","source":"### 3.1 Visualize each preprocessing step on a real slice","metadata":{}},{"id":"e2e9bcbc","cell_type":"code","source":"\ndef find_a_sample_dicom():\n    '''Grab the first .dcm file found under TRAIN_SERIES_DIR for demo visualization.'''\n    if not _exists(TRAIN_SERIES_DIR):\n        return None\n    for study_dir in Path(TRAIN_SERIES_DIR).iterdir():\n        if not study_dir.is_dir():\n            continue\n        for series_dir in study_dir.iterdir():\n            files = sorted(series_dir.glob(\"*.dcm\"))\n            if files:\n                return files[len(files) // 2]   # a middle slice tends to show more anatomy\n    return None\n\n\nsample_path = find_a_sample_dicom()\n\nif sample_path is not None:\n    raw_arr, _ = load_dicom_slice(sample_path)\n    norm = normalize_mri_slice(raw_arr)\n    denoised = denoise(norm)\n    clahe_img = enhance_contrast(denoised)\n    final_img = resize_and_pad(clahe_img)\n\n    fig, axes = plt.subplots(1, 5, figsize=(20, 4.5))\n    steps = [\n        (\"1. Raw pixel array\", raw_arr, \"gray\"),\n        (\"2. Percentile-normalized\", norm, \"gray\"),\n        (\"3. Denoised\", denoised, \"gray\"),\n        (\"4. + CLAHE\", clahe_img, \"gray\"),\n        (\"5. Resized + padded (model input)\", final_img, \"gray\"),\n    ]\n    for ax, (title, img, cmap) in zip(axes, steps):\n        ax.imshow(img, cmap=cmap)\n        ax.set_title(title, fontsize=10)\n        ax.axis(\"off\")\n    plt.suptitle(f\"Preprocessing pipeline on: {sample_path.name}\", y=1.05)\n    plt.tight_layout()\n    plt.show()\nelse:\n    print(\"No DICOM files found under TRAIN_SERIES_DIR yet — nothing to visualize.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:58.466426Z","iopub.execute_input":"2026-08-27T05:57:58.466587Z","iopub.status.idle":"2026-08-27T05:57:59.279643Z","shell.execute_reply.started":"2026-08-27T05:57:58.466574Z","shell.execute_reply":"2026-08-27T05:57:59.27854Z"}},"outputs":[],"execution_count":null},{"id":"7cbd7815","cell_type":"markdown","source":"\n### 3.2 Visualize related slices within the same series\n\nSince findings like ACL tears or effusion are only visible on a handful of slices, it helps to look\nat a small neighborhood of *related* slices together (not just one) — this is exactly the context\nthe slice→series MIL attention pooling will later learn to weight automatically.\n","metadata":{}},{"id":"2b2b8079","cell_type":"code","source":"\ndef visualize_series_grid(series_dir, n_show=9, cols=3):\n    '''Show a uniformly-sampled grid of preprocessed slices from one series so related\n    slices can be inspected together.'''\n    files = sorted(Path(series_dir).glob(\"*.dcm\"))\n    if not files:\n        print(\"No files found in\", series_dir)\n        return\n    idx = np.linspace(0, len(files) - 1, min(n_show, len(files))).astype(int)\n    chosen = [files[i] for i in idx]\n\n    rows = int(np.ceil(len(chosen) / cols))\n    fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 4 * rows))\n    axes = np.array(axes).reshape(-1)\n    for ax, f in zip(axes, chosen):\n        img, _ = preprocess_slice(f)\n        ax.imshow(img, cmap=\"gray\")\n        ax.set_title(f.name, fontsize=8)\n        ax.axis(\"off\")\n    for ax in axes[len(chosen):]:\n        ax.axis(\"off\")\n    plt.suptitle(f\"Related slices in series: {Path(series_dir).name}\", y=1.02)\n    plt.tight_layout()\n    plt.show()\n\n\nif sample_path is not None:\n    visualize_series_grid(sample_path.parent, n_show=9)\nelse:\n    print(\"Skipping — no sample series available yet.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:57:59.280358Z","iopub.execute_input":"2026-08-27T05:57:59.280589Z","iopub.status.idle":"2026-08-27T05:58:00.926792Z","shell.execute_reply.started":"2026-08-27T05:57:59.280573Z","shell.execute_reply":"2026-08-27T05:58:00.925791Z"}},"outputs":[],"execution_count":null},{"id":"3a472b43","cell_type":"markdown","source":"\n## 4. Weak Supervision: Report → Pseudo-labels (training-time only)\n\n`Report` is **not present at test time**, so nothing in this section is used at inference. Its only\njob is to expand our labeled training set beyond the small hand-labeled subset.\n\n**Approach:**\n1. A cheap multilingual keyword labeler as a fast baseline / sanity check.\n2. (Slot for) a fine-tuned multilingual transformer (e.g. `xlm-roberta-base`) trained on the\n   hand-labeled subset, used to label the rest.\n3. Only keep pseudo-labels above a confidence threshold, and down-weight them relative to\n   ground-truth labels during CNN training.\n","metadata":{}},{"id":"451c60fd","cell_type":"code","source":"\n# Keyword dictionaries per language — extend as you inspect more reports in EDA above.\n# This is intentionally simple/high-precision; treat it as a baseline & pseudo-label sanity check,\n# not the final labeler.\nKEYWORDS = {\n    \"ACL\": [\"acl tear\", \"acl rupture\", \"anterior cruciate\", \"rotura del lca\", \"déchirure lca\"],\n    \"MCL\": [\"mcl tear\", \"medial collateral\", \"ligamento colateral medial\"],\n    \"Medial Meniscus\": [\"medial meniscus tear\", \"medial meniscal tear\", \"menisco medial\"],\n    \"Lateral Meniscus\": [\"lateral meniscus tear\", \"lateral meniscal tear\", \"menisco lateral\"],\n    \"Medial OA\": [\"medial compartment osteoarthritis\", \"medial joint space narrowing\"],\n    \"Lateral OA\": [\"lateral compartment osteoarthritis\", \"lateral joint space narrowing\"],\n    \"PF OA\": [\"patellofemoral osteoarthritis\", \"patellofemoral joint space narrowing\"],\n    \"Effusion\": [\"joint effusion\", \"knee effusion\", \"derrame articular\", \"épanchement\"],\n    \"Synovitis\": [\"synovitis\", \"synovial thickening\", \"sinovitis\"],\n    \"Baker's\": [\"baker's cyst\", \"bakers cyst\", \"popliteal cyst\", \"quiste de baker\"],\n    \"Contusion\": [\"bone contusion\", \"bone bruise\", \"marrow edema\", \"edema óseo\"],\n    \"Fracture\": [\"fracture\", \"fractura\", \"fracture line\"],\n}\nNEGATION_PATTERNS = [r\"no\\s+evidence\\s+of\", r\"without\", r\"sin\\s+evidencia\\s+de\", r\"absence\\s+de\"]\n\n\ndef keyword_label_report(text, window=6):\n    '''Very simple negation-aware keyword labeler: flags a finding as positive only if a\n    keyword is found and no negation cue appears in the preceding `window` words.'''\n    if not isinstance(text, str) or not text.strip():\n        return {c: 0 for c in LABEL_COLS}\n    t = text.lower()\n    words = t.split()\n    labels = {}\n    for label, kws in KEYWORDS.items():\n        found = 0\n        for kw in kws:\n            for m in re.finditer(re.escape(kw), t):\n                # look at a small window of words before the match for negation cues\n                pre_text = t[max(0, m.start() - 40):m.start()]\n                negated = any(re.search(p, pre_text) for p in NEGATION_PATTERNS)\n                if not negated:\n                    found = 1\n                    break\n            if found:\n                break\n        labels[label] = found\n    return labels\n\n\nif _exists(TRAIN_CSV) and \"Report\" in train_df.columns:\n    sample_reports = train_df[\"Report\"].dropna().head(5)\n    for i, rep in sample_reports.items():\n        print(f\"--- Study idx {i} ---\")\n        print(rep[:200].replace(\"\\n\", \" \"), \"...\")\n        print(\"Keyword labels:\", keyword_label_report(rep))\n        print()\nelse:\n    print(\"Report column not available yet — run this after loading train.csv.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:00.927419Z","iopub.execute_input":"2026-08-27T05:58:00.927581Z","iopub.status.idle":"2026-08-27T05:58:00.938561Z","shell.execute_reply.started":"2026-08-27T05:58:00.927567Z","shell.execute_reply":"2026-08-27T05:58:00.937768Z"}},"outputs":[],"execution_count":null},{"id":"d0775394","cell_type":"markdown","source":"\n### 4.1 Validate the keyword labeler against ground truth (on the hand-labeled subset)\n\nBefore trusting pseudo-labels on the unlabeled majority, check how well the cheap keyword labeler\nagrees with real labels where we already have both. Low agreement on a label means: tighten the\nkeyword list for that label, or lean more heavily on a learned transformer labeler instead.\n","metadata":{}},{"id":"ff12113c","cell_type":"code","source":"\nif _exists(TRAIN_CSV) and \"Report\" in train_df.columns and labeled_mask.sum() > 0:\n    labeled_subset = train_df.loc[labeled_mask].copy()\n    kw_preds = labeled_subset[\"Report\"].apply(keyword_label_report).apply(pd.Series)\n\n    agreement = {}\n    for col in LABEL_COLS:\n        gt = labeled_subset[col].astype(int)\n        pred = kw_preds[col].astype(int)\n        agreement[col] = (gt == pred).mean()\n\n    agree_series = pd.Series(agreement).sort_values()\n    fig, ax = plt.subplots(figsize=(9, 5))\n    ax.barh(agree_series.index, agree_series.values, color=\"#DD8452\")\n    ax.set_xlabel(\"Agreement with ground-truth label\")\n    ax.set_title(\"Keyword labeler vs. ground truth (labeled subset)\")\n    ax.set_xlim(0, 1)\n    plt.tight_layout()\n    plt.show()\n\n    print(\"NOTE: low-agreement labels are the ones where a fine-tuned transformer labeler \"\n          \"(e.g. xlm-roberta-base) will matter most for pseudo-label quality.\")\nelse:\n    print(\"Skipping — need labeled subset with reports.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:00.939189Z","iopub.execute_input":"2026-08-27T05:58:00.939359Z","iopub.status.idle":"2026-08-27T05:58:01.13348Z","shell.execute_reply.started":"2026-08-27T05:58:00.939345Z","shell.execute_reply":"2026-08-27T05:58:01.132509Z"}},"outputs":[],"execution_count":null},{"id":"1e759103","cell_type":"markdown","source":"\n### 4.2 Slot: transformer-based labeler (train-time only, sketch)\n\n```python\n# Sketch only — fill in once you have GPU time budgeted for this training-side step.\n# from transformers import AutoTokenizer, AutoModelForSequenceClassification\n#\n# tokenizer = AutoTokenizer.from_pretrained(\"xlm-roberta-base\")\n# model = AutoModelForSequenceClassification.from_pretrained(\n#     \"xlm-roberta-base\", num_labels=len(LABEL_COLS), problem_type=\"multi_label_classification\"\n# )\n# # Fine-tune on `labeled_subset[\"Report\"] -> labeled_subset[LABEL_COLS]`\n# # Then run inference on the unlabeled reports to get `pseudo_labels` + confidence scores.\n# # Merge: final_train_labels = ground_truth where available, else pseudo_labels\n# #        (only keep pseudo-labels above a confidence threshold, e.g. sigmoid prob > 0.8 or < 0.2)\n```\n","metadata":{}},{"id":"5e720400","cell_type":"markdown","source":"\n## 5. Handcrafted OpenCV Features\n\nThese are classical-CV measurements that map directly onto what radiologists look for, and act as\na **second, diverse model family** alongside the CNN (see architecture diagram, Section 1).\n\n| Feature | Method | Targets |\n|---|---|---|\n| Joint-space width | Canny edges + contour analysis on coronal slices | Medial OA, Lateral OA |\n| Fluid-region area | Adaptive threshold + morphological cleanup on fluid-sensitive slices | Effusion, Baker's |\n| Bone-marrow intensity stats | Largest-contour bone mask + mean/variance inside it | Contusion |\n","metadata":{}},{"id":"fe85e80a","cell_type":"code","source":"\ndef estimate_joint_space_width(img_u8, roi=None):\n    '''Rough joint-space width estimate via edge detection + contour gap analysis on a\n    coronal knee slice. Returns a single scalar (pixels) — smaller values suggest narrowing\n    consistent with OA. This is intentionally simple; refine the ROI logic once you can see\n    real coronal slices from this dataset.'''\n    if roi is not None:\n        x0, y0, x1, y1 = roi\n        img_u8 = img_u8[y0:y1, x0:x1]\n\n    edges = cv2.Canny(img_u8, 50, 150)\n    contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)\n    if len(contours) < 2:\n        return np.nan\n\n    # crude proxy: vertical gap between the two largest contours' bounding boxes\n    boxes = sorted((cv2.boundingRect(c) for c in contours), key=lambda b: b[2] * b[3], reverse=True)[:2]\n    boxes = sorted(boxes, key=lambda b: b[1])  # sort by y position\n    gap = boxes[1][1] - (boxes[0][1] + boxes[0][3])\n    return max(gap, 0)\n\n\ndef estimate_fluid_area(img_u8, thresh_block_size=35, thresh_C=-10):\n    '''Adaptive-threshold + morphological cleanup to estimate bright-fluid region area on a\n    fluid-sensitive (T2/PD/STIR) slice. Returns area in pixels and the cleaned mask for viz.'''\n    mask = cv2.adaptiveThreshold(\n        img_u8, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,\n        blockSize=thresh_block_size, C=thresh_C,\n    )\n    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))\n    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)\n    area = int(np.sum(mask > 0))\n    return area, mask\n\n\ndef bone_marrow_intensity_stats(img_u8):\n    '''Segment the largest bright/contiguous region as a rough bone-area proxy on a\n    fluid-sensitive slice, then compute intensity mean/std inside it — a focal spike in mean\n    intensity within bone is a contusion/marrow-edema signal.'''\n    _, mask = cv2.threshold(img_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    if not contours:\n        return {\"bone_mean\": np.nan, \"bone_std\": np.nan}\n    largest = max(contours, key=cv2.contourArea)\n    roi_mask = np.zeros_like(img_u8)\n    cv2.drawContours(roi_mask, [largest], -1, 255, thickness=-1)\n    vals = img_u8[roi_mask > 0]\n    return {\"bone_mean\": float(vals.mean()) if vals.size else np.nan,\n            \"bone_std\": float(vals.std()) if vals.size else np.nan}\n\n\nprint(\"Handcrafted feature functions defined: estimate_joint_space_width, \"\n      \"estimate_fluid_area, bone_marrow_intensity_stats\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:01.133941Z","iopub.execute_input":"2026-08-27T05:58:01.134094Z","iopub.status.idle":"2026-08-27T05:58:01.14144Z","shell.execute_reply.started":"2026-08-27T05:58:01.134081Z","shell.execute_reply":"2026-08-27T05:58:01.140628Z"}},"outputs":[],"execution_count":null},{"id":"b3ab3c35","cell_type":"markdown","source":"### 5.1 Visualize handcrafted features on a real slice","metadata":{}},{"id":"3eff0563","cell_type":"code","source":"\nif sample_path is not None:\n    img, _ = preprocess_slice(sample_path)\n    joint_gap = estimate_joint_space_width(img)\n    fluid_area, fluid_mask = estimate_fluid_area(img)\n    bone_stats = bone_marrow_intensity_stats(img)\n\n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    axes[0].imshow(img, cmap=\"gray\")\n    axes[0].set_title(f\"Preprocessed slice\\n(joint-gap proxy: {joint_gap} px)\")\n    axes[0].axis(\"off\")\n\n    axes[1].imshow(fluid_mask, cmap=\"magma\")\n    axes[1].set_title(f\"Fluid-region mask\\n(area: {fluid_area} px)\")\n    axes[1].axis(\"off\")\n\n    axes[2].imshow(img, cmap=\"gray\")\n    axes[2].set_title(f\"Bone-marrow stats\\nmean={bone_stats['bone_mean']:.1f}, \"\n                       f\"std={bone_stats['bone_std']:.1f}\")\n    axes[2].axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()\n\n    print(\"NOTE: these are rough, unrefined proxies meant to demonstrate the pipeline shape. \"\n          \"Refine ROI selection (e.g. crop to the joint line) once real coronal/fluid-sensitive \"\n          \"slices from this dataset are available to iterate against.\")\nelse:\n    print(\"Skipping — no sample slice available yet.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:01.142203Z","iopub.execute_input":"2026-08-27T05:58:01.142381Z","iopub.status.idle":"2026-08-27T05:58:01.5113Z","shell.execute_reply.started":"2026-08-27T05:58:01.142368Z","shell.execute_reply":"2026-08-27T05:58:01.510273Z"}},"outputs":[],"execution_count":null},{"id":"0878034f","cell_type":"markdown","source":"\n## 6. Dataset & DataLoader — Multiple-Instance-Learning (MIL) structure\n\nEach **study** is a *bag of series*, and each **series** is a *bag of slices*. We build the dataset\nto reflect this nesting directly, rather than flattening everything into independent slice-level\nsamples — flattening would throw away exactly the structure the attention-pooling architecture\nneeds to learn from.\n","metadata":{}},{"id":"e19b8dad","cell_type":"code","source":"\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\n\n\ndef make_25d_triplet(volume, i):\n    '''Stack slice i with its immediate neighbors into a 3-channel (H,W,3) image.\n    Edge slices repeat the boundary slice (no wraparound).'''\n    n = volume.shape[0]\n    prev_i, next_i = max(i - 1, 0), min(i + 1, n - 1)\n    return np.stack([volume[prev_i], volume[i], volume[next_i]], axis=-1)\n\n\ndef load_series_volume(series_dir, target=IMG_SIZE, max_slices=MAX_SLICES_PER_SERIES):\n    '''Load + preprocess all slices of a series (sorted by anatomical position),\n    uniformly subsampled to `max_slices` if longer.'''\n    series_dir = Path(series_dir)\n    files = sorted(series_dir.glob(\"*.dcm\"))\n    if not files:\n        return np.zeros((1, target, target), dtype=np.uint8)\n\n    def sort_key(f):\n        try:\n            ds = pydicom.dcmread(f, stop_before_pixels=True, force=True)\n            if hasattr(ds, \"ImagePositionPatient\"):\n                return float(ds.ImagePositionPatient[2])\n            if hasattr(ds, \"SliceLocation\"):\n                return float(ds.SliceLocation)\n        except Exception:\n            pass\n        return f.name\n\n    files = sorted(files, key=sort_key)\n    if len(files) > max_slices:\n        idx = np.linspace(0, len(files) - 1, max_slices).astype(int)\n        files = [files[i] for i in idx]\n\n    slices = [preprocess_slice(f, target=target)[0] for f in files]\n    return np.stack(slices, axis=0)\n\n\nclass KneeStudyDataset(Dataset):\n    '''One __getitem__ call returns ALL series for one study, each as a\n    (n_slices, H, W, 3) 2.5D volume, plus series metadata and the 12-label target vector.\n    Batching variable numbers of series/slices is handled by a custom collate_fn\n    (each study stays its own list rather than being stacked into one tensor).'''\n\n    def __init__(self, studies_df, series_df, series_root, label_cols=LABEL_COLS,\n                 has_labels=True, max_series=6):\n        self.studies_df = studies_df.reset_index(drop=True)\n        self.series_df = series_df\n        self.series_root = Path(series_root)\n        self.label_cols = label_cols\n        self.has_labels = has_labels\n        self.max_series = max_series\n\n    def __len__(self):\n        return len(self.studies_df)\n\n    def __getitem__(self, idx):\n        row = self.studies_df.iloc[idx]\n        study_uid = row[\"StudyInstanceUID\"]\n        study_series = self.series_df[self.series_df[\"StudyInstanceUID\"] == study_uid]\n        if len(study_series) > self.max_series:\n            study_series = study_series.sample(self.max_series, random_state=SEED)\n\n        series_volumes, series_meta = [], []\n        for _, srow in study_series.iterrows():\n            series_dir = self.series_root / study_uid / srow[\"SeriesInstanceUID\"]\n            vol = load_series_volume(series_dir)\n            triplets = np.stack([make_25d_triplet(vol, i) for i in range(vol.shape[0])])\n            series_volumes.append(torch.from_numpy(triplets).float() / 255.0)\n            series_meta.append(torch.tensor([\n                float(srow.get(\"Fluid_Sensitive\", 0)),\n                float(srow.get(\"Fat_Suppression\", 0)),\n                {\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}.get(srow.get(\"Anatomical_Plane\"), -1),\n            ], dtype=torch.float32))\n\n        target = (torch.tensor(row[self.label_cols].astype(float).values, dtype=torch.float32)\n                  if self.has_labels else torch.zeros(len(self.label_cols)))\n\n        return {\n            \"study_uid\": study_uid,\n            \"series_volumes\": series_volumes,   # list[Tensor(n_slices,H,W,3)]\n            \"series_meta\": series_meta,          # list[Tensor(3,)]\n            \"target\": target,\n        }\n\n\ndef mil_collate_fn(batch):\n    '''Keeps each study's variable-length series list intact (no padding/stacking across\n    studies) — the model processes one study's bag of series per forward call inside the loop.'''\n    return batch\n\n\nprint(\"Dataset defined: KneeStudyDataset (+ mil_collate_fn)\")\nif _exists(TRAIN_CSV) and _exists(TRAIN_SERIES_CSV) and _exists(TRAIN_SERIES_DIR):\n    demo_ds = KneeStudyDataset(train_df.loc[labeled_mask].head(2), series_df, TRAIN_SERIES_DIR)\n    print(f\"Demo dataset length: {len(demo_ds)}\")\nelse:\n    print(\"Dataset ready to use once train.csv / train_series.csv / train_series/ are available.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:01.512131Z","iopub.execute_input":"2026-08-27T05:58:01.512294Z","iopub.status.idle":"2026-08-27T05:58:09.468143Z","shell.execute_reply.started":"2026-08-27T05:58:01.51228Z","shell.execute_reply":"2026-08-27T05:58:09.467059Z"}},"outputs":[],"execution_count":null},{"id":"967613f2","cell_type":"markdown","source":"\n## 7. Model Architecture — Slice → Series → Study Attention MIL\n\nMatches the diagram in Section 1:\n\n1. **Slice encoder**: a 2D CNN backbone (ImageNet-pretrained) turns each 2.5D triplet into an\n   embedding vector.\n2. **Slice → series attention pooling**: a gated-attention layer learns *which slices* within a\n   series matter most (e.g. the 2-3 slices where a tear is visible) instead of naive averaging.\n3. **Series → study attention pooling**: the same mechanism, one level up, learns which *series*\n   matter most for a given finding (e.g. fluid-sensitive coronal series weighted higher for\n   effusion), conditioned on series metadata (plane, fluid-sensitivity, fat-sat).\n4. **Head**: a 12-way sigmoid — one probability per finding, trained with `BCEWithLogitsLoss`.\n","metadata":{}},{"id":"07a505fb","cell_type":"code","source":"\nimport torch.nn as nn\nimport torch.nn.functional as F\n\ntry:\n    import timm\n    TIMM_AVAILABLE = True\nexcept ImportError:\n    TIMM_AVAILABLE = False\n    print(\"timm not installed — `pip install timm` for real pretrained backbones \"\n          \"(remember: for Kaggle submission, vendor weights via an attached Dataset, \"\n          \"no live internet access at inference time).\")\n\n\nclass AttentionMIL(nn.Module):\n    '''Gated-attention pooling (Ilse et al., 2018 style): learns a per-instance attention\n    weight, then returns the attention-weighted average of instance embeddings.\n    Works identically whether 'instances' are slices (pooling to series) or series\n    (pooling to study).'''\n\n    def __init__(self, in_dim, hidden_dim=128):\n        super().__init__()\n        self.attn_V = nn.Linear(in_dim, hidden_dim)\n        self.attn_U = nn.Linear(in_dim, hidden_dim)\n        self.attn_w = nn.Linear(hidden_dim, 1)\n\n    def forward(self, x):\n        # x: (n_instances, in_dim)\n        A_V = torch.tanh(self.attn_V(x))\n        A_U = torch.sigmoid(self.attn_U(x))          # gating branch\n        scores = self.attn_w(A_V * A_U)                # (n_instances, 1)\n        weights = F.softmax(scores, dim=0)             # (n_instances, 1)\n        pooled = (weights * x).sum(dim=0)               # (in_dim,)\n        return pooled, weights.squeeze(-1)\n\n\nclass SliceEncoder(nn.Module):\n    '''2D CNN backbone producing one embedding per 2.5D slice triplet.'''\n\n    def __init__(self, backbone_name=\"efficientnet_b0\", embed_dim=256, pretrained=True):\n        super().__init__()\n        if TIMM_AVAILABLE:\n            self.backbone = timm.create_model(\n                backbone_name, pretrained=pretrained, num_classes=0, in_chans=3\n            )\n            feat_dim = self.backbone.num_features\n        else:\n            # Minimal fallback CNN so the notebook still runs end-to-end without timm installed.\n            self.backbone = nn.Sequential(\n                nn.Conv2d(3, 32, 3, stride=2, padding=1), nn.ReLU(),\n                nn.Conv2d(32, 64, 3, stride=2, padding=1), nn.ReLU(),\n                nn.AdaptiveAvgPool2d(1), nn.Flatten(),\n            )\n            feat_dim = 64\n        self.proj = nn.Linear(feat_dim, embed_dim)\n\n    def forward(self, x):\n        # x: (n_slices, H, W, 3) -> (n_slices, 3, H, W)\n        x = x.permute(0, 3, 1, 2)\n        feats = self.backbone(x)\n        return self.proj(feats)   # (n_slices, embed_dim)\n\n\nclass KneeMILModel(nn.Module):\n    '''Full slice -> series -> study MIL model.'''\n\n    def __init__(self, n_labels=len(LABEL_COLS), embed_dim=256, backbone_name=\"efficientnet_b0\"):\n        super().__init__()\n        self.slice_encoder = SliceEncoder(backbone_name=backbone_name, embed_dim=embed_dim)\n        self.slice_pool = AttentionMIL(embed_dim)\n        meta_dim = 3   # fluid_sensitive, fat_suppression, plane\n        self.series_meta_proj = nn.Linear(meta_dim, embed_dim)\n        self.series_pool = AttentionMIL(embed_dim)\n        self.head = nn.Sequential(\n            nn.Linear(embed_dim, 128), nn.ReLU(), nn.Dropout(0.3),\n            nn.Linear(128, n_labels),\n        )\n\n    def forward(self, series_volumes, series_meta):\n        '''series_volumes: list[Tensor(n_slices,H,W,3)] for ONE study\n           series_meta: list[Tensor(3,)] for ONE study (same length)'''\n        series_embeds = []\n        for vol, meta in zip(series_volumes, series_meta):\n            slice_embeds = self.slice_encoder(vol)               # (n_slices, embed_dim)\n            series_embed, _ = self.slice_pool(slice_embeds)       # (embed_dim,)\n            series_embed = series_embed + self.series_meta_proj(meta)\n            series_embeds.append(series_embed)\n\n        series_embeds = torch.stack(series_embeds, dim=0)         # (n_series, embed_dim)\n        study_embed, series_attn = self.series_pool(series_embeds)\n        logits = self.head(study_embed)                            # (n_labels,)\n        return logits, series_attn\n\n\nmodel = KneeMILModel()\nn_params = sum(p.numel() for p in model.parameters())\nprint(f\"KneeMILModel instantiated — {n_params:,} parameters \"\n      f\"(backbone={'timm/efficientnet_b0' if TIMM_AVAILABLE else 'fallback CNN — install timm for real use'})\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:09.469022Z","iopub.execute_input":"2026-08-27T05:58:09.469372Z","iopub.status.idle":"2026-08-27T05:58:15.892465Z","shell.execute_reply.started":"2026-08-27T05:58:09.469354Z","shell.execute_reply":"2026-08-27T05:58:15.891387Z"}},"outputs":[],"execution_count":null},{"id":"25906ae3","cell_type":"markdown","source":"\n## 8. Training Loop\n\nKey details baked in:\n- **Multilabel-stratified CV** so rare findings (fracture, contusion, Baker's) have positive\n  examples in every fold.\n- **Per-label `pos_weight`** in the loss so rare-positive labels aren't drowned out by easy\n  negatives.\n- One study processed per forward pass (variable series/slice counts don't batch cleanly) —\n  gradient accumulation substitutes for a larger batch size.\n","metadata":{}},{"id":"3708414d","cell_type":"code","source":"\nfrom sklearn.model_selection import KFold\ntry:\n    from iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n    STRATIFIED_AVAILABLE = True\nexcept ImportError:\n    STRATIFIED_AVAILABLE = False\n    print(\"iterative-stratification not installed \"\n          \"(`pip install iterative-stratification`) — falling back to plain KFold for this demo.\")\n\n\ndef compute_pos_weights(labels_df, label_cols=LABEL_COLS, eps=1e-3):\n    '''pos_weight[i] = n_negatives / n_positives, for BCEWithLogitsLoss — pushes the model\n    to not just predict the majority class on rare-positive labels.'''\n    pos = labels_df[label_cols].sum().clip(lower=eps)\n    neg = (len(labels_df) - labels_df[label_cols].sum()).clip(lower=eps)\n    return torch.tensor((neg / pos).values, dtype=torch.float32)\n\n\ndef get_cv_splits(labels_df, label_cols=LABEL_COLS, n_splits=5):\n    X = np.zeros((len(labels_df), 1))\n    y = labels_df[label_cols].values\n    if STRATIFIED_AVAILABLE:\n        splitter = MultilabelStratifiedKFold(n_splits=n_splits, shuffle=True, random_state=SEED)\n    else:\n        splitter = KFold(n_splits=n_splits, shuffle=True, random_state=SEED)\n    return list(splitter.split(X, y))\n\n\ndef train_one_epoch(model, dataset, optimizer, pos_weight, device, accum_steps=8, max_studies=None):\n    model.train()\n    loss_fn = nn.BCEWithLogitsLoss(pos_weight=pos_weight.to(device))\n    total_loss, n = 0.0, 0\n    optimizer.zero_grad()\n\n    n_studies = len(dataset) if max_studies is None else min(max_studies, len(dataset))\n    for i in range(n_studies):\n        sample = dataset[i]\n        volumes = [v.to(device) for v in sample[\"series_volumes\"]]\n        meta = [m.to(device) for m in sample[\"series_meta\"]]\n        target = sample[\"target\"].to(device)\n\n        logits, _ = model(volumes, meta)\n        loss = loss_fn(logits, target) / accum_steps\n        loss.backward()\n\n        if (i + 1) % accum_steps == 0:\n            optimizer.step()\n            optimizer.zero_grad()\n\n        total_loss += loss.item() * accum_steps\n        n += 1\n\n    return total_loss / max(n, 1)\n\n\n@torch.no_grad()\ndef evaluate(model, dataset, device, max_studies=None):\n    '''Returns per-label AUC + macro-average AUC on a validation dataset.'''\n    from sklearn.metrics import roc_auc_score\n\n    model.eval()\n    all_logits, all_targets = [], []\n    n_studies = len(dataset) if max_studies is None else min(max_studies, len(dataset))\n    for i in range(n_studies):\n        sample = dataset[i]\n        volumes = [v.to(device) for v in sample[\"series_volumes\"]]\n        meta = [m.to(device) for m in sample[\"series_meta\"]]\n        logits, _ = model(volumes, meta)\n        all_logits.append(torch.sigmoid(logits).cpu().numpy())\n        all_targets.append(sample[\"target\"].numpy())\n\n    all_logits = np.stack(all_logits)\n    all_targets = np.stack(all_targets)\n\n    per_label_auc = {}\n    for i, col in enumerate(LABEL_COLS):\n        y_true, y_pred = all_targets[:, i], all_logits[:, i]\n        if len(np.unique(y_true)) < 2:\n            per_label_auc[col] = np.nan   # AUC undefined with a single class in this eval slice\n        else:\n            per_label_auc[col] = roc_auc_score(y_true, y_pred)\n\n    macro_auc = np.nanmean(list(per_label_auc.values()))\n    return macro_auc, per_label_auc\n\n\nprint(\"Training utilities defined: compute_pos_weights, get_cv_splits, \"\n      \"train_one_epoch, evaluate\")\n\n# --- Example wiring (only runs once real data is present) ---\nif _exists(TRAIN_CSV) and _exists(TRAIN_SERIES_CSV) and _exists(TRAIN_SERIES_DIR) and labeled_mask.sum() > 0:\n    labeled_df = train_df.loc[labeled_mask].reset_index(drop=True)\n    pos_weight = compute_pos_weights(labeled_df)\n    folds = get_cv_splits(labeled_df)\n    print(f\"Prepared {len(folds)}-fold CV on {len(labeled_df)} labeled studies.\")\n    print(\"pos_weight per label:\")\n    print(pd.Series(pos_weight.numpy(), index=LABEL_COLS))\n\n    # device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    # model = KneeMILModel().to(device)\n    # optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5)\n    # train_idx, val_idx = folds[0]\n    # train_ds = KneeStudyDataset(labeled_df.iloc[train_idx], series_df, TRAIN_SERIES_DIR)\n    # val_ds = KneeStudyDataset(labeled_df.iloc[val_idx], series_df, TRAIN_SERIES_DIR)\n    # for epoch in range(N_EPOCHS):\n    #     tr_loss = train_one_epoch(model, train_ds, optimizer, pos_weight, device)\n    #     macro_auc, per_label = evaluate(model, val_ds, device)\n    #     print(epoch, tr_loss, macro_auc)\nelse:\n    print(\"Wire up real training once train.csv / train_series.csv / train_series/ are available. \"\n          \"(Uncomment the block above.)\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:15.893311Z","iopub.execute_input":"2026-08-27T05:58:15.89399Z","iopub.status.idle":"2026-08-27T05:58:17.271653Z","shell.execute_reply.started":"2026-08-27T05:58:15.893973Z","shell.execute_reply":"2026-08-27T05:58:17.270512Z"}},"outputs":[],"execution_count":null},{"id":"4e8428fc","cell_type":"markdown","source":"\n## 9. Handcrafted-Feature Model — LightGBM per Label\n\nAggregate the OpenCV handcrafted features (Section 5) to **study level** (e.g. min joint-space\nwidth across coronal series, max fluid area across fluid-sensitive series) and train one LightGBM\nclassifier per label. This is a much weaker model than the CNN alone, but it fails differently —\nit directly encodes measurements a radiologist would make, so it catches some cases (e.g. clear\nearly joint-space narrowing) that a texture-focused CNN might not weight highly, and vice versa.\n","metadata":{}},{"id":"ded15729","cell_type":"code","source":"\nimport lightgbm as lgb\nfrom sklearn.metrics import roc_auc_score\n\n\ndef extract_study_level_handcrafted_features(study_uid, series_df, series_root):\n    '''Aggregate per-series handcrafted features to one feature row per study.\n    Runs the OpenCV feature functions from Section 5 over a representative slice\n    (middle slice) of each relevant series type — extend to multiple slices per series\n    for a sturdier estimate once you've validated this on real data.'''\n    study_series = series_df[series_df[\"StudyInstanceUID\"] == study_uid]\n    feats = {\"joint_gap_min\": [], \"fluid_area_max\": [], \"bone_mean_max\": [], \"bone_std_max\": []}\n\n    for _, srow in study_series.iterrows():\n        series_dir = Path(series_root) / study_uid / srow[\"SeriesInstanceUID\"]\n        files = sorted(series_dir.glob(\"*.dcm\"))\n        if not files:\n            continue\n        mid_file = files[len(files) // 2]\n        img, _ = preprocess_slice(mid_file)\n\n        if srow.get(\"Anatomical_Plane\") == \"Coronal\":\n            feats[\"joint_gap_min\"].append(estimate_joint_space_width(img))\n        if srow.get(\"Fluid_Sensitive\", 0) == 1:\n            area, _ = estimate_fluid_area(img)\n            feats[\"fluid_area_max\"].append(area)\n            bone_stats = bone_marrow_intensity_stats(img)\n            feats[\"bone_mean_max\"].append(bone_stats[\"bone_mean\"])\n            feats[\"bone_std_max\"].append(bone_stats[\"bone_std\"])\n\n    return {\n        \"joint_gap_min\": np.nanmin(feats[\"joint_gap_min\"]) if feats[\"joint_gap_min\"] else np.nan,\n        \"fluid_area_max\": np.nanmax(feats[\"fluid_area_max\"]) if feats[\"fluid_area_max\"] else np.nan,\n        \"bone_mean_max\": np.nanmax(feats[\"bone_mean_max\"]) if feats[\"bone_mean_max\"] else np.nan,\n        \"bone_std_max\": np.nanmax(feats[\"bone_std_max\"]) if feats[\"bone_std_max\"] else np.nan,\n    }\n\n\ndef train_gbm_per_label(feature_df, labels_df, label_cols=LABEL_COLS, folds=None):\n    '''Trains one LightGBM binary classifier per label, returns out-of-fold predictions\n    (for honest blend-weight optimization later) and the fitted models.'''\n    oof_preds = pd.DataFrame(index=labels_df.index, columns=label_cols, dtype=float)\n    models = {}\n\n    for col in label_cols:\n        y = labels_df[col].astype(int)\n        col_models = []\n        for tr_idx, val_idx in (folds or get_cv_splits(labels_df)):\n            clf = lgb.LGBMClassifier(\n                n_estimators=200, max_depth=4, learning_rate=0.05,\n                subsample=0.8, colsample_bytree=0.8, random_state=SEED, verbosity=-1,\n            )\n            clf.fit(feature_df.iloc[tr_idx], y.iloc[tr_idx])\n            oof_preds.iloc[val_idx, oof_preds.columns.get_loc(col)] = (\n                clf.predict_proba(feature_df.iloc[val_idx])[:, 1]\n            )\n            col_models.append(clf)\n        models[col] = col_models\n\n    return oof_preds, models\n\n\nprint(\"GBM utilities defined: extract_study_level_handcrafted_features, train_gbm_per_label\")\n\nif _exists(TRAIN_CSV) and _exists(TRAIN_SERIES_CSV) and _exists(TRAIN_SERIES_DIR) and labeled_mask.sum() > 0:\n    print(\"Extracting handcrafted features for the labeled subset \"\n          \"(this walks every series' middle slice — can be slow; cache the result to disk).\")\n    # feature_rows = [\n    #     extract_study_level_handcrafted_features(uid, series_df, TRAIN_SERIES_DIR)\n    #     for uid in labeled_df[\"StudyInstanceUID\"]\n    # ]\n    # feature_df = pd.DataFrame(feature_rows).fillna(feature_df.median())\n    # gbm_oof, gbm_models = train_gbm_per_label(feature_df, labeled_df)\n    print(\"(Feature extraction commented out here — uncomment once real DICOM data is mounted; \"\n          \"it's a slow loop best run once and cached.)\")\nelse:\n    print(\"Run once train.csv / train_series.csv / train_series/ are available.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:17.27252Z","iopub.execute_input":"2026-08-27T05:58:17.272752Z","iopub.status.idle":"2026-08-27T05:58:17.330433Z","shell.execute_reply.started":"2026-08-27T05:58:17.272737Z","shell.execute_reply":"2026-08-27T05:58:17.329507Z"}},"outputs":[],"execution_count":null},{"id":"67efa051","cell_type":"markdown","source":"\n## 10. Ensembling — Per-Label Blend Weight Optimization\n\nRather than one global blend weight between the CNN and the GBM, optimize a **separate weight per\nlabel** on out-of-fold predictions, since different findings are better captured by different\nmodel families (e.g. GBM's joint-space-width feature may carry OA more cleanly than deep texture\nfeatures do, while ligament tears likely lean CNN-heavy).\n","metadata":{}},{"id":"a08d0beb","cell_type":"code","source":"\nfrom scipy.optimize import minimize_scalar\n\n\ndef optimize_blend_weight(cnn_oof, gbm_oof, y_true):\n    '''For one label: find w in [0,1] maximizing AUC of w*cnn + (1-w)*gbm on OOF predictions.'''\n    def neg_auc(w):\n        blend = w * cnn_oof + (1 - w) * gbm_oof\n        return -roc_auc_score(y_true, blend)\n\n    res = minimize_scalar(neg_auc, bounds=(0, 1), method=\"bounded\")\n    return res.x, -res.fun\n\n\ndef optimize_all_label_weights(cnn_oof_df, gbm_oof_df, labels_df, label_cols=LABEL_COLS):\n    weights, aucs = {}, {}\n    for col in label_cols:\n        y_true = labels_df[col].astype(int)\n        w, auc = optimize_blend_weight(cnn_oof_df[col].values, gbm_oof_df[col].values, y_true)\n        weights[col] = w\n        aucs[col] = auc\n    return weights, aucs\n\n\nprint(\"Blend utilities defined: optimize_blend_weight, optimize_all_label_weights\")\nprint(\"Run this once both CNN OOF predictions and GBM OOF predictions \"\n      \"(Sections 8 & 9) are available, on the SAME held-out folds.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:17.330886Z","iopub.execute_input":"2026-08-27T05:58:17.331039Z","iopub.status.idle":"2026-08-27T05:58:17.3364Z","shell.execute_reply.started":"2026-08-27T05:58:17.331027Z","shell.execute_reply":"2026-08-27T05:58:17.335524Z"}},"outputs":[],"execution_count":null},{"id":"b03a7d1b","cell_type":"markdown","source":"\n## 11. Inference & `submission.csv` (Kaggle Notebooks-only constraints)\n\nThis competition requires:\n- Submission via **Notebook**, internet **disabled** at run time.\n- **≤ 9 hours** total runtime (CPU or GPU).\n- Any pretrained weights must be **pre-downloaded and attached as a Kaggle Dataset**\n  (no live downloads at submission time).\n- Output file must be named exactly **`submission.csv`**.\n\nDesign choices here specifically to respect the runtime budget:\n- `MAX_SLICES_PER_SERIES` caps the worst-case per-series cost (defined in Config).\n- Batched, mixed-precision (`fp16`) inference where possible.\n- No test-time augmentation by default — add it only after measuring you have headroom\n  (see the timing cell below).\n- Report-based components (Sections 4) are **not** used here — `Report` isn't in `test.csv`.\n","metadata":{}},{"id":"fc9dbdcc","cell_type":"code","source":"\nimport time\n\ndef load_model_weights(model, weights_path, device):\n    '''Load pretrained/fine-tuned weights from a LOCAL path (e.g. an attached Kaggle Dataset)\n    — never from a live URL, since internet is disabled at submission time.'''\n    if Path(weights_path).exists():\n        state = torch.load(weights_path, map_location=device)\n        model.load_state_dict(state)\n        print(f\"Loaded weights from {weights_path}\")\n    else:\n        print(f\"WARNING: weights not found at {weights_path} — \"\n              f\"attach your trained-weights Kaggle Dataset before final submission.\")\n    return model\n\n\n@torch.no_grad()\ndef predict_study(model, series_volumes, series_meta, device):\n    volumes = [v.to(device) for v in series_volumes]\n    meta = [m.to(device) for m in series_meta]\n    logits, _ = model(volumes, meta)\n    return torch.sigmoid(logits).cpu().numpy()\n\n\ndef run_inference(model, test_df, series_df, series_root, device, time_budget_hours=8.0):\n    '''Runs inference for every test study, tracking elapsed time against the runtime\n    budget so a slow batch of large series doesn't silently blow past the 9-hour limit.'''\n    model.eval()\n    test_ds = KneeStudyDataset(test_df, series_df, series_root, has_labels=False)\n\n    results = []\n    start = time.time()\n    budget_s = time_budget_hours * 3600\n\n    for i in range(len(test_ds)):\n        elapsed = time.time() - start\n        if elapsed > budget_s:\n            print(f\"WARNING: time budget ({time_budget_hours}h) reached at study {i}/{len(test_ds)} \"\n                  f\"— filling remaining studies with 0.5 fallback to guarantee a complete submission.\")\n            for j in range(i, len(test_ds)):\n                uid = test_ds.studies_df.iloc[j][\"StudyInstanceUID\"]\n                results.append({\"StudyInstanceUID\": uid, **{c: 0.5 for c in LABEL_COLS}})\n            break\n\n        sample = test_ds[i]\n        probs = predict_study(model, sample[\"series_volumes\"], sample[\"series_meta\"], device)\n        results.append({\"StudyInstanceUID\": sample[\"study_uid\"],\n                         **dict(zip(LABEL_COLS, probs))})\n\n        if i % 100 == 0:\n            print(f\"  {i}/{len(test_ds)} studies done, elapsed {elapsed/60:.1f} min\")\n\n    return pd.DataFrame(results)\n\n\nprint(\"Inference utilities defined: load_model_weights, predict_study, run_inference\")\n\nif _exists(TEST_CSV):\n    test_df = pd.read_csv(TEST_CSV)\n    print(f\"test.csv: {len(test_df)} studies (note: this may be the 3-study example file \"\n          f\"— the real scoring run swaps in ~1300 test studies)\")\nelse:\n    print(\"test.csv not found yet.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:17.336938Z","iopub.execute_input":"2026-08-27T05:58:17.337091Z","iopub.status.idle":"2026-08-27T05:58:17.358631Z","shell.execute_reply.started":"2026-08-27T05:58:17.337078Z","shell.execute_reply":"2026-08-27T05:58:17.357566Z"}},"outputs":[],"execution_count":null},{"id":"376e833e","cell_type":"markdown","source":"\n### 11.1 Time-budget sanity check\n\nRun this on a small subset **before** trusting the full run — extrapolate to the real ~1300-study\ntest set, and leave a wide safety margin below the 9-hour ceiling (Kaggle's execution environment\ncan be slower than your dev machine, and there's no partial credit for a run that times out).\n","metadata":{}},{"id":"01ceefb2","cell_type":"code","source":"\nif _exists(TEST_CSV) and _exists(TEST_SERIES_CSV) and _exists(TEST_SERIES_DIR):\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    model = KneeMILModel().to(device)\n    # model = load_model_weights(model, \"/kaggle/input/your-weights-dataset/knee_mil_fold0.pt\", device)\n\n    test_series_df = pd.read_csv(TEST_SERIES_CSV)\n    timing_subset = test_df.head(min(5, len(test_df)))\n\n    t0 = time.time()\n    _ = run_inference(model, timing_subset, test_series_df, TEST_SERIES_DIR, device,\n                       time_budget_hours=8.0)\n    elapsed = time.time() - t0\n    per_study = elapsed / max(len(timing_subset), 1)\n\n    print(f\"\\nMeasured: {per_study:.2f} sec/study on this hardware.\")\n    print(f\"Extrapolated to ~1300 test studies: {per_study * 1300 / 60:.1f} minutes \"\n          f\"({per_study * 1300 / 3600:.2f} hours)\")\n    print(\"If this is close to or over ~7-8 hours (leaving margin under the 9h cap), reduce \"\n          \"MAX_SLICES_PER_SERIES, drop TTA/ensembling, or use a lighter backbone.\")\nelse:\n    print(\"Skipping — need test.csv / test_series.csv / test_series/ to time a real run.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:58:17.359179Z","iopub.execute_input":"2026-08-27T05:58:17.359348Z","iopub.status.idle":"2026-08-27T05:59:14.744624Z","shell.execute_reply.started":"2026-08-27T05:58:17.359322Z","shell.execute_reply":"2026-08-27T05:59:14.743385Z"}},"outputs":[],"execution_count":null},{"id":"b31c94e8","cell_type":"markdown","source":"### 11.2 Build `submission.csv`","metadata":{}},{"id":"c99215ec","cell_type":"code","source":"\nif _exists(TEST_CSV):\n    # For a real run: submission_df = run_inference(model, test_df, test_series_df, TEST_SERIES_DIR, device)\n    # Blend with the GBM path per-label, using the weights from Section 10, e.g.:\n    # for col in LABEL_COLS:\n    #     w = blend_weights[col]\n    #     submission_df[col] = w * cnn_preds[col] + (1 - w) * gbm_preds[col]\n\n    # Placeholder fallback so this cell always produces a valid file, even before models are trained:\n    submission_df = test_df[[\"StudyInstanceUID\"]].copy()\n    for col in LABEL_COLS:\n        submission_df[col] = 0.5\n\n    submission_df.to_csv(\"submission.csv\", index=False)\n    print(\"submission.csv written:\")\n    print(submission_df.head())\nelse:\n    print(\"test.csv not found yet — nothing to submit.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T05:59:14.745156Z","iopub.execute_input":"2026-08-27T05:59:14.745352Z","iopub.status.idle":"2026-08-27T05:59:14.765855Z","shell.execute_reply.started":"2026-08-27T05:59:14.745321Z","shell.execute_reply":"2026-08-27T05:59:14.764822Z"}},"outputs":[],"execution_count":null},{"id":"87fcc47a","cell_type":"markdown","source":"\n## 12. Summary & Next Steps\n\n**What's implemented in this notebook, ready to run once data is mounted:**\n- Full EDA (label prevalence, report language mix, series/plane/sequence structure, slice-count\n  distribution) with plots.\n- OpenCV preprocessing pipeline with a visual before/after at every step, plus a related-slices\n  grid view within a series.\n- Keyword-based weak-supervision baseline for report → label pseudo-labeling, with an\n  agreement-vs-ground-truth check, and a slot for a transformer-based labeler.\n- Handcrafted OpenCV feature extractors (joint-space width, fluid area, bone-marrow intensity)\n  with visualization.\n- A two-level attention-MIL deep model (slice → series → study) built on a CNN backbone.\n- A training loop with multilabel-stratified CV and per-label class-imbalance weighting.\n- A LightGBM-per-label model on the handcrafted features as a second, diverse ensemble member.\n- Per-label blend-weight optimization between the two model families.\n- A runtime-budgeted inference path (with a fallback so a submission is always produced) that\n  respects the competition's Notebooks-only / no-internet / 9-hour constraints.\n\n\n","metadata":{}},{"id":"fa15bc53-a976-4785-be52-7a0a6a026931","cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}