{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"from pathlib import Path\nimport os\n\nroot = Path(\"/kaggle/input\")\n\nfor item in root.iterdir():\n    print(item)","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:45:47.61225Z","iopub.execute_input":"2026-08-17T16:45:47.612654Z","iopub.status.idle":"2026-08-17T16:45:47.624856Z","shell.execute_reply.started":"2026-08-17T16:45:47.612609Z","shell.execute_reply":"2026-08-17T16:45:47.623774Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions\")\n\nfor item in root.iterdir():\n    print(item)\n    ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:46:34.204884Z","iopub.execute_input":"2026-08-17T16:46:34.205374Z","iopub.status.idle":"2026-08-17T16:46:34.211586Z","shell.execute_reply.started":"2026-08-17T16:46:34.205342Z","shell.execute_reply":"2026-08-17T16:46:34.210716Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\nfor item in sorted(root.iterdir()):\n    print(item)\n    ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:47:00.460498Z","iopub.execute_input":"2026-08-17T16:47:00.460856Z","iopub.status.idle":"2026-08-17T16:47:00.467487Z","shell.execute_reply.started":"2026-08-17T16:47:00.46083Z","shell.execute_reply":"2026-08-17T16:47:00.466511Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\nfor filename in [\n    \"train.csv\",\n    \"test.csv\",\n    \"train_series.csv\",\n    \"test_series.csv\",\n    \"sample_submission.csv\"\n]:\n    print(\"\\n\" + \"=\" * 70)\n    print(filename)\n\n    df = pd.read_csv(root / filename)\n\n    print(\"Shape:\", df.shape)\n    print(\"Columns:\")\n    print(df.columns.tolist())\n\n    print(\"\\nFirst 3 rows:\")\n    display(df.head(3))\n    ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:47:31.879914Z","iopub.execute_input":"2026-08-17T16:47:31.880411Z","iopub.status.idle":"2026-08-17T16:47:32.644285Z","shell.execute_reply.started":"2026-08-17T16:47:31.88038Z","shell.execute_reply":"2026-08-17T16:47:32.643421Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\ntrain = pd.read_csv(root / \"train.csv\")\n\nprint(\"Shape:\", train.shape)\nprint(\"\\nColumns:\")\nprint(train.columns.tolist())\n\nprint(\"\\nFirst 5 rows:\")\ndisplay(train.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:48:09.518561Z","iopub.execute_input":"2026-08-17T16:48:09.519507Z","iopub.status.idle":"2026-08-17T16:48:09.652582Z","shell.execute_reply.started":"2026-08-17T16:48:09.519438Z","shell.execute_reply":"2026-08-17T16:48:09.651685Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\ntrain = pd.read_csv(root / \"train.csv\")\n\ntargets = [\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]\n\nprint(\"Shape:\", train.shape)\n\nprint(\"\\nData types:\")\nprint(train[targets].dtypes)\n\nprint(\"\\nMissing values:\")\nprint(train[targets].isna().sum())\n\nprint(\"\\nUnique values for every target:\")\nfor col in targets:\n    print(f\"\\n--- {col} ---\")\n    print(train[col].value_counts(dropna=False).sort_index())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:49:14.192162Z","iopub.execute_input":"2026-08-17T16:49:14.192461Z","iopub.status.idle":"2026-08-17T16:49:14.344132Z","shell.execute_reply.started":"2026-08-17T16:49:14.192436Z","shell.execute_reply":"2026-08-17T16:49:14.34313Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\ntrain = pd.read_csv(root / \"train.csv\")\ntest = pd.read_csv(root / \"test.csv\")\ntrain_series = pd.read_csv(root / \"train_series.csv\")\ntest_series = pd.read_csv(root / \"test_series.csv\")\n\nprint(\"TRAIN\")\nprint(train.shape)\n\nprint(\"\\nTEST\")\nprint(test.shape)\n\nprint(\"\\nTRAIN SERIES\")\nprint(train_series.shape)\n\nprint(\"\\nTEST SERIES\")\nprint(test_series.shape)\n\nprint(\"\\nLabeled train rows:\")\nprint(train[\"ACL\"].notna().sum())\n\nprint(\"\\nFirst 5 labeled StudyInstanceUIDs:\")\nprint(train.loc[train[\"ACL\"].notna(), \"StudyInstanceUID\"].head().tolist())\n\nprint(\"\\nFirst 5 unlabeled StudyInstanceUIDs:\")\nprint(train.loc[train[\"ACL\"].isna(), \"StudyInstanceUID\"].head().tolist())\n\nprint(\"\\nLast 5 rows of train:\")\ndisplay(train.tail())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:50:11.726556Z","iopub.execute_input":"2026-08-17T16:50:11.726981Z","iopub.status.idle":"2026-08-17T16:50:11.930252Z","shell.execute_reply.started":"2026-08-17T16:50:11.726951Z","shell.execute_reply":"2026-08-17T16:50:11.929275Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\nprint(\"Shape:\", train_series.shape)\n\nprint(\"\\nColumns:\")\nprint(train_series.columns.tolist())\n\nprint(\"\\nFirst 10 rows:\")\ndisplay(train_series.head(10))\n\nprint(\"\\nData types:\")\nprint(train_series.dtypes)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:50:49.52549Z","iopub.execute_input":"2026-08-17T16:50:49.526182Z","iopub.status.idle":"2026-08-17T16:50:49.609471Z","shell.execute_reply.started":"2026-08-17T16:50:49.526133Z","shell.execute_reply":"2026-08-17T16:50:49.608178Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\nNumber of unique studies:\")\nprint(train_series[\"StudyInstanceUID\"].nunique())\n\nprint(\"\\nNumber of unique series:\")\nprint(train_series[\"SeriesInstanceUID\"].nunique())\n\nprint(\"\\nSeries per study:\")\nseries_per_study = train_series.groupby(\"StudyInstanceUID\")[\"SeriesInstanceUID\"].nunique()\n\nprint(series_per_study.describe())\n\nprint(\"\\nFirst 20 studies:\")\ndisplay(series_per_study.head(20))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:51:02.576069Z","iopub.execute_input":"2026-08-17T16:51:02.576665Z","iopub.status.idle":"2026-08-17T16:51:02.619642Z","shell.execute_reply.started":"2026-08-17T16:51:02.576635Z","shell.execute_reply":"2026-08-17T16:51:02.618713Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport pandas as pd\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\nprint(\"Shape:\", train_series.shape)\n\nprint(\"\\nColumns:\")\nprint(train_series.columns.tolist())\n\nprint(\"\\nFirst 20 rows:\")\ndisplay(train_series.head(20))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:51:44.752576Z","iopub.execute_input":"2026-08-17T16:51:44.752889Z","iopub.status.idle":"2026-08-17T16:51:44.829197Z","shell.execute_reply.started":"2026-08-17T16:51:44.752865Z","shell.execute_reply":"2026-08-17T16:51:44.828177Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nfrom pathlib import Path\nimport pandas as pd\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\nstudy_id = pd.read_csv(root / \"train_series.csv\").iloc[0][\"StudyInstanceUID\"]\nseries_id = pd.read_csv(root / \"train_series.csv\").iloc[0][\"SeriesInstanceUID\"]\n\nprint(\"StudyInstanceUID:\")\nprint(study_id)\n\nprint(\"\\nSeriesInstanceUID:\")\nprint(series_id)\n\nprint(\"\\nSearching for DICOM files...\")\n\nseries_path = root / \"train_series\" / str(study_id) / str(series_id)\n\nprint(\"Expected series path:\")\nprint(series_path)\n\nif series_path.exists():\n    files = sorted([p for p in series_path.rglob(\"*\") if p.is_file()])\n    print(\"\\nNumber of files:\", len(files))\n\n    print(\"\\nFirst 10 files:\")\n    for f in files[:10]:\n        print(f)\nelse:\n    print(\"\\nSeries path does not exist at this exact structure.\")\n    print(\"We will inspect the directory structure next.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:52:12.963049Z","iopub.execute_input":"2026-08-17T16:52:12.963738Z","iopub.status.idle":"2026-08-17T16:52:13.106415Z","shell.execute_reply.started":"2026-08-17T16:52:12.963704Z","shell.execute_reply":"2026-08-17T16:52:13.105355Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pydicom\nimport matplotlib.pyplot as plt\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\nstudy_id = \"1.2.826.0.1.3680043.8.498.10004873229099053869093324292195817260\"\nseries_id = \"1.2.826.0.1.3680043.8.498.12343110195036213483454091715412333772\"\n\nseries_path = root / \"train_series\" / study_id / series_id\n\nfiles = sorted(series_path.glob(\"*.dcm\"))\n\nprint(\"Number of DICOM files:\", len(files))\n\n# Load all slices\nslices = []\n\nfor f in files:\n    ds = pydicom.dcmread(f)\n    slices.append((ds, ds.pixel_array))\n\n# Show metadata from first slice\nds = slices[0][0]\n\nprint(\"\\nDICOM metadata\")\nprint(\"Rows:\", ds.Rows)\nprint(\"Columns:\", ds.Columns)\nprint(\"Modality:\", getattr(ds, \"Modality\", \"N/A\"))\nprint(\"Series Description:\", getattr(ds, \"SeriesDescription\", \"N/A\"))\nprint(\"Instance Number:\", getattr(ds, \"InstanceNumber\", \"N/A\"))\nprint(\"Slice Thickness:\", getattr(ds, \"SliceThickness\", \"N/A\"))\nprint(\"Pixel Spacing:\", getattr(ds, \"PixelSpacing\", \"N/A\"))\n\n# Show 6 evenly spaced slices\nindices = [0, 4, 8, 12, 16, 21]\n\nfig, axes = plt.subplots(2, 3, figsize=(15, 10))\n\nfor ax, idx in zip(axes.ravel(), indices):\n    image = slices[idx][1]\n    ax.imshow(image, cmap=\"gray\")\n    ax.set_title(f\"Slice {idx + 1}/{len(slices)}\")\n    ax.axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:52:47.734696Z","iopub.execute_input":"2026-08-17T16:52:47.735785Z","iopub.status.idle":"2026-08-17T16:52:50.158393Z","shell.execute_reply.started":"2026-08-17T16:52:47.735741Z","shell.execute_reply":"2026-08-17T16:52:50.157161Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pydicom\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\nstudy_id = \"1.2.826.0.1.3680043.8.498.10004873229099053869093324292195817260\"\nseries_id = \"1.2.826.0.1.3680043.8.498.12343110195036213483454091715412333772\"\n\nseries_path = root / \"train_series\" / study_id / series_id\n\nrecords = []\n\nfor f in series_path.glob(\"*.dcm\"):\n    ds = pydicom.dcmread(f)\n\n    position = getattr(ds, \"ImagePositionPatient\", None)\n    orientation = getattr(ds, \"ImageOrientationPatient\", None)\n    instance = getattr(ds, \"InstanceNumber\", None)\n\n    records.append({\n        \"file\": f,\n        \"instance\": instance,\n        \"position\": position,\n        \"orientation\": orientation,\n    })\n\nprint(\"Number of slices:\", len(records))\n\nprint(\"\\nFirst 10 metadata records:\")\nfor r in records[:10]:\n    print(\n        \"Instance:\", r[\"instance\"],\n        \"| Position:\", r[\"position\"],\n        \"| File:\", r[\"file\"].name\n    )\n\n# Sort using ImagePositionPatient when available.\nif all(r[\"position\"] is not None for r in records):\n    records = sorted(\n        records,\n        key=lambda r: tuple(float(x) for x in r[\"position\"])\n    )\n    sorting_method = \"ImagePositionPatient\"\nelse:\n    records = sorted(\n        records,\n        key=lambda r: (\n            r[\"instance\"] if r[\"instance\"] is not None else 0\n        )\n    )\n    sorting_method = \"InstanceNumber\"\n\nprint(\"\\nSorting method:\", sorting_method)\n\nprint(\"\\nOrdered slices:\")\nfor i, r in enumerate(records):\n    print(\n        i,\n        \"| Instance:\", r[\"instance\"],\n        \"| Position:\", r[\"position\"]\n    )\n\n# Visualize ordered slices\nimages = []\n\nfor r in records:\n    ds = pydicom.dcmread(r[\"file\"])\n    images.append(ds.pixel_array.astype(np.float32))\n\nindices = np.linspace(\n    0,\n    len(images) - 1,\n    min(6, len(images)),\n    dtype=int\n)\n\nfig, axes = plt.subplots(2, 3, figsize=(12, 8))\n\nfor ax, idx in zip(axes.ravel(), indices):\n    ax.imshow(images[idx], cmap=\"gray\")\n    ax.set_title(f\"Ordered slice {idx + 1}/{len(images)}\")\n    ax.axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:55:20.740211Z","iopub.execute_input":"2026-08-17T16:55:20.741468Z","iopub.status.idle":"2026-08-17T16:55:21.859428Z","shell.execute_reply.started":"2026-08-17T16:55:20.741432Z","shell.execute_reply":"2026-08-17T16:55:21.858231Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pydicom\nimport numpy as np\nfrom pathlib import Path\n\n\ndef load_dicom_series(series_path):\n    \"\"\"\n    Load a DICOM MRI series and order slices using\n    ImageOrientationPatient + ImagePositionPatient.\n    \"\"\"\n\n    series_path = Path(series_path)\n\n    dicom_files = list(series_path.glob(\"*.dcm\"))\n\n    if not dicom_files:\n        raise FileNotFoundError(f\"No DICOM files found in: {series_path}\")\n\n    records = []\n\n    for file_path in dicom_files:\n        ds = pydicom.dcmread(file_path)\n\n        if not hasattr(ds, \"ImagePositionPatient\"):\n            raise ValueError(\n                f\"ImagePositionPatient missing: {file_path.name}\"\n            )\n\n        if not hasattr(ds, \"ImageOrientationPatient\"):\n            raise ValueError(\n                f\"ImageOrientationPatient missing: {file_path.name}\"\n            )\n\n        position = np.asarray(\n            ds.ImagePositionPatient,\n            dtype=np.float64\n        )\n\n        orientation = np.asarray(\n            ds.ImageOrientationPatient,\n            dtype=np.float64\n        )\n\n        row_direction = orientation[:3]\n        column_direction = orientation[3:]\n\n        slice_normal = np.cross(\n            row_direction,\n            column_direction\n        )\n\n        slice_position = np.dot(\n            position,\n            slice_normal\n        )\n\n        records.append({\n            \"file\": file_path,\n            \"dataset\": ds,\n            \"position\": position,\n            \"slice_position\": slice_position,\n            \"instance\": getattr(ds, \"InstanceNumber\", None),\n        })\n\n    # Sort by physical slice position\n    records.sort(key=lambda x: x[\"slice_position\"])\n\n    images = []\n\n    for record in records:\n        image = record[\"dataset\"].pixel_array.astype(np.float32)\n        images.append(image)\n\n    volume = np.stack(images, axis=0)\n\n    metadata = {\n        \"num_slices\": len(records),\n        \"height\": volume.shape[1],\n        \"width\": volume.shape[2],\n        \"modality\": getattr(records[0][\"dataset\"], \"Modality\", None),\n        \"series_description\": getattr(\n            records[0][\"dataset\"],\n            \"SeriesDescription\",\n            None\n        ),\n        \"pixel_spacing\": getattr(\n            records[0][\"dataset\"],\n            \"PixelSpacing\",\n            None\n        ),\n        \"slice_thickness\": getattr(\n            records[0][\"dataset\"],\n            \"SliceThickness\",\n            None\n        ),\n        \"slice_positions\": [\n            r[\"slice_position\"] for r in records\n        ],\n        \"instance_numbers\": [\n            r[\"instance\"] for r in records\n        ],\n    }\n\n    return volume, metadata","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:57:54.212002Z","iopub.execute_input":"2026-08-17T16:57:54.212895Z","iopub.status.idle":"2026-08-17T16:57:54.224823Z","shell.execute_reply.started":"2026-08-17T16:57:54.212856Z","shell.execute_reply":"2026-08-17T16:57:54.223898Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"root = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\nstudy_id = (\n    \"1.2.826.0.1.3680043.8.498.\"\n    \"10004873229099053869093324292195817260\"\n)\n\nseries_id = (\n    \"1.2.826.0.1.3680043.8.498.\"\n    \"12343110195036213483454091715412333772\"\n)\n\nseries_path = (\n    root\n    / \"train_series\"\n    / study_id\n    / series_id\n)\n\nvolume, metadata = load_dicom_series(series_path)\n\nprint(\"Volume shape:\", volume.shape)\n\nprint(\"\\nMetadata:\")\nfor key, value in metadata.items():\n    if key not in [\"slice_positions\"]:\n        print(f\"{key}: {value}\")\n\nprint(\"\\nFirst 5 physical positions:\")\nprint(metadata[\"slice_positions\"][:5])\n\nprint(\"\\nLast 5 physical positions:\")\nprint(metadata[\"slice_positions\"][-5:])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T16:58:11.315199Z","iopub.execute_input":"2026-08-17T16:58:11.315505Z","iopub.status.idle":"2026-08-17T16:58:11.44761Z","shell.execute_reply.started":"2026-08-17T16:58:11.31548Z","shell.execute_reply":"2026-08-17T16:58:11.446789Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\ntrain_series = pd.read_csv(root / \"train_series.csv\")\ntrain = pd.read_csv(root / \"train.csv\")\n\nprint(\"Total series rows:\", len(train_series))\nprint(\"Unique studies:\", train_series[\"StudyInstanceUID\"].nunique())\nprint(\"Unique series:\", train_series[\"SeriesInstanceUID\"].nunique())\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"ANATOMICAL PLANE\")\nprint(\"=\"*60)\ndisplay(\n    train_series[\"Anatomical_Plane\"]\n    .value_counts(dropna=False)\n    .to_frame(\"count\")\n)\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"FLUID SENSITIVE\")\nprint(\"=\"*60)\ndisplay(\n    train_series[\"Fluid_Sensitive\"]\n    .value_counts(dropna=False)\n    .to_frame(\"count\")\n)\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"FAT SUPPRESSION\")\nprint(\"=\"*60)\ndisplay(\n    train_series[\"Fat_Suppression\"]\n    .value_counts(dropna=False)\n    .to_frame(\"count\")\n)\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"PLANE × FLUID SENSITIVE × FAT SUPPRESSION\")\nprint(\"=\"*60)\n\ndisplay(\n    train_series\n    .groupby(\n        [\"Anatomical_Plane\",\n         \"Fluid_Sensitive\",\n         \"Fat_Suppression\"],\n        dropna=False\n    )\n    .size()\n    .reset_index(name=\"number_of_series\")\n    .sort_values(\"number_of_series\", ascending=False)\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:03:15.506826Z","iopub.execute_input":"2026-08-17T17:03:15.507129Z","iopub.status.idle":"2026-08-17T17:03:15.730904Z","shell.execute_reply.started":"2026-08-17T17:03:15.507103Z","shell.execute_reply":"2026-08-17T17:03:15.730075Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# How many series of each type does a typical study contain?\n\nstudy_series_summary = (\n    train_series\n    .groupby([\"StudyInstanceUID\", \"Anatomical_Plane\"])\n    .size()\n    .unstack(fill_value=0)\n)\n\nprint(\"Series counts per study by anatomical plane:\")\ndisplay(study_series_summary.describe())\n\nprint(\"\\nFirst 20 studies:\")\ndisplay(study_series_summary.head(20))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:03:39.056513Z","iopub.execute_input":"2026-08-17T17:03:39.056856Z","iopub.status.idle":"2026-08-17T17:03:39.109732Z","shell.execute_reply.started":"2026-08-17T17:03:39.056828Z","shell.execute_reply":"2026-08-17T17:03:39.108761Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Create one row per study showing which series types are present\n\nseries_types = (\n    train_series\n    .groupby(\n        [\n            \"StudyInstanceUID\",\n            \"Anatomical_Plane\",\n            \"Fluid_Sensitive\",\n            \"Fat_Suppression\"\n        ]\n    )\n    .size()\n    .reset_index(name=\"count\")\n)\n\n# Turn the six sequence combinations into columns\nstudy_matrix = (\n    series_types\n    .assign(\n        sequence_type=lambda x:\n        x[\"Anatomical_Plane\"].astype(str)\n        + \"_F\"\n        + x[\"Fluid_Sensitive\"].astype(str)\n        + \"_FS\"\n        + x[\"Fat_Suppression\"].astype(str)\n    )\n    .pivot_table(\n        index=\"StudyInstanceUID\",\n        columns=\"sequence_type\",\n        values=\"count\",\n        fill_value=0\n    )\n)\n\nprint(\"Number of studies:\", len(study_matrix))\n\nprint(\"\\nHow many sequence types does each study contain?\")\n\nnum_sequence_types = (study_matrix > 0).sum(axis=1)\n\ndisplay(\n    num_sequence_types\n    .value_counts()\n    .sort_index()\n    .to_frame(\"number_of_studies\")\n)\n\nprint(\"\\nSequence presence across studies:\")\n\npresence = (study_matrix > 0).mean().sort_values(ascending=False)\n\ndisplay(\n    (presence * 100)\n    .round(2)\n    .to_frame(\"percentage_of_studies\")\n)\n\nprint(\"\\nFirst 20 studies:\")\ndisplay(study_matrix.head(20))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:04:07.72678Z","iopub.execute_input":"2026-08-17T17:04:07.727254Z","iopub.status.idle":"2026-08-17T17:04:07.821536Z","shell.execute_reply.started":"2026-08-17T17:04:07.727224Z","shell.execute_reply":"2026-08-17T17:04:07.820599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Exact study-level sequence completeness\n\nnum_sequence_types = (study_matrix > 0).sum(axis=1)\n\nprint(\"=\" * 60)\nprint(\"NUMBER OF DIFFERENT SEQUENCE TYPES PER STUDY\")\nprint(\"=\" * 60)\n\ndisplay(\n    num_sequence_types\n    .value_counts()\n    .sort_index()\n    .rename(\"number_of_studies\")\n    .to_frame()\n)\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"PERCENTAGE OF STUDIES\")\nprint(\"=\" * 60)\n\ndisplay(\n    (\n        num_sequence_types\n        .value_counts(normalize=True)\n        .sort_index()\n        .mul(100)\n        .round(2)\n        .rename(\"percentage\")\n        .to_frame()\n    )\n)\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"TOTAL NUMBER OF STUDIES\")\nprint(\"=\" * 60)\n\nprint(len(study_matrix))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:05:21.686671Z","iopub.execute_input":"2026-08-17T17:05:21.687945Z","iopub.status.idle":"2026-08-17T17:05:21.711186Z","shell.execute_reply.started":"2026-08-17T17:05:21.687909Z","shell.execute_reply":"2026-08-17T17:05:21.710339Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"=\" * 60)\nprint(\"PRESENCE OF EACH SEQUENCE TYPE\")\nprint(\"=\" * 60)\n\npresence = (study_matrix > 0).sum(axis=0)\ntotal_studies = len(study_matrix)\n\nsummary = pd.DataFrame({\n    \"studies_with_sequence\": presence,\n    \"percentage\": (presence / total_studies * 100).round(2)\n})\n\ndisplay(summary.sort_values(\"percentage\", ascending=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:05:34.011166Z","iopub.execute_input":"2026-08-17T17:05:34.011576Z","iopub.status.idle":"2026-08-17T17:05:34.025568Z","shell.execute_reply.started":"2026-08-17T17:05:34.011545Z","shell.execute_reply":"2026-08-17T17:05:34.024486Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\nimport numpy as np\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\n# Only the four common sequence types for now\ncommon_types = [\n    (\"Axial\", 1, 1),\n    (\"Sagittal\", 0, 0),\n    (\"Coronal\", 1, 1),\n    (\"Sagittal\", 1, 1),\n]\n\nrows = []\n\nfor plane, fluid, fat in common_types:\n    \n    subset = train_series[\n        (train_series[\"Anatomical_Plane\"] == plane) &\n        (train_series[\"Fluid_Sensitive\"] == fluid) &\n        (train_series[\"Fat_Suppression\"] == fat)\n    ]\n\n    # Count actual DICOM files for up to 100 series first\n    for _, r in subset.head(100).iterrows():\n\n        series_path = (\n            root\n            / \"train_series\"\n            / str(r[\"StudyInstanceUID\"])\n            / str(r[\"SeriesInstanceUID\"])\n        )\n\n        n_files = len(list(series_path.glob(\"*.dcm\")))\n\n        rows.append({\n            \"sequence_type\": f\"{plane}_F{fluid}_FS{fat}\",\n            \"StudyInstanceUID\": r[\"StudyInstanceUID\"],\n            \"SeriesInstanceUID\": r[\"SeriesInstanceUID\"],\n            \"num_slices\": n_files\n        })\n\nslice_summary = pd.DataFrame(rows)\n\nprint(\"Sampled series:\", len(slice_summary))\n\ndisplay(\n    slice_summary\n    .groupby(\"sequence_type\")[\"num_slices\"]\n    .describe()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:06:00.310393Z","iopub.execute_input":"2026-08-17T17:06:00.310748Z","iopub.status.idle":"2026-08-17T17:06:05.84834Z","shell.execute_reply.started":"2026-08-17T17:06:00.310719Z","shell.execute_reply":"2026-08-17T17:06:05.847404Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\ntrain = pd.read_csv(root / \"train.csv\")\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\ntargets = [\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]\n\n# These are the studies with known labels\nlabelled = train[train[targets].notna().all(axis=1)].copy()\n\nprint(\"Total studies:\", len(train))\nprint(\"Fully labelled studies:\", len(labelled))\n\n# Sequence availability among ONLY labelled studies\ncommon_types = [\n    (\"Axial\", 1, 1),\n    (\"Sagittal\", 0, 0),\n    (\"Coronal\", 1, 1),\n    (\"Sagittal\", 1, 1),\n]\n\nlabelled_series = train_series[\n    train_series[\"StudyInstanceUID\"].isin(labelled[\"StudyInstanceUID\"])\n].copy()\n\nprint(\"\\nLabelled-study sequence availability:\")\n\nfor plane, fluid, fat in common_types:\n    mask = (\n        (labelled_series[\"Anatomical_Plane\"] == plane) &\n        (labelled_series[\"Fluid_Sensitive\"] == fluid) &\n        (labelled_series[\"Fat_Suppression\"] == fat)\n    )\n\n    n = labelled_series.loc[mask, \"StudyInstanceUID\"].nunique()\n    pct = n / len(labelled) * 100\n\n    print(\n        f\"{plane}_F{fluid}_FS{fat}: \"\n        f\"{n}/{len(labelled)} studies ({pct:.2f}%)\"\n    )\n\nprint(\"\\nLabelled studies:\")\ndisplay(labelled[[\"StudyInstanceUID\"] + targets].head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:07:06.223931Z","iopub.execute_input":"2026-08-17T17:07:06.224621Z","iopub.status.idle":"2026-08-17T17:07:06.415635Z","shell.execute_reply.started":"2026-08-17T17:07:06.22459Z","shell.execute_reply":"2026-08-17T17:07:06.414882Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\ntrain = pd.read_csv(root / \"train.csv\")\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\ntargets = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"\n]\n\n# Exactly the studies with complete labels\nlabelled = train[train[targets].notna().all(axis=1)].copy()\n\nprint(\"Total studies:\", len(train))\nprint(\"Fully labelled studies:\", len(labelled))\n\nlabelled_series = train_series[\n    train_series[\"StudyInstanceUID\"].isin(labelled[\"StudyInstanceUID\"])\n].copy()\n\nsequence_types = [\n    (\"Axial\", 1, 1),\n    (\"Sagittal\", 0, 0),\n    (\"Coronal\", 1, 1),\n    (\"Sagittal\", 1, 1),\n    (\"Coronal\", 0, 0),\n    (\"Axial\", 0, 0),\n]\n\nprint(\"\\nSequence availability among the 58 labelled studies:\\n\")\n\nfor plane, fluid, fat in sequence_types:\n    mask = (\n        (labelled_series[\"Anatomical_Plane\"] == plane) &\n        (labelled_series[\"Fluid_Sensitive\"] == fluid) &\n        (labelled_series[\"Fat_Suppression\"] == fat)\n    )\n\n    n_studies = labelled_series.loc[\n        mask, \"StudyInstanceUID\"\n    ].nunique()\n\n    percentage = 100 * n_studies / len(labelled)\n\n    print(\n        f\"{plane}_F{fluid}_FS{fat}: \"\n        f\"{n_studies}/58 ({percentage:.2f}%)\"\n    )\n\nprint(\"\\nLabel distribution in the 58 labelled studies:\")\n\ndistribution = pd.DataFrame({\n    \"positive\": labelled[targets].sum(),\n    \"negative\": (1 - labelled[targets]).sum()\n})\n\ndistribution[\"positive_rate_%\"] = (\n    distribution[\"positive\"] / len(labelled) * 100\n).round(2)\n\ndisplay(distribution)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:07:39.041432Z","iopub.execute_input":"2026-08-17T17:07:39.041896Z","iopub.status.idle":"2026-08-17T17:07:39.234654Z","shell.execute_reply.started":"2026-08-17T17:07:39.041866Z","shell.execute_reply":"2026-08-17T17:07:39.23384Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\nSequence availability among the 58 labelled studies:\\n\")\n\nfor plane, fluid, fat in [\n    (\"Axial\", 1, 1),\n    (\"Sagittal\", 0, 0),\n    (\"Coronal\", 1, 1),\n    (\"Sagittal\", 1, 1),\n    (\"Coronal\", 0, 0),\n    (\"Axial\", 0, 0),\n]:\n    mask = (\n        (labelled_series[\"Anatomical_Plane\"] == plane) &\n        (labelled_series[\"Fluid_Sensitive\"] == fluid) &\n        (labelled_series[\"Fat_Suppression\"] == fat)\n    )\n\n    n = labelled_series.loc[\n        mask, \"StudyInstanceUID\"\n    ].nunique()\n\n    print(\n        f\"{plane}_F{fluid}_FS{fat}: \"\n        f\"{n}/58 ({100*n/58:.2f}%)\"\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:08:44.543968Z","iopub.execute_input":"2026-08-17T17:08:44.544501Z","iopub.status.idle":"2026-08-17T17:08:44.559723Z","shell.execute_reply.started":"2026-08-17T17:08:44.544467Z","shell.execute_reply":"2026-08-17T17:08:44.558535Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sequence_cols = [\n    (\"Axial\", 1, 1, \"Axial_F1_FS1\"),\n    (\"Sagittal\", 0, 0, \"Sagittal_F0_FS0\"),\n    (\"Coronal\", 1, 1, \"Coronal_F1_FS1\"),\n    (\"Sagittal\", 1, 1, \"Sagittal_F1_FS1\"),\n]\n\nprint(\"Duplicate series counts within labelled studies:\\n\")\n\nfor plane, fluid, fat, name in sequence_cols:\n\n    subset = labelled_series[\n        (labelled_series[\"Anatomical_Plane\"] == plane) &\n        (labelled_series[\"Fluid_Sensitive\"] == fluid) &\n        (labelled_series[\"Fat_Suppression\"] == fat)\n    ]\n\n    counts = (\n        subset.groupby(\"StudyInstanceUID\")\n        .size()\n    )\n\n    print(f\"\\n{name}\")\n    print(\"Studies with sequence:\", counts.size)\n    print(\"Studies with >1 series:\", int((counts > 1).sum()))\n    print(\"Maximum series in one study:\", int(counts.max()))\n    print(\"\\nDistribution:\")\n    print(counts.value_counts().sort_index())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:09:30.15378Z","iopub.execute_input":"2026-08-17T17:09:30.154923Z","iopub.status.idle":"2026-08-17T17:09:30.176175Z","shell.execute_reply.started":"2026-08-17T17:09:30.154883Z","shell.execute_reply":"2026-08-17T17:09:30.174942Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\ntrain = pd.read_csv(root / \"train.csv\")\ntrain_series = pd.read_csv(root / \"train_series.csv\")\n\ntargets = [\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]\n\n# ---------------------------------------------------------\n# 1. Keep only completely labelled studies\n# ---------------------------------------------------------\n\nlabelled = train[\n    train[targets].notna().all(axis=1)\n].copy()\n\nprint(\"Labelled studies:\", len(labelled))\n\n# ---------------------------------------------------------\n# 2. Define the four sequence types for Baseline V1\n# ---------------------------------------------------------\n\nsequence_types = [\n    (\"Axial\", 1, 1, \"axial_f1_fs1\"),\n    (\"Sagittal\", 0, 0, \"sagittal_f0_fs0\"),\n    (\"Coronal\", 1, 1, \"coronal_f1_fs1\"),\n    (\"Sagittal\", 1, 1, \"sagittal_f1_fs1\"),\n]\n\n# ---------------------------------------------------------\n# 3. Find candidate series for every study/sequence type\n# ---------------------------------------------------------\n\nrows = []\n\nfor plane, fluid, fat, name in sequence_types:\n\n    subset = train_series[\n        (train_series[\"Anatomical_Plane\"] == plane) &\n        (train_series[\"Fluid_Sensitive\"] == fluid) &\n        (train_series[\"Fat_Suppression\"] == fat)\n    ].copy()\n\n    for study_id, group in subset.groupby(\"StudyInstanceUID\"):\n\n        # Only studies in our labelled set\n        if study_id not in set(labelled[\"StudyInstanceUID\"]):\n            continue\n\n        candidates = []\n\n        for _, series_row in group.iterrows():\n\n            series_path = (\n                root\n                / \"train_series\"\n                / str(study_id)\n                / str(series_row[\"SeriesInstanceUID\"])\n            )\n\n            n_slices = len(\n                list(series_path.glob(\"*.dcm\"))\n            )\n\n            candidates.append({\n                \"SeriesInstanceUID\":\n                    series_row[\"SeriesInstanceUID\"],\n                \"num_slices\":\n                    n_slices\n            })\n\n        # Pick longest series\n        candidates = sorted(\n            candidates,\n            key=lambda x: x[\"num_slices\"],\n            reverse=True\n        )\n\n        if candidates:\n            selected = candidates[0]\n\n            rows.append({\n                \"StudyInstanceUID\": study_id,\n                \"sequence_type\": name,\n                \"SeriesInstanceUID\":\n                    selected[\"SeriesInstanceUID\"],\n                \"num_slices\":\n                    selected[\"num_slices\"],\n                \"num_candidate_series\":\n                    len(candidates)\n            })\n\nselected_series = pd.DataFrame(rows)\n\nprint(\"\\nSelected series:\")\ndisplay(selected_series.head(20))\n\nprint(\"\\nRows:\", len(selected_series))\n\nprint(\"\\nSelected series by sequence type:\")\ndisplay(\n    selected_series[\"sequence_type\"]\n    .value_counts()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:10:20.536712Z","iopub.execute_input":"2026-08-17T17:10:20.537628Z","iopub.status.idle":"2026-08-17T17:10:24.207425Z","shell.execute_reply.started":"2026-08-17T17:10:20.537595Z","shell.execute_reply":"2026-08-17T17:10:24.206526Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ---------------------------------------------------------\n# 4. Pivot into one row per study\n# ---------------------------------------------------------\n\nstudy_metadata = labelled[\n    [\"StudyInstanceUID\", \"Report\"] + targets\n].copy()\n\nseries_pivot = selected_series.pivot(\n    index=\"StudyInstanceUID\",\n    columns=\"sequence_type\",\n    values=\"SeriesInstanceUID\"\n).reset_index()\n\nstudy_metadata = study_metadata.merge(\n    series_pivot,\n    on=\"StudyInstanceUID\",\n    how=\"left\"\n)\n\nprint(\"Final metadata table shape:\", study_metadata.shape)\n\ndisplay(study_metadata.head())\n\nprint(\"\\nMissing selected series:\")\ndisplay(\n    study_metadata[\n        [\n            \"axial_f1_fs1\",\n            \"sagittal_f0_fs0\",\n            \"coronal_f1_fs1\",\n            \"sagittal_f1_fs1\"\n        ]\n    ].isna().sum()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:10:35.446993Z","iopub.execute_input":"2026-08-17T17:10:35.447692Z","iopub.status.idle":"2026-08-17T17:10:35.484066Z","shell.execute_reply.started":"2026-08-17T17:10:35.447657Z","shell.execute_reply":"2026-08-17T17:10:35.483114Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"output_path = Path(\"/kaggle/working/study_metadata_v1.csv\")\n\nstudy_metadata.to_csv(\n    output_path,\n    index=False\n)\n\nprint(\"Saved:\", output_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:11:16.909264Z","iopub.execute_input":"2026-08-17T17:11:16.910449Z","iopub.status.idle":"2026-08-17T17:11:16.922311Z","shell.execute_reply.started":"2026-08-17T17:11:16.910406Z","shell.execute_reply":"2026-08-17T17:11:16.921367Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\nmetadata_path = Path(\"/kaggle/working/study_metadata_v1.csv\")\n\nstudy_metadata = pd.read_csv(metadata_path)\n\nprint(\"Shape:\", study_metadata.shape)\nprint(\"\\nColumns:\")\nprint(study_metadata.columns.tolist())\n\nprint(\"\\nMissing values:\")\ndisplay(\n    study_metadata.isna().sum().to_frame(\"missing\")\n)\n\nprint(\"\\nSelected series columns:\")\ndisplay(\n    study_metadata[\n        [\n            \"StudyInstanceUID\",\n            \"axial_f1_fs1\",\n            \"sagittal_f0_fs0\",\n            \"coronal_f1_fs1\",\n            \"sagittal_f1_fs1\"\n        ]\n    ].head(10)\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:11:42.677048Z","iopub.execute_input":"2026-08-17T17:11:42.677797Z","iopub.status.idle":"2026-08-17T17:11:42.703275Z","shell.execute_reply.started":"2026-08-17T17:11:42.677764Z","shell.execute_reply":"2026-08-17T17:11:42.702358Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport pandas as pd\nimport pydicom\n\nroot = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\nstudy_metadata = pd.read_csv(\n    \"/kaggle/working/study_metadata_v1.csv\"\n)\n\nsequence_columns = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\nresults = []\n\nfor _, row in study_metadata.iterrows():\n\n    study_id = row[\"StudyInstanceUID\"]\n\n    for seq in sequence_columns:\n\n        series_id = row[seq]\n\n        if pd.isna(series_id):\n            results.append({\n                \"StudyInstanceUID\": study_id,\n                \"sequence\": seq,\n                \"exists\": False,\n                \"num_dicom\": 0,\n                \"readable\": False,\n            })\n            continue\n\n        series_path = (\n            root\n            / \"train_series\"\n            / str(study_id)\n            / str(series_id)\n        )\n\n        dcm_files = list(series_path.glob(\"*.dcm\"))\n\n        readable = False\n\n        if dcm_files:\n            try:\n                ds = pydicom.dcmread(\n                    dcm_files[0],\n                    stop_before_pixels=True\n                )\n                readable = True\n            except Exception:\n                readable = False\n\n        results.append({\n            \"StudyInstanceUID\": study_id,\n            \"sequence\": seq,\n            \"exists\": series_path.exists(),\n            \"num_dicom\": len(dcm_files),\n            \"readable\": readable,\n        })\n\nintegrity = pd.DataFrame(results)\n\nprint(\"Total checks:\", len(integrity))\n\nprint(\"\\nDirectory existence:\")\ndisplay(\n    integrity[\"exists\"].value_counts(dropna=False)\n)\n\nprint(\"\\nReadable DICOM:\")\ndisplay(\n    integrity[\"readable\"].value_counts(dropna=False)\n)\n\nprint(\"\\nDICOM slice-count summary:\")\ndisplay(\n    integrity\n    .groupby(\"sequence\")[\"num_dicom\"]\n    .describe()\n)\n\nprint(\"\\nAny problematic entries:\")\ndisplay(\n    integrity[\n        (~integrity[\"exists\"]) |\n        (~integrity[\"readable\"]) |\n        (integrity[\"num_dicom\"] == 0)\n    ]\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:12:23.264032Z","iopub.execute_input":"2026-08-17T17:12:23.264517Z","iopub.status.idle":"2026-08-17T17:12:26.004128Z","shell.execute_reply.started":"2026-08-17T17:12:23.264487Z","shell.execute_reply":"2026-08-17T17:12:26.003138Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Pick the first problematic entry automatically\n\nbad = integrity[\n    (~integrity[\"exists\"]) |\n    (~integrity[\"readable\"]) |\n    (integrity[\"num_dicom\"] == 0)\n].iloc[0]\n\nprint(\"Problematic study:\")\nprint(\"StudyInstanceUID:\", bad[\"StudyInstanceUID\"])\nprint(\"Sequence:\", bad[\"sequence\"])\n\nstudy_id = bad[\"StudyInstanceUID\"]\n\nseries_id = study_metadata.loc[\n    study_metadata[\"StudyInstanceUID\"] == study_id,\n    bad[\"sequence\"]\n].iloc[0]\n\nprint(\"\\nSelected SeriesInstanceUID:\")\nprint(series_id)\n\nstudy_path = root / \"train_series\" / str(study_id)\n\nprint(\"\\nStudy directory exists:\", study_path.exists())\nprint(\"Study directory:\", study_path)\n\nif study_path.exists():\n    print(\"\\nActual contents:\")\n    for item in sorted(study_path.iterdir()):\n        print(item)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:14:38.771239Z","iopub.execute_input":"2026-08-17T17:14:38.772155Z","iopub.status.idle":"2026-08-17T17:14:38.790041Z","shell.execute_reply.started":"2026-08-17T17:14:38.77201Z","shell.execute_reply":"2026-08-17T17:14:38.788996Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"study_id = \"1.2.826.0.1.3680043.8.498.25695966186129395049687747156570466645\"\n\nrow = study_metadata[\n    study_metadata[\"StudyInstanceUID\"] == study_id\n].iloc[0]\n\nsequence_columns = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\nprint(\"Selected series for this study:\\n\")\n\nfor col in sequence_columns:\n    print(f\"{col}: {row[col]}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:15:19.422214Z","iopub.execute_input":"2026-08-17T17:15:19.422692Z","iopub.status.idle":"2026-08-17T17:15:19.430368Z","shell.execute_reply.started":"2026-08-17T17:15:19.422664Z","shell.execute_reply":"2026-08-17T17:15:19.429328Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\nActual series from train_series.csv:\\n\")\n\nactual = train_series[\n    train_series[\"StudyInstanceUID\"] == study_id\n]\n\ndisplay(\n    actual[\n        [\n            \"SeriesInstanceUID\",\n            \"Fluid_Sensitive\",\n            \"Fat_Suppression\",\n            \"Anatomical_Plane\"\n        ]\n    ]\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:15:29.825986Z","iopub.execute_input":"2026-08-17T17:15:29.826405Z","iopub.status.idle":"2026-08-17T17:15:29.841549Z","shell.execute_reply.started":"2026-08-17T17:15:29.826377Z","shell.execute_reply":"2026-08-17T17:15:29.840534Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nmetadata_path = Path(\"/kaggle/working/study_metadata_v1.csv\")\n\nstudy_metadata = pd.read_csv(metadata_path)\n\nsequence_columns = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\nfor col in sequence_columns:\n    study_metadata[col + \"_available\"] = (\n        study_metadata[col].notna().astype(int)\n    )\n\navailability_columns = [\n    col + \"_available\"\n    for col in sequence_columns\n]\n\nprint(\"Sequence availability:\")\ndisplay(\n    study_metadata[availability_columns]\n    .value_counts()\n    .reset_index(name=\"number_of_studies\")\n)\n\nprint(\"\\nMissing sequence count:\")\nfor col in sequence_columns:\n    print(\n        f\"{col}: \"\n        f\"{study_metadata[col].isna().sum()} missing\"\n    )\n\nnew_path = Path(\n    \"/kaggle/working/study_metadata_v2.csv\"\n)\n\nstudy_metadata.to_csv(\n    new_path,\n    index=False\n)\n\nprint(\"\\nSaved:\", new_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:16:07.656891Z","iopub.execute_input":"2026-08-17T17:16:07.657312Z","iopub.status.idle":"2026-08-17T17:16:07.690749Z","shell.execute_reply.started":"2026-08-17T17:16:07.65728Z","shell.execute_reply":"2026-08-17T17:16:07.689972Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport pydicom\nimport torch\n\nfrom pathlib import Path\nfrom torch.utils.data import Dataset\nimport torch.nn.functional as F\n\n\nROOT = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\nMETADATA_PATH = Path(\n    \"/kaggle/working/study_metadata_v2.csv\"\n)\n\nmetadata = pd.read_csv(METADATA_PATH)\n\n\nTARGETS = [\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]\n\n\nSEQUENCES = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\n\ndef load_dicom_volume(series_path):\n    \"\"\"\n    Load one DICOM series.\n\n    Slice ordering:\n        ImageOrientationPatient\n        +\n        ImagePositionPatient\n\n    Returns:\n        volume: numpy array [N, H, W]\n    \"\"\"\n\n    series_path = Path(series_path)\n\n    files = list(series_path.glob(\"*.dcm\"))\n\n    if not files:\n        raise FileNotFoundError(\n            f\"No DICOM files found: {series_path}\"\n        )\n\n    records = []\n\n    for file_path in files:\n\n        ds = pydicom.dcmread(file_path)\n\n        if not hasattr(ds, \"ImagePositionPatient\"):\n            raise ValueError(\n                f\"Missing ImagePositionPatient: {file_path}\"\n            )\n\n        if not hasattr(ds, \"ImageOrientationPatient\"):\n            raise ValueError(\n                f\"Missing ImageOrientationPatient: {file_path}\"\n            )\n\n        position = np.asarray(\n            ds.ImagePositionPatient,\n            dtype=np.float64\n        )\n\n        orientation = np.asarray(\n            ds.ImageOrientationPatient,\n            dtype=np.float64\n        )\n\n        row_direction = orientation[:3]\n        column_direction = orientation[3:]\n\n        normal = np.cross(\n            row_direction,\n            column_direction\n        )\n\n        physical_position = np.dot(\n            position,\n            normal\n        )\n\n        records.append(\n            (\n                physical_position,\n                file_path\n            )\n        )\n\n    records.sort(key=lambda x: x[0])\n\n    images = []\n\n    for _, file_path in records:\n\n        ds = pydicom.dcmread(file_path)\n\n        image = ds.pixel_array.astype(\n            np.float32\n        )\n\n        images.append(image)\n\n    volume = np.stack(\n        images,\n        axis=0\n    )\n\n    return volume\n\n\ndef sample_slices(volume, num_slices=16):\n    \"\"\"\n    Uniformly sample a fixed number of slices.\n    \"\"\"\n\n    n = volume.shape[0]\n\n    if n == num_slices:\n        return volume\n\n    indices = np.linspace(\n        0,\n        n - 1,\n        num_slices\n    ).round().astype(int)\n\n    return volume[indices]\n\n\ndef normalize_slice(image):\n    \"\"\"\n    Robust per-slice percentile normalization.\n\n    Output approximately [0, 1].\n    \"\"\"\n\n    image = image.astype(\n        np.float32\n    )\n\n    low = np.percentile(\n        image,\n        1\n    )\n\n    high = np.percentile(\n        image,\n        99\n    )\n\n    if high <= low:\n        return np.zeros_like(\n            image,\n            dtype=np.float32\n        )\n\n    image = np.clip(\n        image,\n        low,\n        high\n    )\n\n    image = (\n        image - low\n    ) / (\n        high - low\n    )\n\n    return image.astype(\n        np.float32\n    )\n\n\ndef preprocess_volume(\n    volume,\n    num_slices=16,\n    image_size=224\n):\n    \"\"\"\n    Convert [N,H,W] volume into\n    [N,1,image_size,image_size].\n    \"\"\"\n\n    volume = sample_slices(\n        volume,\n        num_slices=num_slices\n    )\n\n    processed = []\n\n    for image in volume:\n\n        image = normalize_slice(\n            image\n        )\n\n        tensor = torch.from_numpy(\n            image\n        ).unsqueeze(0).unsqueeze(0)\n\n        tensor = F.interpolate(\n            tensor,\n            size=(image_size, image_size),\n            mode=\"bilinear\",\n            align_corners=False\n        )\n\n        processed.append(\n            tensor.squeeze(0)\n        )\n\n    return torch.stack(\n        processed,\n        dim=0\n    )\n\n\nclass KneeStudyDataset(Dataset):\n\n    def __init__(\n        self,\n        dataframe,\n        root,\n        num_slices=16,\n        image_size=224,\n    ):\n\n        self.df = dataframe.reset_index(\n            drop=True\n        )\n\n        self.root = Path(root)\n\n        self.num_slices = num_slices\n        self.image_size = image_size\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n\n        row = self.df.iloc[idx]\n\n        study_id = row[\n            \"StudyInstanceUID\"\n        ]\n\n        sequence_tensors = []\n        availability = []\n\n        for sequence in SEQUENCES:\n\n            series_id = row[\n                sequence\n            ]\n\n            if pd.isna(series_id):\n\n                # Missing sequence\n                tensor = torch.zeros(\n                    self.num_slices,\n                    1,\n                    self.image_size,\n                    self.image_size,\n                    dtype=torch.float32\n                )\n\n                availability.append(0)\n\n            else:\n\n                series_path = (\n                    self.root\n                    / \"train_series\"\n                    / str(study_id)\n                    / str(series_id)\n                )\n\n                volume = load_dicom_volume(\n                    series_path\n                )\n\n                tensor = preprocess_volume(\n                    volume,\n                    num_slices=self.num_slices,\n                    image_size=self.image_size\n                )\n\n                availability.append(1)\n\n            sequence_tensors.append(\n                tensor\n            )\n\n        labels = torch.tensor(\n            row[TARGETS].values.astype(\n                np.float32\n            ),\n            dtype=torch.float32\n        )\n\n        return {\n            \"study_id\": study_id,\n\n            \"images\": torch.stack(\n                sequence_tensors,\n                dim=0\n            ),\n\n            \"availability\": torch.tensor(\n                availability,\n                dtype=torch.float32\n            ),\n\n            \"labels\": labels,\n        }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:16:49.319966Z","iopub.execute_input":"2026-08-17T17:16:49.32031Z","iopub.status.idle":"2026-08-17T17:16:52.659303Z","shell.execute_reply.started":"2026-08-17T17:16:49.320283Z","shell.execute_reply":"2026-08-17T17:16:52.658261Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"dataset = KneeStudyDataset(\n    dataframe=metadata,\n    root=ROOT,\n    num_slices=16,\n    image_size=224\n)\n\nsample = dataset[0]\n\nprint(\"Study ID:\")\nprint(sample[\"study_id\"])\n\nprint(\"\\nImages shape:\")\nprint(sample[\"images\"].shape)\n\nprint(\"\\nAvailability:\")\nprint(sample[\"availability\"])\n\nprint(\"\\nLabels:\")\nprint(sample[\"labels\"])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:17:08.853417Z","iopub.execute_input":"2026-08-17T17:17:08.853774Z","iopub.status.idle":"2026-08-17T17:17:11.049668Z","shell.execute_reply.started":"2026-08-17T17:17:08.853747Z","shell.execute_reply":"2026-08-17T17:17:11.048446Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport numpy as np\n\nsample = dataset[0]\n\nimages = sample[\"images\"]\navailability = sample[\"availability\"]\n\nsequence_names = [\n    \"Axial F1/FS1\",\n    \"Sagittal F0/FS0\",\n    \"Coronal F1/FS1\",\n    \"Sagittal F1/FS1\",\n]\n\n# Pick representative slices\nslice_indices = [2, 6, 10, 13]\n\nfig, axes = plt.subplots(\n    4,\n    4,\n    figsize=(12, 12)\n)\n\nfor row_idx, sequence_name in enumerate(sequence_names):\n\n    for col_idx, slice_idx in enumerate(slice_indices):\n\n        ax = axes[row_idx, col_idx]\n\n        if availability[row_idx] == 0:\n            ax.text(\n                0.5,\n                0.5,\n                \"MISSING\",\n                ha=\"center\",\n                va=\"center\",\n                fontsize=14\n            )\n            ax.axis(\"off\")\n            continue\n\n        image = (\n            images[row_idx, slice_idx, 0]\n            .numpy()\n        )\n\n        ax.imshow(\n            image,\n            cmap=\"gray\"\n        )\n\n        ax.set_title(\n            f\"{sequence_name}\\nSlice {slice_idx + 1}\"\n        )\n\n        ax.axis(\"off\")\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:17:50.12865Z","iopub.execute_input":"2026-08-17T17:17:50.129181Z","iopub.status.idle":"2026-08-17T17:17:52.115524Z","shell.execute_reply.started":"2026-08-17T17:17:50.129146Z","shell.execute_reply":"2026-08-17T17:17:52.112966Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Full dataset preprocessing validation\n\nprint(\"Testing all 58 labelled studies...\\n\")\n\nfailed = []\n\nfor idx in range(len(dataset)):\n\n    try:\n        sample = dataset[idx]\n\n        images = sample[\"images\"]\n        availability = sample[\"availability\"]\n        labels = sample[\"labels\"]\n\n        # Expected tensor structure\n        assert images.shape == (\n            4, 16, 1, 224, 224\n        )\n\n        assert availability.shape == (4,)\n        assert labels.shape == (12,)\n\n        # Check numerical validity\n        assert torch.isfinite(images).all()\n        assert torch.isfinite(labels).all()\n\n    except Exception as e:\n\n        failed.append({\n            \"index\": idx,\n            \"study_id\": metadata.iloc[idx][\"StudyInstanceUID\"],\n            \"error\": str(e)\n        })\n\n    if (idx + 1) % 10 == 0:\n        print(f\"Processed {idx + 1}/{len(dataset)}\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"VALIDATION RESULT\")\nprint(\"=\" * 60)\n\nprint(\"Total studies:\", len(dataset))\nprint(\"Successful:\", len(dataset) - len(failed))\nprint(\"Failed:\", len(failed))\n\nif failed:\n    print(\"\\nFailed studies:\")\n    display(pd.DataFrame(failed))\nelse:\n    print(\"\\nALL STUDIES PASSED.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:19:10.572723Z","iopub.execute_input":"2026-08-17T17:19:10.573271Z","iopub.status.idle":"2026-08-17T17:21:22.758676Z","shell.execute_reply.started":"2026-08-17T17:19:10.573236Z","shell.execute_reply":"2026-08-17T17:21:22.757493Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\n\n# ---------------------------------------------------------\n# Multilabel-aware greedy 5-fold split\n# ---------------------------------------------------------\n\ntargets = [\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]\n\ndf = metadata.copy().reset_index(drop=True)\n\nY = df[targets].values.astype(int)\n\nN_FOLDS = 5\nRANDOM_STATE = 42\n\nrng = np.random.default_rng(RANDOM_STATE)\n\nn_samples = len(df)\n\n# Random starting order, then prioritize rare labels\nsample_order = np.arange(n_samples)\nrng.shuffle(sample_order)\n\nlabel_frequency = Y.sum(axis=0)\n\n# Rare-label priority\nsample_priority = np.zeros(n_samples)\n\nfor i in range(n_samples):\n    positive_labels = np.where(Y[i] == 1)[0]\n\n    if len(positive_labels) > 0:\n        sample_priority[i] = sum(\n            1.0 / max(label_frequency[j], 1)\n            for j in positive_labels\n        )\n\n# Sort from rarest / most informative samples first\nsample_order = sorted(\n    sample_order,\n    key=lambda i: sample_priority[i],\n    reverse=True\n)\n\nfold_indices = [[] for _ in range(N_FOLDS)]\nfold_label_counts = np.zeros(\n    (N_FOLDS, len(targets)),\n    dtype=float\n)\n\nfold_sizes = np.zeros(\n    N_FOLDS,\n    dtype=int\n)\n\n# Assign each sample to the fold currently most in need\nfor idx in sample_order:\n\n    labels = Y[idx]\n\n    best_fold = None\n    best_score = None\n\n    for fold in range(N_FOLDS):\n\n        # Prefer folds with fewer samples\n        size_penalty = fold_sizes[fold]\n\n        # Prefer folds with fewer examples of this sample's labels\n        label_penalty = np.sum(\n            labels * fold_label_counts[fold]\n        )\n\n        score = (\n            label_penalty * 10\n            + size_penalty\n        )\n\n        if best_score is None or score < best_score:\n            best_score = score\n            best_fold = fold\n\n    fold_indices[best_fold].append(idx)\n\n    fold_label_counts[best_fold] += labels\n    fold_sizes[best_fold] += 1\n\n\n# Add fold assignments to metadata\nfold_assignment = np.full(\n    n_samples,\n    -1,\n    dtype=int\n)\n\nfor fold, indices in enumerate(fold_indices):\n    fold_assignment[indices] = fold\n\ndf[\"fold\"] = fold_assignment\n\nprint(\"Fold sizes:\")\nprint(\n    df[\"fold\"]\n    .value_counts()\n    .sort_index()\n)\n\nprint(\"\\nLabel distribution by fold:\")\n\nfor fold in range(N_FOLDS):\n\n    subset = df[df[\"fold\"] == fold]\n\n    distribution = pd.DataFrame({\n        \"positive\": subset[targets].sum(),\n        \"total\": len(subset),\n        \"positive_rate_%\":\n            (subset[targets].mean() * 100).round(1)\n    })\n\n    print(f\"\\n{'='*50}\")\n    print(f\"FOLD {fold}\")\n    print(f\"{'='*50}\")\n\n    display(distribution)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:22:14.916982Z","iopub.execute_input":"2026-08-17T17:22:14.917863Z","iopub.status.idle":"2026-08-17T17:22:14.987978Z","shell.execute_reply.started":"2026-08-17T17:22:14.917831Z","shell.execute_reply":"2026-08-17T17:22:14.98725Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Compact 5-fold validation summary\n\nfold_summary = []\n\nfor fold in range(N_FOLDS):\n    subset = df[df[\"fold\"] == fold]\n\n    row = {\n        \"fold\": fold,\n        \"n_studies\": len(subset)\n    }\n\n    for target in targets:\n        row[target] = int(subset[target].sum())\n\n    fold_summary.append(row)\n\nfold_summary = pd.DataFrame(fold_summary)\n\nprint(\"Positive cases in each validation fold:\")\ndisplay(fold_summary)\n\nprint(\"\\nMinimum positives available for each target across folds:\")\n\nmin_positive = fold_summary[targets].min().sort_values()\n\ndisplay(\n    min_positive.to_frame(\"minimum_positive_cases_in_any_fold\")\n)\n\nprint(\"\\nFold sizes:\")\ndisplay(\n    fold_summary[[\"fold\", \"n_studies\"]]\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:22:46.767705Z","iopub.execute_input":"2026-08-17T17:22:46.768037Z","iopub.status.idle":"2026-08-17T17:22:46.802938Z","shell.execute_reply.started":"2026-08-17T17:22:46.768005Z","shell.execute_reply":"2026-08-17T17:22:46.802105Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"Positive cases in each fold:\")\n\nfor fold in range(5):\n    subset = df[df[\"fold\"] == fold]\n\n    print(f\"\\nFOLD {fold} — {len(subset)} studies\")\n\n    for target in targets:\n        print(\n            f\"{target:20s}: \"\n            f\"{int(subset[target].sum())}\"\n        )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:23:10.599779Z","iopub.execute_input":"2026-08-17T17:23:10.600562Z","iopub.status.idle":"2026-08-17T17:23:10.614709Z","shell.execute_reply.started":"2026-08-17T17:23:10.60053Z","shell.execute_reply":"2026-08-17T17:23:10.613724Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Freeze Baseline V1 folds\n\nfold_path = \"/kaggle/working/study_metadata_v2_folds.csv\"\n\ndf.to_csv(\n    fold_path,\n    index=False\n)\n\nprint(\"Saved frozen CV split:\")\nprint(fold_path)\n\nprint(\"\\nFold counts:\")\nprint(\n    df[\"fold\"]\n    .value_counts()\n    .sort_index()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:23:36.799507Z","iopub.execute_input":"2026-08-17T17:23:36.79999Z","iopub.status.idle":"2026-08-17T17:23:36.81419Z","shell.execute_reply.started":"2026-08-17T17:23:36.799953Z","shell.execute_reply":"2026-08-17T17:23:36.81315Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport torch\nimport pandas as pd\nimport time\n\n# ---------------------------------------------------------\n# Paths\n# ---------------------------------------------------------\n\ncache_root = Path(\"/kaggle/working/knee_cache\")\ncache_root.mkdir(parents=True, exist_ok=True)\n\nfolded_metadata = pd.read_csv(\n    \"/kaggle/working/study_metadata_v2_folds.csv\"\n)\n\nprint(\"Studies to cache:\", len(folded_metadata))\nprint(\"Cache directory:\", cache_root)\n\n\n# ---------------------------------------------------------\n# Cache every labelled study\n# ---------------------------------------------------------\n\ncache_results = []\n\nstart_time = time.time()\n\nfor idx in range(len(folded_metadata)):\n\n    row = folded_metadata.iloc[idx]\n    study_id = row[\"StudyInstanceUID\"]\n\n    output_path = cache_root / f\"{study_id}.pt\"\n\n    # Skip if already cached\n    if output_path.exists():\n\n        cache_results.append({\n            \"study_id\": study_id,\n            \"cached\": True,\n            \"status\": \"already_exists\"\n        })\n\n        continue\n\n    try:\n\n        # Use our verified Dataset implementation\n        sample = dataset[idx]\n\n        torch.save(\n            {\n                \"study_id\": sample[\"study_id\"],\n                \"images\": sample[\"images\"].to(\n                    torch.float32\n                ),\n                \"availability\": sample[\"availability\"].to(\n                    torch.float32\n                ),\n                \"labels\": sample[\"labels\"].to(\n                    torch.float32\n                ),\n                \"fold\": int(row[\"fold\"]),\n            },\n            output_path\n        )\n\n        cache_results.append({\n            \"study_id\": study_id,\n            \"cached\": True,\n            \"status\": \"created\"\n        })\n\n    except Exception as e:\n\n        cache_results.append({\n            \"study_id\": study_id,\n            \"cached\": False,\n            \"status\": str(e)\n        })\n\n    if (idx + 1) % 10 == 0:\n        elapsed = time.time() - start_time\n        print(\n            f\"Processed {idx + 1}/{len(folded_metadata)} \"\n            f\"| elapsed: {elapsed:.1f}s\"\n        )\n\n\ncache_results = pd.DataFrame(cache_results)\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"CACHE RESULT\")\nprint(\"=\" * 60)\n\ndisplay(\n    cache_results[\"status\"]\n    .value_counts()\n    .to_frame(\"count\")\n)\n\nfailed = cache_results[\n    ~cache_results[\"cached\"]\n]\n\nprint(\"\\nFailed:\", len(failed))\n\nif len(failed):\n    display(failed)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:24:05.984908Z","iopub.execute_input":"2026-08-17T17:24:05.985617Z","iopub.status.idle":"2026-08-17T17:25:10.275031Z","shell.execute_reply.started":"2026-08-17T17:24:05.985553Z","shell.execute_reply":"2026-08-17T17:25:10.274181Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Verify cached tensors\n\nfiles = sorted(cache_root.glob(\"*.pt\"))\n\nprint(\"Cached studies:\", len(files))\n\nsample_file = files[0]\n\nsample_cache = torch.load(\n    sample_file,\n    map_location=\"cpu\"\n)\n\nprint(\"\\nSample cache contents:\")\n\nfor key, value in sample_cache.items():\n\n    if torch.is_tensor(value):\n        print(\n            key,\n            \"shape =\", tuple(value.shape),\n            \"dtype =\", value.dtype\n        )\n    else:\n        print(\n            key,\n            \"=\",\n            value\n        )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:29:23.876717Z","iopub.execute_input":"2026-08-17T17:29:23.879309Z","iopub.status.idle":"2026-08-17T17:29:24.015874Z","shell.execute_reply.started":"2026-08-17T17:29:23.879233Z","shell.execute_reply":"2026-08-17T17:29:24.014826Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torchvision\n\nprint(\"PyTorch:\", torch.__version__)\nprint(\"Torchvision:\", torchvision.__version__)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:29:48.838664Z","iopub.execute_input":"2026-08-17T17:29:48.838976Z","iopub.status.idle":"2026-08-17T17:29:53.207404Z","shell.execute_reply.started":"2026-08-17T17:29:48.838952Z","shell.execute_reply":"2026-08-17T17:29:53.206382Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torchvision.models as models\n\n\nclass BaselineV1(nn.Module):\n\n    def __init__(\n        self,\n        num_classes=12,\n        num_sequences=4,\n        pretrained=True,\n    ):\n        super().__init__()\n\n        weights = (\n            models.ResNet18_Weights.DEFAULT\n            if pretrained\n            else None\n        )\n\n        backbone = models.resnet18(\n            weights=weights\n        )\n\n        feature_dim = backbone.fc.in_features\n        backbone.fc = nn.Identity()\n\n        self.encoder = backbone\n        self.feature_dim = feature_dim\n        self.num_sequences = num_sequences\n\n        fusion_dim = (\n            num_sequences * feature_dim\n            + num_sequences\n        )\n\n        self.fusion = nn.Sequential(\n            nn.Linear(fusion_dim, 512),\n            nn.ReLU(),\n            nn.Dropout(0.30),\n\n            nn.Linear(512, 256),\n            nn.ReLU(),\n            nn.Dropout(0.20),\n        )\n\n        self.classifier = nn.Linear(\n            256,\n            num_classes\n        )\n\n    def forward(self, x, availability):\n\n        B, S, N, C, H, W = x.shape\n\n        x = x.reshape(\n            B * S * N,\n            C,\n            H,\n            W\n        )\n\n        # Grayscale → RGB\n        x = x.repeat(1, 3, 1, 1)\n\n        features = self.encoder(x)\n\n        features = features.reshape(\n            B,\n            S,\n            N,\n            self.feature_dim\n        )\n\n        # Slice pooling\n        sequence_features = features.mean(dim=2)\n\n        # Explicitly remove missing-sequence contribution\n        sequence_features = (\n            sequence_features\n            * availability.unsqueeze(-1)\n        )\n\n        sequence_features = sequence_features.reshape(\n            B,\n            S * self.feature_dim\n        )\n\n        fused = torch.cat(\n            [\n                sequence_features,\n                availability\n            ],\n            dim=1\n        )\n\n        fused = self.fusion(fused)\n\n        logits = self.classifier(fused)\n\n        return logits","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:31:49.897709Z","iopub.execute_input":"2026-08-17T17:31:49.898118Z","iopub.status.idle":"2026-08-17T17:31:49.909292Z","shell.execute_reply.started":"2026-08-17T17:31:49.898069Z","shell.execute_reply":"2026-08-17T17:31:49.908196Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"device = torch.device(\n    \"cuda\" if torch.cuda.is_available()\n    else \"cpu\"\n)\n\nprint(\"Device:\", device)\n\nmodel = BaselineV1(\n    num_classes=12,\n    num_sequences=4,\n    pretrained=True\n).to(device)\n\nprint(\"\\nModel created successfully.\")\n\n# Load one cached study\ncache_file = sorted(cache_root.glob(\"*.pt\"))[0]\n\nsample_cache = torch.load(\n    cache_file,\n    map_location=\"cpu\",\n    weights_only=False\n)\n\nimages = sample_cache[\"images\"].unsqueeze(0).to(device)\navailability = sample_cache[\"availability\"].unsqueeze(0).to(device)\n\nprint(\"\\nInput shape:\", images.shape)\nprint(\"Availability shape:\", availability.shape)\n\nwith torch.no_grad():\n    logits = model(\n        images,\n        availability\n    )\n\nprint(\"\\nOutput shape:\", logits.shape)\nprint(\"Output:\", logits)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:30:23.934535Z","iopub.execute_input":"2026-08-17T17:30:23.934893Z","iopub.status.idle":"2026-08-17T17:30:29.277421Z","shell.execute_reply.started":"2026-08-17T17:30:23.934854Z","shell.execute_reply":"2026-08-17T17:30:29.276577Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model = BaselineV1(\n    num_classes=12,\n    num_sequences=4,\n    pretrained=True\n).to(device)\n\nwith torch.no_grad():\n    logits = model(\n        images,\n        availability\n    )\n\nprint(\"Input:\", images.shape)\nprint(\"Output:\", logits.shape)\nprint(logits)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:32:10.088927Z","iopub.execute_input":"2026-08-17T17:32:10.089395Z","iopub.status.idle":"2026-08-17T17:32:14.350908Z","shell.execute_reply.started":"2026-08-17T17:32:10.089358Z","shell.execute_reply":"2026-08-17T17:32:14.349832Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.metrics import roc_auc_score\nfrom pathlib import Path\nimport numpy as np\n\n\n# =========================================================\n# 1. Cached dataset\n# =========================================================\n\nclass CachedKneeDataset(Dataset):\n\n    def __init__(self, cache_files):\n        self.files = list(cache_files)\n\n    def __len__(self):\n        return len(self.files)\n\n    def __getitem__(self, idx):\n\n        data = torch.load(\n            self.files[idx],\n            map_location=\"cpu\",\n            weights_only=False\n        )\n\n        return {\n            \"images\": data[\"images\"].float(),\n            \"availability\": data[\"availability\"].float(),\n            \"labels\": data[\"labels\"].float(),\n            \"study_id\": data[\"study_id\"],\n            \"fold\": int(data[\"fold\"])\n        }\n\n\ncache_files = sorted(\n    Path(\"/kaggle/working/knee_cache\").glob(\"*.pt\")\n)\n\nall_cached = CachedKneeDataset(cache_files)\n\nprint(\"Cached studies:\", len(all_cached))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:32:45.813751Z","iopub.execute_input":"2026-08-17T17:32:45.814302Z","iopub.status.idle":"2026-08-17T17:32:47.107494Z","shell.execute_reply.started":"2026-08-17T17:32:45.814265Z","shell.execute_reply":"2026-08-17T17:32:47.10655Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fold = 0\n\ntrain_files = []\nvalid_files = []\n\nfor file in cache_files:\n\n    data = torch.load(\n        file,\n        map_location=\"cpu\",\n        weights_only=False\n    )\n\n    if int(data[\"fold\"]) == fold:\n        valid_files.append(file)\n    else:\n        train_files.append(file)\n\nprint(\"Fold:\", fold)\nprint(\"Training studies:\", len(train_files))\nprint(\"Validation studies:\", len(valid_files))\n\ntrain_dataset = CachedKneeDataset(train_files)\nvalid_dataset = CachedKneeDataset(valid_files)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:32:58.031267Z","iopub.execute_input":"2026-08-17T17:32:58.032383Z","iopub.status.idle":"2026-08-17T17:33:00.465396Z","shell.execute_reply.started":"2026-08-17T17:32:58.032349Z","shell.execute_reply":"2026-08-17T17:33:00.464153Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_loader = DataLoader(\n    train_dataset,\n    batch_size=2,\n    shuffle=True,\n    num_workers=0,\n    pin_memory=torch.cuda.is_available()\n)\n\nvalid_loader = DataLoader(\n    valid_dataset,\n    batch_size=2,\n    shuffle=False,\n    num_workers=0,\n    pin_memory=torch.cuda.is_available()\n)\n\nprint(\"Train batches:\", len(train_loader))\nprint(\"Validation batches:\", len(valid_loader))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:33:15.149047Z","iopub.execute_input":"2026-08-17T17:33:15.149735Z","iopub.status.idle":"2026-08-17T17:33:15.156322Z","shell.execute_reply.started":"2026-08-17T17:33:15.149705Z","shell.execute_reply":"2026-08-17T17:33:15.155263Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"device = torch.device(\n    \"cuda\" if torch.cuda.is_available()\n    else \"cpu\"\n)\n\nmodel = BaselineV1(\n    num_classes=12,\n    num_sequences=4,\n    pretrained=True\n).to(device)\n\ncriterion = nn.BCEWithLogitsLoss()\n\noptimizer = torch.optim.AdamW(\n    model.parameters(),\n    lr=1e-4,\n    weight_decay=1e-4\n)\n\nprint(\"Device:\", device)\nprint(\"Model parameters:\",\n      sum(p.numel() for p in model.parameters()))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T17:33:26.845384Z","iopub.execute_input":"2026-08-17T17:33:26.846244Z","iopub.status.idle":"2026-08-17T17:33:27.049073Z","shell.execute_reply.started":"2026-08-17T17:33:26.846207Z","shell.execute_reply":"2026-08-17T17:33:27.04802Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model.train()\n\nrunning_loss = 0.0\n\nfor batch_idx, batch in enumerate(train_loader):\n\n    images = batch[\"images\"].to(\n        device,\n        non_blocking=True\n    )\n\n    availability = batch[\"availability\"].to(\n        device,\n        non_blocking=True\n    )\n\n    labels = batch[\"labels\"].to(\n        device,\n        non_blocking=True\n    )\n\n    optimizer.zero_grad()\n\n    logits = model(\n        images,\n        availability\n    )\n\n    loss = criterion(\n        logits,\n        labels\n    )\n\n    loss.backward()\n\n    optimizer.step()\n\n    running_loss += loss.item()\n\n    print(\n        f\"Batch {batch_idx + 1}/{len(train_loader)} \"\n        f\"| loss = {loss.item():.4f}\"\n    )\n\nprint(\n    \"\\nAverage training loss:\",\n    running_loss / len(train_loader)\n)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model.eval()\n\nall_predictions = []\nall_labels = []\n\nwith torch.no_grad():\n\n    for batch in valid_loader:\n\n        images = batch[\"images\"].to(device)\n        availability = batch[\"availability\"].to(device)\n\n        logits = model(\n            images,\n            availability\n        )\n\n        probabilities = torch.sigmoid(\n            logits\n        )\n\n        all_predictions.append(\n            probabilities.cpu().numpy()\n        )\n\n        all_labels.append(\n            batch[\"labels\"].numpy()\n        )\n\npredictions = np.concatenate(\n    all_predictions,\n    axis=0\n)\n\nlabels = np.concatenate(\n    all_labels,\n    axis=0\n)\n\nprint(\"Prediction shape:\", predictions.shape)\nprint(\"Label shape:\", labels.shape)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"auc_results = {}\n\nfor i, target in enumerate(targets):\n\n    y_true = labels[:, i]\n    y_pred = predictions[:, i]\n\n    # AUC is undefined if validation contains only one class\n    if len(np.unique(y_true)) < 2:\n        auc_results[target] = np.nan\n        continue\n\n    auc_results[target] = roc_auc_score(\n        y_true,\n        y_pred\n    )\n\nauc_table = pd.DataFrame(\n    {\n        \"target\": list(auc_results.keys()),\n        \"auc\": list(auc_results.values())\n    }\n)\n\ndisplay(auc_table)\n\nmacro_auc = np.nanmean(\n    list(auc_results.values())\n)\n\nprint(\"\\nFold 0 Macro ROC-AUC:\", macro_auc)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# RSNA Knee Abnormality Detection\n# BASELINE V1 — Frozen ResNet18 + Study-Level Classifier\n#\n# Pipeline:\n# cached MRI tensors\n#     ↓\n# ImageNet-pretrained ResNet18 (FROZEN)\n#     ↓\n# 16 slice embeddings → mean pooling\n#     ↓\n# 4 sequence embeddings\n#     ↓\n# availability mask\n#     ↓\n# study-level logistic regression\n#     ↓\n# 5-fold OOF predictions\n#     ↓\n# macro ROC-AUC\n# ============================================================\n\nimport os\nimport time\nimport random\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torchvision.models as models\n\nfrom sklearn.pipeline import Pipeline\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.metrics import roc_auc_score\n\n\n# ------------------------------------------------------------\n# 0. Reproducibility\n# ------------------------------------------------------------\n\nSEED = 42\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(SEED)\n\ndevice = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nprint(\"Device:\", device)\n\n\n# ------------------------------------------------------------\n# 1. Paths and configuration\n# ------------------------------------------------------------\n\nCACHE_ROOT = Path(\n    \"/kaggle/working/knee_cache\"\n)\n\nMETADATA_PATH = Path(\n    \"/kaggle/working/study_metadata_v2_folds.csv\"\n)\n\nOUTPUT_ROOT = Path(\n    \"/kaggle/working/baseline_v1\"\n)\n\nOUTPUT_ROOT.mkdir(\n    parents=True,\n    exist_ok=True\n)\n\nNUM_SEQUENCES = 4\nNUM_SLICES = 16\nFEATURE_DIM = 512\n\nSEQUENCES = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\nTARGETS = [\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]\n\n\n# ------------------------------------------------------------\n# 2. Load metadata\n# ------------------------------------------------------------\n\nmetadata = pd.read_csv(\n    METADATA_PATH\n).reset_index(drop=True)\n\nprint(\n    \"\\nNumber of labelled studies:\",\n    len(metadata)\n)\n\nprint(\n    \"Fold counts:\"\n)\n\nprint(\n    metadata[\"fold\"]\n    .value_counts()\n    .sort_index()\n)\n\n\n# ------------------------------------------------------------\n# 3. Load pretrained ResNet18\n# ------------------------------------------------------------\n\nprint(\"\\nLoading pretrained ResNet18...\")\n\nweights = models.ResNet18_Weights.DEFAULT\n\nbackbone = models.resnet18(\n    weights=weights\n)\n\nbackbone.fc = nn.Identity()\n\nbackbone = backbone.to(device)\n\nbackbone.eval()\n\n# IMPORTANT:\n# We are freezing the image encoder.\nfor parameter in backbone.parameters():\n    parameter.requires_grad = False\n\nfeature_extractor_preprocess = weights.transforms()\n\nprint(\n    \"Feature dimension:\",\n    FEATURE_DIM\n)\n\n\n# ------------------------------------------------------------\n# 4. Extract frozen image features\n# ------------------------------------------------------------\n#\n# Each study becomes:\n#\n#   sequence 1 → 512\n#   sequence 2 → 512\n#   sequence 3 → 512\n#   sequence 4 → 512\n#   availability → 4\n#\n# Final vector:\n#\n#   512*4 + 4 = 2052 dimensions\n#\n# ------------------------------------------------------------\n\nembedding_path = (\n    OUTPUT_ROOT / \"study_embeddings.pt\"\n)\n\nif embedding_path.exists():\n\n    print(\n        \"\\nExisting embedding file found.\"\n    )\n\n    embedding_data = torch.load(\n        embedding_path,\n        map_location=\"cpu\",\n        weights_only=False\n    )\n\n    embeddings = embedding_data[\"embeddings\"]\n    availability_matrix = embedding_data[\n        \"availability\"\n    ]\n    study_ids = embedding_data[\n        \"study_ids\"\n    ]\n\n    print(\n        \"Loaded cached embeddings:\",\n        embeddings.shape\n    )\n\nelse:\n\n    print(\n        \"\\nExtracting frozen image embeddings...\"\n    )\n\n    study_embeddings = []\n    study_availability = []\n    study_ids = []\n\n    cache_files = sorted(\n        CACHE_ROOT.glob(\"*.pt\")\n    )\n\n    # Map study ID → cache file\n    cache_map = {}\n\n    for file_path in cache_files:\n\n        cache_map[\n            file_path.stem\n        ] = file_path\n\n    start_time = time.time()\n\n    with torch.inference_mode():\n\n        for index, row in metadata.iterrows():\n\n            study_id = row[\n                \"StudyInstanceUID\"\n            ]\n\n            cache_file = cache_map.get(\n                study_id\n            )\n\n            if cache_file is None:\n                raise FileNotFoundError(\n                    f\"No cache found for {study_id}\"\n                )\n\n            sample = torch.load(\n                cache_file,\n                map_location=\"cpu\",\n                weights_only=False\n            )\n\n            images = sample[\n                \"images\"\n            ].float()\n\n            availability = sample[\n                \"availability\"\n            ].float()\n\n            # ------------------------------------------------\n            # images shape:\n            # [4, 16, 1, 224, 224]\n            # ------------------------------------------------\n\n            sequence_features = []\n\n            for sequence_idx in range(\n                NUM_SEQUENCES\n            ):\n\n                if (\n                    availability[\n                        sequence_idx\n                    ].item() == 0\n                ):\n                    # Missing sequence:\n                    # use an all-zero feature vector.\n                    sequence_feature = torch.zeros(\n                        FEATURE_DIM,\n                        dtype=torch.float32\n                    )\n\n                else:\n\n                    sequence_images = images[\n                        sequence_idx\n                    ]\n\n                    # [16, 1, 224, 224]\n                    #\n                    # Repeat grayscale → RGB\n                    sequence_images = (\n                        sequence_images\n                        .repeat(1, 3, 1, 1)\n                    )\n\n                    # ImageNet normalization\n                    processed = []\n\n                    for image in sequence_images:\n\n                        image = (\n                            image * 255.0\n                        ).clamp(\n                            0,\n                            255\n                        )\n\n                        image = (\n                            image / 255.0\n                        )\n\n                        image = (\n                            image\n                        )\n\n                        image = (\n                            image\n                        )\n\n                        image = (\n                            image\n                        )\n\n                        # Normalize with\n                        # ImageNet statistics\n                        image = (\n                            image\n                            - torch.tensor(\n                                [0.485, 0.456, 0.406]\n                            ).view(3, 1, 1)\n                        ) / torch.tensor(\n                            [0.229, 0.224, 0.225]\n                        ).view(3, 1, 1)\n\n                        processed.append(\n                            image\n                        )\n\n                    sequence_images = torch.stack(\n                        processed,\n                        dim=0\n                    )\n\n                    # Process all 16 slices\n                    # together through ResNet.\n                    sequence_images = (\n                        sequence_images\n                        .to(device)\n                    )\n\n                    features = backbone(\n                        sequence_images\n                    )\n\n                    # [16, 512]\n                    #\n                    # Study-level representation\n                    # for this sequence.\n                    sequence_feature = (\n                        features.mean(\n                            dim=0\n                        )\n                        .cpu()\n                    )\n\n                sequence_features.append(\n                    sequence_feature\n                )\n\n            # [4, 512]\n            sequence_features = torch.stack(\n                sequence_features\n            )\n\n            # Flatten:\n            # [2048]\n            sequence_features = (\n                sequence_features\n                .flatten()\n            )\n\n            # Add availability:\n            # [2052]\n            final_embedding = torch.cat(\n                [\n                    sequence_features,\n                    availability\n                ]\n            )\n\n            study_embeddings.append(\n                final_embedding\n            )\n\n            study_availability.append(\n                availability\n            )\n\n            study_ids.append(\n                study_id\n            )\n\n            if (\n                (index + 1) % 10 == 0\n                or index == len(metadata) - 1\n            ):\n\n                elapsed = (\n                    time.time()\n                    - start_time\n                )\n\n                print(\n                    f\"Processed \"\n                    f\"{index + 1}/{len(metadata)} \"\n                    f\"| elapsed \"\n                    f\"{elapsed:.1f}s\"\n                )\n\n    embeddings = torch.stack(\n        study_embeddings\n    )\n\n    availability_matrix = torch.stack(\n        study_availability\n    )\n\n    torch.save(\n        {\n            \"embeddings\": embeddings,\n            \"availability\": availability_matrix,\n            \"study_ids\": study_ids,\n        },\n        embedding_path\n    )\n\n    print(\n        \"\\nSaved embeddings:\",\n        embedding_path\n    )\n\n\nprint(\n    \"\\nEmbedding shape:\",\n    tuple(embeddings.shape)\n)\n\n\n# ------------------------------------------------------------\n# 5. Prepare labels and folds\n# ------------------------------------------------------------\n\nX = embeddings.numpy().astype(\n    np.float32\n)\n\nY = metadata[\n    TARGETS\n].values.astype(\n    np.float32\n)\n\nfolds = metadata[\n    \"fold\"\n].values.astype(\n    int\n)\n\n\n# ------------------------------------------------------------\n# 6. Five-fold OOF training\n# ------------------------------------------------------------\n#\n# We use a regularized logistic regression because:\n#\n# - only 58 labelled studies exist\n# - feature dimension is high\n# - a large neural classifier would overfit easily\n# - the resulting model is fast and reproducible\n#\n# ------------------------------------------------------------\n\noof_predictions = np.zeros_like(\n    Y,\n    dtype=np.float32\n)\n\nfold_results = []\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"STARTING 5-FOLD BASELINE V1\"\n)\n\nprint(\n    \"=\" * 70\n)\n\n\nfor fold in range(5):\n\n    print(\n        f\"\\n{'=' * 70}\"\n    )\n\n    print(\n        f\"FOLD {fold}\"\n    )\n\n    print(\n        f\"{'=' * 70}\"\n    )\n\n    train_idx = np.where(\n        folds != fold\n    )[0]\n\n    valid_idx = np.where(\n        folds == fold\n    )[0]\n\n    X_train = X[\n        train_idx\n    ]\n\n    X_valid = X[\n        valid_idx\n    ]\n\n    print(\n        \"Train studies:\",\n        len(train_idx)\n    )\n\n    print(\n        \"Validation studies:\",\n        len(valid_idx)\n    )\n\n    fold_auc = []\n\n    for target_idx, target in enumerate(\n        TARGETS\n    ):\n\n        y_train = Y[\n            train_idx,\n            target_idx\n        ]\n\n        y_valid = Y[\n            valid_idx,\n            target_idx\n        ]\n\n        # ----------------------------------------------------\n        # Regularized classifier\n        # ----------------------------------------------------\n\n        model = Pipeline(\n            [\n                (\n                    \"scaler\",\n                    StandardScaler()\n                ),\n                (\n                    \"classifier\",\n                    LogisticRegression(\n                        C=0.1,\n                        class_weight=\"balanced\",\n                        max_iter=3000,\n                        solver=\"liblinear\",\n                        random_state=SEED\n                    )\n                )\n            ]\n        )\n\n        model.fit(\n            X_train,\n            y_train\n        )\n\n        probabilities = model.predict_proba(\n            X_valid\n        )[:, 1]\n\n        oof_predictions[\n            valid_idx,\n            target_idx\n        ] = probabilities\n\n        # AUC should be valid because\n        # we explicitly checked that each\n        # target has positives in every fold.\n        auc = roc_auc_score(\n            y_valid,\n            probabilities\n        )\n\n        fold_auc.append(\n            auc\n        )\n\n        print(\n            f\"{target:20s} \"\n            f\"AUC = {auc:.4f}\"\n        )\n\n    macro_auc = float(\n        np.mean(fold_auc)\n    )\n\n    fold_results.append(\n        {\n            \"fold\": fold,\n            \"macro_auc\": macro_auc,\n            \"n_train\": len(train_idx),\n            \"n_valid\": len(valid_idx)\n        }\n    )\n\n    print(\n        f\"\\nFold {fold} Macro AUC: \"\n        f\"{macro_auc:.4f}\"\n    )\n\n\n# ------------------------------------------------------------\n# 7. Overall OOF score\n# ------------------------------------------------------------\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"OVERALL OOF RESULTS\"\n)\n\nprint(\n    \"=\" * 70\n)\n\n\noof_auc = {}\n\nfor target_idx, target in enumerate(\n    TARGETS\n):\n\n    auc = roc_auc_score(\n        Y[:, target_idx],\n        oof_predictions[:, target_idx]\n    )\n\n    oof_auc[target] = auc\n\n\nresults_table = pd.DataFrame(\n    {\n        \"target\": TARGETS,\n        \"OOF_AUC\": [\n            oof_auc[t]\n            for t in TARGETS\n        ]\n    }\n)\n\nresults_table = results_table.sort_values(\n    \"OOF_AUC\",\n    ascending=False\n).reset_index(\n    drop=True\n)\n\ndisplay(\n    results_table\n)\n\noverall_macro_auc = float(\n    results_table[\"OOF_AUC\"].mean()\n)\n\nprint(\n    \"\\nOverall OOF Macro ROC-AUC:\",\n    f\"{overall_macro_auc:.6f}\"\n)\n\n\n# ------------------------------------------------------------\n# 8. Fold summary\n# ------------------------------------------------------------\n\nfold_results_df = pd.DataFrame(\n    fold_results\n)\n\nprint(\n    \"\\nFold-level results:\"\n)\n\ndisplay(\n    fold_results_df\n)\n\n\nprint(\n    \"\\nMean fold Macro AUC:\",\n    f\"{fold_results_df['macro_auc'].mean():.6f}\"\n)\n\nprint(\n    \"Std fold Macro AUC:\",\n    f\"{fold_results_df['macro_auc'].std(ddof=1):.6f}\"\n)\n\n\n# ------------------------------------------------------------\n# 9. Save predictions/results\n# ------------------------------------------------------------\n\noof_df = pd.DataFrame(\n    oof_predictions,\n    columns=[\n        f\"{target}_prediction\"\n        for target in TARGETS\n    ]\n)\n\noof_df.insert(\n    0,\n    \"StudyInstanceUID\",\n    metadata[\n        \"StudyInstanceUID\"\n    ].values\n)\n\noof_df[\"fold\"] = folds\n\noof_path = (\n    OUTPUT_ROOT\n    / \"oof_predictions.csv\"\n)\n\noof_df.to_csv(\n    oof_path,\n    index=False\n)\n\nresults_path = (\n    OUTPUT_ROOT\n    / \"baseline_v1_results.csv\"\n)\n\nresults_table.to_csv(\n    results_path,\n    index=False\n)\n\nfold_results_path = (\n    OUTPUT_ROOT\n    / \"fold_results.csv\"\n)\n\nfold_results_df.to_csv(\n    fold_results_path,\n    index=False\n)\n\n\n# ------------------------------------------------------------\n# 10. Final report\n# ------------------------------------------------------------\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"BASELINE V1 COMPLETE\"\n)\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"Overall OOF Macro ROC-AUC:\",\n    f\"{overall_macro_auc:.6f}\"\n)\n\nprint(\n    \"\\nSaved:\"\n)\n\nprint(\n    \"Embeddings:\",\n    embedding_path\n)\n\nprint(\n    \"OOF predictions:\",\n    oof_path\n)\n\nprint(\n    \"Target results:\",\n    results_path\n)\n\nprint(\n    \"Fold results:\",\n    fold_results_path\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T18:10:43.667708Z","iopub.execute_input":"2026-08-17T18:10:43.668222Z","iopub.status.idle":"2026-08-17T18:13:07.016147Z","shell.execute_reply.started":"2026-08-17T18:10:43.668176Z","shell.execute_reply":"2026-08-17T18:13:07.014951Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"results = pd.read_csv(\n    \"/kaggle/working/baseline_v1/baseline_v1_results.csv\"\n)\n\ndisplay(\n    results.sort_values(\n        \"OOF_AUC\",\n        ascending=False\n    )\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T18:15:32.746598Z","iopub.execute_input":"2026-08-17T18:15:32.747267Z","iopub.status.idle":"2026-08-17T18:15:32.766132Z","shell.execute_reply.started":"2026-08-17T18:15:32.747215Z","shell.execute_reply":"2026-08-17T18:15:32.764954Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# RSNA Knee Abnormality Detection\n# BASELINE V2 — Target-Aware Slice Attention\n#\n# V1:\n#   16 slice embeddings -> MEAN -> sequence feature\n#\n# V2:\n#   16 slice embeddings -> target-aware ATTENTION -> sequence feature\n#\n# The ResNet-18 image encoder remains frozen.\n# We keep the exact same 5 folds as V1.\n# ============================================================\n\nimport random\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torchvision.models as models\n\nfrom sklearn.metrics import roc_auc_score\n\n\n# ============================================================\n# 0. Configuration\n# ============================================================\n\nSEED = 42\nNUM_FOLDS = 5\n\nNUM_SEQUENCES = 4\nNUM_SLICES = 16\nFEATURE_DIM = 512\nNUM_TARGETS = 12\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nCACHE_ROOT = Path(\n    \"/kaggle/working/knee_cache\"\n)\n\nMETADATA_PATH = Path(\n    \"/kaggle/working/study_metadata_v2_folds.csv\"\n)\n\nOUTPUT_ROOT = Path(\n    \"/kaggle/working/baseline_v2\"\n)\n\nOUTPUT_ROOT.mkdir(\n    parents=True,\n    exist_ok=True\n)\n\nSEQUENCES = [\n    \"axial_f1_fs1\",\n    \"sagittal_f0_fs0\",\n    \"coronal_f1_fs1\",\n    \"sagittal_f1_fs1\",\n]\n\nTARGETS = [\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]\n\n\n# ============================================================\n# 1. Reproducibility\n# ============================================================\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(SEED)\n\nprint(\"Device:\", DEVICE)\n\n\n# ============================================================\n# 2. Load fixed metadata / folds\n# ============================================================\n\nmetadata = pd.read_csv(\n    METADATA_PATH\n).reset_index(drop=True)\n\nY = metadata[TARGETS].values.astype(\n    np.float32\n)\n\nFOLDS = metadata[\"fold\"].values.astype(\n    int\n)\n\nprint(\"\\nStudies:\", len(metadata))\nprint(\"\\nFrozen fold sizes:\")\nprint(\n    metadata[\"fold\"]\n    .value_counts()\n    .sort_index()\n)\n\n\n# ============================================================\n# 3. Frozen ResNet-18\n# ============================================================\n\nprint(\"\\nLoading pretrained ResNet-18...\")\n\nweights = models.ResNet18_Weights.DEFAULT\n\nbackbone = models.resnet18(\n    weights=weights\n)\n\nbackbone.fc = nn.Identity()\n\nbackbone = backbone.to(DEVICE)\nbackbone.eval()\n\nfor p in backbone.parameters():\n    p.requires_grad = False\n\nprint(\"Backbone feature dimension:\", FEATURE_DIM)\n\n\n# ============================================================\n# 4. Extract PER-SLICE embeddings\n#\n# Saved structure:\n#\n# [58, 4, 16, 512]\n#\n# 58 studies\n# 4 sequences\n# 16 slices\n# 512 features per slice\n# ============================================================\n\nslice_embedding_path = (\n    OUTPUT_ROOT / \"slice_embeddings.pt\"\n)\n\nif slice_embedding_path.exists():\n\n    print(\n        \"\\nLoading existing slice embeddings...\"\n    )\n\n    saved = torch.load(\n        slice_embedding_path,\n        map_location=\"cpu\",\n        weights_only=False\n    )\n\n    slice_embeddings = saved[\n        \"slice_embeddings\"\n    ]\n\n    availability = saved[\n        \"availability\"\n    ]\n\n    print(\n        \"Loaded:\",\n        tuple(slice_embeddings.shape)\n    )\n\nelse:\n\n    print(\n        \"\\nExtracting frozen per-slice embeddings...\"\n    )\n\n    cache_map = {\n        p.stem: p\n        for p in CACHE_ROOT.glob(\"*.pt\")\n    }\n\n    all_embeddings = []\n    all_availability = []\n\n    extraction_start = time.time()\n\n    with torch.inference_mode():\n\n        for study_idx, row in metadata.iterrows():\n\n            study_id = row[\n                \"StudyInstanceUID\"\n            ]\n\n            cache_file = cache_map.get(\n                study_id\n            )\n\n            if cache_file is None:\n                raise FileNotFoundError(\n                    f\"Cache missing: {study_id}\"\n                )\n\n            sample = torch.load(\n                cache_file,\n                map_location=\"cpu\",\n                weights_only=False\n            )\n\n            images = sample[\n                \"images\"\n            ].float()\n\n            study_availability = sample[\n                \"availability\"\n            ].float()\n\n            sequence_features = []\n\n            for seq_idx in range(\n                NUM_SEQUENCES\n            ):\n\n                if study_availability[\n                    seq_idx\n                ].item() == 0:\n\n                    sequence_features.append(\n                        torch.zeros(\n                            NUM_SLICES,\n                            FEATURE_DIM,\n                            dtype=torch.float32\n                        )\n                    )\n\n                    continue\n\n                sequence_images = images[\n                    seq_idx\n                ]\n\n                # [16,1,224,224] -> [16,3,224,224]\n                sequence_images = (\n                    sequence_images\n                    .repeat(1, 3, 1, 1)\n                )\n\n                # ImageNet normalization\n                mean = torch.tensor(\n                    [0.485, 0.456, 0.406],\n                    dtype=torch.float32\n                ).view(1, 3, 1, 1)\n\n                std = torch.tensor(\n                    [0.229, 0.224, 0.225],\n                    dtype=torch.float32\n                ).view(1, 3, 1, 1)\n\n                sequence_images = (\n                    sequence_images - mean\n                ) / std\n\n                sequence_images = (\n                    sequence_images\n                    .to(DEVICE)\n                )\n\n                features = backbone(\n                    sequence_images\n                )\n\n                # [16,512]\n                sequence_features.append(\n                    features.cpu()\n                )\n\n            # [4,16,512]\n            all_embeddings.append(\n                torch.stack(\n                    sequence_features\n                )\n            )\n\n            all_availability.append(\n                study_availability\n            )\n\n            if (\n                (study_idx + 1) % 10 == 0\n                or study_idx == len(metadata) - 1\n            ):\n\n                elapsed = (\n                    time.time()\n                    - extraction_start\n                )\n\n                print(\n                    f\"Processed \"\n                    f\"{study_idx + 1}/\"\n                    f\"{len(metadata)} \"\n                    f\"| {elapsed:.1f}s\"\n                )\n\n    slice_embeddings = torch.stack(\n        all_embeddings\n    )\n\n    availability = torch.stack(\n        all_availability\n    )\n\n    torch.save(\n        {\n            \"slice_embeddings\":\n                slice_embeddings,\n            \"availability\":\n                availability,\n        },\n        slice_embedding_path\n    )\n\n    print(\n        \"\\nSaved:\",\n        slice_embedding_path\n    )\n\nprint(\n    \"Slice embedding shape:\",\n    tuple(slice_embeddings.shape)\n)\n\n\n# ============================================================\n# 5. Target-Aware Attention Model\n# ============================================================\n\nclass TargetAwareAttention(nn.Module):\n    \"\"\"\n    For each target, learn a query vector.\n\n    For each sequence:\n\n        16 slice features\n              ↓\n        similarity with target query\n              ↓\n        softmax attention\n              ↓\n        weighted slice representation\n\n    This lets ACL, MCL, OA, etc. focus on\n    different slices.\n    \"\"\"\n\n    def __init__(\n        self,\n        num_targets=12,\n        num_sequences=4,\n        feature_dim=512,\n    ):\n        super().__init__()\n\n        self.num_targets = num_targets\n        self.num_sequences = num_sequences\n        self.feature_dim = feature_dim\n\n        # Small target-specific queries.\n        self.query = nn.Parameter(\n            torch.randn(\n                num_targets,\n                feature_dim\n            ) * 0.02\n        )\n\n        # Temperature prevents extremely sharp\n        # attention at initialization.\n        self.temperature = nn.Parameter(\n            torch.ones(num_targets)\n        )\n\n        # One lightweight target-specific\n        # classifier per target.\n        self.heads = nn.ModuleList(\n            [\n                nn.Sequential(\n                    nn.Linear(\n                        num_sequences * feature_dim\n                        + num_sequences,\n                        128\n                    ),\n                    nn.LayerNorm(128),\n                    nn.GELU(),\n                    nn.Dropout(0.25),\n                    nn.Linear(128, 1)\n                )\n                for _ in range(num_targets)\n            ]\n        )\n\n    def forward(\n        self,\n        x,\n        availability\n    ):\n        \"\"\"\n        x:\n            [B, S, N, D]\n\n        availability:\n            [B, S]\n\n        returns:\n            logits [B, T]\n        \"\"\"\n\n        B, S, N, D = x.shape\n\n        target_outputs = []\n\n        # ----------------------------------------------------\n        # Each target gets its own attention mechanism.\n        # ----------------------------------------------------\n\n        for target_idx in range(\n            self.num_targets\n        ):\n\n            query = self.query[\n                target_idx\n            ]\n\n            temperature = (\n                self.temperature[\n                    target_idx\n                ].abs()\n                + 1e-4\n            )\n\n            # ------------------------------------------------\n            # x:\n            # [B,S,N,D]\n            #\n            # query:\n            # [D]\n            #\n            # scores:\n            # [B,S,N]\n            # ------------------------------------------------\n\n            scores = torch.einsum(\n                \"bsnd,d->bsn\",\n                x,\n                query\n            )\n\n            scores = (\n                scores\n                / temperature\n            )\n\n            # ------------------------------------------------\n            # Mask completely missing sequences.\n            # ------------------------------------------------\n\n            missing_mask = (\n                availability == 0\n            )\n\n            scores = scores.masked_fill(\n                missing_mask.unsqueeze(-1),\n                -1e4\n            )\n\n            # ------------------------------------------------\n            # Attention over slices\n            # ------------------------------------------------\n\n            attention = torch.softmax(\n                scores,\n                dim=2\n            )\n\n            # ------------------------------------------------\n            # Weighted slice pooling\n            # ------------------------------------------------\n\n            pooled = torch.sum(\n                x * attention.unsqueeze(-1),\n                dim=2\n            )\n\n            # Remove representations from\n            # missing sequences explicitly.\n            pooled = (\n                pooled\n                * availability.unsqueeze(-1)\n            )\n\n            # [B,S,D] -> [B,S*D]\n            pooled = pooled.reshape(\n                B,\n                S * D\n            )\n\n            fused = torch.cat(\n                [\n                    pooled,\n                    availability\n                ],\n                dim=1\n            )\n\n            logits = self.heads[\n                target_idx\n            ](\n                fused\n            )\n\n            target_outputs.append(\n                logits\n            )\n\n        return torch.cat(\n            target_outputs,\n            dim=1\n        )\n\n\n# ============================================================\n# 6. Training helpers\n# ============================================================\n\ndef evaluate_model(\n    model,\n    X_valid,\n    A_valid,\n    y_valid\n):\n\n    model.eval()\n\n    with torch.no_grad():\n\n        logits = model(\n            X_valid,\n            A_valid\n        )\n\n        probabilities = (\n            torch.sigmoid(\n                logits\n            )\n            .cpu()\n            .numpy()\n        )\n\n    y_true = (\n        y_valid\n        .cpu()\n        .numpy()\n    )\n\n    aucs = []\n\n    for target_idx in range(\n        NUM_TARGETS\n    ):\n\n        auc = roc_auc_score(\n            y_true[:, target_idx],\n            probabilities[:, target_idx]\n        )\n\n        aucs.append(auc)\n\n    return (\n        np.asarray(aucs),\n        probabilities\n    )\n\n\n# ============================================================\n# 7. Five-fold training\n# ============================================================\n\noof_predictions = np.zeros(\n    (\n        len(metadata),\n        NUM_TARGETS\n    ),\n    dtype=np.float32\n)\n\nfold_summaries = []\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"BASELINE V2 — TARGET-AWARE SLICE ATTENTION\"\n)\n\nprint(\n    \"=\" * 70\n)\n\n\nfor fold in range(\n    NUM_FOLDS\n):\n\n    print(\n        f\"\\n{'=' * 70}\"\n    )\n\n    print(\n        f\"FOLD {fold}\"\n    )\n\n    print(\n        f\"{'=' * 70}\"\n    )\n\n    train_idx = np.where(\n        FOLDS != fold\n    )[0]\n\n    valid_idx = np.where(\n        FOLDS == fold\n    )[0]\n\n    X_train = (\n        slice_embeddings[\n            train_idx\n        ]\n        .to(DEVICE)\n    )\n\n    A_train = (\n        availability[\n            train_idx\n        ]\n        .to(DEVICE)\n    )\n\n    y_train = torch.tensor(\n        Y[train_idx],\n        dtype=torch.float32,\n        device=DEVICE\n    )\n\n    X_valid = (\n        slice_embeddings[\n            valid_idx\n        ]\n        .to(DEVICE)\n    )\n\n    A_valid = (\n        availability[\n            valid_idx\n        ]\n        .to(DEVICE)\n    )\n\n    y_valid = torch.tensor(\n        Y[valid_idx],\n        dtype=torch.float32,\n        device=DEVICE\n    )\n\n    model = TargetAwareAttention(\n        num_targets=NUM_TARGETS,\n        num_sequences=NUM_SEQUENCES,\n        feature_dim=FEATURE_DIM\n    ).to(DEVICE)\n\n    # --------------------------------------------------------\n    # Positive-class weighting computed ONLY from training fold.\n    # --------------------------------------------------------\n\n    pos = (\n        Y[train_idx]\n        .sum(axis=0)\n    )\n\n    neg = (\n        len(train_idx)\n        - pos\n    )\n\n    pos_weight = torch.tensor(\n        neg / np.maximum(pos, 1),\n        dtype=torch.float32,\n        device=DEVICE\n    )\n\n    criterion = nn.BCEWithLogitsLoss(\n        pos_weight=pos_weight\n    )\n\n    optimizer = torch.optim.AdamW(\n        model.parameters(),\n        lr=2e-3,\n        weight_decay=1e-3\n    )\n\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n        optimizer,\n        T_max=120\n    )\n\n    best_auc = -np.inf\n    best_state = None\n    patience = 25\n    epochs_without_improvement = 0\n\n    for epoch in range(1, 121):\n\n        model.train()\n\n        optimizer.zero_grad()\n\n        logits = model(\n            X_train,\n            A_train\n        )\n\n        loss = criterion(\n            logits,\n            y_train\n        )\n\n        loss.backward()\n\n        torch.nn.utils.clip_grad_norm_(\n            model.parameters(),\n            max_norm=1.0\n        )\n\n        optimizer.step()\n        scheduler.step()\n\n        # ----------------------------------------------------\n        # Validation every epoch.\n        # Dataset is tiny, so this is cheap.\n        # ----------------------------------------------------\n\n        valid_aucs, _ = evaluate_model(\n            model,\n            X_valid,\n            A_valid,\n            y_valid\n        )\n\n        macro_auc = float(\n            np.mean(valid_aucs)\n        )\n\n        if macro_auc > best_auc:\n\n            best_auc = macro_auc\n\n            best_state = {\n                k: v.detach()\n                .cpu()\n                .clone()\n                for k, v in model.state_dict().items()\n            }\n\n            epochs_without_improvement = 0\n\n        else:\n\n            epochs_without_improvement += 1\n\n        if (\n            epoch == 1\n            or epoch % 10 == 0\n            or epoch == 120\n        ):\n\n            print(\n                f\"Epoch {epoch:03d} \"\n                f\"| loss {loss.item():.4f} \"\n                f\"| val macro AUC \"\n                f\"{macro_auc:.4f} \"\n                f\"| best \"\n                f\"{best_auc:.4f}\"\n            )\n\n        if (\n            epochs_without_improvement\n            >= patience\n        ):\n            print(\n                f\"Early stopping at epoch \"\n                f\"{epoch}\"\n            )\n            break\n\n    # --------------------------------------------------------\n    # Restore best epoch.\n    # --------------------------------------------------------\n\n    model.load_state_dict(\n        best_state\n    )\n\n    final_aucs, final_predictions = (\n        evaluate_model(\n            model,\n            X_valid,\n            A_valid,\n            y_valid\n        )\n    )\n\n    oof_predictions[\n        valid_idx\n    ] = final_predictions\n\n    fold_macro = float(\n        np.mean(final_aucs)\n    )\n\n    fold_summaries.append(\n        {\n            \"fold\": fold,\n            \"n_train\": len(train_idx),\n            \"n_valid\": len(valid_idx),\n            \"best_macro_auc\": fold_macro\n        }\n    )\n\n    print(\n        f\"\\nFold {fold} FINAL macro AUC:\"\n        f\" {fold_macro:.6f}\"\n    )\n\n    # Save fold model\n    torch.save(\n        {\n            \"model_state_dict\":\n                model.state_dict(),\n            \"fold\": fold,\n            \"best_macro_auc\":\n                fold_macro\n        },\n        OUTPUT_ROOT\n        / f\"fold_{fold}_model.pt\"\n    )\n\n\n# ============================================================\n# 8. Overall OOF evaluation\n# ============================================================\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"BASELINE V2 — OOF RESULTS\"\n)\n\nprint(\n    \"=\" * 70\n)\n\noverall_aucs = []\n\nfor target_idx, target in enumerate(\n    TARGETS\n):\n\n    auc = roc_auc_score(\n        Y[:, target_idx],\n        oof_predictions[:, target_idx]\n    )\n\n    overall_aucs.append(\n        auc\n    )\n\n\nresults = pd.DataFrame(\n    {\n        \"target\": TARGETS,\n        \"OOF_AUC\": overall_aucs\n    }\n).sort_values(\n    \"OOF_AUC\",\n    ascending=False\n).reset_index(\n    drop=True\n)\n\ndisplay(\n    results\n)\n\noverall_macro_auc = float(\n    np.mean(overall_aucs)\n)\n\nprint(\n    \"\\nOverall OOF Macro ROC-AUC:\",\n    f\"{overall_macro_auc:.6f}\"\n)\n\n\n# ============================================================\n# 9. Fold summary\n# ============================================================\n\nfold_results = pd.DataFrame(\n    fold_summaries\n)\n\nprint(\n    \"\\nFold summary:\"\n)\n\ndisplay(\n    fold_results\n)\n\nprint(\n    \"\\nMean fold Macro AUC:\",\n    f\"{fold_results['best_macro_auc'].mean():.6f}\"\n)\n\nprint(\n    \"Std fold Macro AUC:\",\n    f\"{fold_results['best_macro_auc'].std(ddof=1):.6f}\"\n)\n\n\n# ============================================================\n# 10. Compare against V1\n# ============================================================\n\nV1_AUC = 0.626641\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"V1 vs V2\"\n)\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    f\"V1 Macro AUC: \"\n    f\"{V1_AUC:.6f}\"\n)\n\nprint(\n    f\"V2 Macro AUC: \"\n    f\"{overall_macro_auc:.6f}\"\n)\n\nprint(\n    f\"Absolute improvement: \"\n    f\"{overall_macro_auc - V1_AUC:+.6f}\"\n)\n\n\n# ============================================================\n# 11. Save everything\n# ============================================================\n\noof_output = pd.DataFrame(\n    oof_predictions,\n    columns=[\n        f\"{target}_prediction\"\n        for target in TARGETS\n    ]\n)\n\noof_output.insert(\n    0,\n    \"StudyInstanceUID\",\n    metadata[\n        \"StudyInstanceUID\"\n    ].values\n)\n\noof_output[\"fold\"] = FOLDS\n\noof_output.to_csv(\n    OUTPUT_ROOT\n    / \"oof_predictions.csv\",\n    index=False\n)\n\nresults.to_csv(\n    OUTPUT_ROOT\n    / \"baseline_v2_results.csv\",\n    index=False\n)\n\nfold_results.to_csv(\n    OUTPUT_ROOT\n    / \"fold_results.csv\",\n    index=False\n)\n\nprint(\n    \"\\nSaved V2 results to:\"\n)\n\nprint(\n    OUTPUT_ROOT\n)\n\nprint(\n    \"\\nBASELINE V2 COMPLETE.\"\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T18:17:31.669858Z","iopub.execute_input":"2026-08-17T18:17:31.670822Z","iopub.status.idle":"2026-08-17T18:20:23.415656Z","shell.execute_reply.started":"2026-08-17T18:17:31.670784Z","shell.execute_reply":"2026-08-17T18:20:23.414674Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# RSNA KNEE ABNORMALITY DETECTION\n# FINAL V1 SUBMISSION PIPELINE\n#\n# Uses the BEST VALIDATED model so far:\n#   Frozen ResNet18 embeddings\n#   + mean slice pooling\n#   + 4 sequence fusion\n#   + availability mask\n#   + one LogisticRegression classifier per target\n#\n# IMPORTANT:\n# - Uses the SAME preprocessing as V1\n# - Uses the SAME four sequence types\n# - Trains final classifiers on all 58 labelled studies\n# - Generates predictions for ALL test studies\n# - Writes submission.csv\n# ============================================================\n\nimport time\nimport random\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport pydicom\nimport torchvision.models as models\n\nfrom sklearn.pipeline import Pipeline\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\n\n\n# ============================================================\n# 1. Configuration\n# ============================================================\n\nSEED = 42\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(SEED)\n\nDEVICE = torch.device(\n    \"cuda\" if torch.cuda.is_available() else \"cpu\"\n)\n\nROOT = Path(\n    \"/kaggle/input/competitions/\"\n    \"rsna-knee-abnormality-detection\"\n)\n\nTRAIN_PATH = ROOT / \"train.csv\"\nTEST_PATH = ROOT / \"test.csv\"\nTRAIN_SERIES_PATH = ROOT / \"train_series.csv\"\nTEST_SERIES_PATH = ROOT / \"test_series.csv\"\nSAMPLE_PATH = ROOT / \"sample_submission.csv\"\n\nOUTPUT_DIR = Path(\n    \"/kaggle/working/final_submission_v1\"\n)\nOUTPUT_DIR.mkdir(\n    parents=True,\n    exist_ok=True\n)\n\nTARGETS = [\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]\n\nSEQUENCE_TYPES = [\n    (\"Axial\", 1, 1, \"axial_f1_fs1\"),\n    (\"Sagittal\", 0, 0, \"sagittal_f0_fs0\"),\n    (\"Coronal\", 1, 1, \"coronal_f1_fs1\"),\n    (\"Sagittal\", 1, 1, \"sagittal_f1_fs1\"),\n]\n\nNUM_SLICES = 16\nIMAGE_SIZE = 224\nFEATURE_DIM = 512\n\nprint(\"Device:\", DEVICE)\n\n\n# ============================================================\n# 2. Load competition tables\n# ============================================================\n\ntrain = pd.read_csv(TRAIN_PATH)\ntest = pd.read_csv(TEST_PATH)\ntrain_series = pd.read_csv(TRAIN_SERIES_PATH)\ntest_series = pd.read_csv(TEST_SERIES_PATH)\nsample_submission = pd.read_csv(SAMPLE_PATH)\n\nprint(\"\\nTrain shape:\", train.shape)\nprint(\"Test shape:\", test.shape)\nprint(\"Train series shape:\", train_series.shape)\nprint(\"Test series shape:\", test_series.shape)\nprint(\"Sample submission shape:\", sample_submission.shape)\n\n\n# ============================================================\n# 3. Select the 58 genuinely labelled studies\n# ============================================================\n\nlabelled_train = train[\n    train[TARGETS].notna().all(axis=1)\n].copy()\n\nprint(\n    \"\\nFully labelled studies:\",\n    len(labelled_train)\n)\n\nif len(labelled_train) != 58:\n    raise RuntimeError(\n        f\"Expected 58 labelled studies, \"\n        f\"found {len(labelled_train)}.\"\n    )\n\n\n# ============================================================\n# 4. DICOM utilities\n# ============================================================\n\ndef load_dicom_volume(series_path):\n    \"\"\"\n    Load one DICOM series and order slices using\n    ImageOrientationPatient + ImagePositionPatient.\n    \"\"\"\n\n    series_path = Path(series_path)\n\n    files = list(series_path.glob(\"*.dcm\"))\n\n    if not files:\n        raise FileNotFoundError(\n            f\"No DICOM files found in {series_path}\"\n        )\n\n    records = []\n\n    for file_path in files:\n\n        ds = pydicom.dcmread(\n            file_path,\n            stop_before_pixels=False\n        )\n\n        position = np.asarray(\n            ds.ImagePositionPatient,\n            dtype=np.float64\n        )\n\n        orientation = np.asarray(\n            ds.ImageOrientationPatient,\n            dtype=np.float64\n        )\n\n        row_direction = orientation[:3]\n        column_direction = orientation[3:]\n\n        normal = np.cross(\n            row_direction,\n            column_direction\n        )\n\n        physical_position = np.dot(\n            position,\n            normal\n        )\n\n        records.append(\n            (\n                physical_position,\n                file_path\n            )\n        )\n\n    records.sort(\n        key=lambda x: x[0]\n    )\n\n    images = []\n\n    for _, file_path in records:\n\n        ds = pydicom.dcmread(\n            file_path,\n            stop_before_pixels=False\n        )\n\n        image = ds.pixel_array.astype(\n            np.float32\n        )\n\n        images.append(image)\n\n    return np.stack(\n        images,\n        axis=0\n    )\n\n\ndef sample_slices(\n    volume,\n    num_slices=NUM_SLICES\n):\n\n    n = volume.shape[0]\n\n    indices = np.linspace(\n        0,\n        n - 1,\n        num_slices\n    ).round().astype(int)\n\n    return volume[indices]\n\n\ndef normalize_slice(image):\n\n    image = image.astype(\n        np.float32\n    )\n\n    low = np.percentile(\n        image,\n        1\n    )\n\n    high = np.percentile(\n        image,\n        99\n    )\n\n    if high <= low:\n        return np.zeros_like(\n            image,\n            dtype=np.float32\n        )\n\n    image = np.clip(\n        image,\n        low,\n        high\n    )\n\n    image = (\n        image - low\n    ) / (\n        high - low\n    )\n\n    return image.astype(\n        np.float32\n    )\n\n\ndef preprocess_volume(volume):\n\n    volume = sample_slices(\n        volume,\n        NUM_SLICES\n    )\n\n    processed = []\n\n    for image in volume:\n\n        image = normalize_slice(\n            image\n        )\n\n        tensor = torch.from_numpy(\n            image\n        ).unsqueeze(0).unsqueeze(0)\n\n        tensor = torch.nn.functional.interpolate(\n            tensor,\n            size=(\n                IMAGE_SIZE,\n                IMAGE_SIZE\n            ),\n            mode=\"bilinear\",\n            align_corners=False\n        )\n\n        processed.append(\n            tensor.squeeze(0)\n        )\n\n    return torch.stack(\n        processed\n    )\n\n\n# ============================================================\n# 5. Build deterministic series selection\n# ============================================================\n\ndef build_selected_series_table(\n    series_df,\n    study_ids\n):\n\n    rows = []\n\n    study_id_set = set(\n        study_ids\n    )\n\n    for plane, fluid, fat, name in SEQUENCE_TYPES:\n\n        subset = series_df[\n            (series_df[\"Anatomical_Plane\"] == plane) &\n            (series_df[\"Fluid_Sensitive\"] == fluid) &\n            (series_df[\"Fat_Suppression\"] == fat) &\n            (series_df[\"StudyInstanceUID\"].isin(study_id_set))\n        ].copy()\n\n        for study_id, group in subset.groupby(\n            \"StudyInstanceUID\"\n        ):\n\n            best_series = None\n            best_count = -1\n\n            for _, series_row in group.iterrows():\n\n                series_id = series_row[\n                    \"SeriesInstanceUID\"\n                ]\n\n                series_path = (\n                    ROOT\n                    / (\n                        \"test_series\"\n                        if series_df is test_series\n                        else \"train_series\"\n                    )\n                    / str(study_id)\n                    / str(series_id)\n                )\n\n                n_slices = len(\n                    list(\n                        series_path.glob(\n                            \"*.dcm\"\n                        )\n                    )\n                )\n\n                if n_slices > best_count:\n\n                    best_count = n_slices\n                    best_series = series_id\n\n            if best_series is not None:\n\n                rows.append(\n                    {\n                        \"StudyInstanceUID\":\n                            study_id,\n                        \"sequence_type\":\n                            name,\n                        \"SeriesInstanceUID\":\n                            best_series,\n                        \"num_slices\":\n                            best_count\n                    }\n                )\n\n    selected = pd.DataFrame(rows)\n\n    pivot = selected.pivot(\n        index=\"StudyInstanceUID\",\n        columns=\"sequence_type\",\n        values=\"SeriesInstanceUID\"\n    ).reset_index()\n\n    for _, _, _, name in SEQUENCE_TYPES:\n\n        if name not in pivot.columns:\n            pivot[name] = np.nan\n\n    return pivot[\n        [\n            \"StudyInstanceUID\",\n            \"axial_f1_fs1\",\n            \"sagittal_f0_fs0\",\n            \"coronal_f1_fs1\",\n            \"sagittal_f1_fs1\"\n        ]\n    ]\n\n\nprint(\n    \"\\nBuilding test study → series mapping...\"\n)\n\ntest_series_map = build_selected_series_table(\n    test_series,\n    test[\"StudyInstanceUID\"].tolist()\n)\n\nprint(\n    \"Test studies with mappings:\",\n    len(test_series_map)\n)\n\ndisplay(\n    test_series_map.head()\n)\n\n\n# ============================================================\n# 6. Load frozen ResNet-18 feature extractor\n# ============================================================\n\nprint(\n    \"\\nLoading pretrained ResNet-18...\"\n)\n\nweights = models.ResNet18_Weights.DEFAULT\n\nbackbone = models.resnet18(\n    weights=weights\n)\n\nbackbone.fc = nn.Identity()\n\nbackbone = backbone.to(\n    DEVICE\n)\n\nbackbone.eval()\n\nfor parameter in backbone.parameters():\n    parameter.requires_grad = False\n\n\n# ImageNet normalization\nimagenet_mean = torch.tensor(\n    [0.485, 0.456, 0.406],\n    dtype=torch.float32,\n    device=DEVICE\n).view(\n    1, 3, 1, 1\n)\n\nimagenet_std = torch.tensor(\n    [0.229, 0.224, 0.225],\n    dtype=torch.float32,\n    device=DEVICE\n).view(\n    1, 3, 1, 1\n)\n\n\n# ============================================================\n# 7. Extract final TRAIN embeddings\n#\n# We use the SAME representation as V1:\n#\n# 4 sequences × mean of 16 slice embeddings\n# + 4 availability indicators\n#\n# Final dimension = 2052\n# ============================================================\n\nprint(\n    \"\\nExtracting final training embeddings...\"\n)\n\ntrain_series_map = build_selected_series_table(\n    train_series,\n    labelled_train[\"StudyInstanceUID\"].tolist()\n)\n\ntrain_embedding_rows = []\n\nstart = time.time()\n\nwith torch.inference_mode():\n\n    for count, study_id in enumerate(\n        labelled_train[\"StudyInstanceUID\"]\n    ):\n\n        mapping = train_series_map[\n            train_series_map[\"StudyInstanceUID\"]\n            == study_id\n        ]\n\n        if len(mapping) != 1:\n            raise RuntimeError(\n                f\"Missing/duplicate mapping for \"\n                f\"train study {study_id}\"\n            )\n\n        mapping = mapping.iloc[0]\n\n        sequence_features = []\n        availability = []\n\n        for sequence in [\n            \"axial_f1_fs1\",\n            \"sagittal_f0_fs0\",\n            \"coronal_f1_fs1\",\n            \"sagittal_f1_fs1\"\n        ]:\n\n            series_id = mapping[\n                sequence\n            ]\n\n            if pd.isna(series_id):\n\n                sequence_features.append(\n                    torch.zeros(\n                        FEATURE_DIM,\n                        dtype=torch.float32\n                    )\n                )\n\n                availability.append(0.0)\n                continue\n\n            series_path = (\n                ROOT\n                / \"train_series\"\n                / str(study_id)\n                / str(series_id)\n            )\n\n            volume = load_dicom_volume(\n                series_path\n            )\n\n            images = preprocess_volume(\n                volume\n            )\n\n            # [16,1,224,224] → RGB\n            images = images.repeat(\n                1, 3, 1, 1\n            )\n\n            images = (\n                images\n                .to(DEVICE)\n            )\n\n            images = (\n                images - imagenet_mean\n            ) / imagenet_std\n\n            slice_features = backbone(\n                images\n            )\n\n            study_feature = (\n                slice_features\n                .mean(dim=0)\n                .cpu()\n            )\n\n            sequence_features.append(\n                study_feature\n            )\n\n            availability.append(1.0)\n\n        feature_vector = torch.cat(\n            sequence_features\n            + [\n                torch.tensor(\n                    availability,\n                    dtype=torch.float32\n                )\n            ]\n        )\n\n        train_embedding_rows.append(\n            feature_vector.numpy()\n        )\n\n        if (\n            (count + 1) % 10 == 0\n            or count == len(labelled_train) - 1\n        ):\n\n            elapsed = (\n                time.time() - start\n            )\n\n            print(\n                f\"Train embeddings: \"\n                f\"{count + 1}/\"\n                f\"{len(labelled_train)} \"\n                f\"| {elapsed:.1f}s\"\n            )\n\nX_train = np.stack(\n    train_embedding_rows\n).astype(\n    np.float32\n)\n\nY_train = labelled_train[\n    TARGETS\n].values.astype(\n    np.float32\n)\n\nprint(\n    \"\\nFinal train embedding shape:\",\n    X_train.shape\n)\n\n\n# ============================================================\n# 8. Train FINAL classifiers on ALL 58 labelled studies\n# ============================================================\n\nprint(\n    \"\\nTraining final 12 target classifiers...\"\n)\n\nfinal_models = []\n\nfor target_idx, target in enumerate(\n    TARGETS\n):\n\n    y = Y_train[\n        :,\n        target_idx\n    ]\n\n    model = Pipeline(\n        [\n            (\n                \"scaler\",\n                StandardScaler()\n            ),\n            (\n                \"classifier\",\n                LogisticRegression(\n                    C=0.1,\n                    class_weight=\"balanced\",\n                    max_iter=3000,\n                    solver=\"liblinear\",\n                    random_state=SEED\n                )\n            )\n        ]\n    )\n\n    model.fit(\n        X_train,\n        y\n    )\n\n    final_models.append(\n        model\n    )\n\n    print(\n        f\"Trained: {target}\"\n    )\n\n\n# ============================================================\n# 9. Extract TEST embeddings\n# ============================================================\n\nprint(\n    \"\\nExtracting test embeddings...\"\n)\n\ntest_embedding_rows = []\ntest_ids = test[\n    \"StudyInstanceUID\"\n].tolist()\n\nstart = time.time()\n\nwith torch.inference_mode():\n\n    for count, study_id in enumerate(\n        test_ids\n    ):\n\n        mapping = test_series_map[\n            test_series_map[\"StudyInstanceUID\"]\n            == study_id\n        ]\n\n        if len(mapping) != 1:\n            raise RuntimeError(\n                f\"Missing/duplicate mapping \"\n                f\"for test study {study_id}\"\n            )\n\n        mapping = mapping.iloc[0]\n\n        sequence_features = []\n        availability = []\n\n        for sequence in [\n            \"axial_f1_fs1\",\n            \"sagittal_f0_fs0\",\n            \"coronal_f1_fs1\",\n            \"sagittal_f1_fs1\"\n        ]:\n\n            series_id = mapping[\n                sequence\n            ]\n\n            if pd.isna(series_id):\n\n                sequence_features.append(\n                    torch.zeros(\n                        FEATURE_DIM,\n                        dtype=torch.float32\n                    )\n                )\n\n                availability.append(0.0)\n\n                continue\n\n            series_path = (\n                ROOT\n                / \"test_series\"\n                / str(study_id)\n                / str(series_id)\n            )\n\n            if not series_path.exists():\n\n                raise FileNotFoundError(\n                    f\"Missing test series:\\n\"\n                    f\"{series_path}\"\n                )\n\n            volume = load_dicom_volume(\n                series_path\n            )\n\n            images = preprocess_volume(\n                volume\n            )\n\n            images = images.repeat(\n                1, 3, 1, 1\n            )\n\n            images = images.to(\n                DEVICE\n            )\n\n            images = (\n                images - imagenet_mean\n            ) / imagenet_std\n\n            slice_features = backbone(\n                images\n            )\n\n            study_feature = (\n                slice_features\n                .mean(dim=0)\n                .cpu()\n            )\n\n            sequence_features.append(\n                study_feature\n            )\n\n            availability.append(\n                1.0\n            )\n\n        feature_vector = torch.cat(\n            sequence_features\n            + [\n                torch.tensor(\n                    availability,\n                    dtype=torch.float32\n                )\n            ]\n        )\n\n        test_embedding_rows.append(\n            feature_vector.numpy()\n        )\n\n        if (\n            (count + 1) % 25 == 0\n            or count == len(test_ids) - 1\n        ):\n\n            elapsed = (\n                time.time() - start\n            )\n\n            print(\n                f\"Test embeddings: \"\n                f\"{count + 1}/\"\n                f\"{len(test_ids)} \"\n                f\"| {elapsed:.1f}s\"\n            )\n\nX_test = np.stack(\n    test_embedding_rows\n).astype(\n    np.float32\n)\n\nprint(\n    \"\\nFinal test embedding shape:\",\n    X_test.shape\n)\n\n\n# ============================================================\n# 10. Generate FINAL predictions\n# ============================================================\n\npredictions = {}\n\nfor target_idx, target in enumerate(\n    TARGETS\n):\n\n    probabilities = (\n        final_models[target_idx]\n        .predict_proba(\n            X_test\n        )[:, 1]\n    )\n\n    probabilities = np.clip(\n        probabilities,\n        1e-6,\n        1 - 1e-6\n    )\n\n    predictions[target] = probabilities\n\n\nprediction_df = pd.DataFrame(\n    predictions\n)\n\nprediction_df.insert(\n    0,\n    \"StudyInstanceUID\",\n    test_ids\n)\n\n\n# ============================================================\n# 11. Force exact sample-submission column order\n# ============================================================\n\nexpected_columns = list(\n    sample_submission.columns\n)\n\nmissing_columns = [\n    col\n    for col in expected_columns\n    if col not in prediction_df.columns\n]\n\nextra_columns = [\n    col\n    for col in prediction_df.columns\n    if col not in expected_columns\n]\n\nif missing_columns:\n    raise RuntimeError(\n        f\"Missing submission columns: \"\n        f\"{missing_columns}\"\n    )\n\nif extra_columns:\n    print(\n        \"Extra columns removed:\",\n        extra_columns\n    )\n\nsubmission = prediction_df[\n    expected_columns\n].copy()\n\n\n# ============================================================\n# 12. Final validation\n# ============================================================\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"SUBMISSION VALIDATION\"\n)\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"Rows:\",\n    len(submission)\n)\n\nprint(\n    \"Expected rows:\",\n    len(sample_submission)\n)\n\nprint(\n    \"Columns:\"\n)\n\nprint(\n    submission.columns.tolist()\n)\n\nprint(\n    \"\\nMissing values:\",\n    int(\n        submission.isna().sum().sum()\n    )\n)\n\nprediction_columns = [\n    col\n    for col in expected_columns\n    if col != \"StudyInstanceUID\"\n]\n\nprint(\n    \"Minimum prediction:\",\n    float(\n        submission[\n            prediction_columns\n        ].min().min()\n    )\n)\n\nprint(\n    \"Maximum prediction:\",\n    float(\n        submission[\n            prediction_columns\n        ].max().max()\n    )\n)\n\nassert len(submission) == len(\n    sample_submission\n)\n\nassert submission.columns.tolist() == (\n    expected_columns\n)\n\nassert (\n    submission.isna().sum().sum()\n    == 0\n)\n\nassert (\n    submission[prediction_columns]\n    .applymap(\n        lambda x: 0 <= x <= 1\n    )\n    .all()\n    .all()\n)\n\nassert (\n    submission[\"StudyInstanceUID\"]\n    .tolist()\n    ==\n    sample_submission[\n        \"StudyInstanceUID\"\n    ].tolist()\n)\n\n# Save\nsubmission_path = (\n    OUTPUT_DIR / \"submission.csv\"\n)\n\nsubmission.to_csv(\n    submission_path,\n    index=False\n)\n\nprint(\n    \"\\n\"\n    + \"=\" * 70\n)\n\nprint(\n    \"SUBMISSION READY\"\n)\n\nprint(\n    \"=\" * 70\n)\n\nprint(\n    \"File:\",\n    submission_path\n)\n\nprint(\n    \"\\nPreview:\"\n)\n\ndisplay(\n    submission.head()\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T18:34:30.241674Z","iopub.execute_input":"2026-08-17T18:34:30.242743Z","iopub.status.idle":"2026-08-17T18:38:02.833733Z","shell.execute_reply.started":"2026-08-17T18:34:30.242708Z","shell.execute_reply":"2026-08-17T18:38:02.83225Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# FINAL KAGGLE SUBMISSION FILE\n# Save directly in /kaggle/working/\n\nsubmission_path = \"/kaggle/working/submission.csv\"\n\nsubmission.to_csv(\n    submission_path,\n    index=False\n)\n\nprint(\"FINAL FILE:\", submission_path)\nprint(\"EXISTS:\", os.path.exists(submission_path))\nprint(\"SIZE:\", os.path.getsize(submission_path), \"bytes\")\n\nprint(\"\\nSubmission preview:\")\ndisplay(submission.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-17T19:03:52.92279Z","iopub.execute_input":"2026-08-17T19:03:52.923823Z","iopub.status.idle":"2026-08-17T19:03:52.959549Z","shell.execute_reply.started":"2026-08-17T19:03:52.923788Z","shell.execute_reply":"2026-08-17T19:03:52.958473Z"}},"outputs":[],"execution_count":null}]}