{"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":[{"cell_type":"markdown","source":"# RSNA Knee Abnormality Detection - Baseline v1\n\nEnd-to-end pipeline: weak-label extraction from radiology reports, a 2.5D ResNet18 image model with masked multi-label loss, validation on the 58 gold-labeled studies, and `submission.csv` generation.\n\n**Before running:** Settings -> Accelerator -> GPU.\n\nIf DICOM decoding fails with an \"Unable to decompress\" error, run the pip-install cell below first.","metadata":{}},{"cell_type":"markdown","source":"## Imports & Config","metadata":{}},{"cell_type":"code","source":"import os\nimport re\nimport glob\nimport random\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as T\nimport torchvision.models as models\nfrom sklearn.metrics import roc_auc_score\nfrom tqdm import tqdm\n\n# ---------------------------------------------------------------------\n# CONFIG\n# ---------------------------------------------------------------------\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nDATA_DIR = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\nTRAIN_SERIES_DIR = os.path.join(DATA_DIR, \"train_series\")\nTEST_SERIES_DIR = os.path.join(DATA_DIR, \"test_series\")\n\nIMG_SIZE = 224\nN_SLICES_PER_SERIES = 3          # -> 3-channel \"2.5D\" image\nMAX_SERIES_PER_STUDY = 3         # full run: 1 sagittal + 1 coronal + 1 axial\nBATCH_SIZE = 16\nEPOCHS = 8                       # full run\nLR = 1e-4\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# Quick-test subsample. None = use ALL training studies (full run).\nQUICK_TEST_N_TRAIN_STUDIES = None\n\nLABEL_COLS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\",\n]\n\nprint(\"Device:\", DEVICE)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. LOAD METADATA","metadata":{}},{"cell_type":"code","source":"train = pd.read_csv(os.path.join(DATA_DIR, \"train.csv\"))\ntrain_series = pd.read_csv(os.path.join(DATA_DIR, \"train_series.csv\"))\ntest = pd.read_csv(os.path.join(DATA_DIR, \"test.csv\"))\ntest_series = pd.read_csv(os.path.join(DATA_DIR, \"test_series.csv\"))\nsample_sub = pd.read_csv(os.path.join(DATA_DIR, \"sample_submission.csv\"))\n\npresent_labels = [c for c in LABEL_COLS if c in train.columns]\nhas_gold = train[present_labels].notna().any(axis=1)\nprint(f\"Gold-labeled studies: {has_gold.sum()} / {len(train)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. WEAK LABELS FROM REPORTS (reuse the v1 extractor - condensed here)","metadata":{}},{"cell_type":"code","source":"NEGATION = [\n    r\"\\bno\\b\", r\"\\bnot\\b\", r\"\\bwithout\\b\", r\"\\bintact\\b\", r\"\\bnormal\\b\",\n    r\"\\bpreserved\\b\", r\"\\bunremarkable\\b\", r\"\\bnegative for\\b\",\n    r\"\\bno evidence of\\b\", r\"\\bsin\\b\", r\"\\bno hay\\b\", r\"\\bconservad[ao]s?\\b\",\n    r\"\\bíntegr[ao]s?\\b\", r\"\\bintact[ao]s?\\b\",\n]\nLEXICON = {\n    \"ACL\": [r\"\\bacl\\b.{0,40}\\b(tear|rupture)\\b\", r\"anterior cruciate ligament.{0,40}(tear|rupture|disrupt)\"],\n    \"MCL\": [r\"\\bmcl\\b.{0,40}\\b(tear|sprain|injury)\\b\", r\"medial collateral ligament.{0,40}(tear|sprain|injury)\"],\n    \"Medial Meniscus\": [r\"medial meniscus.{0,60}(tear|torn|extrusion)\"],\n    \"Lateral Meniscus\": [r\"lateral meniscus.{0,60}(tear|torn|extrusion)\"],\n    \"Medial OA\": [r\"medial compartment.{0,60}(cartilage loss|chondral|osteoarthrit|degenerat)\"],\n    \"Lateral OA\": [r\"lateral compartment.{0,60}(cartilage loss|chondral|osteoarthrit|degenerat)\"],\n    \"PF OA\": [r\"patellofemoral.{0,60}(cartilage loss|chondral|osteoarthrit|degenerat)\"],\n    \"Effusion\": [r\"\\beffusion\\b\", r\"\\bderrame articular\\b\"],\n    \"Synovitis\": [r\"\\bsynovitis\\b\", r\"\\bsinovitis\\b\"],\n    \"Baker's\": [r\"baker'?s? cyst\", r\"popliteal cyst\"],\n    \"Contusion\": [r\"bone (bruise|contusion)\", r\"marrow edema\"],\n    \"Fracture\": [r\"\\bfracture\\b\", r\"\\bfractura\\b\"],\n}\n\n\ndef split_sentences(text):\n    if not isinstance(text, str):\n        return []\n    return [c.strip() for c in re.split(r\"[\\n\\r]+|(?<=[.;])\\s+(?=[A-ZÁÉÍÓÚÑ])\", text) if c.strip()]\n\n\ndef extract_weak_labels(text):\n    sentences = split_sentences(text)\n    out = {f: np.nan for f in LABEL_COLS}\n    for finding, patterns in LEXICON.items():\n        mentioned, positive = False, False\n        for sent in sentences:\n            for p in patterns:\n                m = re.search(p, sent, re.IGNORECASE)\n                if m:\n                    mentioned = True\n                    negated = any(re.search(n, sent[: m.start()], re.IGNORECASE) for n in NEGATION)\n                    if not negated:\n                        positive = True\n        if mentioned:\n            out[finding] = 1.0 if positive else 0.0\n    return out\n\n\nprint(\"Extracting weak labels from reports (this can take a minute)...\")\nweak = train[\"Report\"].apply(extract_weak_labels).apply(pd.Series)\n\n# Final label table: gold overrides weak; mask = 1 wherever we have\n# either a gold or a weak (extractor-confident) value.\nlabel_table = weak.copy()\nmask_table = weak.notna().astype(float)\nfor c in present_labels:\n    gold_vals = train[c]\n    override = gold_vals.notna()\n    label_table.loc[override, c] = gold_vals[override]\n    mask_table.loc[override, c] = 1.0\n\nlabel_table = label_table.fillna(0.0)  # unknowns filled but MASKED OUT in loss\nprint(\"Label table ready:\", label_table.shape)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. SERIES SELECTION","metadata":{}},{"cell_type":"code","source":"def pick_series_for_study(study_uid, series_df, series_root):\n    rows = series_df[series_df[\"StudyInstanceUID\"] == study_uid]\n    if rows.empty:\n        return []\n    chosen = []\n    for plane in [\"Sagittal\", \"Coronal\", \"Axial\"]:\n        plane_rows = rows[rows[\"Anatomical_Plane\"] == plane]\n        if plane_rows.empty:\n            continue\n        # prefer fluid sensitive series\n        plane_rows = plane_rows.sort_values(\"Fluid_Sensitive\", ascending=False)\n        series_uid = plane_rows.iloc[0][\"SeriesInstanceUID\"]\n        series_path = os.path.join(series_root, study_uid, series_uid)\n        if os.path.isdir(series_path):\n            chosen.append(series_path)\n        if len(chosen) >= MAX_SERIES_PER_STUDY:\n            break\n    if not chosen:\n        # fallback: just take the first available series\n        series_uid = rows.iloc[0][\"SeriesInstanceUID\"]\n        series_path = os.path.join(series_root, study_uid, series_uid)\n        if os.path.isdir(series_path):\n            chosen.append(series_path)\n    return chosen","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. DICOM LOADING -> 2.5D image","metadata":{}},{"cell_type":"code","source":"def load_series_as_image(series_path, n_slices=N_SLICES_PER_SERIES, size=IMG_SIZE):\n    files = sorted(glob.glob(os.path.join(series_path, \"*.dcm\")))\n    if not files:\n        return np.zeros((size, size, n_slices), dtype=np.float32)\n\n    idxs = np.linspace(0, len(files) - 1, num=min(n_slices, len(files))).astype(int)\n    idxs = list(idxs)\n    while len(idxs) < n_slices:  # pad if series has fewer files than n_slices\n        idxs.append(idxs[-1])\n\n    slices = []\n    for i in idxs:\n        try:\n            ds = pydicom.dcmread(files[i])\n            arr = ds.pixel_array.astype(np.float32)\n            # basic normalization\n            arr -= arr.min()\n            if arr.max() > 0:\n                arr /= arr.max()\n            slices.append(arr)\n        except Exception:\n            slices.append(np.zeros((size, size), dtype=np.float32))\n\n    # resize each slice to (size, size)\n    resized = []\n    for arr in slices:\n        t = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0)\n        t = torch.nn.functional.interpolate(t, size=(size, size), mode=\"bilinear\", align_corners=False)\n        resized.append(t.squeeze().numpy())\n\n    img = np.stack(resized, axis=-1)  # (H, W, n_slices)\n    return img","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. DATASET","metadata":{}},{"cell_type":"code","source":"IMAGENET_MEAN = [0.485, 0.456, 0.406]\nIMAGENET_STD = [0.229, 0.224, 0.225]\nnormalize = T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD)\n\n\nclass KneeStudyDataset(Dataset):\n    \"\"\"One (study, series) pair per item; study-level label is shared\n    across its series during training. At inference we average\n    predictions across a study's series.\"\"\"\n\n    def __init__(self, study_uids, series_df, series_root, labels_df=None, mask_df=None):\n        self.items = []  # list of (study_uid, series_path)\n        for uid in study_uids:\n            for sp in pick_series_for_study(uid, series_df, series_root):\n                self.items.append((uid, sp))\n        self.labels_df = labels_df\n        self.mask_df = mask_df\n        self.study_index = {uid: i for i, uid in enumerate(study_uids)}\n\n    def __len__(self):\n        return len(self.items)\n\n    def __getitem__(self, idx):\n        study_uid, series_path = self.items[idx]\n        img = load_series_as_image(series_path)  # (H, W, 3)\n        img = torch.from_numpy(img).permute(2, 0, 1).float()  # (3, H, W)\n        img = normalize(img)\n\n        if self.labels_df is not None:\n            y = torch.tensor(self.labels_df.loc[self.study_index[study_uid]].values, dtype=torch.float32)\n            m = torch.tensor(self.mask_df.loc[self.study_index[study_uid]].values, dtype=torch.float32)\n            return img, y, m, study_uid\n        return img, study_uid","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. TRAIN / VAL SPLIT","metadata":{}},{"cell_type":"code","source":"all_study_uids = train[\"StudyInstanceUID\"].tolist()\ngold_uids = train.loc[has_gold, \"StudyInstanceUID\"].tolist()\ntrain_uids = train.loc[~has_gold, \"StudyInstanceUID\"].tolist()\n\nif QUICK_TEST_N_TRAIN_STUDIES is not None and len(train_uids) > QUICK_TEST_N_TRAIN_STUDIES:\n    train_uids = random.sample(train_uids, QUICK_TEST_N_TRAIN_STUDIES)\n    print(f\"[QUICK TEST MODE] Subsampled train_uids down to {len(train_uids)} studies\")\n\nlabel_table = label_table.reset_index(drop=True)\nmask_table = mask_table.reset_index(drop=True)\n\ntrain_ds = KneeStudyDataset(train_uids, train_series, TRAIN_SERIES_DIR, label_table, mask_table)\nval_ds = KneeStudyDataset(gold_uids, train_series, TRAIN_SERIES_DIR, label_table, mask_table)\n\nprint(f\"Train items (study-series pairs): {len(train_ds)}\")\nprint(f\"Val items (study-series pairs): {len(val_ds)}\")\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. MODEL","metadata":{}},{"cell_type":"code","source":"model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)\nmodel.fc = nn.Linear(model.fc.in_features, len(LABEL_COLS))\nmodel = model.to(DEVICE)\n\ncriterion = nn.BCEWithLogitsLoss(reduction=\"none\")\noptimizer = torch.optim.AdamW(model.parameters(), lr=LR)\n\n\ndef masked_bce(logits, targets, mask):\n    loss = criterion(logits, targets)\n    loss = (loss * mask).sum() / mask.sum().clamp(min=1.0)\n    return loss","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. TRAIN LOOP (keeps the best checkpoint by val AUC, since AUC on","metadata":{}},{"cell_type":"code","source":"best_auc = -1.0\nbest_state = None\n\nfor epoch in range(EPOCHS):\n    model.train()\n    total_loss = 0.0\n    for imgs, ys, ms, _ in tqdm(train_loader, desc=f\"Epoch {epoch+1}/{EPOCHS} [train]\"):\n        imgs, ys, ms = imgs.to(DEVICE), ys.to(DEVICE), ms.to(DEVICE)\n        optimizer.zero_grad()\n        logits = model(imgs)\n        loss = masked_bce(logits, ys, ms)\n        loss.backward()\n        optimizer.step()\n        total_loss += loss.item() * imgs.size(0)\n    avg_loss = total_loss / len(train_ds)\n\n    # ---- validation on gold studies only ----\n    model.eval()\n    study_logits = {}\n    study_targets = {}\n    with torch.no_grad():\n        for imgs, ys, ms, uids in tqdm(val_loader, desc=f\"Epoch {epoch+1}/{EPOCHS} [val]\"):\n            imgs = imgs.to(DEVICE)\n            logits = torch.sigmoid(model(imgs)).cpu().numpy()\n            for i, uid in enumerate(uids):\n                study_logits.setdefault(uid, []).append(logits[i])\n                study_targets[uid] = ys[i].numpy()\n\n    aucs = []\n    for j, finding in enumerate(LABEL_COLS):\n        y_true, y_pred = [], []\n        for uid in study_logits:\n            y_true.append(study_targets[uid][j])\n            y_pred.append(np.mean([p[j] for p in study_logits[uid]]))\n        y_true = np.array(y_true)\n        y_pred = np.array(y_pred)\n        if len(np.unique(y_true)) > 1:\n            aucs.append(roc_auc_score(y_true, y_pred))\n    mean_auc = np.mean(aucs) if aucs else float(\"nan\")\n\n    print(f\"Epoch {epoch+1}/{EPOCHS} - train_loss={avg_loss:.4f} - val_mean_AUC(gold)={mean_auc:.4f}\")\n\n    if mean_auc > best_auc:\n        best_auc = mean_auc\n        best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n        print(f\"  -> new best (val_mean_AUC={best_auc:.4f}), checkpoint saved\")\n\nif best_state is not None:\n    model.load_state_dict(best_state)\n    print(f\"\\nLoaded best checkpoint (val_mean_AUC(gold)={best_auc:.4f}) for inference.\")\nelse:\n    print(\"\\nWARNING: no valid AUC computed in any epoch, using last-epoch weights.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. INFERENCE ON TEST SET -> submission.csv","metadata":{}},{"cell_type":"code","source":"test_uids = test[\"StudyInstanceUID\"].tolist()\ntest_ds = KneeStudyDataset(test_uids, test_series, TEST_SERIES_DIR)\ntest_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)\n\nmodel.eval()\ntest_preds = {}\nwith torch.no_grad():\n    for imgs, uids in tqdm(test_loader, desc=\"Inference [test]\"):\n        imgs = imgs.to(DEVICE)\n        probs = torch.sigmoid(model(imgs)).cpu().numpy()\n        for i, uid in enumerate(uids):\n            test_preds.setdefault(uid, []).append(probs[i])\n\nrows = []\nfor uid in test_uids:\n    if uid in test_preds:\n        avg_probs = np.mean(test_preds[uid], axis=0)\n    else:\n        avg_probs = np.full(len(LABEL_COLS), 0.5)  # fallback if no series found\n    rows.append([uid] + list(avg_probs))\n\nsubmission = pd.DataFrame(rows, columns=[\"StudyInstanceUID\"] + LABEL_COLS)\n\n# make sure column order / names exactly match sample_submission.csv\nsubmission = submission[sample_sub.columns]\nsubmission.to_csv(\"submission.csv\", index=False)\nprint(\"\\nsubmission.csv written:\")\nprint(submission.head())","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}