{"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":"GPU","dataSources":[],"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# RSNA Knee Abnormality Detection — Baseline + CNN Pipeline\n\nPublic reference notebook: preprocessing, a crude logistic-regression baseline, and a transfer-learning CNN with multilabel-stratified cross-validation.\n\n**Note on disabled cells:** exploratory cells that would print real radiology report text, real label values, or display actual patient MRI images have been commented out — the logic is kept for reference, but running this notebook as-is will not reproduce or display any raw Competition Data. Everything needed to train and generate a submission is active.","metadata":{}},{"cell_type":"markdown","source":"## 0. Setup","metadata":{}},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport os\n\nbase = '/kaggle/input/competitions/rsna-knee-abnormality-detection'\n\ntrain = pd.read_csv(f'{base}/train.csv')\ntrain_series = pd.read_csv(f'{base}/train_series.csv')\ntest = pd.read_csv(f'{base}/test.csv')\ntest_series = pd.read_csv(f'{base}/test_series.csv')\nsample_sub = pd.read_csv(f'{base}/sample_submission.csv')\n\nfindings = ['ACL','MCL','Medial Meniscus','Lateral Meniscus','Medial OA',\n            'Lateral OA','PF OA','Effusion','Synovitis',\"Baker's\",\n            'Contusion','Fracture']\n\nprint(train.shape, train_series.shape, test.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:14.966593Z","iopub.execute_input":"2026-09-09T07:34:14.966821Z","iopub.status.idle":"2026-09-09T07:34:15.403681Z","shell.execute_reply.started":"2026-09-09T07:34:14.966799Z","shell.execute_reply":"2026-09-09T07:34:15.402768Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Data Exploration *(disabled)*\n\nThese cells were used to understand the label/report relationship — checking near-empty reports, reading real report text next to real labels, and testing keyword + negation heuristics. Kept for reference; disabled because running them prints real Competition Data (report text, label values).","metadata":{}},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # shortest = train.loc[train['Report'].str.len().idxmin()]\n# # print(shortest['Report'])\n# #\n# # gold = train[train['ACL'].notna()].iloc[0]\n# # print(gold[findings])\n# # print(gold['Report'])\n# #\n# # short = train[train['Report'].str.len() < 100]\n# # print('near-empty reports:', len(short))\n# # print('of which gold-labeled:', short['ACL'].notna().sum())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.404984Z","iopub.execute_input":"2026-09-09T07:34:15.405308Z","iopub.status.idle":"2026-09-09T07:34:15.409008Z","shell.execute_reply.started":"2026-09-09T07:34:15.405274Z","shell.execute_reply":"2026-09-09T07:34:15.40834Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # # keyword-presence vs. true label, for a few findings with fairly stable vocabulary\n# # gold = train[train['ACL'].notna()].copy()\n# # keyword_map = {\n# #     'ACL': 'acl', 'MCL': 'mcl', 'Medial Meniscus': 'medial meniscus',\n# #     'Lateral Meniscus': 'lateral meniscus', 'Effusion': 'effusion',\n# #     \"Baker's\": 'baker', 'Fracture': 'fracture',\n# # }\n# # for finding, kw in keyword_map.items():\n# #     gold[f'mentions_{finding}'] = gold['Report'].str.contains(kw, case=False, na=False)\n# #     print(finding)\n# #     print(pd.crosstab(gold[f'mentions_{finding}'], gold[finding]))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.411469Z","iopub.execute_input":"2026-09-09T07:34:15.411815Z","iopub.status.idle":"2026-09-09T07:34:15.42495Z","shell.execute_reply.started":"2026-09-09T07:34:15.41179Z","shell.execute_reply":"2026-09-09T07:34:15.424045Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Negation-aware text check — the FUNCTION is safe/generic to keep active,\n# only the demo call that prints real data below is disabled.\nnegation_words = ['intact', 'normal', 'no ', 'without', 'preserved']\n\ndef mentions_but_negated(report, keyword):\n    report_lower = report.lower()\n    if keyword not in report_lower:\n        return None\n    idx = report_lower.find(keyword)\n    window = report_lower[max(0, idx-30):idx+30]\n    return any(neg in window for neg in negation_words)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.426041Z","iopub.execute_input":"2026-09-09T07:34:15.426524Z","iopub.status.idle":"2026-09-09T07:34:15.438669Z","shell.execute_reply.started":"2026-09-09T07:34:15.426488Z","shell.execute_reply":"2026-09-09T07:34:15.437817Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # gold['acl_negated'] = gold['Report'].apply(lambda r: mentions_but_negated(r, 'acl'))\n# # print(pd.crosstab(gold['acl_negated'], gold['ACL'], dropna=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.439588Z","iopub.execute_input":"2026-09-09T07:34:15.43986Z","iopub.status.idle":"2026-09-09T07:34:15.449001Z","shell.execute_reply.started":"2026-09-09T07:34:15.439828Z","shell.execute_reply":"2026-09-09T07:34:15.448155Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # # inspecting real folder structure / one real study's DICOM headers and pixel data\n# # print(os.listdir(f'{base}/train_series')[:5])\n# # print(len(os.listdir(f'{base}/train_series')))\n# #\n# # example_study_id = train.loc[29, 'StudyInstanceUID']\n# # series_for_study = train_series[train_series['StudyInstanceUID'] == example_study_id]\n# # print(series_for_study)\n# #\n# # import pydicom, matplotlib.pyplot as plt\n# # example_series_id = series_for_study.iloc[2]['SeriesInstanceUID']\n# # series_folder = f'{base}/train_series/{example_study_id}/{example_series_id}'\n# # files = os.listdir(series_folder)\n# # dcm = pydicom.dcmread(f'{series_folder}/{files[0]}')\n# # print(dcm.pixel_array.shape)\n# # plt.imshow(dcm.pixel_array, cmap='gray')   # would display real patient MRI data\n# # plt.show()\n# # print(dcm)   # full DICOM header, real acquisition metadata","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.450061Z","iopub.execute_input":"2026-09-09T07:34:15.450399Z","iopub.status.idle":"2026-09-09T07:34:15.461165Z","shell.execute_reply.started":"2026-09-09T07:34:15.450368Z","shell.execute_reply":"2026-09-09T07:34:15.460325Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Baseline v1 — crude single-feature logistic regression\n\nPurely a plumbing baseline: one number (mean pixel intensity of one Coronal, fluid-sensitive slice) per study, 12 independent logistic regressions, one per finding. Scored ~0.53 macro AUC on the public leaderboard — a sanity check that the submission pipeline works.","metadata":{}},{"cell_type":"code","source":"import pydicom\n\ndef study_crude_feature(study_id, series_df, base_dir, plane='Coronal', fluid_sensitive=1):\n    study_series = series_df[series_df['StudyInstanceUID'] == study_id]\n    candidates = study_series[\n        (study_series['Anatomical_Plane'] == plane) &\n        (study_series['Fluid_Sensitive'] == fluid_sensitive)\n    ]\n    if len(candidates) == 0:\n        return np.nan\n    series_id = candidates.iloc[0]['SeriesInstanceUID']\n    series_path = f'{base_dir}/{study_id}/{series_id}'\n    slice_files = sorted(os.listdir(series_path))\n    middle_file = slice_files[len(slice_files)//2]\n    try:\n        dcm = pydicom.dcmread(f'{series_path}/{middle_file}')\n        return dcm.pixel_array.mean()\n    except Exception:\n        return np.nan","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.462271Z","iopub.execute_input":"2026-09-09T07:34:15.463098Z","iopub.status.idle":"2026-09-09T07:34:15.783587Z","shell.execute_reply.started":"2026-09-09T07:34:15.463075Z","shell.execute_reply":"2026-09-09T07:34:15.782631Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.linear_model import LogisticRegression\n\ngold_studies = train[train['ACL'].notna()].copy()\ngold_studies['crude_feature'] = gold_studies['StudyInstanceUID'].apply(\n    lambda sid: study_crude_feature(sid, train_series, f'{base}/train_series')\n)\ngold_clean = gold_studies.dropna(subset=['crude_feature']).copy()\nprint('gold studies available for training:', len(gold_clean))\n\nbase_rates = {finding: gold_clean[finding].mean() for finding in findings}\n\nX_train = gold_clean[['crude_feature']].values\nbaseline_models = {}\nfor finding in findings:\n    y = gold_clean[finding].values\n    clf = LogisticRegression()\n    clf.fit(X_train, y)\n    baseline_models[finding] = clf","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:15.78464Z","iopub.execute_input":"2026-09-09T07:34:15.784926Z","iopub.status.idle":"2026-09-09T07:34:17.096217Z","shell.execute_reply.started":"2026-09-09T07:34:15.784895Z","shell.execute_reply":"2026-09-09T07:34:17.095637Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"test_df = pd.DataFrame({'StudyInstanceUID': test['StudyInstanceUID']})\ntest_df['crude_feature'] = test_df['StudyInstanceUID'].apply(\n    lambda sid: study_crude_feature(sid, test_series, f'{base}/test_series')\n)\n\nbaseline_submission = pd.DataFrame({'StudyInstanceUID': test_df['StudyInstanceUID']})\nfor finding in findings:\n    preds = []\n    for _, row in test_df.iterrows():\n        if pd.isna(row['crude_feature']):\n            preds.append(base_rates[finding])   # fallback: base rate for studies missing the feature\n        else:\n            preds.append(baseline_models[finding].predict_proba([[row['crude_feature']]])[0][1])\n    baseline_submission[finding] = preds\n\nprint(baseline_submission.columns.tolist() == sample_sub.columns.tolist())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:17.09692Z","iopub.execute_input":"2026-09-09T07:34:17.097338Z","iopub.status.idle":"2026-09-09T07:34:17.163774Z","shell.execute_reply.started":"2026-09-09T07:34:17.09731Z","shell.execute_reply":"2026-09-09T07:34:17.163101Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Preprocessing pipeline — true slice ordering, windowing, letterboxing, normalization\n\nMRI DICOM filenames are random UIDs, not slice order — true anatomical order comes from the `SliceLocation` header field, not filename or `InstanceNumber` (which can be interleaved). Resolutions also vary widely across series, so images are letterboxed (resized without distortion, then padded) to a consistent size, and intensity-normalized per image since MRI has no standardized brightness scale across scanners.","metadata":{}},{"cell_type":"code","source":"def load_ordered_series(study_id, series_id, base_dir):\n    series_path = f'{base_dir}/{study_id}/{series_id}'\n    files = os.listdir(series_path)\n    slices = []\n    for f in files:\n        try:\n            dcm = pydicom.dcmread(f'{series_path}/{f}')\n            try:\n                loc = float(dcm.SliceLocation)\n            except AttributeError:\n                loc = int(dcm.InstanceNumber)\n            slices.append((loc, dcm.pixel_array))\n        except Exception:\n            continue   # skip any slice that fails to decode, rather than crashing\n    if len(slices) == 0:\n        return None\n    slices.sort(key=lambda x: x[0])\n    return np.stack([s[1] for s in slices])\ndef extract_window(volume, window_size=9):\n    center = volume.shape[0] // 2\n    half = window_size // 2\n    start = max(0, center - half)\n    end = start + window_size\n    if end > volume.shape[0]:\n        end = volume.shape[0]\n        start = max(0, end - window_size)\n    return volume[start:end]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:17.165304Z","iopub.execute_input":"2026-09-09T07:34:17.168383Z","iopub.status.idle":"2026-09-09T07:34:17.182685Z","shell.execute_reply.started":"2026-09-09T07:34:17.168352Z","shell.execute_reply":"2026-09-09T07:34:17.181821Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\n\ndef resize_with_padding(image, target_size=256):\n    h, w = image.shape\n    scale = target_size / max(h, w)\n    new_h, new_w = int(h * scale), int(w * scale)\n    resized = cv2.resize(image, (new_w, new_h))\n    pad_h, pad_w = target_size - new_h, target_size - new_w\n    top, bottom = pad_h // 2, pad_h - pad_h // 2\n    left, right = pad_w // 2, pad_w - pad_w // 2\n    return cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0)\n\n\ndef preprocess_window(window, target_size=256):\n    return np.stack([resize_with_padding(s, target_size) for s in window])\n\n\ndef normalize(volume):\n    return (volume - volume.mean()) / (volume.std() + 1e-8)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:17.183518Z","iopub.execute_input":"2026-09-09T07:34:17.184941Z","iopub.status.idle":"2026-09-09T07:34:17.255012Z","shell.execute_reply.started":"2026-09-09T07:34:17.184911Z","shell.execute_reply":"2026-09-09T07:34:17.254133Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Example usage (disabled — would display real patient imaging data if run):\n# # volume = load_ordered_series(example_study_id, example_series_id, f'{base}/train_series')\n# # window = extract_window(volume, window_size=9)\n# # processed = preprocess_window(window)\n# # normalized = normalize(processed)\n# # plt.imshow(normalized[0], cmap='gray')\n# # plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:17.256167Z","iopub.execute_input":"2026-09-09T07:34:17.257434Z","iopub.status.idle":"2026-09-09T07:34:17.262552Z","shell.execute_reply.started":"2026-09-09T07:34:17.257402Z","shell.execute_reply":"2026-09-09T07:34:17.261726Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Slice-triplets (\"pseudo-RGB\") for transfer learning\n\nPretrained ImageNet backbones expect 3-channel input. A 9-slice window is split into 3 groups of 3 consecutive slices, each group treated as a pseudo-3-channel image.","metadata":{}},{"cell_type":"code","source":"def get_triplets_for_study(study_id, series_df, base_dir, plane='Coronal', fluid_sensitive=1, target_size=256):\n    study_series = series_df[series_df['StudyInstanceUID'] == study_id]\n    candidates = study_series[\n        (study_series['Anatomical_Plane'] == plane) &\n        (study_series['Fluid_Sensitive'] == fluid_sensitive)\n    ]\n    if len(candidates) == 0:\n        return None\n\n    series_id = candidates.iloc[0]['SeriesInstanceUID']\n    volume = load_ordered_series(study_id, series_id, base_dir)\n    if volume is None or volume.shape[0] < 9:\n        return None\n\n    window = extract_window(volume, window_size=9)\n    processed = preprocess_window(window, target_size=target_size)\n    normalized = normalize(processed)\n    return normalized.reshape(3, 3, target_size, target_size)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:17.266321Z","iopub.execute_input":"2026-09-09T07:34:17.266601Z","iopub.status.idle":"2026-09-09T07:34:17.274729Z","shell.execute_reply.started":"2026-09-09T07:34:17.266568Z","shell.execute_reply":"2026-09-09T07:34:17.274026Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. PyTorch `Dataset`","metadata":{}},{"cell_type":"code","source":"import torch\nfrom torch.utils.data import Dataset, DataLoader\n\nclass KneeDataset(Dataset):\n    def __init__(self, studies_df, series_df, base_dir, findings):\n        self.studies_df = studies_df.reset_index(drop=True)\n        self.series_df = series_df\n        self.base_dir = base_dir\n        self.findings = findings\n\n    def __len__(self):\n        return len(self.studies_df)\n\n    def __getitem__(self, idx):\n        row = self.studies_df.iloc[idx]\n        study_id = row['StudyInstanceUID']\n        triplets = get_triplets_for_study(study_id, self.series_df, self.base_dir)\n\n        if triplets is None:\n            # weak placeholder for studies missing the target plane/sequence — revisit if this\n            # turns out to be common; currently affects ~2/58 gold studies\n            triplets = np.zeros((3, 3, 256, 256), dtype=np.float32)\n\n        labels = row[self.findings].values.astype(np.float32)\n        return torch.tensor(triplets, dtype=torch.float32), torch.tensor(labels, dtype=torch.float32)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:28.931612Z","iopub.execute_input":"2026-09-09T07:34:28.931888Z","iopub.status.idle":"2026-09-09T07:34:30.561499Z","shell.execute_reply.started":"2026-09-09T07:34:28.931865Z","shell.execute_reply":"2026-09-09T07:34:30.560766Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # gold_clean_list = train[train['ACL'].notna()].reset_index(drop=True)\n# # dataset = KneeDataset(gold_clean_list, train_series, f'{base}/train_series', findings)\n# # sample_triplets, sample_labels = dataset[0]\n# # print(sample_triplets.shape, sample_labels.shape)  # shapes only — omit printing real label values","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Multilabel-stratified cross-validation folds\n\nWith only ~56-58 gold-labeled studies and 12 independent, imbalanced targets, a plain random split risks folds with zero positive cases for rare findings. `MultilabelStratifiedKFold` balances all 12 findings' positive rates across folds simultaneously.","metadata":{}},{"cell_type":"code","source":"!pip install --no-index --find-links=/kaggle/input/datasets/jayantabiswas/iterative-stratification/istrat_pkg/iterative_stratification-0.1.9-py3-none-any.whl iterative-stratification\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedKFold","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:41:47.492586Z","iopub.execute_input":"2026-09-09T07:41:47.493462Z","iopub.status.idle":"2026-09-09T07:41:50.744864Z","shell.execute_reply.started":"2026-09-09T07:41:47.493429Z","shell.execute_reply":"2026-09-09T07:41:50.743801Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !pip install iterative-stratification --quiet\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n\nX_dummy = gold_clean[['StudyInstanceUID']].values\ny_multilabel = gold_clean[findings].values\n\nmskf = MultilabelStratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n\nfold_assignments = np.zeros(len(gold_clean), dtype=int)\nfor fold_idx, (_, val_idx) in enumerate(mskf.split(X_dummy, y_multilabel)):\n    fold_assignments[val_idx] = fold_idx\n\ngold_clean['fold'] = fold_assignments\nprint(gold_clean['fold'].value_counts().sort_index())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:41:52.490907Z","iopub.execute_input":"2026-09-09T07:41:52.492084Z","iopub.status.idle":"2026-09-09T07:41:52.509366Z","shell.execute_reply.started":"2026-09-09T07:41:52.492043Z","shell.execute_reply":"2026-09-09T07:41:52.508682Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Model architecture — ResNet18 backbone, shared weights across triplets, offline-safe\n\nCode competitions run with no internet access, so the pretrained backbone weights must be loaded from a local file (pre-downloaded once, uploaded as a Kaggle Dataset) rather than fetched live. Set `RESNET_WEIGHTS_PATH` to your own dataset's path.","metadata":{}},{"cell_type":"code","source":"import torch.nn as nn\nimport torchvision.models as models\n\n# One-time step (run with internet ON, then upload the saved file as a Kaggle Dataset):\n# pretrained = models.resnet18(weights='IMAGENET1K_V1')\n# torch.save(pretrained.state_dict(), '/kaggle/working/resnet18_imagenet.pt')\n\nRESNET_WEIGHTS_PATH = '/kaggle/input/datasets/jayantabiswas/resnet18-imagenet/resnet18_imagenet.pt'  # <-- set this\n\nclass KneeModel(nn.Module):\n    def __init__(self, num_findings=12, pretrained_path=None):\n        super().__init__()\n        backbone = models.resnet18(weights=None)  # architecture only, no download\n        if pretrained_path is not None:\n            backbone.load_state_dict(torch.load(pretrained_path))\n        self.backbone = nn.Sequential(*list(backbone.children())[:-1])\n        self.classifier = nn.Linear(512, num_findings)\n\n    def forward(self, triplets):\n        # triplets shape: (batch_size, 3, 3, H, W)\n        batch_size, num_triplets, channels, H, W = triplets.shape\n        features = []\n        for i in range(num_triplets):\n            f = self.backbone(triplets[:, i])\n            f = f.view(batch_size, -1)\n            features.append(f)\n        avg_features = torch.stack(features).mean(dim=0)   # average across the 3 triplets\n        return self.classifier(avg_features)                # raw scores, sigmoid applied in loss/eval","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:40:01.463883Z","iopub.execute_input":"2026-09-09T07:40:01.46474Z","iopub.status.idle":"2026-09-09T07:40:02.748555Z","shell.execute_reply.started":"2026-09-09T07:40:01.464709Z","shell.execute_reply":"2026-09-09T07:40:02.74767Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Training loop with early stopping (per fold)\n\nMulti-label setup: 12 independent sigmoid outputs, trained with `BCEWithLogitsLoss`. Each fold trains a fresh model, and the best validation-AUC epoch's weights are kept (early stopping) rather than trusting the final epoch.","metadata":{}},{"cell_type":"code","source":"import torch.optim as optim\nfrom sklearn.metrics import roc_auc_score\n\ndef evaluate(model, loader, findings):\n    model.eval()\n    all_outputs, all_labels = [], []\n    with torch.no_grad():\n        for triplets, labels in loader:\n            triplets = triplets.to('cuda')\n            outputs = model(triplets)\n            probs = torch.sigmoid(outputs)\n            all_outputs.append(probs.cpu().numpy())\n            all_labels.append(labels.numpy())\n    all_outputs = np.concatenate(all_outputs)\n    all_labels = np.concatenate(all_labels)\n    aucs = {}\n    for i, finding in enumerate(findings):\n        try:\n            aucs[finding] = roc_auc_score(all_labels[:, i], all_outputs[:, i])\n        except ValueError:\n            aucs[finding] = None\n    return aucs","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:44:12.185636Z","iopub.execute_input":"2026-09-09T07:44:12.186386Z","iopub.status.idle":"2026-09-09T07:44:12.192253Z","shell.execute_reply.started":"2026-09-09T07:44:12.186355Z","shell.execute_reply":"2026-09-09T07:44:12.191475Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_one_fold(fold_num, gold_clean, train_series, base, findings, pretrained_path, num_epochs=10):\n    train_df = gold_clean[gold_clean['fold'] != fold_num].reset_index(drop=True)\n    val_df = gold_clean[gold_clean['fold'] == fold_num].reset_index(drop=True)\n\n    train_dataset = KneeDataset(train_df, train_series, f'{base}/train_series', findings)\n    val_dataset = KneeDataset(val_df, train_series, f'{base}/train_series', findings)\n    train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True)\n    val_loader = DataLoader(val_dataset, batch_size=4, shuffle=False)\n\n    model = KneeModel(num_findings=12, pretrained_path=pretrained_path).to('cuda')\n    criterion = nn.BCEWithLogitsLoss()\n    optimizer = optim.Adam(model.parameters(), lr=1e-4)\n\n    best_macro_auc = -1\n    best_state_dict = None\n\n    for epoch in range(num_epochs):\n        model.train()\n        for triplets, labels in train_loader:\n            triplets, labels = triplets.to('cuda'), labels.to('cuda')\n            optimizer.zero_grad()\n            outputs = model(triplets)\n            loss = criterion(outputs, labels)\n            loss.backward()\n            optimizer.step()\n\n        val_aucs = evaluate(model, val_loader, findings)\n        macro_auc = np.mean([v for v in val_aucs.values() if v is not None])\n\n        if macro_auc > best_macro_auc:\n            best_macro_auc = macro_auc\n            best_state_dict = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n\n        print(f'  fold {fold_num} epoch {epoch+1}: val macro AUC = {macro_auc:.4f} (best so far: {best_macro_auc:.4f})')\n\n    torch.save(best_state_dict, f'/kaggle/working/fold_{fold_num}_best.pt')\n    return best_macro_auc, best_state_dict","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Full 5-fold training and ensembling","metadata":{}},{"cell_type":"code","source":"all_fold_scores = []\nall_fold_state_dicts = []\n\nfor fold in range(5):\n    print(f'=== Fold {fold} ===')\n    best_auc, state_dict = train_one_fold(fold, gold_clean, train_series, base, findings, RESNET_WEIGHTS_PATH, num_epochs=10)\n    all_fold_scores.append(best_auc)\n    all_fold_state_dicts.append(state_dict)\n\nprint('Average across folds:', np.mean(all_fold_scores))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def ensemble_predict(study_ids, series_df, base_dir, findings, fold_paths, base_rates, target_size=256):\n    all_fold_probs = []\n    for path in fold_paths:\n        model = KneeModel(num_findings=12, pretrained_path=None).to('cuda')\n        model.load_state_dict(torch.load(path))\n        model.eval()\n\n        probs_this_fold = []\n        with torch.no_grad():\n            for sid in study_ids:\n                triplets = get_triplets_for_study(sid, series_df, base_dir, target_size=target_size)\n                if triplets is None:\n                    probs_this_fold.append(np.full(len(findings), np.nan))\n                    continue\n                t = torch.tensor(triplets, dtype=torch.float32).unsqueeze(0).to('cuda')\n                output = model(t)\n                probs = torch.sigmoid(output).cpu().numpy()[0]\n                probs_this_fold.append(probs)\n\n        all_fold_probs.append(np.array(probs_this_fold))\n\n    stacked = np.stack(all_fold_probs)\n    ensembled = np.nanmean(stacked, axis=0)\n\n    # fallback for studies where every fold failed (e.g. no matching series at all)\n    for i, finding in enumerate(findings):\n        nan_mask = np.isnan(ensembled[:, i])\n        ensembled[nan_mask, i] = base_rates[finding]\n\n    return ensembled","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. Generate submission","metadata":{}},{"cell_type":"code","source":"fold_paths = [f'/kaggle/working/fold_{i}_best.pt' for i in range(5)]\ntest_study_ids = test['StudyInstanceUID'].tolist()\n\nensembled_probs = ensemble_predict(test_study_ids, test_series, f'{base}/test_series', findings, fold_paths, base_rates)\n#ensembled_probs = ensemble_predict(test_study_ids, test_series, f'{base}/test_series', findings, fold_paths)\n\nprint('any NaN in final predictions:', np.isnan(ensembled_probs).any())\n\nsubmission = pd.DataFrame({'StudyInstanceUID': test_study_ids})\nfor i, finding in enumerate(findings):\n    submission[finding] = ensembled_probs[:, i]\n\nprint('columns match sample_submission:', submission.columns.tolist() == sample_sub.columns.tolist())\n#submission.to_csv('/kaggle/working/submission.csv', index=False)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for plane in ['Sagittal', 'Coronal', 'Axial']:\n    matches = train_series[(train_series['Anatomical_Plane'] == plane) & (train_series['Fluid_Sensitive'] == 1)]\n    studies_with_plane = matches['StudyInstanceUID'].nunique()\n    print(f'{plane}: {studies_with_plane} / {train[\"StudyInstanceUID\"].nunique()} studies have a fluid-sensitive series')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:26:47.043398Z","iopub.execute_input":"2026-09-09T07:26:47.044286Z","iopub.status.idle":"2026-09-09T07:26:47.067583Z","shell.execute_reply.started":"2026-09-09T07:26:47.044254Z","shell.execute_reply":"2026-09-09T07:26:47.066886Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"finding_groups = {\n    'Sagittal': ['ACL', 'Medial Meniscus', 'Lateral Meniscus', 'Effusion', 'Synovitis'],\n    'Coronal': ['MCL', 'Medial OA', 'Lateral OA', 'Contusion', 'Fracture'],\n    'Axial': ['PF OA', \"Baker's\"],\n}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:35:47.765597Z","iopub.execute_input":"2026-09-09T07:35:47.766372Z","iopub.status.idle":"2026-09-09T07:35:47.770479Z","shell.execute_reply.started":"2026-09-09T07:35:47.766338Z","shell.execute_reply":"2026-09-09T07:35:47.769509Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class KneeDatasetGrouped(Dataset):\n    def __init__(self, studies_df, series_df, base_dir, findings_subset, plane):\n        self.studies_df = studies_df.reset_index(drop=True)\n        self.series_df = series_df\n        self.base_dir = base_dir\n        self.findings_subset = findings_subset\n        self.plane = plane\n\n    def __len__(self):\n        return len(self.studies_df)\n\n    def __getitem__(self, idx):\n        row = self.studies_df.iloc[idx]\n        study_id = row['StudyInstanceUID']\n        triplets = get_triplets_for_study(study_id, self.series_df, self.base_dir, plane=self.plane)\n\n        if triplets is None:\n            triplets = np.zeros((3, 3, 256, 256), dtype=np.float32)\n\n        labels = row[self.findings_subset].values.astype(np.float32)\n        return torch.tensor(triplets, dtype=torch.float32), torch.tensor(labels, dtype=torch.float32)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:34:47.329991Z","iopub.execute_input":"2026-09-09T07:34:47.330808Z","iopub.status.idle":"2026-09-09T07:34:47.336791Z","shell.execute_reply.started":"2026-09-09T07:34:47.330774Z","shell.execute_reply":"2026-09-09T07:34:47.336095Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"gold_clean_list = train[train['ACL'].notna()].reset_index(drop=True)\ncoronal_findings = finding_groups['Coronal']\n\ncoronal_dataset = KneeDatasetGrouped(gold_clean_list, train_series, f'{base}/train_series', coronal_findings, plane='Coronal')\nsample_triplets, sample_labels = coronal_dataset[0]\nprint(sample_triplets.shape, sample_labels.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:35:53.786936Z","iopub.execute_input":"2026-09-09T07:35:53.787676Z","iopub.status.idle":"2026-09-09T07:35:53.868833Z","shell.execute_reply.started":"2026-09-09T07:35:53.787644Z","shell.execute_reply":"2026-09-09T07:35:53.868016Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_one_fold_grouped(fold_num, gold_clean, train_series, base, findings_subset, plane, pretrained_path, num_epochs=10):\n    train_df = gold_clean[gold_clean['fold'] != fold_num].reset_index(drop=True)\n    val_df = gold_clean[gold_clean['fold'] == fold_num].reset_index(drop=True)\n\n    train_dataset = KneeDatasetGrouped(train_df, train_series, f'{base}/train_series', findings_subset, plane)\n    val_dataset = KneeDatasetGrouped(val_df, train_series, f'{base}/train_series', findings_subset, plane)\n    train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True)\n    val_loader = DataLoader(val_dataset, batch_size=4, shuffle=False)\n\n    model = KneeModel(num_findings=len(findings_subset), pretrained_path=pretrained_path).to('cuda')\n    criterion = nn.BCEWithLogitsLoss()\n    optimizer = optim.Adam(model.parameters(), lr=1e-4)\n\n    best_macro_auc = -1\n    best_state_dict = None\n\n    for epoch in range(num_epochs):\n        model.train()\n        for triplets, labels in train_loader:\n            triplets, labels = triplets.to('cuda'), labels.to('cuda')\n            optimizer.zero_grad()\n            outputs = model(triplets)\n            loss = criterion(outputs, labels)\n            loss.backward()\n            optimizer.step()\n\n        val_aucs = evaluate(model, val_loader, findings_subset)\n        macro_auc = np.mean([v for v in val_aucs.values() if v is not None])\n\n        if macro_auc > best_macro_auc:\n            best_macro_auc = macro_auc\n            best_state_dict = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n\n        print(f'  [{plane}] fold {fold_num} epoch {epoch+1}: val macro AUC = {macro_auc:.4f} (best so far: {best_macro_auc:.4f})')\n\n    return best_macro_auc, best_state_dict","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:40:36.781948Z","iopub.execute_input":"2026-09-09T07:40:36.782773Z","iopub.status.idle":"2026-09-09T07:40:36.791289Z","shell.execute_reply.started":"2026-09-09T07:40:36.782737Z","shell.execute_reply":"2026-09-09T07:40:36.790377Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"best_auc, state_dict = train_one_fold_grouped(0, gold_clean, train_series, base, finding_groups['Coronal'], 'Coronal', RESNET_WEIGHTS_PATH, num_epochs=10)\nprint(best_auc)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:44:28.874137Z","iopub.execute_input":"2026-09-09T07:44:28.87482Z","iopub.status.idle":"2026-09-09T07:45:12.116374Z","shell.execute_reply.started":"2026-09-09T07:44:28.874789Z","shell.execute_reply":"2026-09-09T07:45:12.115616Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.optim as optim","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:43:16.955756Z","iopub.execute_input":"2026-09-09T07:43:16.956542Z","iopub.status.idle":"2026-09-09T07:43:16.960972Z","shell.execute_reply.started":"2026-09-09T07:43:16.956508Z","shell.execute_reply":"2026-09-09T07:43:16.960206Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"required = ['torch', 'nn', 'optim', 'DataLoader', 'Dataset', 'np', 'pd', 'os',\n            'KneeModel', 'KneeDatasetGrouped', 'get_triplets_for_study', 'evaluate',\n            'finding_groups', 'RESNET_WEIGHTS_PATH', 'gold_clean']\nmissing = [name for name in required if name not in dir()]\nprint('missing:', missing)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:44:18.864491Z","iopub.execute_input":"2026-09-09T07:44:18.865275Z","iopub.status.idle":"2026-09-09T07:44:18.870339Z","shell.execute_reply.started":"2026-09-09T07:44:18.865245Z","shell.execute_reply":"2026-09-09T07:44:18.8695Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"original_model = KneeModel(num_findings=12, pretrained_path=None).to('cuda')\noriginal_model.load_state_dict(torch.load('/kaggle/working/fold_0_best.pt'))\n\nval_df = gold_clean[gold_clean['fold'] == 0].reset_index(drop=True)\nval_dataset_full = KneeDataset(val_df, train_series, f'{base}/train_series', findings)\nval_loader_full = DataLoader(val_dataset_full, batch_size=4, shuffle=False)\n\noriginal_aucs = evaluate(original_model, val_loader_full, findings)\ncoronal_findings = finding_groups['Coronal']\noriginal_coronal_avg = np.mean([original_aucs[f] for f in coronal_findings if original_aucs[f] is not None])\nprint('Original model, Coronal-findings-only average:', original_coronal_avg)\nprint('Specialized Coronal model average:', 0.6778)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:48:42.929517Z","iopub.execute_input":"2026-09-09T07:48:42.930388Z","iopub.status.idle":"2026-09-09T07:48:43.956002Z","shell.execute_reply.started":"2026-09-09T07:48:42.930351Z","shell.execute_reply":"2026-09-09T07:48:43.955268Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_all_folds_grouped(finding_groups_dict, plane, gold_clean, train_series, base, pretrained_path, num_epochs=10):\n    findings_subset = finding_groups_dict[plane]\n    fold_scores = []\n    fold_state_dicts = []\n\n    for fold in range(5):\n        print(f'=== {plane} — Fold {fold} ===')\n        best_auc, state_dict = train_one_fold_grouped(\n            fold, gold_clean, train_series, base, findings_subset, plane, pretrained_path, num_epochs=num_epochs\n        )\n        fold_scores.append(best_auc)\n        fold_state_dicts.append(state_dict)\n        torch.save(state_dict, f'/kaggle/working/{plane.lower()}_fold_{fold}_best.pt')\n\n    print(f'\\n{plane} average across folds:', np.mean(fold_scores))\n    return fold_scores, fold_state_dicts","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:51:06.26233Z","iopub.execute_input":"2026-09-09T07:51:06.262652Z","iopub.status.idle":"2026-09-09T07:51:06.268391Z","shell.execute_reply.started":"2026-09-09T07:51:06.262629Z","shell.execute_reply":"2026-09-09T07:51:06.267525Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"coronal_scores, coronal_state_dicts = train_all_folds_grouped(finding_groups, 'Coronal', gold_clean, train_series, base, RESNET_WEIGHTS_PATH, num_epochs=10)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:51:27.295607Z","iopub.execute_input":"2026-09-09T07:51:27.296238Z","iopub.status.idle":"2026-09-09T07:54:46.735989Z","shell.execute_reply.started":"2026-09-09T07:51:27.296181Z","shell.execute_reply":"2026-09-09T07:54:46.734962Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"original_coronal_averages = []\n\nfor fold in range(5):\n    original_model = KneeModel(num_findings=12, pretrained_path=None).to('cuda')\n    original_model.load_state_dict(torch.load(f'/kaggle/working/fold_{fold}_best.pt'))\n\n    val_df = gold_clean[gold_clean['fold'] == fold].reset_index(drop=True)\n    val_dataset_full = KneeDataset(val_df, train_series, f'{base}/train_series', findings)\n    val_loader_full = DataLoader(val_dataset_full, batch_size=4, shuffle=False)\n\n    original_aucs = evaluate(original_model, val_loader_full, findings)\n    coronal_findings = finding_groups['Coronal']\n    fold_avg = np.mean([original_aucs[f] for f in coronal_findings if original_aucs[f] is not None])\n    original_coronal_averages.append(fold_avg)\n    print(f'fold {fold}: original model, Coronal-findings-only = {fold_avg:.4f}')\n\nprint('\\nOriginal (all-12) model, Coronal-findings average across folds:', np.mean(original_coronal_averages))\nprint('Specialized Coronal model average across folds:', np.mean(coronal_scores))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T07:57:55.536471Z","iopub.execute_input":"2026-09-09T07:57:55.537335Z","iopub.status.idle":"2026-09-09T07:58:00.461607Z","shell.execute_reply.started":"2026-09-09T07:57:55.537301Z","shell.execute_reply":"2026-09-09T07:58:00.460928Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sagittal_scores, sagittal_state_dicts = train_all_folds_grouped(finding_groups, 'Sagittal', gold_clean, train_series, base, RESNET_WEIGHTS_PATH, num_epochs=10)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:30:11.880107Z","iopub.execute_input":"2026-09-09T08:30:11.880457Z","iopub.status.idle":"2026-09-09T08:34:17.367476Z","shell.execute_reply.started":"2026-09-09T08:30:11.880431Z","shell.execute_reply":"2026-09-09T08:34:17.366415Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"original_sagittal_averages = []\n\nfor fold in range(5):\n    original_model = KneeModel(num_findings=12, pretrained_path=None).to('cuda')\n    original_model.load_state_dict(torch.load(f'/kaggle/working/fold_{fold}_best.pt'))\n\n    val_df = gold_clean[gold_clean['fold'] == fold].reset_index(drop=True)\n    val_dataset_full = KneeDataset(val_df, train_series, f'{base}/train_series', findings)\n    val_loader_full = DataLoader(val_dataset_full, batch_size=4, shuffle=False)\n\n    original_aucs = evaluate(original_model, val_loader_full, findings)\n    sagittal_findings = finding_groups['Sagittal']\n    fold_avg = np.mean([original_aucs[f] for f in sagittal_findings if original_aucs[f] is not None])\n    original_sagittal_averages.append(fold_avg)\n    print(f'fold {fold}: original model, Sagittal-findings-only = {fold_avg:.4f}')\n\nprint('\\nOriginal (all-12) model, Sagittal-findings average across folds:', np.mean(original_sagittal_averages))\nprint('Specialized Sagittal model average across folds:', np.mean(sagittal_scores))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:34:48.34399Z","iopub.execute_input":"2026-09-09T08:34:48.344695Z","iopub.status.idle":"2026-09-09T08:34:53.809091Z","shell.execute_reply.started":"2026-09-09T08:34:48.34466Z","shell.execute_reply":"2026-09-09T08:34:53.80828Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"axial_scores, axial_state_dicts = train_all_folds_grouped(finding_groups, 'Axial', gold_clean, train_series, base, RESNET_WEIGHTS_PATH, num_epochs=10)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:36:20.910599Z","iopub.execute_input":"2026-09-09T08:36:20.910915Z","iopub.status.idle":"2026-09-09T08:41:28.309058Z","shell.execute_reply.started":"2026-09-09T08:36:20.910892Z","shell.execute_reply":"2026-09-09T08:41:28.308315Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"original_axial_averages = []\n\nfor fold in range(5):\n    original_model = KneeModel(num_findings=12, pretrained_path=None).to('cuda')\n    original_model.load_state_dict(torch.load(f'/kaggle/working/fold_{fold}_best.pt'))\n\n    val_df = gold_clean[gold_clean['fold'] == fold].reset_index(drop=True)\n    val_dataset_full = KneeDataset(val_df, train_series, f'{base}/train_series', findings)\n    val_loader_full = DataLoader(val_dataset_full, batch_size=4, shuffle=False)\n\n    original_aucs = evaluate(original_model, val_loader_full, findings)\n    axial_findings = finding_groups['Axial']\n    fold_avg = np.mean([original_aucs[f] for f in axial_findings if original_aucs[f] is not None])\n    original_axial_averages.append(fold_avg)\n    print(f'fold {fold}: original model, Axial-findings-only = {fold_avg:.4f}')\n\nprint('\\nOriginal (all-12) model, Axial-findings average across folds:', np.mean(original_axial_averages))\nprint('Specialized Axial model average across folds:', np.mean(axial_scores))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:44:56.591954Z","iopub.execute_input":"2026-09-09T08:44:56.592998Z","iopub.status.idle":"2026-09-09T08:45:02.927658Z","shell.execute_reply.started":"2026-09-09T08:44:56.592942Z","shell.execute_reply":"2026-09-09T08:45:02.926763Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def ensemble_predict_grouped(study_ids, series_df, base_dir, findings_subset, plane, fold_paths, base_rates, target_size=256):\n    all_fold_probs = []\n    for path in fold_paths:\n        model = KneeModel(num_findings=len(findings_subset), pretrained_path=None).to('cuda')\n        model.load_state_dict(torch.load(path))\n        model.eval()\n\n        probs_this_fold = []\n        with torch.no_grad():\n            for sid in study_ids:\n                triplets = get_triplets_for_study(sid, series_df, base_dir, plane=plane, target_size=target_size)\n                if triplets is None:\n                    probs_this_fold.append(np.full(len(findings_subset), np.nan))\n                    continue\n                t = torch.tensor(triplets, dtype=torch.float32).unsqueeze(0).to('cuda')\n                output = model(t)\n                probs = torch.sigmoid(output).cpu().numpy()[0]\n                probs_this_fold.append(probs)\n\n        all_fold_probs.append(np.array(probs_this_fold))\n\n    stacked = np.stack(all_fold_probs)\n    ensembled = np.nanmean(stacked, axis=0)\n\n    for i, finding in enumerate(findings_subset):\n        nan_mask = np.isnan(ensembled[:, i])\n        ensembled[nan_mask, i] = base_rates[finding]\n\n    return ensembled","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:57:14.39724Z","iopub.execute_input":"2026-09-09T08:57:14.397532Z","iopub.status.idle":"2026-09-09T08:57:14.405309Z","shell.execute_reply.started":"2026-09-09T08:57:14.397508Z","shell.execute_reply":"2026-09-09T08:57:14.404515Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_v3 = pd.DataFrame({'StudyInstanceUID': test_study_ids})\n\nfor plane, findings_subset in finding_groups.items():\n    fold_paths = [f'/kaggle/working/{plane.lower()}_fold_{i}_best.pt' for i in range(5)]\n    probs = ensemble_predict_grouped(test_study_ids, test_series, f'{base}/test_series', findings_subset, plane, fold_paths, base_rates)\n\n    for i, finding in enumerate(findings_subset):\n        submission_v3[finding] = probs[:, i]\n\nprint(submission_v3.columns.tolist() == sample_sub.columns.tolist())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:58:53.56511Z","iopub.execute_input":"2026-09-09T08:58:53.565463Z","iopub.status.idle":"2026-09-09T08:59:05.468818Z","shell.execute_reply.started":"2026-09-09T08:58:53.565436Z","shell.execute_reply":"2026-09-09T08:59:05.467745Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"required = ['test_study_ids', 'finding_groups', 'base_rates', 'sample_sub', 'get_triplets_for_study', 'KneeModel']\nmissing = [name for name in required if name not in dir()]\nprint('missing:', missing)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:58:36.609549Z","iopub.execute_input":"2026-09-09T08:58:36.610404Z","iopub.status.idle":"2026-09-09T08:58:36.614928Z","shell.execute_reply.started":"2026-09-09T08:58:36.610368Z","shell.execute_reply":"2026-09-09T08:58:36.614274Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"test_study_ids = test['StudyInstanceUID'].tolist()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T08:58:45.921314Z","iopub.execute_input":"2026-09-09T08:58:45.922069Z","iopub.status.idle":"2026-09-09T08:58:45.926033Z","shell.execute_reply.started":"2026-09-09T08:58:45.922038Z","shell.execute_reply":"2026-09-09T08:58:45.92503Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_v3 = submission_v3[sample_sub.columns.tolist()]\nprint(submission_v3.columns.tolist() == sample_sub.columns.tolist())\n\nsubmission_v3.to_csv('/kaggle/working/submission_v3.csv', index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T09:35:26.308596Z","iopub.execute_input":"2026-09-09T09:35:26.309331Z","iopub.status.idle":"2026-09-09T09:35:26.317071Z","shell.execute_reply.started":"2026-09-09T09:35:26.309299Z","shell.execute_reply":"2026-09-09T09:35:26.316278Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('any NaN in final submission:', submission_v3.isna().any().any())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-09T09:35:21.26731Z","iopub.execute_input":"2026-09-09T09:35:21.268356Z","iopub.status.idle":"2026-09-09T09:35:21.273962Z","shell.execute_reply.started":"2026-09-09T09:35:21.26832Z","shell.execute_reply":"2026-09-09T09:35:21.273118Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_v3.to_csv('/kaggle/working/submission.csv', index=False)","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}