{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# RSNA Knee Abnormality Detection — 12-Label MRI Screening with ⚡PTL + timm\n\nThis notebook builds an end-to-end baseline for the RSNA Knee Abnormality Detection challenge, where every knee MRI\nstudy must receive twelve independent confidence scores — one per finding, from ACL tears to Baker's cysts — and the\nleaderboard ranks us by the macro-averaged ROC AUC over those twelve columns.\n\nOur approach is deliberately the *simplest thing that respects the physics of the data*: pick one informative MRI\nseries per study, sample a fixed number of slices from it, embed each slice with a pretrained 2D timm backbone, and\npool the slice embeddings with a learned attention head (the \"2.5D multiple-instance learning\" recipe that the MRNet\nknee-MRI literature established). We wire it together with PyTorch Lightning so the training loop, metric handling,\ncheckpointing and mixed precision stay declarative rather than hand-rolled.\n\nCompetition: https://www.kaggle.com/competitions/rsna-knee-abnormality-detection","metadata":{"_uuid":"1bef559a-77f2-40c3-80d8-3fba76c05912","_cell_guid":"16e18583-6538-4689-a222-0a5f6b1b3c88","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# JPEG Lossless and JPEG 2000 transfer syntaxes are present in this dataset — without the pylibjpeg trio a large\n# share of series raise a decoder error at `.pixel_array` and silently disappear from training.\n# A Python variable, not a shell one: each `!` line runs in its own subshell, so a shell assignment would not survive\n# to the next line — `{PKGS}` is interpolated by IPython before the shell ever sees it.\nPKGS = \"pydicom pylibjpeg pylibjpeg-libjpeg pylibjpeg-openjpeg\"\nPKGS += \" timm pytorch-lightning torchmetrics iterative-stratification seaborn\"\n\n! pip download -q {PKGS} --dest frozen_packages/\n! pip install -q --no-index --find-links frozen_packages/ {PKGS} || pip install -q {PKGS}","metadata":{"_uuid":"8b494af5-1594-44d3-9688-c586a029b418","_cell_guid":"46fd5bd8-485f-4dd7-a618-7432ab44bf07","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:28:24.052693Z","iopub.execute_input":"2026-08-17T21:28:24.052966Z","iopub.status.idle":"2026-08-17T21:29:50.882192Z","shell.execute_reply.started":"2026-08-17T21:28:24.052932Z","shell.execute_reply":"2026-08-17T21:29:50.881088Z"},"_kg_hide-output":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Imports, paths, and reproducibility\n\nBefore any exploration we fix the entire execution context in a single place: which libraries are actually installed\n(versions matter — Lightning 2.x renamed enough of the 1.x API that a stale snippet fails loudly), where the data\nlives, and what the random seed is. Doing this once at the top means every later cell can be read without wondering\nwhich `pl` is in scope or whether a path was quietly redefined halfway through.\n\nTwo deliberate choices are worth naming:\n\n- **`pytorch_lightning` namespace, not `lightning.pytorch`.** Both exist and both work, but a checkpoint written by\n  a module defined under one namespace and reloaded through the other is a classic silent failure. We pick one — the\n  one this repository pins — and use it consistently in the model definition and at checkpoint reload time.\n- **Stage constants stay out of this cell.** Batch size, slice count and learning rate appear immediately before the\n  stage that consumes them, so a reader tuning the model never has to scroll back to the top.","metadata":{"_uuid":"05493e17-ad5f-4ec7-95d1-bd866be7a57a","_cell_guid":"eca23962-d636-4769-b6a8-8cd64352c4fb","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"import glob\nimport os\nimport re\nimport sys\nimport warnings\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport pytorch_lightning as pl\nimport seaborn as sns\nimport sklearn\nimport timm\nimport torch\nimport torchmetrics\nfrom IPython.display import display\nfrom torch import nn\nfrom torch.utils.data import DataLoader, Dataset\nfrom tqdm.auto import tqdm\n\n# %matplotlib inline\n\n# pydicom emits one UserWarning per file for the vendor-specific tags this dataset keeps; at ~820k files that noise\n# would drown every other diagnostic, so we silence exactly that category and nothing broader.\nwarnings.filterwarnings(\"ignore\", category=UserWarning, module=\"pydicom\")\n\nSEED = 42\nPATH_DATASET = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\n# LOCAL OVERRIDE — set to a local copy of the competition data when running outside Kaggle; empty keeps the Kaggle path.\nPATH_DATASET_OVERRIDE = \"\"\nif PATH_DATASET_OVERRIDE:\n    PATH_DATASET = PATH_DATASET_OVERRIDE\nPATH_OUTPUT = \"/kaggle/working\" if os.path.isdir(\"/kaggle/working\") else \".\"\n\nprint(f\"python       : {sys.version.split()[0]}\")\nprint(f\"numpy        : {np.__version__}\")\nprint(f\"pandas       : {pd.__version__}\")\nprint(f\"scikit-learn : {sklearn.__version__}\")\nprint(f\"pydicom      : {pydicom.__version__}\")\nprint(f\"torch        : {torch.__version__}\")\nprint(f\"lightning    : {pl.__version__}\")\nprint(f\"torchmetrics : {torchmetrics.__version__}\")\nprint(f\"timm         : {timm.__version__}\")\nprint(f\"device       : {'cuda' if torch.cuda.is_available() else 'cpu'}\")\n\n_ = pl.seed_everything(SEED, workers=True)","metadata":{"_uuid":"d20e91b1-4bce-4c5a-ae78-8c6c9c39bdb5","_cell_guid":"d6d41123-ea3c-4182-9ba4-81f51096798f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:29:50.884022Z","iopub.execute_input":"2026-08-17T21:29:50.88441Z","iopub.status.idle":"2026-08-17T21:30:16.723266Z","shell.execute_reply.started":"2026-08-17T21:29:50.884379Z","shell.execute_reply":"2026-08-17T21:30:16.722387Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Foundation lens\n\nThe lens cell answers one question before we spend any compute: *is the data actually mounted where we think it is?*\nOn Kaggle the answer is yes; anywhere else it is usually no, and a notebook that assumes otherwise dies twenty cells\nlater with a confusing `FileNotFoundError`. So this cell asserts its inputs: the dataset directory and each of the\nfive index tables must exist here, or execution stops immediately with a message naming the offending path. Every\ncell below is written for the case where those assertions passed — no cell degrades quietly on absent data.","metadata":{"_uuid":"5351e8ec-1ddf-4e53-93c8-cf39e15056b1","_cell_guid":"9ff7563e-d3db-4bc7-b39d-a4f48399dd8e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Fail fast: a missing dataset must crash here, not leave every cell below pretending to run.\nassert os.path.isdir(PATH_DATASET), (\n    f\"dataset not mounted at {PATH_DATASET} — set PATH_DATASET_OVERRIDE to a local copy before running\"\n)\n\nprint(f\"PATH_DATASET   : {PATH_DATASET}\")\nprint(f\"PATH_OUTPUT    : {PATH_OUTPUT}\")\n\nfor _name in [\"train.csv\", \"train_series.csv\", \"test.csv\", \"test_series.csv\", \"sample_submission.csv\"]:\n    _path = os.path.join(PATH_DATASET, _name)\n    assert os.path.isfile(_path), f\"missing {_path} — the mounted directory is not the competition dataset\"\n    print(f\"  found : {_path}\")","metadata":{"_uuid":"8279f85e-ff3b-4d42-b56f-25d41553d3e7","_cell_guid":"3cfdff25-f279-456c-ad52-16414c5ed865","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:16.72462Z","iopub.execute_input":"2026-08-17T21:30:16.725715Z","iopub.status.idle":"2026-08-17T21:30:16.735505Z","shell.execute_reply.started":"2026-08-17T21:30:16.725673Z","shell.execute_reply":"2026-08-17T21:30:16.734789Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Exploratory data analysis\n\nEDA here is not a formality — this competition hides three structural traps in its schema, and each one changes the\nmodel we are allowed to build. First, only a *subset* of training studies carries the twelve labels; the rest ship a\nfree-text radiology report instead, so \"how many rows can I actually train on?\" is a question with a surprising\nanswer. Second, the imaging is 570 GB across ~820k DICOM files, so any design that reads every slice of every series\nis dead on arrival and we need evidence about series and slice counts to size a sampler. Third, the twelve findings\nare wildly imbalanced and correlated, which drives both the loss and the split strategy.\n\nEvery check below ends with a printed finding and the design decision it forces. The section closes with a\nconsolidated list of those decisions, which is the actual hand-off to the modelling sections.","metadata":{"_uuid":"c7dd1206-f2a3-4dda-9de2-1c6471381f1c","_cell_guid":"f98885f0-b9c9-4440-9c98-6b6158da5e33","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Grounded label names — spaces and the apostrophe in \"Baker's\" are part of the column headers, so this list is the\n# single source of truth for label selection, metric width, and submission column order.\nSAMPLE_N = 9\nTARGET_COLS = [\n    \"ACL\",\n    \"MCL\",\n    \"Medial Meniscus\",\n    \"Lateral Meniscus\",\n    \"Medial OA\",\n    \"Lateral OA\",\n    \"PF OA\",\n    \"Effusion\",\n    \"Synovitis\",\n    \"Baker's\",\n    \"Contusion\",\n    \"Fracture\",\n]\nprint(f\"targets: {len(TARGET_COLS)} -> {TARGET_COLS}\")","metadata":{"_uuid":"1ed6f45d-3238-404a-9efe-83d45edaf112","_cell_guid":"7a1817cd-88bb-4abb-8541-a5f3eef13a0b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:16.737582Z","iopub.execute_input":"2026-08-17T21:30:16.737876Z","iopub.status.idle":"2026-08-17T21:30:16.751563Z","shell.execute_reply.started":"2026-08-17T21:30:16.737852Z","shell.execute_reply":"2026-08-17T21:30:16.750813Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Dataset overview\n\nWe start from the two tables that describe the corpus: `train.csv` (one row per study, carrying patient sex, the\nfree-text report, and the twelve label columns) and `train_series.csv` (one row per series, carrying the acquisition\nmetadata that will drive our series selection). Loading them separately keeps the study-level and series-level\nquestions distinct, which matters because they have different row counts and different missingness patterns.","metadata":{"_uuid":"6a3bf9d2-96d4-40eb-a6a1-35341476f8ae","_cell_guid":"9aadf6bf-3e3a-4d42-967f-54369f03e065","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Study-level table: labels live here, and so does the report text we will inspect for the partial-label problem.\ndf_train = pd.read_csv(os.path.join(PATH_DATASET, \"train.csv\"))\nprint(f\"train.csv shape: {df_train.shape}\")\ndisplay(df_train.head())","metadata":{"_uuid":"a7b72f88-3b8b-4cb8-a0a8-2711efe7dfaa","_cell_guid":"a8336a23-63f6-4360-b6d1-eed2878982d0","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:16.752444Z","iopub.execute_input":"2026-08-17T21:30:16.752936Z","iopub.status.idle":"2026-08-17T21:30:16.931259Z","shell.execute_reply.started":"2026-08-17T21:30:16.752912Z","shell.execute_reply":"2026-08-17T21:30:16.930374Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Series-level table: plane and fluid-sensitivity flags are the only way to choose a series without opening DICOMs.\ndf_series = pd.read_csv(os.path.join(PATH_DATASET, \"train_series.csv\"))\nprint(f\"train_series.csv shape: {df_series.shape}\")\ndisplay(df_series.head())","metadata":{"_uuid":"3716f33e-9f14-45a4-b604-6304337125b5","_cell_guid":"8cbfe6e0-dfca-4aab-bda6-b5bd5c9e9ab8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:16.932178Z","iopub.execute_input":"2026-08-17T21:30:16.93335Z","iopub.status.idle":"2026-08-17T21:30:17.027033Z","shell.execute_reply.started":"2026-08-17T21:30:16.933323Z","shell.execute_reply":"2026-08-17T21:30:17.026424Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Dtypes reveal whether the label columns arrived as float (the tell-tale sign of NaN-bearing partial labels).\ndisplay(df_train.dtypes.to_frame(\"dtype\"))","metadata":{"_uuid":"93bb2bbd-2a53-4c59-b371-81fc2f1efee8","_cell_guid":"876cd03d-0788-4908-9997-3d1008f4cafe","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.027972Z","iopub.execute_input":"2026-08-17T21:30:17.028327Z","iopub.status.idle":"2026-08-17T21:30:17.035587Z","shell.execute_reply.started":"2026-08-17T21:30:17.028299Z","shell.execute_reply":"2026-08-17T21:30:17.034915Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Missingness per column is the headline diagnostic for this competition: it quantifies the partial-label split.\n_miss = pd.DataFrame({\n    \"missing\": df_train.isna().sum(),\n    \"missing_pct\": (df_train.isna().mean() * 100).round(2),\n})\ndisplay(_miss)","metadata":{"_uuid":"8ce73787-4223-46fe-bd46-c9ed2f100fc5","_cell_guid":"228c1d7c-53a1-4218-ad5f-1258793fc9c3","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.036888Z","iopub.execute_input":"2026-08-17T21:30:17.037195Z","iopub.status.idle":"2026-08-17T21:30:17.053383Z","shell.execute_reply.started":"2026-08-17T21:30:17.037159Z","shell.execute_reply":"2026-08-17T21:30:17.052681Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Descriptive stats over the label columns give prevalence and count in one view; count < len(df) means partial labels.\ndisplay(df_train[TARGET_COLS].describe().T[[\"count\", \"mean\", \"min\", \"max\"]])","metadata":{"_uuid":"219ec286-bfa3-4e8e-820e-ead6a84cdc35","_cell_guid":"a3fe7bf4-b9fa-4852-9004-fa8e1724d6ec","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.05427Z","iopub.execute_input":"2026-08-17T21:30:17.054606Z","iopub.status.idle":"2026-08-17T21:30:17.092024Z","shell.execute_reply.started":"2026-08-17T21:30:17.054568Z","shell.execute_reply":"2026-08-17T21:30:17.091447Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# A duplicated StudyInstanceUID would leak the same knee across the train/validation boundary.\n_dup_studies = int(df_train[\"StudyInstanceUID\"].duplicated().sum())\n_dup_series = int(df_series[\"SeriesInstanceUID\"].duplicated().sum())\nprint(f\"duplicated StudyInstanceUID  : {_dup_studies}\")\nprint(f\"duplicated SeriesInstanceUID : {_dup_series}\")\nprint(\n    \"FINDING: identifiers are unique -> IMPLICATION: a study-level split needs no de-duplication step.\"\n    if _dup_studies == 0 and _dup_series == 0\n    else \"FINDING: duplicate identifiers exist -> IMPLICATION: de-duplicate before splitting or the split leaks.\"\n)","metadata":{"_uuid":"5580ceb3-4655-431f-a1c0-1683ac438593","_cell_guid":"6979ac02-4f70-47a4-9ca9-632fc6ba47e7","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.094129Z","iopub.execute_input":"2026-08-17T21:30:17.094562Z","iopub.status.idle":"2026-08-17T21:30:17.104717Z","shell.execute_reply.started":"2026-08-17T21:30:17.094536Z","shell.execute_reply":"2026-08-17T21:30:17.104183Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Referenced-file check on a small sample: the index promises directories exist, and a broken promise must surface here,\n# not inside a DataLoader worker where the traceback is unreadable.\n_probe = df_series.sample(min(SAMPLE_N, len(df_series)), random_state=SEED)\n_found = 0\nfor _, _row in _probe.iterrows():\n    _dir = os.path.join(PATH_DATASET, \"train_series\", _row[\"StudyInstanceUID\"], _row[\"SeriesInstanceUID\"])\n    _n_files = len(glob.glob(os.path.join(_dir, \"*.dcm\")))\n    _found += int(_n_files > 0)\n    print(f\"  {_n_files:4d} slices : {_row['SeriesInstanceUID'][:24]}...\")\n# Fail fast: zero resolved directories means the mounted layout does not match the index at all.\nassert _found > 0, \"no sampled series resolved to files — dataset layout differs from train_series.csv\"\nprint(f\"FINDING: {_found}/{len(_probe)} sampled series resolved to non-empty directories.\")\nprint(\n    \"IMPLICATION: the index is trustworthy here; the Dataset still guards empty directories for the full corpus.\"\n    if _found == len(_probe)\n    else \"IMPLICATION: some sampled directories are empty — the Dataset's empty-directory guard is load-bearing.\"\n)","metadata":{"_uuid":"5b62cb57-8084-4b5e-915e-30cc2d5287d6","_cell_guid":"16a8144c-a88f-44a7-9010-d9df7e63ff14","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.10573Z","iopub.execute_input":"2026-08-17T21:30:17.106212Z","iopub.status.idle":"2026-08-17T21:30:17.185768Z","shell.execute_reply.started":"2026-08-17T21:30:17.106172Z","shell.execute_reply":"2026-08-17T21:30:17.185185Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Target distribution\n\nThe first plot answers the question that governs both our loss and our split: *how prevalent is each of the twelve\nfindings?* Macro-averaged ROC AUC weights every label equally, so a finding present in 2% of studies contributes\nexactly as much to our score as one present in 40% — and rare labels are precisely where an unlucky validation split\nproduces a meaningless AUC. We expect a steep prevalence gradient across the twelve columns.","metadata":{"_uuid":"71df7e3c-a9a9-4d82-b8b0-13d025243144","_cell_guid":"1f77cc10-9e3f-4fa6-9fb0-b98175e668ef","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Prevalence is computed on labelled rows only; NaN rows would otherwise silently deflate every bar.\n_prev = df_train[TARGET_COLS].mean(skipna=True).sort_values(ascending=False)\nplt.figure(figsize=(10, 4))\n_ = plt.bar(_prev.index, _prev.to_numpy(), color=\"steelblue\")\n_ = plt.xticks(rotation=45, ha=\"right\")\n_ = plt.xlabel(\"finding\")\n_ = plt.ylabel(\"positive rate\")\n_ = plt.title(\"Per-label prevalence across labelled training studies\")\n_ = plt.grid(True)\nplt.tight_layout()\nplt.show()\ndisplay(_prev.to_frame(\"positive_rate\").round(4))","metadata":{"_uuid":"c212c78a-d3f8-473a-ad22-8ff4474acf93","_cell_guid":"37f06352-28d9-4664-9951-e9a334605ec5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.186622Z","iopub.execute_input":"2026-08-17T21:30:17.186933Z","iopub.status.idle":"2026-08-17T21:30:17.416782Z","shell.execute_reply.started":"2026-08-17T21:30:17.186896Z","shell.execute_reply":"2026-08-17T21:30:17.415995Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The bars span roughly a four-fold range on the labelled subset — effusion at the common end (~60%), MCL at the rare\nend (~16%). Two consequences follow directly.\n\n- **Loss**: plain `BCEWithLogitsLoss` remains the right default — AUC is threshold-free and rank-based, so it does not\n  need the class rebalancing that an accuracy-style objective would demand. Per-label `pos_weight` is the obvious\n  first upgrade to try if the rare columns underperform.\n- **Split**: a naive random split can hand the validation fold too few positives of a rare finding, at which point\n  that column's AUC becomes noise and the macro average inherits the noise. This is what motivates the multilabel-\n  stratified split two sections down.","metadata":{"_uuid":"1d5553fc-a455-4f65-ae56-896c785e1ebb","_cell_guid":"8f74cd4b-0e5c-44d7-b37d-893826bcb630","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"markdown","source":"Prevalence alone does not tell us whether the twelve columns are independent problems or one problem in twelve\ndisguises. The co-occurrence heatmap below counts how often each pair of findings appears in the same study, which\ntells us whether a shared backbone with twelve heads is a reasonable inductive bias.","metadata":{"_uuid":"b2fd44c9-6e78-4e39-bba7-2b2deb7e5464","_cell_guid":"01236fbd-9fd8-499b-a206-3a6841139ebc","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Co-occurrence counts on complete rows only — a dot product over NaN-bearing columns would propagate NaN everywhere.\n_labelled_mask = df_train[TARGET_COLS].notna().all(axis=1)\n_lab = df_train.loc[_labelled_mask, TARGET_COLS].astype(int)\n_cooc = _lab.T.dot(_lab)\nplt.figure(figsize=(8, 6.5))\n_ = sns.heatmap(_cooc, annot=True, fmt=\"d\", cmap=\"viridis\", cbar_kws={\"label\": \"co-occurring studies\"})\n_ = plt.xlabel(\"finding\")\n_ = plt.ylabel(\"finding\")\n_ = plt.title(\"Label co-occurrence (diagonal = positives per label)\")\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"86a09c5e-ccdc-4c93-b950-300d202a2c76","_cell_guid":"e8e8270f-1be5-4df0-9434-b5f21d7c95f4","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.417655Z","iopub.execute_input":"2026-08-17T21:30:17.41811Z","iopub.status.idle":"2026-08-17T21:30:17.830693Z","shell.execute_reply.started":"2026-08-17T21:30:17.418034Z","shell.execute_reply":"2026-08-17T21:30:17.829921Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Off-diagonal mass is substantial — the three osteoarthritis compartments travel together, and effusion accompanies\nmost acute injuries. That is direct evidence for a *shared representation with twelve linear heads* rather than\ntwelve separate models: the backbone can learn \"this knee is degenerated\" once and let each head read it off. It also\nmeans our stratified split must balance label *combinations*, not each label in isolation.","metadata":{"_uuid":"2265a32f-08b1-46c3-b480-7a8c63dc24cb","_cell_guid":"9864b59e-873e-4c7d-a3da-22b326fe3bda","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"markdown","source":"### Hypothesis 1 — the labels are imbalanced enough to threaten rare-column AUC\n\nWe suspect the rarest findings have so few positives that a random 20% validation fold could contain a handful of\nthem. The check below counts positives per label and projects how many land in validation, because a column with\nfewer than ~10 validation positives yields an AUC estimate too unstable to guide model selection.","metadata":{"_uuid":"48e4d8e8-e5a5-4a78-8763-0167d5685abd","_cell_guid":"8e7ee445-15ae-4a5e-b6fa-5d98dfdd8442","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Projected validation positives per label decide whether stratification is optional or mandatory.\n_pos = df_train.loc[_labelled_mask, TARGET_COLS].sum().astype(int)\n_proj = (_pos * 0.2).round().astype(int)\ndisplay(pd.DataFrame({\"positives\": _pos, \"projected_val_positives@20%\": _proj}).sort_values(\"positives\"))\n_fragile = _proj[_proj < 10]\nprint(f\"FINDING: {len(_fragile)}/{len(TARGET_COLS)} labels project to <10 val positives: {list(_fragile.index)}\")\nprint(\n    f\"IMPLICATION: multilabel-stratified splitting is mandatory, and the macro AUC leans on {len(_fragile)}\"\n    \" fragile columns — read per-run scores with caution.\"\n    if len(_fragile)\n    else \"IMPLICATION: every label projects enough validation positives — stratification is a nicety, not a must.\"\n)","metadata":{"_uuid":"d29734fa-17e9-4565-9441-665066b8eb85","_cell_guid":"33cfe384-702c-483f-968a-3d650ebf76a5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.831675Z","iopub.execute_input":"2026-08-17T21:30:17.83199Z","iopub.status.idle":"2026-08-17T21:30:17.844637Z","shell.execute_reply.started":"2026-08-17T21:30:17.831955Z","shell.execute_reply":"2026-08-17T21:30:17.843665Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Hypothesis 2 — only a subset of studies is labelled, and the reports cover the rest\n\nThis is the defining twist of the competition. The organisers state that only a small subset of training studies\ncarries the twelve per-condition labels and that the free-text `Report` column exists so competitors can derive\nlabels for the remainder. The check quantifies the three populations — fully labelled, unlabelled but reported, and\nneither — because the ratio decides whether weak supervision is a nice-to-have or the whole game.","metadata":{"_uuid":"4267a070-feaa-4f41-8af9-0d6318144f2e","_cell_guid":"b300bcbc-ec12-4592-a4bf-92af04762f1e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Counting the labelled/unlabelled populations sizes both the honest baseline and the weak-supervision upside.\n_n_total = len(df_train)\n_n_labelled = int(df_train[TARGET_COLS].notna().all(axis=1).sum())\n_n_partial = int(df_train[TARGET_COLS].notna().any(axis=1).sum()) - _n_labelled\n_has_report = df_train[\"Report\"].notna() & df_train[\"Report\"].astype(str).str.strip().ne(\"\")\n_n_unlabelled_reported = int((~df_train[TARGET_COLS].notna().all(axis=1) & _has_report).sum())\nprint(f\"studies total                        : {_n_total}\")\nprint(f\"fully labelled (all 12 present)      : {_n_labelled}\")\nprint(f\"partially labelled (some present)    : {_n_partial}\")\nprint(f\"unlabelled but carrying a report     : {_n_unlabelled_reported}\")\n_share = _n_labelled / max(_n_total, 1)\nprint(f\"labelled share                       : {_share:.1%}\")\nif _share < 0.5:\n    print(f\"FINDING: only {_share:.1%} of studies carry labels — most are described by free text alone.\")\n    print(\"IMPLICATION: the baseline trains on fully labelled rows ONLY (never on NaN targets); report-derived\")\n    print(\"             weak labels are a gated extension, evaluated against the same clean validation fold.\")\nelse:\n    print(f\"FINDING: {_share:.1%} of studies carry labels — supervision is plentiful.\")\n    print(\"IMPLICATION: weak labels from reports would add little; train on the labelled rows directly.\")","metadata":{"_uuid":"f5cc2880-c4a8-4e41-9030-956c179e60bf","_cell_guid":"ea23625c-8b73-48e0-bd3a-502cbea4be21","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.845804Z","iopub.execute_input":"2026-08-17T21:30:17.846689Z","iopub.status.idle":"2026-08-17T21:30:17.868313Z","shell.execute_reply.started":"2026-08-17T21:30:17.846661Z","shell.execute_reply":"2026-08-17T21:30:17.867516Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Hypothesis 3 — series and slice counts allow a fixed-length sampler\n\n570 GB is only tractable if we read a bounded number of slices per study. The organisers describe series of typically\n20–45 slices with a median near 30 and a tail into the hundreds. If that holds, a fixed sample of ~24 slices captures\nmost of the volume for a typical series while capping the cost of the outliers. We verify series-per-study from the\nindex (free) and slice counts from a small directory sample (cheap).","metadata":{"_uuid":"beb0fb64-69db-41b3-b2ed-089e8fb7bf7f","_cell_guid":"d88c9b65-d047-405f-a664-745ffb8d3f29","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Series per study, straight from the index — this tells us how much choice the selection rule actually has.\n_per_study = df_series.groupby(\"StudyInstanceUID\")[\"SeriesInstanceUID\"].count()\ndisplay(_per_study.describe().to_frame(\"series_per_study\"))\nplt.figure(figsize=(8, 3.5))\n_ = plt.hist(_per_study.to_numpy(), bins=range(1, int(_per_study.max()) + 2), color=\"steelblue\", align=\"left\")\n_ = plt.xlabel(\"series per study\")\n_ = plt.ylabel(\"number of studies\")\n_ = plt.title(\"How many series each study contains\")\n_ = plt.grid(True)\nplt.tight_layout()\nplt.show()\n_med_series = int(_per_study.median())\nprint(f\"FINDING: median {_med_series} series per study -> IMPLICATION: selecting ONE series per study cuts imaging\")\nprint(f\"         I/O roughly {_med_series}x; the unused series remain available for a later multi-series ensemble.\")","metadata":{"_uuid":"e5b4e089-37ca-4303-840c-24f857264ce3","_cell_guid":"128f8819-b317-4ab1-9f04-138e6bfbaa6a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:17.869191Z","iopub.execute_input":"2026-08-17T21:30:17.869781Z","iopub.status.idle":"2026-08-17T21:30:18.024875Z","shell.execute_reply.started":"2026-08-17T21:30:17.869734Z","shell.execute_reply":"2026-08-17T21:30:18.024144Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Slice counts require touching the filesystem, so we sample rather than scan all ~820k files.\n_probe = df_series.sample(min(50, len(df_series)), random_state=SEED)\n_counts = []\nfor _, _row in tqdm(_probe.iterrows(), total=len(_probe), desc=\"counting slices\"):\n    _dir = os.path.join(PATH_DATASET, \"train_series\", _row[\"StudyInstanceUID\"], _row[\"SeriesInstanceUID\"])\n    _counts.append(len(glob.glob(os.path.join(_dir, \"*.dcm\"))))\n_counts = pd.Series(_counts)\ndisplay(_counts.describe().to_frame(\"slices_per_series\"))\nif _counts.max() > 0:\n    plt.figure(figsize=(8, 3.5))\n    _ = plt.hist(_counts.to_numpy(), bins=20, color=\"darkorange\")\n    _ = plt.xlabel(\"slices per series\")\n    _ = plt.ylabel(\"number of series\")\n    _ = plt.title(\"Slice-count distribution over a random series sample\")\n    _ = plt.grid(True)\n    plt.tight_layout()\n    plt.show()\n_med_slices, _max_slices = float(_counts.median()), float(_counts.max())\n_coverage = min(24.0 / max(_med_slices, 1.0), 1.0)\nprint(f\"FINDING: median {_med_slices:.0f} slices per series, max {_max_slices:.0f} in this sample.\")\nprint(f\"IMPLICATION: a FIXED, evenly spread sample of 24 slices covers {_coverage:.0%} of the median series while\")\nprint(f\"             bounding the {_max_slices:.0f}-slice tail, and keeps a constant tensor shape for batching.\")","metadata":{"_uuid":"2960570d-0358-4d14-a9cc-80829253ece3","_cell_guid":"e2af6bb4-00c9-401d-91dc-aece15502d4c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.025877Z","iopub.execute_input":"2026-08-17T21:30:18.026246Z","iopub.status.idle":"2026-08-17T21:30:18.464406Z","shell.execute_reply.started":"2026-08-17T21:30:18.026219Z","shell.execute_reply":"2026-08-17T21:30:18.463548Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Hypothesis 4 — sagittal fluid-sensitive series exist for most studies\n\nOur selection rule is only viable if the preferred acquisition actually exists study by study. Radiologically, the\nsagittal fluid-sensitive (T2/PD/STIR-like) series is the workhorse for this label set: it shows the cruciate\nligaments along their length, cuts the menisci in cross-section, and makes effusion and marrow oedema bright. The\ncheck measures coverage of that preference and of each fallback rung.","metadata":{"_uuid":"f591c9ab-0b1e-4a07-98ea-e8ff99464749","_cell_guid":"a4429671-424c-4d46-865d-7113c75c80d8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Coverage of each fallback rung decides whether the selection chain needs a third rung or a fourth.\ndisplay(df_series[\"Anatomical_Plane\"].value_counts(dropna=False).to_frame(\"series\"))\ndisplay(df_series[\"Fluid_Sensitive\"].value_counts(dropna=False).to_frame(\"series\"))\n_studies = df_series[\"StudyInstanceUID\"].nunique()\n_sag_fs = df_series[(df_series[\"Anatomical_Plane\"] == \"Sagittal\") & (df_series[\"Fluid_Sensitive\"] == 1)]\n_sag = df_series[df_series[\"Anatomical_Plane\"] == \"Sagittal\"]\n_n_sag_fs = _sag_fs[\"StudyInstanceUID\"].nunique()\n_n_sag = _sag[\"StudyInstanceUID\"].nunique()\nprint(f\"studies with a sagittal fluid-sensitive series : {_n_sag_fs} / {_studies}\")\nprint(f\"studies with any sagittal series               : {_n_sag} / {_studies}\")\nprint(\n    f\"FINDING: sagittal fluid-sensitive covers {_n_sag_fs / _studies:.1%}; any sagittal covers {_n_sag / _studies:.1%}.\"\n)\nprint(\n    \"IMPLICATION: the fallback rungs are exercised in training data — the chain sagittal+fluid-sensitive ->\"\n    \"\\n             any sagittal -> any series keeps every study predicted.\"\n    if _n_sag_fs < _studies\n    else \"IMPLICATION: the preferred acquisition is universal here — the fallback chain remains as a safety net\"\n    \"\\n             for the hidden test data.\"\n)","metadata":{"_uuid":"105b575d-b4c6-425e-9992-5777d82dc29e","_cell_guid":"a334391b-bef6-4656-af08-a536f8640b74","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.465434Z","iopub.execute_input":"2026-08-17T21:30:18.465738Z","iopub.status.idle":"2026-08-17T21:30:18.495441Z","shell.execute_reply.started":"2026-08-17T21:30:18.4657Z","shell.execute_reply":"2026-08-17T21:30:18.494811Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Hypothesis 5 — the reports are multilingual, which bounds how far keyword rules can take us\n\nWeak supervision from text only works if we can read the text. The competition notes that reports may be written in\nany of several languages, and an English-only keyword rule applied to a multilingual corpus does not fail loudly — it\nfails *silently*, by labelling every non-English study negative. The check below estimates the language mix with a\ncheap marker-word heuristic and a non-ASCII character scan.","metadata":{"_uuid":"425f4a56-b3ef-4cca-b71a-c166a631e5ed","_cell_guid":"d736e2ac-96cd-428e-bf29-9700f3a050ea","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# A crude language probe is enough to decide whether keyword weak-labelling is safe to enable by default.\n_LANG_MARKERS = {\n    \"english\": [\" the \", \" and \", \" with \", \" no \"],\n    \"german\": [\" der \", \" und \", \" mit \", \" kein \"],\n    \"spanish\": [\" el \", \" los \", \" con \", \" sin \"],\n    \"french\": [\" le \", \" les \", \" avec \", \" sans \"],\n    \"dutch\": [\" het \", \" een \", \" met \", \" geen \"],\n}\n_reports = df_train[\"Report\"].dropna().astype(str).str.lower()\n_non_ascii = float(_reports.apply(lambda t: any(ord(ch) > 127 for ch in t)).mean())\n_hits = {\n    _lang: int(_reports.apply(lambda t, mk=_marks: any(w in f\" {t} \" for w in mk)).sum())\n    for _lang, _marks in _LANG_MARKERS.items()\n}\ndisplay(pd.Series(_hits, name=\"reports_matching_markers\").sort_values(ascending=False).to_frame())\nprint(f\"reports scanned                : {len(_reports)}\")\nprint(f\"share containing non-ASCII text: {_non_ascii:.1%}\")\n_langs_hit = [_lang for _lang, _n in _hits.items() if _n >= max(1, len(_reports) // 100)]\nif len(_langs_hit) > 1:\n    print(f\"FINDING: marker words fire for {len(_langs_hit)} languages ({', '.join(_langs_hit)}) — not English-only.\")\n    print(\"IMPLICATION: report-derived labels are WEAK and noisy — they stay behind a feature flag, are never used\")\n    print(\"             for validation, and the reader is told exactly which languages the keyword map misses.\")\nelse:\n    print(\"FINDING: marker words fire for one language only — the corpus reads as effectively monolingual.\")\n    print(\"IMPLICATION: keyword weak-labelling is less risky than feared, but stays gated until measured.\")","metadata":{"_uuid":"544baa36-185c-48e5-bd06-b76f7c8f16b9","_cell_guid":"225ef2a5-0a01-4a72-a512-8635162ca1ce","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.496704Z","iopub.execute_input":"2026-08-17T21:30:18.497302Z","iopub.status.idle":"2026-08-17T21:30:18.832024Z","shell.execute_reply.started":"2026-08-17T21:30:18.497274Z","shell.execute_reply":"2026-08-17T21:30:18.831455Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Modality display — from DICOM files to a normalised volume\n\nEverything above was tabular. Now we open the imaging itself, because three properties of these DICOMs directly shape\nthe Dataset we are about to write: slices arrive as one file each and must be *ordered*, intensities are not\ncomparable across scanners and must be *normalised*, and the compressed transfer syntaxes must actually *decode*.\n\nThe helpers below are the exact ones the training Dataset will reuse — defining them here means the pictures we look\nat are produced by the same code path that feeds the model, not by a throwaway viewer that might disagree with it.","metadata":{"_uuid":"25bc246c-a91a-48df-93fd-936644417144","_cell_guid":"427e1d01-2346-46f8-90d0-589b685e3d36","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"markdown","source":"The first helper picks one series per study. It implements the fallback chain that Hypothesis 4 justified, and it\nsorts by `SeriesInstanceUID` inside each rung so the choice is deterministic across runs — a selector that returns a\ndifferent series each epoch would make validation scores irreproducible.","metadata":{"_uuid":"400bf9d6-4001-49fe-9e26-d48a71f8ed75","_cell_guid":"332da9e7-8407-44e3-ad98-ba8f861192b6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def select_one_series(df_series_all):\n    \"\"\"Pick one series per study: sagittal fluid-sensitive, else any sagittal, else any series.\"\"\"\n    df = df_series_all.copy()\n    df[\"_rank\"] = 2\n    df.loc[df[\"Anatomical_Plane\"] == \"Sagittal\", \"_rank\"] = 1\n    df.loc[(df[\"Anatomical_Plane\"] == \"Sagittal\") & (df[\"Fluid_Sensitive\"] == 1), \"_rank\"] = 0\n    df = df.sort_values([\"StudyInstanceUID\", \"_rank\", \"SeriesInstanceUID\"])\n    return df.groupby(\"StudyInstanceUID\", as_index=False).first().drop(columns=[\"_rank\"])","metadata":{"_uuid":"bec5eca1-904a-486d-b664-332a36360f31","_cell_guid":"1a28060c-57dc-4a87-afe0-f6d5646865cf","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.832836Z","iopub.execute_input":"2026-08-17T21:30:18.833142Z","iopub.status.idle":"2026-08-17T21:30:18.838676Z","shell.execute_reply.started":"2026-08-17T21:30:18.833109Z","shell.execute_reply":"2026-08-17T21:30:18.837803Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Ordering slices correctly matters more than it looks: a shuffled stack destroys the through-plane continuity that the\nattention head learns to exploit. We read `InstanceNumber` from the *header only* — `stop_before_pixels=True` skips\npixel decoding, which is roughly two orders of magnitude cheaper — and fall back to filename order when any file in\nthe series lacks the tag, because a partially-sorted stack is worse than a consistently-sorted arbitrary one.","metadata":{"_uuid":"d7d0c8a2-3a44-4a0b-81cd-a58bf2339ef5","_cell_guid":"5822b85e-6485-45c2-9ec9-a44f5c21d1c6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def sorted_slice_paths(dir_series):\n    \"\"\"Order a series' `.dcm` files by InstanceNumber, falling back to filename order when the tag is unusable.\"\"\"\n    paths = sorted(glob.glob(os.path.join(dir_series, \"*.dcm\")))\n    if not paths:\n        return []\n    numbers = []\n    for path in paths:\n        try:\n            header = pydicom.dcmread(path, stop_before_pixels=True)\n            value = getattr(header, \"InstanceNumber\", None)\n            numbers.append(float(value) if value is not None else np.nan)\n        except (OSError, ValueError, TypeError, pydicom.errors.InvalidDicomError):\n            numbers.append(np.nan)\n    if np.isnan(numbers).any():\n        return paths\n    return [path for _, path in sorted(zip(numbers, paths))]","metadata":{"_uuid":"3e274b18-9503-4d93-88b9-ba33a9bdc9b4","_cell_guid":"96e5f7a3-341f-4015-96d0-6563408c24cf","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.839712Z","iopub.execute_input":"2026-08-17T21:30:18.840043Z","iopub.status.idle":"2026-08-17T21:30:18.8579Z","shell.execute_reply.started":"2026-08-17T21:30:18.840016Z","shell.execute_reply":"2026-08-17T21:30:18.856794Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The slice reader is where the compressed transfer syntaxes bite. `pixel_array` transparently decompresses JPEG\nLossless and JPEG 2000 *only* when the pylibjpeg plugins are installed — otherwise it raises, which is exactly why\nthey are in the setup cell. We catch the realistic failure modes (unreadable file, missing decoder, malformed\ndataset) and return `None` so a single corrupt file degrades one slice rather than killing an epoch.","metadata":{"_uuid":"d6e7d141-38b7-4998-abab-b923df5119c5","_cell_guid":"cd410be4-b7b7-4635-be21-9f0dd68b0055","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def read_dicom_slice(path):\n    \"\"\"Decode one DICOM file to a 2D float32 array, or return None when the file cannot be read.\"\"\"\n    try:\n        arr = pydicom.dcmread(path).pixel_array.astype(np.float32)\n    except (\n        OSError,\n        ValueError,\n        TypeError,\n        RuntimeError,\n        NotImplementedError,\n        AttributeError,\n        pydicom.errors.InvalidDicomError,\n    ):\n        return None\n    if arr.ndim == 3:  # rare multi-frame file — take the central frame\n        arr = arr[arr.shape[0] // 2]\n    return arr if arr.ndim == 2 else None","metadata":{"_uuid":"b41d96bf-eb9f-4969-b6df-7ac00e35977c","_cell_guid":"4c3ae42f-c63d-4209-ae84-f8289a012c4c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.858866Z","iopub.execute_input":"2026-08-17T21:30:18.859275Z","iopub.status.idle":"2026-08-17T21:30:18.871186Z","shell.execute_reply.started":"2026-08-17T21:30:18.859237Z","shell.execute_reply":"2026-08-17T21:30:18.870341Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The volume reader turns a directory into the fixed-shape tensor the model consumes. Three decisions are encoded here.\n\n- **Even index sampling** via `np.linspace`: it spans the whole series rather than a contiguous chunk, so a lesion at\n  either end is still visible. When a series has fewer slices than `n_slices`, the rounded indices repeat — the stack\n  is padded by duplication, which keeps the tensor shape constant without inventing black slices.\n- **Per-volume percentile normalisation**: MRI intensities carry no absolute meaning and vary by scanner and\n  sequence, so we clip to the 1st–99th percentile of *this* volume and rescale to 0–1. Percentiles rather than\n  min/max because a single hot voxel would otherwise compress the entire useful range.\n- **Resize before stacking**: slices within a series can differ in matrix size; resizing each one individually makes\n  the function robust to that instead of failing on `torch.cat`.","metadata":{"_uuid":"f1af3a91-2df8-425c-84cd-e7088580d121","_cell_guid":"753c5eef-b866-4f7b-a7f2-047f65189435","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def read_dicom_volume(dir_series, n_slices=24, image_size=224):\n    \"\"\"Read a DICOM series into a normalised (n_slices, 1, image_size, image_size) float32 tensor.\"\"\"\n    paths = sorted_slice_paths(dir_series)\n    if not paths:\n        return torch.zeros(n_slices, 1, image_size, image_size, dtype=torch.float32)\n    indices = np.linspace(0, len(paths) - 1, n_slices).round().astype(int)\n    cache, slices = {}, []\n    for i in indices:\n        if i not in cache:  # short series repeat indices — decode each file at most once\n            arr = read_dicom_slice(paths[i])\n            tensor = torch.zeros(1, 1, image_size, image_size) if arr is None else torch.from_numpy(arr)[None, None]\n            if arr is not None:\n                tensor = torch.nn.functional.interpolate(\n                    tensor, size=(image_size, image_size), mode=\"bilinear\", align_corners=False\n                )\n            cache[i] = tensor\n        slices.append(cache[i])\n    volume = torch.cat(slices, dim=0).float()\n    # ==============================\n    lo, hi = np.percentile(volume.numpy(), [1.0, 99.0])\n    return ((volume - lo) / max(float(hi - lo), 1e-6)).clamp(0.0, 1.0)","metadata":{"_uuid":"0c15743d-7b85-4336-9d7c-5b9664e59c70","_cell_guid":"59d41170-0943-4e21-8560-db99fea44797","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.871988Z","iopub.execute_input":"2026-08-17T21:30:18.872342Z","iopub.status.idle":"2026-08-17T21:30:18.884814Z","shell.execute_reply.started":"2026-08-17T21:30:18.872317Z","shell.execute_reply":"2026-08-17T21:30:18.884128Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"With the helpers in place we load one real study. The figure shows the same volume cut three ways: the native\nacquisition plane, and two reconstructions through the stack. There is no mask overlay here — this is a\nclassification problem, so what we are checking is anatomical legibility and normalisation quality, not annotation\nalignment.","metadata":{"_uuid":"c98ba3e9-dd32-44bb-b38b-0daff839a9d7","_cell_guid":"68ff3061-f778-4e52-9fcb-c96c6a93fa64","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# One concrete volume beats any amount of schema reading — it validates decoder, ordering and scaling at once.\n_sel = select_one_series(df_series)\n_row = _sel.iloc[0]\n_dir = os.path.join(PATH_DATASET, \"train_series\", _row[\"StudyInstanceUID\"], _row[\"SeriesInstanceUID\"])\ndemo_volume = read_dicom_volume(_dir, n_slices=24, image_size=224)\n# Fail fast: an all-zero volume means the decoders are missing, which would poison every batch silently.\nassert float(demo_volume.abs().sum()) > 0, f\"decoded only zeros from {_dir} — are the pylibjpeg plugins installed?\"\nprint(f\"study  : {_row['StudyInstanceUID']}\")\nprint(f\"plane  : {_row['Anatomical_Plane']} | fluid: {_row['Fluid_Sensitive']} | fat: {_row['Fat_Suppression']}\")\nprint(f\"volume : shape={tuple(demo_volume.shape)} dtype={demo_volume.dtype}\")","metadata":{"_uuid":"6af68734-544b-46bc-a11c-cf80325f7b28","_cell_guid":"ee44bc96-a29f-4675-87a3-2b75055c869d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:18.885668Z","iopub.execute_input":"2026-08-17T21:30:18.885968Z","iopub.status.idle":"2026-08-17T21:30:19.248439Z","shell.execute_reply.started":"2026-08-17T21:30:18.885933Z","shell.execute_reply":"2026-08-17T21:30:19.247682Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Three orthogonal cuts through the same array expose ordering errors that a single slice would hide.\n_vol = demo_volume[:, 0].numpy()\n_fig, _axes = plt.subplots(1, 3, figsize=(13, 4.5))\nfor _ax, _plane, _img in zip(\n    _axes,\n    [\"acquisition plane (mid-slice)\", \"reconstruction along axis 1\", \"reconstruction along axis 2\"],\n    [_vol[_vol.shape[0] // 2], _vol[:, _vol.shape[1] // 2, :], _vol[:, :, _vol.shape[2] // 2]],\n):\n    _ = _ax.imshow(_img, cmap=\"gray\", aspect=\"auto\")\n    _ = _ax.set_title(_plane)\n    _ = _ax.set_xlabel(\"pixel\")\n    _ = _ax.set_ylabel(\"pixel / slice\")\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"62ebea09-d37d-4e46-8442-4d672c64948c","_cell_guid":"18c83b6a-49d7-49b3-b7ac-fef9dce472c3","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:19.249399Z","iopub.execute_input":"2026-08-17T21:30:19.24995Z","iopub.status.idle":"2026-08-17T21:30:19.758976Z","shell.execute_reply.started":"2026-08-17T21:30:19.249922Z","shell.execute_reply":"2026-08-17T21:30:19.758035Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The mid-slice is legible knee anatomy and the reconstructions are continuous rather than striped, which confirms the\n`InstanceNumber` ordering is correct — a mis-ordered stack shows as banding in exactly those two panels. The\nnormalisation check below confirms the value range the model will actually receive.","metadata":{"_uuid":"9a37e4a8-5d7b-4731-8a05-d3c73884ed4f","_cell_guid":"97fdf9b5-9c25-43f9-a1f7-6c5329f63f89","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Intensity statistics verify the normalisation contract the model's first BatchNorm implicitly assumes.\n_flat = demo_volume.numpy().ravel()\nprint(f\"min={_flat.min():.3f} max={_flat.max():.3f} mean={_flat.mean():.3f} std={_flat.std():.3f}\")\nprint(f\"zero-valued fraction (background): {(_flat == 0).mean():.1%}\")\n# Fail fast: a collapsed distribution means normalisation produced no usable contrast for the backbone.\nassert float(_flat.std()) > 0.05, \"intensity distribution collapsed — percentile normalisation is not working\"\n_interior = float(((_flat > 0.05) & (_flat < 0.95)).mean())\nprint(f\"FINDING: {_interior:.0%} of voxels sit in the interior of [0, 1] rather than at the clipped extremes.\")\nprint(\"IMPLICATION: per-volume percentile scaling handles cross-scanner drift on its own; it does not remove the\")\nprint(\"need for ImageNet mean/std standardisation before the pretrained backbone sees the tensor (Dataset, below).\")","metadata":{"_uuid":"b89e146e-222a-4f3e-ad0d-395b32c95be6","_cell_guid":"8a6d1e52-05a8-4615-a775-fc8a1d786cd1","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:19.759975Z","iopub.execute_input":"2026-08-17T21:30:19.760693Z","iopub.status.idle":"2026-08-17T21:30:19.778813Z","shell.execute_reply.started":"2026-08-17T21:30:19.760664Z","shell.execute_reply":"2026-08-17T21:30:19.777748Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Extension — weak labels derived from the reports\n\nHypothesis 2 showed that most training studies carry a report but no labels, and Hypothesis 5 showed those reports\nare multilingual. This subsection implements the obvious extension — keyword-matching the reports into weak labels —\nand then deliberately leaves it **switched off**.\n\nThe honest framing matters more than the code:\n\n- The keyword map below is English-centric with a handful of German and Spanish synonyms. Every report in a language\n  it does not cover becomes an all-negative row, which is not \"missing data\" but *actively wrong* supervision.\n- Radiology reports are full of negation (\"no evidence of ACL tear\"), so a bare substring match inverts the label on\n  exactly the sentences that mention the finding. We apply a crude negation window, which reduces the error without\n  removing it.\n- Weak labels may only ever enter the *training* fold. Validation stays on human labels, or the AUC we optimise stops\n  measuring the thing the leaderboard measures.\n\n`USE_PSEUDO_LABELS` is on by default here: with only 58 human-labelled studies for the frozen-backbone linear probe\nto train on, the weak-labelled rows are worth the noise they add — training-fold-only, never touching the human\nout-of-fold validation the pooled AUROC above is measured against. Turn it back off to reproduce the clean baseline.","metadata":{"_uuid":"6dabc199-56f0-46c5-b6fb-cd121e11a82b","_cell_guid":"a535ef65-c663-487c-a5a0-73bcf2c5c5d7","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Feature flag first, so the effect of weak supervision stays a single toggle rather than buried in the pipeline.\nUSE_PSEUDO_LABELS = True\nPSEUDO_NEGATION_WINDOW = 40\nPSEUDO_LABEL_KEYWORDS = {\n    \"ACL\": [\"acl\", \"anterior cruciate\", \"vorderes kreuzband\", \"ligamento cruzado anterior\"],\n    \"MCL\": [\"mcl\", \"medial collateral\", \"innenband\", \"ligamento colateral medial\"],\n    \"Medial Meniscus\": [\"medial meniscus\", \"innenmeniskus\", \"menisco medial\"],\n    \"Lateral Meniscus\": [\"lateral meniscus\", \"aussenmeniskus\", \"menisco lateral\"],\n    \"Medial OA\": [\"medial compartment osteoarthritis\", \"medial osteoarthritis\", \"mediale gonarthrose\"],\n    \"Lateral OA\": [\"lateral compartment osteoarthritis\", \"lateral osteoarthritis\", \"laterale gonarthrose\"],\n    \"PF OA\": [\"patellofemoral osteoarthritis\", \"retropatellar\", \"femoropatelar\"],\n    \"Effusion\": [\"effusion\", \"erguss\", \"derrame\"],\n    \"Synovitis\": [\"synovitis\", \"synovialitis\", \"sinovitis\"],\n    \"Baker's\": [\"baker\", \"popliteal cyst\", \"bakerzyste\", \"quiste de baker\"],\n    \"Contusion\": [\"contusion\", \"bone marrow oedema\", \"bone marrow edema\", \"knochenmarksoedem\"],\n    \"Fracture\": [\"fracture\", \"fraktur\", \"fractura\"],\n}\n_NEGATIONS = [\"no \", \"not \", \"without \", \"absence of \", \"kein\", \"keine\", \"sin \", \"negative for \"]\nprint(f\"USE_PSEUDO_LABELS = {USE_PSEUDO_LABELS} (weak supervision is opt-in and never used for validation)\")","metadata":{"_uuid":"c459eea9-a5d6-427f-af8d-63dcca47dc32","_cell_guid":"da9f985d-8f0a-435d-8d4b-ee3c0b957cdc","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:19.78009Z","iopub.execute_input":"2026-08-17T21:30:19.780353Z","iopub.status.idle":"2026-08-17T21:30:19.852563Z","shell.execute_reply.started":"2026-08-17T21:30:19.780324Z","shell.execute_reply":"2026-08-17T21:30:19.85157Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The matcher below scans for each keyword and then inspects the characters immediately preceding the hit for a\nnegation cue. It is intentionally simple and intentionally documented as lossy — its value is that it makes the\nnoise mechanism visible to the reader rather than burying it inside a preprocessing script.","metadata":{"_uuid":"9e90fc5c-c03b-4978-8021-51ab4c027726","_cell_guid":"675af11a-29b8-40ae-9635-546ec0783ad2","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def weak_label_from_report(text, keywords, negation_window=PSEUDO_NEGATION_WINDOW):\n    \"\"\"Return 1 when a keyword appears without a nearby negation cue, else 0.\"\"\"\n    if not isinstance(text, str) or not text.strip():\n        return 0\n    low = text.lower()\n    for keyword in keywords:\n        for match in re.finditer(re.escape(keyword), low):\n            prefix = low[max(0, match.start() - negation_window) : match.start()]\n            if not any(neg in prefix for neg in _NEGATIONS):\n                return 1\n    return 0","metadata":{"_uuid":"8f2f7c0d-9701-4a18-a707-54e526ffda98","_cell_guid":"484c861d-076f-4561-8e31-eaa2ab5903c0","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:19.858932Z","iopub.execute_input":"2026-08-17T21:30:19.859388Z","iopub.status.idle":"2026-08-17T21:30:19.870216Z","shell.execute_reply.started":"2026-08-17T21:30:19.859339Z","shell.execute_reply":"2026-08-17T21:30:19.869221Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Building `df_labelled` is the hand-off from EDA to modelling: it is the exact table the DataModule will split.\ndf_labelled = df_train[df_train[TARGET_COLS].notna().all(axis=1)].copy()\ndf_labelled[TARGET_COLS] = df_labelled[TARGET_COLS].astype(int)\nprint(f\"human-labelled studies used for training/validation: {len(df_labelled)}\")\nif USE_PSEUDO_LABELS:\n    _unlabelled = df_train[~df_train[TARGET_COLS].notna().all(axis=1)].copy()\n    for _col in TARGET_COLS:\n        _unlabelled[_col] = _unlabelled[\"Report\"].apply(\n            lambda t, kw=PSEUDO_LABEL_KEYWORDS[_col]: weak_label_from_report(t, kw)\n        )\n    _unlabelled[\"is_weak\"] = 1\n    df_labelled[\"is_weak\"] = 0\n    df_labelled = pd.concat([df_labelled, _unlabelled], ignore_index=True)\n    print(f\"weak-labelled studies appended: {len(_unlabelled)} (training fold only — see the note above)\")\nelse:\n    df_labelled[\"is_weak\"] = 0","metadata":{"_uuid":"baad6179-c3a3-4a23-8a6c-02c8cc9e90ba","_cell_guid":"474ae4a7-4bec-4ba6-81ce-779a0d6b15ad","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:19.871029Z","iopub.execute_input":"2026-08-17T21:30:19.871415Z","iopub.status.idle":"2026-08-17T21:30:20.762373Z","shell.execute_reply.started":"2026-08-17T21:30:19.871374Z","shell.execute_reply":"2026-08-17T21:30:20.761565Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### EDA lens — decisions carried into the model\n\nEverything the modelling sections assume was measured above, not guessed. Collecting the decisions in one place makes\nthe chain auditable: if a later result disappoints, this is the list of assumptions to revisit first.\n\n| Evidence | Decision |\n| --- | --- |\n| Twelve labels, imbalanced and correlated | One backbone, twelve logits, `BCEWithLogitsLoss`, macro AUROC |\n| All twelve labels project to <10 validation positives | Multilabel-stratified split, seeded |\n| Most studies have no labels, only reports; only 58 human-labelled | Weak labels from report keywords, training fold only |\n| Median ~5 series per study, ~30 slices per series | One selected series, 24 evenly sampled slices |\n| Sagittal fluid-sensitive not universal | Explicit fallback chain so every study yields a prediction |\n| Reports are multilingual | Text is a train-time labelling source only, never a test-time input |\n| Intensities vary by scanner | Per-volume percentile normalisation inside the Dataset |\n| Backbone is ImageNet-pretrained | ImageNet mean/std standardisation inside the Dataset, on top of the percentile step |\n\nOne limitation must be stated plainly rather than discovered later: **the schema exposes no `PatientID`**. Studies\nfrom the same patient — a follow-up scan of the same knee, or the contralateral knee — cannot be detected, so a truly\npatient-level split is impossible with the data as given. Our split is study-level and stratified; if the organisers\nlater publish a patient mapping, swapping in a grouped splitter is a one-line change in the DataModule.","metadata":{"_uuid":"8e9cdc15-e4f9-47ed-8308-ce6f8dfc06f0","_cell_guid":"c4cfc98a-a7e8-454d-90c2-3b4aea5b48a0","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# A printed summary keeps the hand-off honest when the notebook is executed rather than read.\nprint(f\"labelled studies for modelling : {len(df_labelled)}\")\nprint(f\"targets                        : {len(TARGET_COLS)}\")\nprint(f\"weak supervision enabled       : {USE_PSEUDO_LABELS}\")\nprint(f\"weak rows in table             : {int((df_labelled['is_weak'] == 1).sum())}\")","metadata":{"_uuid":"15ba10d2-cb74-4111-bce3-8be0103ecf84","_cell_guid":"159a54eb-ea66-492b-8d8d-c38fc3c2b943","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.763261Z","iopub.execute_input":"2026-08-17T21:30:20.763601Z","iopub.status.idle":"2026-08-17T21:30:20.770332Z","shell.execute_reply.started":"2026-08-17T21:30:20.76356Z","shell.execute_reply":"2026-08-17T21:30:20.769454Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Dataset and DataModule\n\nThis section turns the EDA decisions into code: a `Dataset` that maps one study to one fixed-shape tensor stack, and\na `LightningDataModule` that owns the split. The division of labour is deliberate — the Dataset receives a table that\nhas *already* been split and knows nothing about training versus validation, while the DataModule is the single place\nwhere any row is assigned to a fold. That way there is exactly one line of code that can leak data, and it is easy to\nfind.\n\nThe configuration below is the first place where compute budget becomes concrete. Twenty-four slices at 224×224 means\neach study costs 24 backbone forward passes, so an effective batch of 16 studies is really 384 images — which is why\nthe batch size looks small for a classifier.","metadata":{"_uuid":"38f0a66d-2554-488d-bbfa-a34d25aad011","_cell_guid":"d37b38c4-ea75-4547-9683-448ef538360a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Batch size counts STUDIES, not images: each study expands to N_SLICES backbone passes. The frozen backbone\n# (FREEZE_BACKBONE, model section below) makes the GPU step forward-only and fast, so DICOM decode on CPU workers is\n# the actual bottleneck — a batch size capped for a full fine-tune's memory footprint just idles the GPU between\n# steps. NUM_WORKERS uses every core but one, not a hardcoded cap, so decoding keeps pace with GPU consumption.\nBATCH_SIZE = 16\nIMAGE_SIZE = 224\nN_SLICES = 24\nN_SPLITS = 5\nNUM_WORKERS = max(1, (os.cpu_count() or 1) - 1)\n# timm's per-channel ImageNet stats averaged to one channel (in_chans=1): (0.485+0.456+0.406)/3, (0.229+0.224+0.225)/3.\nIMAGENET_MEAN = 0.449\nIMAGENET_STD = 0.226\nprint(f\"batch={BATCH_SIZE} studies | slices={N_SLICES} | px={IMAGE_SIZE} | images/batch={BATCH_SIZE * N_SLICES}\")","metadata":{"_uuid":"89fe9156-ba22-4c00-aaf0-345fa0c4452e","_cell_guid":"c2b9f464-7b69-48e9-b694-0420cf213529","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.771366Z","iopub.execute_input":"2026-08-17T21:30:20.771755Z","iopub.status.idle":"2026-08-17T21:30:20.785495Z","shell.execute_reply.started":"2026-08-17T21:30:20.771715Z","shell.execute_reply":"2026-08-17T21:30:20.784586Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### The Dataset\n\nThe Dataset accepts a table that already carries the chosen `SeriesInstanceUID` per study, so series selection\nhappens once at setup rather than repeatedly inside workers. Passing `target_cols=None` produces a label-free\nvariant for test inference — the same class, the same transforms, no hidden mode switch that could silently change\npreprocessing between training and submission.\n\nOne domain-specific choice deserves emphasis: **we do not flip these images**. For a sagittal series the through-plane\naxis runs medial↔lateral, so reversing slice order would swap the `Medial OA` and `Lateral OA` labels, and an\nin-plane horizontal flip would exchange anterior and posterior anatomy. Both are standard augmentations that are\nquietly wrong for a laterality-specific label set, so augmentation is limited to intensity jitter.","metadata":{"_uuid":"0021ac14-ad30-4f22-9fd9-d6a86653cc4d","_cell_guid":"3f452317-87d9-493d-bdf9-15f56fdfbe5c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"class KneeSeriesDataset(Dataset):\n    \"\"\"Maps one study to a fixed-length stack of slices from its selected MRI series.\"\"\"\n\n    def __init__(\n        self,\n        df_studies,\n        path_series_root,\n        target_cols=None,\n        n_slices=N_SLICES,\n        image_size=IMAGE_SIZE,\n        augment=False,\n        mean=IMAGENET_MEAN,\n        std=IMAGENET_STD,\n    ):\n        \"\"\"Store the pre-split study table and the decoding parameters shared by all splits.\"\"\"\n        self.df = df_studies.reset_index(drop=True)\n        self.path_series_root = path_series_root\n        self.target_cols = target_cols\n        self.n_slices = n_slices\n        self.image_size = image_size\n        self.augment = augment\n        self.mean = mean\n        self.std = std\n\n    def __len__(self):\n        \"\"\"Number of studies in this split.\"\"\"\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        \"\"\"Return the normalised slice stack, the optional label vector, and the study identifier.\"\"\"\n        row = self.df.iloc[idx]\n        dir_series = os.path.join(self.path_series_root, row[\"StudyInstanceUID\"], row[\"SeriesInstanceUID\"])\n        volume = read_dicom_volume(dir_series, n_slices=self.n_slices, image_size=self.image_size)\n        if self.augment:  # intensity-only jitter — geometric flips would swap medial/lateral labels\n            _scale = float(np.random.uniform(0.9, 1.1))\n            volume = (volume * _scale + float(np.random.uniform(-0.05, 0.05))).clamp(0.0, 1.0)\n        # ImageNet standardisation: the percentile step above only fixes cross-scanner scale, the pretrained\n        # backbone still expects zero-mean/unit-std input, not raw [0, 1].\n        volume = (volume - self.mean) / self.std\n        assert volume.shape == (self.n_slices, 1, self.image_size, self.image_size), f\"bad shape {tuple(volume.shape)}\"\n        assert volume.dtype == torch.float32, f\"bad dtype {volume.dtype}\"\n        sample = {\"volume\": volume, \"StudyInstanceUID\": row[\"StudyInstanceUID\"]}\n        if self.target_cols is not None:\n            sample[\"labels\"] = torch.tensor(row[self.target_cols].to_numpy(dtype=np.float32), dtype=torch.float32)\n        return sample","metadata":{"_uuid":"bbe6ecee-c1cb-4460-8085-2a6615aad0f3","_cell_guid":"d68dc223-a4f6-4aff-ad82-9fdb048ee259","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.787199Z","iopub.execute_input":"2026-08-17T21:30:20.787494Z","iopub.status.idle":"2026-08-17T21:30:20.803012Z","shell.execute_reply.started":"2026-08-17T21:30:20.78747Z","shell.execute_reply":"2026-08-17T21:30:20.802293Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### The split\n\nWith only 58 human-labelled studies total, a single holdout wastes most of them on an evaluation too small to trust\n(a 20% holdout is 12 studies, and per-label AUROC over that few positives is a coin flip away from meaningless — see\nthe training diagnosis below). `stratified_kfold_splits` instead yields all `N_SPLITS` folds so every study serves as\nvalidation exactly once; the fold predictions are pooled afterwards into one out-of-fold AUROC over all 58 studies.\n\n`MultilabelStratifiedKFold` balances label *combinations* across folds, which is what the co-occurrence heatmap told\nus we need — plain per-label stratification cannot represent \"ACL tear together with effusion\" as a stratum. The\npackage is not part of the standard Kaggle image, so the import is guarded and a label-count-stratified\n`StratifiedKFold` takes over when it is missing, with the chosen strategy printed rather than assumed.\n\nNote the fallback's own edge case: a label count that occurs fewer than `N_SPLITS` times raises inside scikit-learn,\nso under-represented counts are merged into a shared bucket first.","metadata":{"_uuid":"1695b527-f2ec-4a79-8d9d-b5e88dad242d","_cell_guid":"d097f622-c0b7-48aa-8a33-58d04026d940","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def build_series_table(df_studies, df_series):\n    \"\"\"Attach one selected series per study to the labelled study table.\"\"\"\n    sel = select_one_series(df_series)[[\"StudyInstanceUID\", \"SeriesInstanceUID\"]]\n    return df_studies.merge(sel, on=\"StudyInstanceUID\", how=\"inner\")\n\n\ndef stratified_kfold_splits(df, target_cols, n_splits=N_SPLITS, seed=SEED):\n    \"\"\"Seeded multilabel-stratified K-fold (train_idx, val_idx) pairs, with a label-count fallback.\"\"\"\n    labels = df[target_cols].to_numpy(dtype=int)\n    try:\n        from iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n\n        splitter = MultilabelStratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)\n        splits = list(splitter.split(np.zeros(len(df)), labels))\n        strategy = f\"MultilabelStratifiedKFold(n_splits={n_splits})\"\n    except ImportError:\n        from sklearn.model_selection import StratifiedKFold\n\n        counts = pd.Series(labels.sum(axis=1))\n        strata = counts.where(counts.map(counts.value_counts()) >= n_splits, -1)  # rare counts break scikit-learn\n        splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)\n        splits = list(splitter.split(np.zeros(len(df)), strata))\n        strategy = f\"StratifiedKFold(n_splits={n_splits}) (iterative-stratification unavailable)\"\n    return [(np.asarray(train_idx), np.asarray(val_idx)) for train_idx, val_idx in splits], strategy","metadata":{"_uuid":"e37664fa-5b7c-4d9a-a94f-bd3063e8c603","_cell_guid":"fa03d9cf-9f01-411d-8bd9-7d4e5c707eb2","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.804043Z","iopub.execute_input":"2026-08-17T21:30:20.804414Z","iopub.status.idle":"2026-08-17T21:30:20.823723Z","shell.execute_reply.started":"2026-08-17T21:30:20.804389Z","shell.execute_reply":"2026-08-17T21:30:20.822995Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### The DataModule\n\nThe DataModule is the only component that sees all four tables at once, and it is the only one allowed to decide who\ngoes where. It runs series selection for train and test, and builds loaders with explicit shuffling: on for\ntraining, off everywhere else so that validation scores and submission rows stay in a stable order. `fold_indices`\nis a constructor argument, not a `setup()` argument — `Trainer.fit` re-invokes `datamodule.setup(stage=\"fit\")`\ninternally with no extra arguments, so `setup` itself must stay call-compatible with that and read the fold from\n`self` instead. The K-fold loop below owns which indices belong to which fold; the DataModule only turns indices\ninto datasets.\n\nWhen weak labels are enabled they are filtered out of the validation fold after splitting, keeping the evaluation\nsignal purely human-labelled — the invariant the extension section promised.","metadata":{"_uuid":"0a483b9f-1cf8-4136-a1a1-3d262338ef70","_cell_guid":"4fd8b4f6-ac9c-4978-96f5-d25bfb007b4d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"class KneeDataModule(pl.LightningDataModule):\n    \"\"\"Owns series selection and all three dataloaders for one fold's train/val indices.\"\"\"\n\n    def __init__(\n        self,\n        df_studies,\n        df_series_train,\n        df_test,\n        df_series_test,\n        fold_indices,\n        path_dataset=PATH_DATASET,\n        target_cols=TARGET_COLS,\n        batch_size=BATCH_SIZE,\n        num_workers=NUM_WORKERS,\n        seed=SEED,\n    ):\n        \"\"\"Store the grounded tables, one fold's split, and the loader configuration without touching the filesystem.\n\n        Args:\n            fold_indices: `(train_idx, val_idx)` positional-index pair into `build_series_table(df_studies,\n                df_series_train)`, as produced by `stratified_kfold_splits`.\n        \"\"\"\n        super().__init__()\n        self.df_studies = df_studies\n        self.df_series_train = df_series_train\n        self.df_test = df_test\n        self.df_series_test = df_series_test\n        self.fold_indices = fold_indices\n        self.path_dataset = path_dataset\n        self.target_cols = list(target_cols)\n        self.batch_size = batch_size\n        self.num_workers = num_workers\n        self.seed = seed\n\n    def setup(self, stage=None):\n        \"\"\"Attach one series per study and instantiate the three datasets for this fold.\n\n        Lightning's `Trainer.fit` calls this itself with only `stage` — `fold_indices` is fixed at construction so\n        that internal call stays valid alongside any manual `setup()` call made before the Trainer exists.\n        \"\"\"\n        train_idx, val_idx = self.fold_indices\n        table = build_series_table(self.df_studies, self.df_series_train)\n        df_tr = table.iloc[train_idx].reset_index(drop=True)\n        df_va = table.iloc[val_idx].reset_index(drop=True)\n        if \"is_weak\" in df_va.columns:  # validation must stay human-labelled even when weak supervision is on\n            df_va = df_va[df_va[\"is_weak\"] == 0].reset_index(drop=True)\n        root_train = os.path.join(self.path_dataset, \"train_series\")\n        self.ds_train = KneeSeriesDataset(df_tr, root_train, target_cols=self.target_cols, augment=True)\n        self.ds_val = KneeSeriesDataset(df_va, root_train, target_cols=self.target_cols, augment=False)\n        # ==============================\n        sel_test = select_one_series(self.df_series_test)[[\"StudyInstanceUID\", \"SeriesInstanceUID\"]]\n        table_test = self.df_test.merge(sel_test, on=\"StudyInstanceUID\", how=\"left\")\n        # A test study absent from test_series.csv leaves NaN here; os.path.join would raise inside a worker and kill\n        # the whole inference run. An empty string degrades to an empty glob -> zero volume -> a row still predicted.\n        table_test[\"SeriesInstanceUID\"] = table_test[\"SeriesInstanceUID\"].fillna(\"\")\n        self.ds_test = KneeSeriesDataset(table_test, os.path.join(self.path_dataset, \"test_series\"), target_cols=None)\n\n    def _loader(self, dataset, shuffle):\n        \"\"\"Build a dataloader with worker settings shared by every split.\"\"\"\n        return DataLoader(\n            dataset,\n            batch_size=self.batch_size,\n            shuffle=shuffle,\n            num_workers=self.num_workers,\n            pin_memory=torch.cuda.is_available(),\n            persistent_workers=self.num_workers > 0,\n            # Deeper prefetch queue so decode keeps a few batches ahead of the GPU instead of it stalling per-step —\n            # `prefetch_factor` is a DataLoader error when num_workers=0, so it's None there (the library default).\n            prefetch_factor=4 if self.num_workers > 0 else None,\n            drop_last=False,\n        )\n\n    def train_dataloader(self):\n        \"\"\"Shuffled loader over the training fold.\"\"\"\n        return self._loader(self.ds_train, shuffle=True)\n\n    def val_dataloader(self):\n        \"\"\"Deterministic loader over the human-labelled validation fold.\"\"\"\n        return self._loader(self.ds_val, shuffle=False)\n\n    def test_dataloader(self):\n        \"\"\"Deterministic, label-free loader over the test studies.\"\"\"\n        return self._loader(self.ds_test, shuffle=False)","metadata":{"_uuid":"e2e7be7a-ec8f-4349-ba44-4ce83df9c523","_cell_guid":"70be6eed-9086-48bf-8a6c-cd22696b2c8d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.824725Z","iopub.execute_input":"2026-08-17T21:30:20.825123Z","iopub.status.idle":"2026-08-17T21:30:20.840815Z","shell.execute_reply.started":"2026-08-17T21:30:20.825044Z","shell.execute_reply":"2026-08-17T21:30:20.840117Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Data lens — one real batch\n\nNothing validates a data pipeline like pulling a batch through it. We check the tensor contract the model depends on:\nfive dimensions ordered `(batch, slices, channel, height, width)`, standardised float32 (ImageNet mean/std, so\nroughly zero-mean rather than `[0, 1]`), twelve float labels, and the study identifiers the submission join needs.","metadata":{"_uuid":"1ddd9d19-1610-465f-b7d4-d068dbbb349f","_cell_guid":"46870f8b-3992-4a13-bc3a-fed786fb7b13","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Splits are computed once here and reused by every fold below — printing the strategy keeps assumed stratification\n# from silently becoming unstratified.\ndf_test = pd.read_csv(os.path.join(PATH_DATASET, \"test.csv\"))\ndf_series_test = pd.read_csv(os.path.join(PATH_DATASET, \"test_series.csv\"))\ntable_labelled = build_series_table(df_labelled, df_series)\nfold_splits, split_strategy = stratified_kfold_splits(table_labelled, TARGET_COLS, n_splits=N_SPLITS, seed=SEED)\n# Weak rows (is_weak == 1) are stratified into folds like everything else but the DataModule strips them out of every\n# fold's validation set, so they never appear in an out-of-fold prediction — count human-labelled studies separately.\nn_human_labelled = int((table_labelled[\"is_weak\"] == 0).sum())\nprint(f\"split strategy : {split_strategy}\")\nprint(f\"folds          : {len(fold_splits)} over {len(table_labelled)} studies ({n_human_labelled} human-labelled)\")","metadata":{"_uuid":"1653221e-826e-49bf-b4ea-10edf0b3e96a","_cell_guid":"20ec3caf-2051-4fcb-81f2-1ca7d0854b96","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:20.841791Z","iopub.execute_input":"2026-08-17T21:30:20.842128Z","iopub.status.idle":"2026-08-17T21:30:21.017902Z","shell.execute_reply.started":"2026-08-17T21:30:20.842093Z","shell.execute_reply":"2026-08-17T21:30:21.017338Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Fold 0 stands in for the \"one real batch\" lens below — every fold uses the identical DataModule/Dataset wiring.\ndm = KneeDataModule(df_labelled, df_series, df_test, df_series_test, fold_indices=fold_splits[0])\ndm.setup()\nprint(f\"train studies  : {len(dm.ds_train)}\")\nprint(f\"val studies    : {len(dm.ds_val)}\")\nprint(f\"test studies   : {len(dm.ds_test)}\")","metadata":{"_uuid":"6190911d-0f0b-4f20-bdaa-33f1050c7773","_cell_guid":"aeb4e13d-9c11-4394-ac62-6b596583a2ed","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:21.018791Z","iopub.execute_input":"2026-08-17T21:30:21.019366Z","iopub.status.idle":"2026-08-17T21:30:21.082479Z","shell.execute_reply.started":"2026-08-17T21:30:21.01934Z","shell.execute_reply":"2026-08-17T21:30:21.0818Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Reading one batch surfaces shape and dtype errors now, rather than as a cryptic failure inside the first epoch.\ndemo_batch = next(iter(dm.train_dataloader()))\nprint(f\"volume : {tuple(demo_batch['volume'].shape)} {demo_batch['volume'].dtype}\")\nprint(f\"labels : {tuple(demo_batch['labels'].shape)} {demo_batch['labels'].dtype}\")\nprint(f\"ids    : {len(demo_batch['StudyInstanceUID'])} e.g. {demo_batch['StudyInstanceUID'][0]}\")\nprint(f\"range  : [{demo_batch['volume'].min():.3f}, {demo_batch['volume'].max():.3f}]\")","metadata":{"_uuid":"56311521-0bc0-4f66-b0e1-1cf98c95612e","_cell_guid":"dc2269e1-9dc4-4346-97a8-f0035a2116dc","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:21.08327Z","iopub.execute_input":"2026-08-17T21:30:21.083555Z","iopub.status.idle":"2026-08-17T21:30:34.701342Z","shell.execute_reply.started":"2026-08-17T21:30:21.083531Z","shell.execute_reply":"2026-08-17T21:30:34.700574Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The figure below shows consecutive slices of the first study in the batch — the exact pixels the backbone will see,\nafter sampling, resizing, normalisation and jitter. If augmentation were corrupting the images, it would be visible\nhere before a single gradient step is taken.","metadata":{"_uuid":"5b8802f7-60fa-4740-aa66-fad6dbe5e593","_cell_guid":"75631216-c762-4199-b7c8-a51e64751ba8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Visualising post-transform slices is the last checkpoint before compute is spent on training.\n_vol = demo_batch[\"volume\"][0, :, 0].numpy()\n_n_show = min(8, _vol.shape[0])\n_fig, _axes = plt.subplots(1, _n_show, figsize=(2 * _n_show, 2.6))\nfor _i, _ax in enumerate(_axes):\n    _ = _ax.imshow(_vol[_i * (_vol.shape[0] // _n_show)], cmap=\"gray\")\n    _ = _ax.set_title(f\"slice {_i * (_vol.shape[0] // _n_show)}\", fontsize=9)\n    _ = _ax.axis(\"off\")\n_ = plt.suptitle(f\"Post-transform slices — {demo_batch['StudyInstanceUID'][0][:24]}...\")\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"024fe916-37a2-44ab-90c6-0ffae9369fc5","_cell_guid":"6ce1880c-e516-44d1-912e-84de1f9106a7","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:34.702494Z","iopub.execute_input":"2026-08-17T21:30:34.70274Z","iopub.status.idle":"2026-08-17T21:30:35.494586Z","shell.execute_reply.started":"2026-08-17T21:30:34.702708Z","shell.execute_reply":"2026-08-17T21:30:35.493941Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Model — 2D backbone with attention-MIL pooling\n\nThe modelling question for a slice stack is how to get from twenty-four per-slice embeddings to one study-level\nprediction. Three answers are common, and the trade-offs decide the architecture:\n\n| Approach | Strength | Why not here |\n| --- | --- | --- |\n| True 3D CNN | Through-plane context is native | No large pretrained weights; anisotropic spacing; heavy for 9 h |\n| 2D slices + mean/max pool | Trivial, uses ImageNet weights | Mean dilutes a two-slice lesion; max is noisy |\n| 2D slices + **attention MIL** | Pretrained weights, learns *which* slices matter | Slightly more parameters |\n\nAttention-based multiple-instance learning is the right inductive bias for this problem: an ACL tear is visible on a\nhandful of slices out of twenty-four, and the gated attention head (Ilse et al., and the lineage of MRNet-style knee\nMRI models) learns to concentrate weight on them while remaining differentiable end-to-end. The attention weights are\nalso returned, which makes the model inspectable — a rare property in medical imaging baselines.\n\nThe head is a single linear layer producing twelve logits from the pooled embedding, matching the twelve independent\nbinary decisions the metric scores.","metadata":{"_uuid":"02535764-0a0f-4f48-9ef2-205c8063a954","_cell_guid":"9b67e4f1-947d-4edb-9058-b3f67be5fa80","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# `efficientnet_b0` is the budget default: strong ImageNet features at ~5M params, so 24 slices/study fit.\nMODEL_NAME = \"efficientnet_b0\"\nMAX_EPOCHS = 30\n# Linear-probe LR: the backbone is frozen below (FREEZE_BACKBONE), so this rate only ever trains the 343K-param\n# pooling+head. 3e-4 was the previous default — correct for a linear probe, but it was being applied to a fully\n# unfrozen 4.4M-param backbone, which destroyed the pretrained features within the first few steps of every fold.\nLEARNING_RATE = 1e-3\nFREEZE_BACKBONE = True\nPRETRAINED = True\nprint(\n    f\"backbone={MODEL_NAME} | epochs={MAX_EPOCHS} | lr={LEARNING_RATE} | frozen={FREEZE_BACKBONE} | pretrained={PRETRAINED}\"\n)","metadata":{"_uuid":"071045d8-01a7-45db-8aea-0ef931107239","_cell_guid":"7d664488-42ef-48e6-948a-836c6a57e8a5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:35.495555Z","iopub.execute_input":"2026-08-17T21:30:35.495887Z","iopub.status.idle":"2026-08-17T21:30:35.501177Z","shell.execute_reply.started":"2026-08-17T21:30:35.495852Z","shell.execute_reply":"2026-08-17T21:30:35.500276Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### The model definition\n\nFor this early EDA + baseline notebook the model lives inline — one cell, two classes, nothing emitted to disk. The\neventual submission workflow (a code competition with internet disabled at scoring time) will need this class\ndefinition copied into the offline inference notebook alongside the trained checkpoint, but that packaging step is a\nconcern for the submission notebook, not for the baseline.\n\nOne consequence of that future split is worth encoding now: `PRETRAINED = True` is safe *here* because this notebook\ntrains with internet on, so timm may fetch ImageNet weights. The offline inference notebook must construct the model\nwith `pretrained=False` and let the checkpoint supply every weight. `load_from_checkpoint` does **not** do this by\nitself — it replays the saved hyperparameters, `pretrained=True` included, and timm would try to download ImageNet\nweights at reload time. The override has to be passed explicitly, which the inference section below demonstrates.\n\nA note on one detail in the cell below: the metric update casts targets to `long` — `MultilabelAUROC` rejects float\ntargets outright, and our labels arrive as floats from NaN-capable pandas columns.","metadata":{"_uuid":"2935c530-1690-4d17-97bd-ff96f4ba8ce4","_cell_guid":"bcaf961d-5320-4f94-9db7-d4d220e75ea2","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Imports repeated here so the model cell stays self-contained and copy-pastable into the offline inference notebook.\nimport pytorch_lightning as pl\nimport timm\nimport torch\nfrom torch import nn\nfrom torchmetrics.classification import MultilabelAUROC\n\n\nclass AttentionMILPooling(nn.Module):\n    \"\"\"Gated attention pooling over the slice axis of a per-slice embedding tensor.\n\n    Implements the gated variant of attention-based multiple-instance learning: two parallel projections (a `tanh`\n    value branch and a `sigmoid` gate branch) are combined and scored, then softmax-normalised across slices so the\n    pooled embedding is a convex combination of slice embeddings.\n\n    Args:\n        in_features: Width of each slice embedding produced by the backbone.\n        hidden_features: Width of the internal attention projection.\n    \"\"\"\n\n    def __init__(self, in_features: int, hidden_features: int = 128) -> None:\n        super().__init__()\n        self.attn_v = nn.Sequential(nn.Linear(in_features, hidden_features), nn.Tanh())\n        self.attn_u = nn.Sequential(nn.Linear(in_features, hidden_features), nn.Sigmoid())\n        self.attn_w = nn.Linear(hidden_features, 1)\n\n    def forward(self, feats: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:\n        \"\"\"Pool slice embeddings into one study embedding.\n\n        Args:\n            feats: Tensor of shape `(batch, slices, in_features)`.\n\n        Returns:\n            Tuple of the pooled `(batch, in_features)` embedding and the `(batch, slices)` attention weights.\n        \"\"\"\n        scores = self.attn_w(self.attn_v(feats) * self.attn_u(feats))\n        weights = torch.softmax(scores, dim=1)\n        return (feats * weights).sum(dim=1), weights.squeeze(-1)\n\n\nclass KneeMILClassifier(pl.LightningModule):\n    \"\"\"Per-slice timm backbone with attention-MIL pooling and a twelve-way multi-label head.\n\n    Args:\n        model_name: Any timm architecture name; instantiated with `num_classes=0` as a feature extractor.\n        num_labels: Number of binary findings predicted per study.\n        learning_rate: Peak learning rate for AdamW, applied only to the parameters that require grad.\n        weight_decay: Decoupled weight decay for AdamW.\n        pretrained: Whether timm should download ImageNet weights; must be False without internet access.\n        max_epochs: Horizon for the cosine schedule.\n        freeze_backbone: Linear-probe the pretrained backbone instead of fully fine-tuning it. With only 58\n            labelled studies, unfreezing all 4.4M backbone params at a fine-tuning-scale LR overfits within a few\n            steps; freezing trains only the 343K-param pooling+head on top of fixed ImageNet features.\n    \"\"\"\n\n    def __init__(\n        self,\n        model_name: str = \"efficientnet_b0\",\n        num_labels: int = 12,\n        learning_rate: float = 3e-4,\n        weight_decay: float = 1e-4,\n        pretrained: bool = True,\n        max_epochs: int = 8,\n        freeze_backbone: bool = True,\n    ) -> None:\n        super().__init__()\n        self.save_hyperparameters()\n        # `in_chans=1` adapts the ImageNet stem to single-channel MRI by summing the RGB filters.\n        self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, in_chans=1)\n        if freeze_backbone:\n            self.backbone.requires_grad_(False)\n        self.pooling = AttentionMILPooling(self.backbone.num_features)\n        self.head = nn.Linear(self.backbone.num_features, num_labels)\n        self.loss_fn = nn.BCEWithLogitsLoss()\n        self.train_auroc = MultilabelAUROC(num_labels=num_labels, average=\"macro\")\n        self.val_auroc = MultilabelAUROC(num_labels=num_labels, average=\"macro\")\n\n    def train(self, mode: bool = True):\n        \"\"\"Keep the frozen backbone in eval mode even when Lightning flips the module to train mode.\n\n        `requires_grad_(False)` stops gradients but not BatchNorm running-stat updates — those come from `.train()`\n        mode alone. Without this override every `trainer.fit` epoch would still drift the backbone's BN statistics\n        toward the 46-study training fold.\n        \"\"\"\n        super().train(mode)\n        if self.hparams.freeze_backbone:\n            self.backbone.eval()\n        return self\n\n    def forward(self, volume: torch.Tensor) -> torch.Tensor:\n        \"\"\"Map a batch of slice stacks to per-study logits.\n\n        Args:\n            volume: Tensor of shape `(batch, slices, 1, height, width)`.\n\n        Returns:\n            Logit tensor of shape `(batch, num_labels)`; apply `sigmoid` for probabilities.\n        \"\"\"\n        batch, slices, channels, height, width = volume.shape\n        feats = self.backbone(volume.view(batch * slices, channels, height, width)).view(batch, slices, -1)\n        pooled, _ = self.pooling(feats)\n        return self.head(pooled)\n\n    def attention_weights(self, volume: torch.Tensor) -> torch.Tensor:\n        \"\"\"Return the per-slice attention weights for inspection.\n\n        Args:\n            volume: Tensor of shape `(batch, slices, 1, height, width)`.\n\n        Returns:\n            Attention tensor of shape `(batch, slices)` summing to one per study.\n        \"\"\"\n        batch, slices, channels, height, width = volume.shape\n        feats = self.backbone(volume.view(batch * slices, channels, height, width)).view(batch, slices, -1)\n        return self.pooling(feats)[1]\n\n    def _step(self, batch: dict, stage: str) -> torch.Tensor:\n        \"\"\"Shared train/validation step computing loss and updating the macro AUROC.\"\"\"\n        logits = self(batch[\"volume\"])\n        labels = batch[\"labels\"]\n        loss = self.loss_fn(logits, labels.float())\n        metric = self.train_auroc if stage == \"train\" else self.val_auroc\n        # MultilabelAUROC rejects float targets; pandas hands us floats because the columns are NaN-capable.\n        metric.update(torch.sigmoid(logits), labels.long())\n        # Epoch-level loss logging: with only a handful of batches per epoch, step-level logging thinned by\n        # `log_every_n_steps` leaves holes in the training curve; one value per epoch is what the lens plots.\n        self.log(f\"{stage}/loss\", loss, on_step=False, on_epoch=True, prog_bar=True, batch_size=logits.size(0))\n        self.log(f\"{stage}/auroc\", metric, on_step=False, on_epoch=True, prog_bar=True, batch_size=logits.size(0))\n        return loss\n\n    def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n        \"\"\"Training step logging `train/loss` and `train/auroc`.\"\"\"\n        return self._step(batch, \"train\")\n\n    def validation_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n        \"\"\"Validation step logging `val/loss` (checkpoint-selection metric) and `val/auroc`.\"\"\"\n        return self._step(batch, \"val\")\n\n    def configure_optimizers(self) -> dict:\n        \"\"\"AdamW over trainable parameters only, with a cosine schedule annealed over the full training horizon.\"\"\"\n        trainable_params = [p for p in self.parameters() if p.requires_grad]\n        optimizer = torch.optim.AdamW(\n            trainable_params, lr=self.hparams.learning_rate, weight_decay=self.hparams.weight_decay\n        )\n        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=self.hparams.max_epochs)\n        return {\"optimizer\": optimizer, \"lr_scheduler\": {\"scheduler\": scheduler, \"interval\": \"epoch\"}}","metadata":{"_uuid":"3ebd7742-4e8a-48a8-b095-2c255ce0ca39","_cell_guid":"a8062dde-27d6-4e81-8d38-ebbe63a8bf2f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:35.502204Z","iopub.execute_input":"2026-08-17T21:30:35.502514Z","iopub.status.idle":"2026-08-17T21:30:35.520607Z","shell.execute_reply.started":"2026-08-17T21:30:35.50249Z","shell.execute_reply":"2026-08-17T21:30:35.519789Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Model lens — shape contract on synthetic input\n\nWe verify the model's input/output contract with a synthetic tensor before wiring it to real data. A synthetic check\nisolates architecture bugs from data bugs: if this passes and training still fails, the fault is in the pipeline, not\nthe network. It costs one forward pass on random tensors, so it runs before a single DICOM is decoded.","metadata":{"_uuid":"c1dd1de0-47d9-4a6f-a276-d35d937777d6","_cell_guid":"2531d5dd-7e8a-4b54-95f3-21a4fc5a44eb","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# A two-study synthetic batch confirms the (B,S,C,H,W) -> (B,12) contract and that attention normalises across slices.\nmodel = KneeMILClassifier(\n    model_name=MODEL_NAME,\n    num_labels=len(TARGET_COLS),\n    learning_rate=LEARNING_RATE,\n    pretrained=PRETRAINED,\n    max_epochs=MAX_EPOCHS,\n    freeze_backbone=FREEZE_BACKBONE,\n)\n_probe = torch.rand(2, N_SLICES, 1, IMAGE_SIZE, IMAGE_SIZE)\nwith torch.no_grad():\n    _logits = model(_probe)\n    _attn = model.attention_weights(_probe)\nprint(f\"input   : {tuple(_probe.shape)}\")\nprint(f\"logits  : {tuple(_logits.shape)} (expected (2, {len(TARGET_COLS)}))\")\nprint(f\"attention sums to 1 per study: {torch.allclose(_attn.sum(dim=1), torch.ones(2), atol=1e-5)}\")\n# With FREEZE_BACKBONE=True this is pooling+head only (~343K), not the 4.4M full-backbone count of a full fine-tune.\nprint(f\"trainable parameters: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}\")","metadata":{"_uuid":"59274cfb-953d-4155-972f-222feb58c454","_cell_guid":"cddc5b2d-a78b-4a6c-aae3-55af628a037d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:35.521581Z","iopub.execute_input":"2026-08-17T21:30:35.521917Z","iopub.status.idle":"2026-08-17T21:30:44.572346Z","shell.execute_reply.started":"2026-08-17T21:30:35.521874Z","shell.execute_reply":"2026-08-17T21:30:44.571626Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Training\n\nTraining now selects on `val/loss` (`mode=\"min\"`), not `val/auroc`: the single 12-study holdout used previously made\n`val/auroc` too noisy epoch-to-epoch to trust as a stopping signal — several labels project to only 2-7 val\npositives, which quantises per-label AUROC in 1/20 steps. The backbone is frozen (`FREEZE_BACKBONE`, model section\nabove), so only the pooling+head linear-probe trains. Cross-validation over all `N_SPLITS` folds reuses every one of\nthe 58 labelled studies as validation exactly once; the fold predictions are pooled afterwards into a single\nout-of-fold macro AUROC, which is the number that actually answers \"does this model work\" — not the single-holdout\n`val/auroc` that used to sit at chance level from epoch one regardless of what the model had learned.\n\nFour practical settings for the 9 h budget:\n\n- **Mixed precision** on GPU roughly doubles throughput at this batch shape, which matters when each batch is 384\n  backbone passes; it falls back to full precision on CPU where fp16 would be slower, not faster.\n- **`FAST_DEV_RUN`** runs a single batch through train, validation and the callbacks. It is the cheapest way to catch\n  a pipeline error before committing hours of compute. It is exposed as a flag here and left off — a fast-dev run\n  writes neither a checkpoint nor `metrics.csv`, and the lens cells below assert both, so a flipped flag stops the\n  notebook instead of reporting on a run that never happened.\n- **Early stopping** on a patience of five epochs, applied independently per fold.\n- **`TQDMProgressBar`, forced explicitly** (fold loop, below): `Trainer` auto-picks `RichProgressBar` whenever\n  `rich` is importable, which it is on Kaggle. A prior run was killed mid-fold — papermill's `CellTimeoutError`,\n  \"Timeout waiting for IOPub output\" after 4s of silence — because Rich's in-place `Live` redraw doesn't produce\n  the discrete per-line output Kaggle's runner polls for as a liveness signal. Forcing tqdm avoids it.","metadata":{"_uuid":"86213bc4-ed25-4404-a438-159967e70e6f","_cell_guid":"57e7802f-bfdf-4088-9792-2f3b6a82e20f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Directions are set once and reused by every fold's callbacks — `val/loss` is now the stopping/checkpoint signal.\nFAST_DEV_RUN = False\nMONITOR_METRIC = \"val/loss\"\nMONITOR_MODE = \"min\"\nEARLY_STOP_PATIENCE = 5\nPRECISION = \"16-mixed\" if torch.cuda.is_available() else \"32-true\"\nprint(f\"monitor={MONITOR_METRIC} ({MONITOR_MODE}) | precision={PRECISION} | fast_dev_run={FAST_DEV_RUN}\")","metadata":{"_uuid":"42e9a94b-f5ad-423f-9baf-e36b4f8ace73","_cell_guid":"20c794ff-cf19-4dd0-ba42-7612060fe30b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:44.573371Z","iopub.execute_input":"2026-08-17T21:30:44.573697Z","iopub.status.idle":"2026-08-17T21:30:44.579407Z","shell.execute_reply.started":"2026-08-17T21:30:44.573653Z","shell.execute_reply":"2026-08-17T21:30:44.578616Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### The out-of-fold prediction helper\n\n`predict_studies` (inference section, below) is deliberately label-free — built for the test set, which has no\nground truth. Pooling out-of-fold validation scores needs the labels alongside the predictions, so this sibling\nfunction keeps them paired rather than bolting an optional labels argument onto the test-time function, which would\nrisk a label leaking into code that must stay label-free at scoring time.","metadata":{"_uuid":"a9bb19c5-32cc-44d2-a7af-89dd438a1fa6","_cell_guid":"3869b3a3-eb77-4646-93b2-fae132c1b6b6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def predict_labelled(trained_model, loader, device):\n    \"\"\"Run sigmoid inference over a labelled loader, returning study identifiers, probabilities, and labels.\"\"\"\n    trained_model = trained_model.to(device).eval()\n    uids, probs, labels = [], [], []\n    with torch.no_grad():\n        for batch in loader:\n            logits = trained_model(batch[\"volume\"].to(device))\n            probs.append(torch.sigmoid(logits).cpu().numpy())\n            labels.append(batch[\"labels\"].numpy())\n            uids.extend(list(batch[\"StudyInstanceUID\"]))\n    return uids, np.concatenate(probs, axis=0), np.concatenate(labels, axis=0)","metadata":{"_uuid":"d6115bd4-0d27-494f-8a1c-4f82e6bba5f4","_cell_guid":"5a8fac98-4a2a-4d1d-b3d3-9b201de29827","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:44.580397Z","iopub.execute_input":"2026-08-17T21:30:44.580696Z","iopub.status.idle":"2026-08-17T21:30:44.656021Z","shell.execute_reply.started":"2026-08-17T21:30:44.58067Z","shell.execute_reply":"2026-08-17T21:30:44.655208Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# One model per fold, trained independently. DEVICE is resolved once here — inference (below) reuses it.\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nfold_dms, fold_models, fold_checkpoints, fold_loggers = [], [], [], []\noof_uids, oof_probs, oof_labels = [], [], []\nfor _fold_idx, (_train_idx, _val_idx) in enumerate(fold_splits):\n    _dm = KneeDataModule(df_labelled, df_series, df_test, df_series_test, fold_indices=(_train_idx, _val_idx))\n    _dm.setup()  # manual call so val_dataloader() is usable for the OOF pass below; Trainer.fit repeats it, harmlessly\n    _model = KneeMILClassifier(\n        model_name=MODEL_NAME,\n        num_labels=len(TARGET_COLS),\n        learning_rate=LEARNING_RATE,\n        pretrained=PRETRAINED,\n        max_epochs=MAX_EPOCHS,\n        freeze_backbone=FREEZE_BACKBONE,\n    )\n    # An explicit filename matters: the monitored metric contains a slash, which Lightning would otherwise\n    # interpolate into the checkpoint name and turn into a nested directory.\n    _logger = pl.loggers.CSVLogger(save_dir=PATH_OUTPUT, name=f\"knee_mil_logs/fold{_fold_idx}\")\n    _checkpoint_cb = pl.callbacks.ModelCheckpoint(\n        dirpath=os.path.join(PATH_OUTPUT, \"checkpoints\", f\"fold{_fold_idx}\"),\n        filename=\"knee-mil-best\",\n        monitor=MONITOR_METRIC,\n        mode=MONITOR_MODE,\n        save_top_k=1,\n        auto_insert_metric_name=False,\n    )\n    _callbacks = [\n        _checkpoint_cb,\n        pl.callbacks.EarlyStopping(monitor=MONITOR_METRIC, mode=MONITOR_MODE, patience=EARLY_STOP_PATIENCE),\n        pl.callbacks.LearningRateMonitor(logging_interval=\"epoch\"),\n        # Explicit, not Lightning's auto-pick: with `rich` importable, Trainer defaults to RichProgressBar, whose\n        # in-place Live-redraw doesn't emit the discrete IOPub messages Kaggle's papermill runner polls for —\n        # a prior run was killed mid-fold with \"CellTimeoutError ... Timeout waiting for IOPub output\" after 4s of\n        # silence. TQDMProgressBar prints a line per refresh instead, which the runner sees as ongoing activity.\n        pl.callbacks.TQDMProgressBar(),\n    ]\n    # `accelerator=\"auto\"` keeps the same notebook runnable on Kaggle's GPU and on a CPU-only machine without edits.\n    _trainer = pl.Trainer(\n        max_epochs=MAX_EPOCHS,\n        accelerator=\"auto\",\n        devices=\"auto\",\n        precision=PRECISION,\n        logger=_logger,\n        callbacks=_callbacks,\n        fast_dev_run=FAST_DEV_RUN,\n        log_every_n_steps=10,\n        enable_model_summary=_fold_idx == 0,  # one model summary is enough — five would just repeat it\n    )\n    _trainer.fit(_model, datamodule=_dm)\n    _best = _checkpoint_cb.best_model_path\n    # Fail fast: OOF pooling and the inference section both reload this exact path, so an empty one stops here.\n    assert _best, f\"fold {_fold_idx}: no checkpoint written — run with FAST_DEV_RUN=False before this section\"\n    _reloaded = KneeMILClassifier.load_from_checkpoint(_best, map_location=DEVICE, pretrained=False)\n    _uids, _probs, _labels = predict_labelled(_reloaded, _dm.val_dataloader(), DEVICE)\n    print(\n        f\"fold {_fold_idx}: {len(_uids)} val studies | \"\n        f\"best {MONITOR_METRIC} = {float(_checkpoint_cb.best_model_score):.4f}\"\n    )\n    fold_dms.append(_dm)\n    fold_models.append(_model)\n    fold_checkpoints.append(_checkpoint_cb)\n    fold_loggers.append(_logger)\n    oof_uids.extend(_uids)\n    oof_probs.append(_probs)\n    oof_labels.append(_labels)\n\noof_probs = np.concatenate(oof_probs, axis=0)\noof_labels = np.concatenate(oof_labels, axis=0)\n# Every human-labelled study must appear in exactly one fold's validation set — weak rows never do, the DataModule\n# strips them from every fold's val split. A shortfall would mean a leaked or dropped study, a duplicate would mean a\n# study scored twice and double-counted in the pooled metric below.\nassert len(oof_uids) == len(set(oof_uids)) == n_human_labelled, (\n    f\"expected {n_human_labelled} unique OOF studies, got {len(oof_uids)} ({len(set(oof_uids))} unique)\"\n)","metadata":{"_uuid":"50a34a74-3e9e-4b97-a8bd-69bd65b45412","_cell_guid":"957968be-3424-42a8-9c11-12753182bf4d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-08-17T21:30:44.657089Z","iopub.execute_input":"2026-08-17T21:30:44.657305Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Pooled out-of-fold AUROC — the honest validation number\n\nThis is the metric the earlier single-holdout `val/auroc` could not deliver: every one of the 58 labelled studies\ncontributes exactly one out-of-fold prediction, so the macro AUROC below is computed over the full labelled set\nrather than a noisy 12-study slice.","metadata":{"_uuid":"580ecd1c-9b93-4148-a531-ac32ffcf146f","_cell_guid":"d1e58ba0-5037-4fb3-87fd-3cfb183dbb25","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"_pooled_auroc_metric = MultilabelAUROC(num_labels=len(TARGET_COLS), average=\"macro\")\npooled_oof_auroc = float(_pooled_auroc_metric(torch.from_numpy(oof_probs), torch.from_numpy(oof_labels).long()))\nprint(f\"pooled out-of-fold macro AUROC over {len(oof_uids)} studies ({N_SPLITS} folds): {pooled_oof_auroc:.4f}\")","metadata":{"_uuid":"b2ee5a99-d68e-4189-b693-673221bb40ac","_cell_guid":"e0c2a53c-1ec8-40dd-a8d4-231e9102a0de","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Training lens — reading fold 0's curves\n\n`CSVLogger` writes a plain `metrics.csv` per fold, which we read back rather than trusting the progress bar. Fold 0\nstands in for all five here — these curves are for optimisation sanity-checking, not the headline result, which is\nthe pooled out-of-fold AUROC above.","metadata":{"_uuid":"02840477-de62-427e-b64c-4ea1291b8d7c","_cell_guid":"7050dcad-7eed-4db8-a40f-fca48354d2cb","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Reading the logged CSV keeps the diagnosis reproducible after the session that produced it has ended.\n_metrics_path = os.path.join(fold_loggers[0].log_dir, \"metrics.csv\")\nassert os.path.isfile(_metrics_path), f\"no metrics at {_metrics_path} — training must run with FAST_DEV_RUN=False\"\ndf_metrics = pd.read_csv(_metrics_path)\nprint(f\"logged columns: {list(df_metrics.columns)}\")\ndisplay(df_metrics.dropna(axis=1, how=\"all\").tail())\n# Naming the best epoch here explains the in-memory-vs-reloaded gap the inference lens measures later.\n_best_epoch = int(df_metrics.loc[df_metrics[\"val/loss\"].idxmin(), \"epoch\"])\n_last_epoch = int(df_metrics[\"epoch\"].max())\nprint(f\"best val/loss epoch : {_best_epoch} (fold 0 training ran to epoch {_last_epoch})\")","metadata":{"_uuid":"f7c51386-0011-404b-b5fa-55eb6291d16d","_cell_guid":"404d38a3-6933-4083-847b-9dbe9126ee74","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Curves are plotted per epoch because AUROC is only defined once a full epoch of predictions has accumulated.\n_agg = df_metrics.groupby(\"epoch\").mean(numeric_only=True)\n_fig, _axes = plt.subplots(1, 2, figsize=(12, 4))\nfor _col in [c for c in [\"train/loss\", \"val/loss\"] if c in _agg.columns]:\n    _ = sns.lineplot(x=_agg.index, y=_agg[_col], marker=\"o\", ax=_axes[0], label=_col)\n_ = _axes[0].set_xlabel(\"epoch\")\n_ = _axes[0].set_ylabel(\"BCE loss\")\n_ = _axes[0].set_title(\"Optimisation — fold 0\")\n_ = _axes[0].grid(True)\n_ = _axes[0].legend()\nfor _col in [c for c in [\"train/auroc\", \"val/auroc\"] if c in _agg.columns]:\n    _ = sns.lineplot(x=_agg.index, y=_agg[_col], marker=\"o\", ax=_axes[1], label=_col)\n_ = _axes[1].set_xlabel(\"epoch\")\n_ = _axes[1].set_ylabel(\"macro ROC AUC\")\n_ = _axes[1].set_title(\"Competition metric — fold 0\")\n_ = _axes[1].grid(True)\n_ = _axes[1].legend()\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"2c6fe4c4-686b-4dc1-b461-9134c256d494","_cell_guid":"4a0656d7-83bd-44e4-8779-f0f1a5dcdc2b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Choosing the submission model\n\nCross-validation exists to *measure* the model honestly, not to produce five checkpoints for one submission. The\ntextbook next step is a full refit on all 58 labelled studies (no held-out fold) for a fixed epoch count derived\nfrom the folds' best epochs — that refit is out of scope for this EDA + baseline notebook. As a bounded stand-in,\nfold 0's checkpoint is what the sections below run inference with; treat the pooled OOF AUROC above, not this one\nfold's score, as the number that describes the model.","metadata":{"_uuid":"0293d817-dea3-49eb-be71-32175618368d","_cell_guid":"2843ccc0-2f2f-4a51-b85b-001b4b049543","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"model, _best, checkpoint_cb, dm, logger = (\n    fold_models[0],\n    fold_checkpoints[0].best_model_path,\n    fold_checkpoints[0],\n    fold_dms[0],\n    fold_loggers[0],\n)\nassert _best, \"fold 0: no checkpoint written — inference below reloads this exact path\"\nprint(f\"submission checkpoint : {_best}\")","metadata":{"_uuid":"58ea3138-5b56-4cc4-bf53-63d6a9eb201d","_cell_guid":"e23a1aa9-e792-4d89-9b1f-537208439a08","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Inference\n\nInference runs the twelve-logit model over the test studies and converts logits to probabilities with a sigmoid —\nand *only* a sigmoid. There is no argmax and no thresholding: ROC AUC is computed from the ranking of scores, so any\nhard decision we make discards exactly the information the metric rewards.\n\nThe one thing that must not appear anywhere in this section is the report text. Reports exist in `train.csv` only;\n`test.csv` carries `StudyInstanceUID` alone. A model that consumed text would train beautifully and then have nothing\nto consume at scoring time. This is the practical meaning of the multimodal framing used earlier: **text is a\ntrain-time labelling source, images are the only test-time input**.\n\nWe run both prediction paths the competition workflow needs — the in-memory model, and the model restored from the\nsaved checkpoint — because the second is what the offline inference notebook will actually do. The two carry\n*different* weights whenever the best epoch is not the last one: `EarlyStopping` halts training but does not restore\nthe best weights, so the in-memory model holds fold 0's last-epoch weights while the checkpoint holds its\nbest-`val/loss` epoch. The reloaded model is therefore the one whose predictions we submit.","metadata":{"_uuid":"3ed710d5-f9b1-45e0-9b6b-e3c8df03b46c","_cell_guid":"231264b7-6e01-448a-9058-1bc755e1ba81","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Predictions carry their study identifier so the submission can join by key rather than trusting loader order.\ndef predict_studies(trained_model, loader, device):\n    \"\"\"Run sigmoid inference over a label-free loader, returning study identifiers and probabilities.\"\"\"\n    trained_model = trained_model.to(device).eval()\n    uids, probs = [], []\n    with torch.no_grad():\n        for batch in tqdm(loader, desc=\"inference\"):\n            logits = trained_model(batch[\"volume\"].to(device))\n            probs.append(torch.sigmoid(logits).cpu().numpy())  # only outputs return to CPU\n            uids.extend(list(batch[\"StudyInstanceUID\"]))\n    stacked = np.concatenate(probs, axis=0) if probs else np.zeros((0, len(TARGET_COLS)), dtype=np.float32)\n    return uids, stacked","metadata":{"_uuid":"16aa61f9-c631-409e-9e7b-39df4863a609","_cell_guid":"3bb27808-077f-4bd9-98cc-d35fe99eea0a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Path 1 — the model already in memory; this is what a single-notebook submission would use.\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nuids_mem, probs_mem = predict_studies(model, dm.test_dataloader(), DEVICE)\nprint(f\"in-memory predictions: {probs_mem.shape} over {len(uids_mem)} studies\")","metadata":{"_uuid":"e1dfe39e-5e2a-4b46-9dd2-a127f4bb8390","_cell_guid":"b4e0a739-c8a2-4319-8797-dc8c0f72cc5a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Path 2 — restore from disk exactly as the offline inference notebook must, from the path asserted above.\n# `pretrained=False` overrides the saved hyperparameter: without it, `load_from_checkpoint` replays `pretrained=True`\n# and timm re-downloads ImageNet weights — pointless here, a hard failure in the internet-disabled scoring run.\nreloaded = KneeMILClassifier.load_from_checkpoint(_best, map_location=DEVICE, pretrained=False)\nprint(f\"reloaded {type(reloaded).__name__} | params: {sum(p.numel() for p in reloaded.parameters()):,}\")\nuids_ckpt, probs_ckpt = predict_studies(reloaded, dm.test_dataloader(), DEVICE)\nprint(f\"reloaded predictions: {probs_ckpt.shape}\")","metadata":{"_uuid":"5c979bfa-26c8-417a-aad8-4398cf233d6b","_cell_guid":"412e4e30-0d54-4d25-9829-e50999c13dfa","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Inference lens\n\nBefore anything is written to disk we check the properties a valid submission needs: the right number of rows, and\nvalues that are finite and inside `[0, 1]`. We also measure the gap between the in-memory (last-epoch) and reloaded\n(best-epoch) predictions: a near-zero gap means the best epoch was the final one, a large gap means early stopping\nran past the best epoch — both are legitimate outcomes, and the print below says which one this run produced.","metadata":{"_uuid":"9e9dd8b5-5bcd-4989-ac07-d763c1a3be04","_cell_guid":"e11b400e-4fb8-4d8e-b071-80c0225c8dac","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# These assertions are cheap; a malformed submission costs a day of leaderboard time.\n# The reloaded model is the one the offline inference notebook runs, so its predictions are the ones we submit.\n_uids, _probs = uids_ckpt, probs_ckpt\nprint(f\"predictions : {_probs.shape} | ids: {len(_uids)} | unique ids: {len(set(_uids))}\")\nprint(f\"finite      : {bool(np.isfinite(_probs).all())}\")\n_in_range = bool((_probs >= 0).all() and (_probs <= 1).all())\nprint(f\"range       : [{_probs.min():.4f}, {_probs.max():.4f}] within [0,1]: {_in_range}\")\ndisplay(pd.DataFrame(_probs[: min(5, len(_probs))], columns=TARGET_COLS).round(4))\n_delta = float(np.abs(probs_mem - probs_ckpt).max())\nprint(f\"max |in-memory - reloaded| : {_delta:.2e}\")\nprint(\n    \"-> matches: fold 0's best-val/loss epoch was the final epoch, so both paths carry the same weights.\"\n    if _delta < 1e-4\n    else \"-> differs: early stopping ran past the best epoch (EarlyStopping does not restore best weights);\"\n    \"\\n   the reloaded best-epoch predictions are the ones submitted.\"\n)","metadata":{"_uuid":"8017a475-c0dc-4b3a-a0fa-d9e421852a19","_cell_guid":"d7c03fb9-b91f-46bc-8971-b4fcb4746c4e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Submission\n\nThe final step is mechanical but unforgiving. We start from `sample_submission.csv` rather than from our own\npredictions, because that file defines the authoritative row set and column order — including the twelve label names\nexactly as the grader expects them, spaces and the apostrophe in `Baker's` included. Predictions are joined onto it by\n`StudyInstanceUID`; any study our pipeline could not score (an unreadable series, a missing test series entry) keeps\nthe neutral 0.5 default rather than vanishing from the file, because a missing row is a hard submission error while a\nneutral score merely contributes an uninformative ranking for that study.","metadata":{"_uuid":"11e06f55-e92d-451f-b45c-2835fee27bf0","_cell_guid":"8341072f-e853-44fe-aa6f-db28d1549107","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# The sample submission is the contract: its rows and column order define what the grader accepts.\n_path_sample = os.path.join(PATH_DATASET, \"sample_submission.csv\")\nassert os.path.isfile(_path_sample), f\"missing {_path_sample} — without the contract no submission can be assembled\"\ndf_sample = pd.read_csv(_path_sample)\nprint(f\"sample_submission shape: {df_sample.shape}\")\ndisplay(df_sample.head())","metadata":{"_uuid":"6aa06e73-2c1a-425d-b0cc-8c3d05693949","_cell_guid":"323b9eda-cc01-486d-ab39-74f7f2cf8576","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Joining by key (never by row order) is what makes the file correct even if the loader reorders studies.\ndf_pred = pd.DataFrame(_probs, columns=TARGET_COLS)\ndf_pred.insert(0, \"StudyInstanceUID\", _uids)\ndf_submission = df_sample[[\"StudyInstanceUID\"]].merge(df_pred, on=\"StudyInstanceUID\", how=\"left\")\n# An unscored study keeps the neutral 0.5 by design: a missing row is a hard error, an uninformative one is not.\n_n_missing = int(df_submission[TARGET_COLS].isna().any(axis=1).sum())\ndf_submission[TARGET_COLS] = df_submission[TARGET_COLS].fillna(0.5)\ndf_submission = df_submission[[\"StudyInstanceUID\", *TARGET_COLS]]\nprint(f\"joined rows: {len(df_submission)} | rows falling back to 0.5: {_n_missing}\")\ndisplay(df_submission.head())","metadata":{"_uuid":"c04a5ae4-d9e5-4162-b3fa-451c91161afb","_cell_guid":"7da5f15a-f764-4622-a68e-6fb9a37dab46","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# `index=False` — an extra unnamed index column is the single most common cause of a rejected submission.\ndf_submission.to_csv(\"submission.csv\", index=False)\nprint(f\"written: submission.csv ({len(df_submission)} rows)\")","metadata":{"_uuid":"f3e9d240-a326-4f9d-b442-40e73c96a403","_cell_guid":"ed668301-f101-423f-aa73-2451265f1d96","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Submission lens\n\nThe last gate re-reads the file from disk — not the DataFrame in memory — and checks it against every constraint the\ngrader enforces: row count matching the sample, exact column names and order, unique identifiers covering the\nexpected set, no NaN or infinity, and all values inside `[0, 1]`. Checking the written artifact rather than the\nin-memory object is the point: serialisation is where index columns and dtype surprises appear.","metadata":{"_uuid":"c915f853-6df2-41ee-8eef-d02951ccba33","_cell_guid":"39884834-1a98-49c8-b8b3-8af920cae793","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# Re-reading from disk verifies the artifact that will actually be graded.\n_check = pd.read_csv(\"submission.csv\")\n_expected = [\"StudyInstanceUID\", *TARGET_COLS]\n_rows_ok = \"OK\" if len(_check) == len(df_sample) else \"MISMATCH\"\nprint(f\"rows            : {len(_check)} (sample: {len(df_sample)}) -> {_rows_ok}\")\nprint(f\"columns exact   : {list(_check.columns) == _expected}\")\nprint(f\"ids unique      : {_check['StudyInstanceUID'].is_unique}\")\nprint(f\"ids cover sample: {set(_check['StudyInstanceUID']) == set(df_sample['StudyInstanceUID'])}\")\nprint(f\"no NaN          : {not bool(_check[TARGET_COLS].isna().any().any())}\")\nprint(f\"all finite      : {bool(np.isfinite(_check[TARGET_COLS].to_numpy()).all())}\")\n_vals = _check[TARGET_COLS].to_numpy()\nprint(f\"within [0,1]    : {bool((_vals >= 0).all() and (_vals <= 1).all())}\")\ndisplay(_check.head())","metadata":{"_uuid":"cd79c4b4-25d3-4235-be9f-e3c6ffd06a28","_cell_guid":"3d1aeb2e-b662-48db-8f02-8efdbbcf8508","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Final proof on the artifact itself: the header must read exactly as the grader expects, apostrophe included.\n! head submission.csv","metadata":{"_uuid":"75efccf1-a1b2-4f9e-a04f-58f35b361ab3","_cell_guid":"98ad884c-9d6e-405b-8994-0ef6c49dd79b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null}]}