{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"37be01f9-aedb-412a-a04b-a991e191e858","cell_type":"code","source":"import os, pandas as pd, pydicom\n\nDATA_DIR = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\n\nprint(os.listdir(DATA_DIR))\n\ntrain = pd.read_csv(f\"{DATA_DIR}/train.csv\")\ntrain_series = pd.read_csv(f\"{DATA_DIR}/train_series.csv\")\nprint(train.shape, train_series.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:43.713519Z","iopub.execute_input":"2026-08-27T06:34:43.7143Z","iopub.status.idle":"2026-08-27T06:34:44.869807Z","shell.execute_reply.started":"2026-08-27T06:34:43.714268Z","shell.execute_reply":"2026-08-27T06:34:44.868743Z"}},"outputs":[],"execution_count":null},{"id":"002e8074-8756-42bb-8b0b-2fc2058e8768","cell_type":"code","source":"label_cols = [\"ACL\",\"MCL\",\"Medial Meniscus\",\"Lateral Meniscus\",\"Medial OA\",\n              \"Lateral OA\",\"PF OA\",\"Effusion\",\"Synovitis\",\"Baker\\'s\",\"Contusion\",\"Fracture\"]\n\nlabeled_mask = train[label_cols].notna().any(axis=1)\nlabeled = train[labeled_mask].copy()        \nunlabeled = train[~labeled_mask].copy()    \n\nprint(f\"{len(labeled)} gold-labeled studies\")\nprint(f\"{len(unlabeled)} unlabeled studies (need pseudo-labels)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:44.871255Z","iopub.execute_input":"2026-08-27T06:34:44.871602Z","iopub.status.idle":"2026-08-27T06:34:44.885463Z","shell.execute_reply.started":"2026-08-27T06:34:44.871521Z","shell.execute_reply":"2026-08-27T06:34:44.884615Z"}},"outputs":[],"execution_count":null},{"id":"0f94d056-9af0-4bb9-8d47-b4ea86528133","cell_type":"code","source":"!pip install anthropic -q\n\nfrom kaggle_secrets import UserSecretsClient\nimport anthropic\n\nsecrets = UserSecretsClient()\napi_key = secrets.get_secret(\"ANTHROPIC_API_KEY\")\nclient = anthropic.Anthropic(api_key=api_key)\n\nLABEL_COLS = label_cols  ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:44.88646Z","iopub.execute_input":"2026-08-27T06:34:44.887217Z","iopub.status.idle":"2026-08-27T06:34:53.564565Z","shell.execute_reply.started":"2026-08-27T06:34:44.887182Z","shell.execute_reply":"2026-08-27T06:34:53.563906Z"}},"outputs":[],"execution_count":null},{"id":"2c68917f-4f3b-4d10-9a45-3a9309528030","cell_type":"code","source":"import json, re\n\nSYSTEM_PROMPT_BASE = \"\"\"You are extracting structured findings from knee MRI radiology reports. \\\nReports may be in any language (English, Spanish, Dutch, Turkish, French, German, Bulgarian, Greek, etc). \\\nFor each of the 12 findings below, output 1 if the report describes a clinically significant \\\nfinding, or 0 if absent, normal, or only mild/grade-1/trivial severity. \\\nMild or interstitial findings (e.g. grade 1 signal change) should be labeled 0 \\\nunless the report clearly indicates moderate-to-severe pathology (tear, rupture, grade 2+, discontinuity).\n\nAdditional label-specific guidance:\n- Effusion: label 1 only for small-to-large joint effusion. Trace, physiologic, or minimal fluid \\\nshould be labeled 0 — this is a normal finding in most knees.\n- Contusion: label 1 only when bone marrow edema is described as a discrete contusion/bone bruise, \\\ntypically from acute trauma. Mild, trace, or reactive edema adjacent to another injury should be labeled 0.\n- Synovitis: label 1 if the report describes synovitis, synovial thickening, synovial hypertrophy, \\\npannus, or synovial proliferation — not only if the literal word \"synovitis\" appears.\n\nFindings (in this order): ACL, MCL, Medial Meniscus tear, Lateral Meniscus tear, Medial compartment OA, \\\nLateral compartment OA, Patellofemoral OA, Joint effusion, Synovitis, Baker\\'s cyst, \\\nBone contusion, Fracture.\n\nDo not explain your reasoning. Output ONLY a JSON array, nothing before or after it.\"\"\"\n\ndef extract_labels_batch(reports_with_ids, max_retries=2):\n    \"\"\"reports_with_ids: list of (uid, report_text) tuples, ideally ~15 at a time.\"\"\"\n    n = len(reports_with_ids)\n    numbered = \"\\n\\n\".join(f\"[REPORT {i}]\\n{text}\" for i, (_, text) in enumerate(reports_with_ids))\n\n    system = SYSTEM_PROMPT_BASE + f\"\"\"\n\nYou will receive {n} reports, each marked [REPORT i]. Respond with ONLY a JSON array\nof exactly {n} objects, in the same order as the reports. Each object must have exactly\nthese keys: {{\"ACL\":0,\"MCL\":0,\"Medial Meniscus\":0,\"Lateral Meniscus\":0,\"Medial OA\":0,\"Lateral OA\":0,\"PF OA\":0,\"Effusion\":0,\"Synovitis\":0,\"Baker\\'s\":0,\"Contusion\":0,\"Fracture\":0}}\"\"\"\n\n    for attempt in range(max_retries):\n        response = client.messages.create(\n            model=\"claude-sonnet-4-6\",\n            max_tokens=250 * n,\n            system=system,\n            messages=[{\"role\": \"user\", \"content\": numbered}]\n        )\n        raw = response.content[0].text.strip()\n        if raw.startswith(\"```\"):\n            raw = raw.split(\"```\")[1]\n            if raw.startswith(\"json\"):\n                raw = raw[4:]\n            raw = raw.strip()\n\n        try:\n            parsed = json.loads(raw)\n            if isinstance(parsed, list) and len(parsed) == n:\n                return {uid: parsed[i] for i, (uid, _) in enumerate(reports_with_ids)}\n        except json.JSONDecodeError:\n            match = re.search(r\"\\[.*\\]\", raw, re.DOTALL)\n            if match:\n                try:\n                    parsed = json.loads(match.group())\n                    if isinstance(parsed, list) and len(parsed) == n:\n                        return {uid: parsed[i] for i, (uid, _) in enumerate(reports_with_ids)}\n                except json.JSONDecodeError:\n                    pass\n\n    return {uid: {c: None for c in LABEL_COLS} for uid, _ in reports_with_ids}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:53.566239Z","iopub.execute_input":"2026-08-27T06:34:53.566601Z","iopub.status.idle":"2026-08-27T06:34:53.577402Z","shell.execute_reply.started":"2026-08-27T06:34:53.56653Z","shell.execute_reply":"2026-08-27T06:34:53.576614Z"}},"outputs":[],"execution_count":null},{"id":"db47bc18-3059-4e0b-b019-b991772154f6","cell_type":"code","source":"PERMANENT_PATH = \"/kaggle/input/datasets/mayas12/rsna-knee-pseudo-labels-final/pseudo_labels_checkpoint (1).csv\"\nWORKING_PATH = \"/kaggle/working/pseudo_labels_checkpoint.csv\"\nGROUP_SIZE = 15  \n\nimport os, pandas as pd\nprint(os.path.exists(PERMANENT_PATH))\ncheck = pd.read_csv(PERMANENT_PATH, index_col=0)\nprint(check.shape)  \n\nif os.path.exists(PERMANENT_PATH):\n    print(\"Found permanent dataset — loading with zero API calls.\")\n    labels_df = pd.read_csv(PERMANENT_PATH, index_col=0)\n\nelse:\n    if os.path.exists(WORKING_PATH):\n        done_df = pd.read_csv(WORKING_PATH, index_col=0)\n        done_ids = set(done_df.index)\n        print(f\"Resuming from working checkpoint — {len(done_ids)} already done\")\n    else:\n        done_df = pd.DataFrame()\n        done_ids = set()\n\n    remaining = unlabeled[~unlabeled[\"StudyInstanceUID\"].isin(done_ids)]\n    print(f\"{len(remaining)} studies left to label\")\n\n    all_results = dict(zip(done_df.index, done_df.to_dict(\"records\"))) if len(done_df) else {}\n    remaining_list = list(zip(remaining[\"StudyInstanceUID\"], remaining[\"Report\"]))\n\n    for start_idx in range(0, len(remaining_list), GROUP_SIZE):\n        group = remaining_list[start_idx:start_idx + GROUP_SIZE]\n        try:\n            group_results = extract_labels_batch(group)\n            all_results.update(group_results)\n        except Exception as e:\n            print(f\"Error on group starting at {start_idx}: {e}\")\n            print(\"Stopping here — add credit if needed, then re-run this cell to resume.\")\n            break\n\n        if (start_idx // GROUP_SIZE) % 5 == 0:  \n            checkpoint_df = pd.DataFrame.from_dict(all_results, orient=\"index\")\n            checkpoint_df.index.name = \"StudyInstanceUID\"\n            checkpoint_df.to_csv(WORKING_PATH)\n            print(f\"Checkpointed: {len(all_results)} / {len(unlabeled)}\")\n\n    checkpoint_df = pd.DataFrame.from_dict(all_results, orient=\"index\")\n    checkpoint_df.index.name = \"StudyInstanceUID\"\n    checkpoint_df.to_csv(WORKING_PATH)\n    print(f\"Done. {len(all_results)} / {len(unlabeled)} labeled.\")\n    print(\">>> NOW go save this as a Kaggle Dataset before doing anything else. <<<\")\n\n    labels_df = checkpoint_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:53.57848Z","iopub.execute_input":"2026-08-27T06:34:53.578737Z","iopub.status.idle":"2026-08-27T06:34:53.634092Z","shell.execute_reply.started":"2026-08-27T06:34:53.578716Z","shell.execute_reply":"2026-08-27T06:34:53.63342Z"}},"outputs":[],"execution_count":null},{"id":"d3bbe9ea-2c90-4fdf-b22c-1e89d6305d4b","cell_type":"code","source":"gold_final = labeled[[\"StudyInstanceUID\", \"Report\"] + label_cols].copy()\ngold_final[\"is_pseudo\"] = 0\n\npseudo_final = unlabeled[[\"StudyInstanceUID\", \"Report\"]].merge(\n    labels_df.reset_index(), on=\"StudyInstanceUID\", how=\"inner\"\n)\npseudo_final = pseudo_final.rename(columns=dict(zip(LABEL_COLS, label_cols)))\npseudo_final = pseudo_final.dropna(subset=label_cols)\npseudo_final[\"is_pseudo\"] = 1\n\nfull_train = pd.concat([gold_final, pseudo_final], ignore_index=True)\nprint(full_train.shape)\nprint(full_train[\"is_pseudo\"].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:53.634854Z","iopub.execute_input":"2026-08-27T06:34:53.635163Z","iopub.status.idle":"2026-08-27T06:34:53.657508Z","shell.execute_reply.started":"2026-08-27T06:34:53.635137Z","shell.execute_reply":"2026-08-27T06:34:53.656948Z"}},"outputs":[],"execution_count":null},{"id":"8e8d601a-70bf-48ef-881d-3626b07bf23a","cell_type":"code","source":"from sklearn.feature_extraction.text import TfidfVectorizer\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.model_selection import KFold\nfrom sklearn.metrics import roc_auc_score\nimport numpy as np\n\npseudo_only = full_train[full_train[\"is_pseudo\"] == 1].reset_index(drop=True)\ngold_only = full_train[full_train[\"is_pseudo\"] == 0].reset_index(drop=True)\n\nX_pseudo = pseudo_only[\"Report\"].fillna(\"\")\nY_pseudo = pseudo_only[label_cols].values\nX_gold = gold_only[\"Report\"].fillna(\"\")\nY_gold = gold_only[label_cols].values\n\nkf = KFold(n_splits=5, shuffle=True, random_state=42)\nfold_aucs = []\n\nfor fold, (gold_train_idx, gold_val_idx) in enumerate(kf.split(X_gold)):\n    X_tr = pd.concat([X_pseudo, X_gold.iloc[gold_train_idx]], ignore_index=True)\n    Y_tr = np.concatenate([Y_pseudo, Y_gold[gold_train_idx]], axis=0)\n    X_val = X_gold.iloc[gold_val_idx]\n    Y_val = Y_gold[gold_val_idx]\n\n    vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2), min_df=2)\n    X_tr_vec = vectorizer.fit_transform(X_tr)\n    X_val_vec = vectorizer.transform(X_val)\n\n    label_aucs, skipped = [], []\n    for i, col in enumerate(label_cols):\n        y_tr_col, y_val_col = Y_tr[:, i], Y_val[:, i]\n        if len(np.unique(y_tr_col)) < 2 or len(np.unique(y_val_col)) < 2:\n            skipped.append(col)\n            continue\n        clf = LogisticRegression(max_iter=1000, class_weight=\"balanced\")\n        clf.fit(X_tr_vec, y_tr_col)\n        preds = clf.predict_proba(X_val_vec)[:, 1]\n        label_aucs.append(roc_auc_score(y_val_col, preds))\n\n    fold_macro_auc = np.mean(label_aucs) if label_aucs else float(\"nan\")\n    fold_aucs.append(fold_macro_auc)\n    print(f\"Fold {fold}: macro AUC = {fold_macro_auc:.4f}  (scored {len(label_aucs)}/12, skipped {skipped})\")\n\nprint(f\"\\nMean macro AUC (validated on gold only): {np.nanmean(fold_aucs):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:34:53.658421Z","iopub.execute_input":"2026-08-27T06:34:53.658862Z","iopub.status.idle":"2026-08-27T06:35:04.690297Z","shell.execute_reply.started":"2026-08-27T06:34:53.658836Z","shell.execute_reply":"2026-08-27T06:35:04.689653Z"}},"outputs":[],"execution_count":null},{"id":"c17dde38-91b1-47a1-938b-6afd832da9f5","cell_type":"code","source":"def pick_series(study_uid, series_df):\n    candidates = series_df[series_df[\"StudyInstanceUID\"] == study_uid]\n    if len(candidates) == 0:\n        return None\n    fluid_sag = candidates[(candidates[\"Fluid_Sensitive\"] == 1) &\n                            (candidates[\"Anatomical_Plane\"] == \"Sagittal\")]\n    if len(fluid_sag) > 0:\n        return fluid_sag.iloc[0][\"SeriesInstanceUID\"]\n    fluid_any = candidates[candidates[\"Fluid_Sensitive\"] == 1]\n    if len(fluid_any) > 0:\n        return fluid_any.iloc[0][\"SeriesInstanceUID\"]\n    return candidates.iloc[0][\"SeriesInstanceUID\"]\n\nfull_train[\"SeriesInstanceUID\"] = full_train[\"StudyInstanceUID\"].apply(\n    lambda uid: pick_series(uid, train_series)\n)\nmissing = full_train[\"SeriesInstanceUID\"].isna().sum()\nprint(f\"{missing} studies with no matching series found\")\nprint(full_train[\"SeriesInstanceUID\"].notna().sum(), \"studies ready for image loading\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:35:04.69106Z","iopub.execute_input":"2026-08-27T06:35:04.691497Z","iopub.status.idle":"2026-08-27T06:35:15.756608Z","shell.execute_reply.started":"2026-08-27T06:35:04.691471Z","shell.execute_reply":"2026-08-27T06:35:15.755982Z"}},"outputs":[],"execution_count":null},{"id":"0571d826-1bab-4b95-946c-3ab1e66cae41","cell_type":"code","source":"import numpy as np\nimport pydicom\nfrom PIL import Image\n\nN_SLICES = 16      \nIMG_SIZE = 128      \n\ndef load_series_as_tensor(study_uid, series_uid, n_slices=N_SLICES, img_size=IMG_SIZE):\n    series_path = f\"{DATA_DIR}/train_series/{study_uid}/{series_uid}\"\n    slice_files = sorted(os.listdir(series_path))\n\n    if len(slice_files) == 0:\n        return None\n\n    if len(slice_files) >= n_slices:\n        indices = np.linspace(0, len(slice_files) - 1, n_slices).astype(int)\n    else:\n        indices = np.linspace(0, len(slice_files) - 1, len(slice_files)).astype(int)\n\n    slices = []\n    for idx in indices:\n        dcm = pydicom.dcmread(os.path.join(series_path, slice_files[idx]))\n        arr = dcm.pixel_array.astype(np.float32)\n\n        arr_min, arr_max = arr.min(), arr.max()\n        if arr_max > arr_min:\n            arr = (arr - arr_min) / (arr_max - arr_min) * 255\n        arr = arr.astype(np.uint8)\n\n        img = Image.fromarray(arr).resize((img_size, img_size))\n        slices.append(np.array(img))\n\n    tensor = np.stack(slices, axis=0)  # shape: (n_slices_actual, img_size, img_size)\n\n    if tensor.shape[0] < n_slices:\n        pad = np.zeros((n_slices - tensor.shape[0], img_size, img_size), dtype=np.uint8)\n        tensor = np.concatenate([tensor, pad], axis=0)\n\n    return tensor\n\ntest_row = full_train[full_train[\"SeriesInstanceUID\"].notna()].iloc[0]\ntest_tensor = load_series_as_tensor(test_row[\"StudyInstanceUID\"], test_row[\"SeriesInstanceUID\"])\nprint(test_tensor.shape, test_tensor.dtype)\nprint(f\"Approx size per study: {test_tensor.nbytes / 1024:.1f} KB\")\nprint(f\"Estimated total cache size for {full_train['SeriesInstanceUID'].notna().sum()} studies: \"\n      f\"{test_tensor.nbytes * full_train['SeriesInstanceUID'].notna().sum() / 1e9:.2f} GB\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:35:15.75741Z","iopub.execute_input":"2026-08-27T06:35:15.757742Z","iopub.status.idle":"2026-08-27T06:35:15.927268Z","shell.execute_reply.started":"2026-08-27T06:35:15.757691Z","shell.execute_reply":"2026-08-27T06:35:15.926612Z"}},"outputs":[],"execution_count":null},{"id":"3c27aecb-e004-478c-bb8d-73d824e794ed","cell_type":"code","source":"print(full_train.shape)\nprint(full_train[\"SeriesInstanceUID\"].notna().sum())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:35:15.929662Z","iopub.execute_input":"2026-08-27T06:35:15.929908Z","iopub.status.idle":"2026-08-27T06:35:15.934891Z","shell.execute_reply.started":"2026-08-27T06:35:15.929885Z","shell.execute_reply":"2026-08-27T06:35:15.934103Z"}},"outputs":[],"execution_count":null},{"id":"41fce0d8-51ff-4317-9904-1594925d95fc","cell_type":"code","source":"import time\n\nCACHE_PATH = \"/kaggle/working/image_cache.npz\"\nPERMANENT_CACHE_PATH = \"/kaggle/input/datasets/mayas12/image-cache/image_cache.npz\"  # your actual saved path\n\nif os.path.exists(PERMANENT_CACHE_PATH):\n    print(\"Found permanent image cache — skipping DICOM re-read entirely.\")\n    tensors = None  \nelse:\n    valid_rows = full_train[full_train[\"SeriesInstanceUID\"].notna()].reset_index(drop=True)\n\n    # Resume support: load whatever's already cached\n    if os.path.exists(CACHE_PATH):\n        existing = np.load(CACHE_PATH)\n        cached_uids = set(existing.files)\n        print(f\"Resuming — {len(cached_uids)} already cached\")\n        tensors = {uid: existing[uid] for uid in existing.files}\n    else:\n        cached_uids = set()\n        tensors = {}\n\n    remaining = valid_rows[~valid_rows[\"StudyInstanceUID\"].isin(cached_uids)]\n    print(f\"{len(remaining)} studies left to load\")\n\n    start = time.time()\n    for i, row in enumerate(remaining.itertuples()):\n        tensor = load_series_as_tensor(row.StudyInstanceUID, row.SeriesInstanceUID)\n        if tensor is not None:\n            tensors[row.StudyInstanceUID] = tensor\n\n        if (i + 1) % 200 == 0:\n            np.savez_compressed(CACHE_PATH, **tensors)\n            elapsed = time.time() - start\n            rate = (i + 1) / elapsed\n            remaining_est = (len(remaining) - (i + 1)) / rate / 60\n            print(f\"Checkpointed {len(tensors)} / {len(valid_rows)}  \"\n                  f\"({rate:.1f} studies/sec, ~{remaining_est:.1f} min remaining)\")\n\n    np.savez_compressed(CACHE_PATH, **tensors)\n    print(f\"Done — {len(tensors)} studies cached to {CACHE_PATH}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:36:05.041181Z","iopub.execute_input":"2026-08-27T06:36:05.041749Z","iopub.status.idle":"2026-08-27T06:36:05.053135Z","shell.execute_reply.started":"2026-08-27T06:36:05.04172Z","shell.execute_reply":"2026-08-27T06:36:05.052376Z"}},"outputs":[],"execution_count":null},{"id":"8eb2d5cb-5d26-4eda-a6d8-15724acb433b","cell_type":"code","source":"import torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.model_selection import KFold\nfrom sklearn.metrics import roc_auc_score\nimport numpy as np\n\nIMAGE_CACHE_PATH = \"/kaggle/input/datasets/mayas12/image-cache/image_cache.npz\"\nimage_cache = np.load(IMAGE_CACHE_PATH)\n\n# Only keep rows that both have a label AND made it into the image cache\nhas_image = full_train[\"StudyInstanceUID\"].isin(image_cache.files)\nimg_train = full_train[has_image].reset_index(drop=True)\nprint(f\"{len(img_train)} studies with both labels and cached images\")\n\nclass KneeDataset(Dataset):\n    def __init__(self, df, image_cache, label_cols):\n        self.uids = df[\"StudyInstanceUID\"].values\n        self.labels = df[label_cols].values.astype(np.float32)\n        self.image_cache = image_cache\n\n    def __len__(self):\n        return len(self.uids)\n\n    def __getitem__(self, idx):\n        uid = self.uids[idx]\n        tensor = self.image_cache[uid].astype(np.float32) / 255.0  \n        tensor = torch.from_numpy(tensor).unsqueeze(0)  \n        label = torch.from_numpy(self.labels[idx])\n        return tensor, label\n\nclass Simple3DCNN(nn.Module):\n    def __init__(self, n_labels=12):\n        super().__init__()\n        self.conv = nn.Sequential(\n            nn.Conv3d(1, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool3d(2),\n            nn.Conv3d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool3d(2),\n            nn.Conv3d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool3d(1),\n        )\n        self.fc = nn.Linear(64, n_labels)\n\n    def forward(self, x):\n        x = self.conv(x)\n        x = x.view(x.size(0), -1)\n        return self.fc(x)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:36:18.838386Z","iopub.execute_input":"2026-08-27T06:36:18.839159Z","iopub.status.idle":"2026-08-27T06:36:22.751066Z","shell.execute_reply.started":"2026-08-27T06:36:18.839129Z","shell.execute_reply":"2026-08-27T06:36:22.750419Z"}},"outputs":[],"execution_count":null},{"id":"017be5e3-1f1f-476f-ab96-5567721dece8","cell_type":"code","source":"device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\n\nimg_gold = img_train[img_train[\"is_pseudo\"] == 0].reset_index(drop=True)\nimg_pseudo = img_train[img_train[\"is_pseudo\"] == 1].reset_index(drop=True)\n\nprint(f\"{len(img_gold)} gold studies with images, {len(img_pseudo)} pseudo studies with images\")\n\ndef train_one_fold(train_df, val_df, epochs=15, batch_size=16, lr=1e-3):\n    train_ds = KneeDataset(train_df, image_cache, label_cols)\n    val_ds = KneeDataset(val_df, image_cache, label_cols)\n    train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)\n    val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)\n\n    model = Simple3DCNN(n_labels=len(label_cols)).to(device)\n    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n    criterion = nn.BCEWithLogitsLoss()\n\n    for epoch in range(epochs):\n        model.train()\n        for x, y in train_loader:\n            x, y = x.to(device), y.to(device)\n            optimizer.zero_grad()\n            out = model(x)\n            loss = criterion(out, y)\n            loss.backward()\n            optimizer.step()\n\n    model.eval()\n    all_preds, all_true = [], []\n    with torch.no_grad():\n        for x, y in val_loader:\n            x = x.to(device)\n            out = torch.sigmoid(model(x)).cpu().numpy()\n            all_preds.append(out)\n            all_true.append(y.numpy())\n\n    return np.concatenate(all_preds), np.concatenate(all_true)\n\n# kf = KFold(n_splits=5, shuffle=True, random_state=42)\n# fold_aucs = []\n\n# for fold, (gold_train_idx, gold_val_idx) in enumerate(kf.split(img_gold)):\n#     train_df = pd.concat([img_pseudo, img_gold.iloc[gold_train_idx]], ignore_index=True)\n#     val_df = img_gold.iloc[gold_val_idx].reset_index(drop=True)\n\n#     preds, true = train_one_fold(train_df, val_df)\n\n#     label_aucs, skipped = [], []\n#     for i, col in enumerate(label_cols):\n#         if len(np.unique(true[:, i])) < 2:\n#             skipped.append(col)\n#             continue\n#         label_aucs.append(roc_auc_score(true[:, i], preds[:, i]))\n\n#     fold_macro_auc = np.mean(label_aucs) if label_aucs else float(\"nan\")\n#     fold_aucs.append(fold_macro_auc)\n#     print(f\"Fold {fold}: macro AUC = {fold_macro_auc:.4f}  (scored {len(label_aucs)}/12, skipped {skipped})\")\n\n# print(f\"\\nMean macro AUC (image-only, validated on gold): {np.nanmean(fold_aucs):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:37:57.353Z","iopub.execute_input":"2026-08-27T06:37:57.353274Z","iopub.status.idle":"2026-08-27T06:37:57.634516Z","shell.execute_reply.started":"2026-08-27T06:37:57.35325Z","shell.execute_reply":"2026-08-27T06:37:57.633785Z"}},"outputs":[],"execution_count":null},{"id":"91ba223e-3ac1-48c8-86c9-7b712a99dd04","cell_type":"code","source":"import torchvision.models as models\n\nclass SliceResNetAggregator(nn.Module):\n    def __init__(self, n_labels=12, n_slices=16):\n        super().__init__()\n        backbone = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)\n        backbone.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)  # 1-channel input, not 3\n        self.backbone = nn.Sequential(*list(backbone.children())[:-1])  # drop the final classification layer\n        self.feature_dim = 512  # ResNet18's output feature size\n\n        self.aggregator = nn.Sequential(\n            nn.Linear(self.feature_dim, 256), nn.ReLU(), nn.Dropout(0.3)\n        )\n        self.classifier = nn.Linear(256, n_labels)\n\n    def forward(self, x):\n        # x shape: (batch, 1, n_slices, H, W) — same as before\n        B, C, S, H, W = x.shape\n        x = x.permute(0, 2, 1, 3, 4).reshape(B * S, C, H, W)  # treat each slice as its own image\n\n        features = self.backbone(x)              # (B*S, 512, 1, 1)\n        features = features.view(B, S, self.feature_dim)  # (B, S, 512)\n        pooled = features.mean(dim=1)             # mean-pool across slices -> (B, 512)\n\n        agg = self.aggregator(pooled)\n        return self.classifier(agg)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:38:00.369935Z","iopub.execute_input":"2026-08-27T06:38:00.370201Z","iopub.status.idle":"2026-08-27T06:38:03.639104Z","shell.execute_reply.started":"2026-08-27T06:38:00.370179Z","shell.execute_reply":"2026-08-27T06:38:03.638223Z"}},"outputs":[],"execution_count":null},{"id":"2e8d448a-3714-4006-8a51-2ffd4b94e4e3","cell_type":"code","source":"def train_one_fold(train_df, val_df, epochs=15, batch_size=16, lr=1e-4):  # note: lower lr for fine-tuning pretrained weights\n    train_ds = KneeDataset(train_df, image_cache, label_cols)\n    val_ds = KneeDataset(val_df, image_cache, label_cols)\n    train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)\n    val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)\n\n    model = SliceResNetAggregator(n_labels=len(label_cols)).to(device)  # <-- only this line changed\n    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n    criterion = nn.BCEWithLogitsLoss()\n\n    for epoch in range(epochs):\n        model.train()\n        for x, y in train_loader:\n            x, y = x.to(device), y.to(device)\n            optimizer.zero_grad()\n            out = model(x)\n            loss = criterion(out, y)\n            loss.backward()\n            optimizer.step()\n\n    model.eval()\n    all_preds, all_true = [], []\n    with torch.no_grad():\n        for x, y in val_loader:\n            x = x.to(device)\n            out = torch.sigmoid(model(x)).cpu().numpy()\n            all_preds.append(out)\n            all_true.append(y.numpy())\n\n    return np.concatenate(all_preds), np.concatenate(all_true)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:38:07.436366Z","iopub.execute_input":"2026-08-27T06:38:07.437326Z","iopub.status.idle":"2026-08-27T06:38:07.444588Z","shell.execute_reply.started":"2026-08-27T06:38:07.437292Z","shell.execute_reply":"2026-08-27T06:38:07.443633Z"}},"outputs":[],"execution_count":null},{"id":"d363d9ad-b6ab-4cf7-b996-4d0ca07c3df2","cell_type":"code","source":"kf = KFold(n_splits=5, shuffle=True, random_state=42)\nfold_aucs = []\n\nfor fold, (gold_train_idx, gold_val_idx) in enumerate(kf.split(img_gold)):\n    train_df = pd.concat([img_pseudo, img_gold.iloc[gold_train_idx]], ignore_index=True)\n    val_df = img_gold.iloc[gold_val_idx].reset_index(drop=True)\n\n    preds, true = train_one_fold(train_df, val_df)\n\n    label_aucs, skipped = [], []\n    for i, col in enumerate(label_cols):\n        if len(np.unique(true[:, i])) < 2:\n            skipped.append(col)\n            continue\n        label_aucs.append(roc_auc_score(true[:, i], preds[:, i]))\n\n    fold_macro_auc = np.mean(label_aucs) if label_aucs else float(\"nan\")\n    fold_aucs.append(fold_macro_auc)\n    print(f\"Fold {fold}: macro AUC = {fold_macro_auc:.4f}  (scored {len(label_aucs)}/12, skipped {skipped})\")\n\nprint(f\"\\nMean macro AUC (ResNet18, validated on gold): {np.nanmean(fold_aucs):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:38:11.635477Z","iopub.execute_input":"2026-08-27T06:38:11.635826Z","iopub.status.idle":"2026-08-27T06:38:17.481942Z","shell.execute_reply.started":"2026-08-27T06:38:11.635802Z","shell.execute_reply":"2026-08-27T06:38:17.480929Z"}},"outputs":[],"execution_count":null},{"id":"e19f35a8-fcf3-4cb7-96f6-31bec67397d7","cell_type":"code","source":"from sklearn.feature_extraction.text import TfidfVectorizer\nfrom sklearn.linear_model import LogisticRegression\n\nkf = KFold(n_splits=5, shuffle=True, random_state=42)\nfold_aucs_fused = []\nfold_aucs_text_only = []\nfold_aucs_image_only = []\n\nfor fold, (gold_train_idx, gold_val_idx) in enumerate(kf.split(img_gold)):\n    train_df = pd.concat([img_pseudo, img_gold.iloc[gold_train_idx]], ignore_index=True)\n    val_df = img_gold.iloc[gold_val_idx].reset_index(drop=True)\n\n    # --- Image model: same as before ---\n    img_preds, true = train_one_fold(train_df, val_df)\n\n    # --- Text model: same TF-IDF + logistic regression approach, same split ---\n    X_tr_text = train_df[\"Report\"].fillna(\"\")\n    X_val_text = val_df[\"Report\"].fillna(\"\")\n    Y_tr = train_df[label_cols].values\n\n    vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2), min_df=2)\n    X_tr_vec = vectorizer.fit_transform(X_tr_text)\n    X_val_vec = vectorizer.transform(X_val_text)\n\n    text_preds = np.zeros((len(val_df), len(label_cols)))\n    for i, col in enumerate(label_cols):\n        y_tr_col = Y_tr[:, i]\n        if len(np.unique(y_tr_col)) < 2:\n            continue\n        clf = LogisticRegression(max_iter=1000, class_weight=\"balanced\")\n        clf.fit(X_tr_vec, y_tr_col)\n        text_preds[:, i] = clf.predict_proba(X_val_vec)[:, 1]\n\n    fused_preds = (img_preds + text_preds) / 2\n\n    def score(preds, true):\n        aucs = []\n        for i in range(len(label_cols)):\n            if len(np.unique(true[:, i])) < 2:\n                continue\n            aucs.append(roc_auc_score(true[:, i], preds[:, i]))\n        return np.mean(aucs) if aucs else float(\"nan\")\n\n    auc_img = score(img_preds, true)\n    auc_text = score(text_preds, true)\n    auc_fused = score(fused_preds, true)\n\n    fold_aucs_image_only.append(auc_img)\n    fold_aucs_text_only.append(auc_text)\n    fold_aucs_fused.append(auc_fused)\n\n    print(f\"Fold {fold}: image={auc_img:.4f}  text={auc_text:.4f}  fused={auc_fused:.4f}\")\n\nprint(f\"\\nMean — image: {np.nanmean(fold_aucs_image_only):.4f}\")\nprint(f\"Mean — text:  {np.nanmean(fold_aucs_text_only):.4f}\")\nprint(f\"Mean — fused: {np.nanmean(fold_aucs_fused):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-27T06:38:21.769856Z","iopub.execute_input":"2026-08-27T06:38:21.770505Z","iopub.status.idle":"2026-08-27T07:57:03.736384Z","shell.execute_reply.started":"2026-08-27T06:38:21.770475Z","shell.execute_reply":"2026-08-27T07:57:03.735616Z"}},"outputs":[],"execution_count":null}]}