{"cells":[{"cell_type":"markdown","id":"d4aa52c5","metadata":{},"source":"# RSNA Knee Abnormality Detection — public GPU baseline\n\nThis notebook is deliberately self-contained: it reads only the official competition\ndata, runs with Internet disabled, writes `/kaggle/working/submission.csv`, and can be\nsubmitted from the Notebook Output tab. The only labels supplied by the competition\ncover 58 studies, so reports are used only for conservative multilingual weak labels."},{"cell_type":"code","execution_count":null,"id":"1f68a52e","metadata":{},"outputs":[],"source":"import os\nimport re\nimport random\nimport warnings\nfrom functools import lru_cache\nfrom pathlib import Path\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport torch\nimport torch.nn as nn\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.model_selection import KFold\nfrom torch.cuda.amp import GradScaler, autocast\nfrom torch.utils.data import DataLoader, Dataset\nfrom torchvision.models import resnet18\n\nwarnings.filterwarnings(\"ignore\", category=UserWarning, module=\"pydicom\")\n\nSEED = 20260806\nN_FOLDS = 3\nEPOCHS = 3\nIMAGE_SIZE = 192\nMAX_SERIES = 5\nBATCH_SIZE = 6\nNUM_WORKERS = min(4, os.cpu_count() or 2)\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nLABELS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\", \"Medial OA\",\n    \"Lateral OA\", \"PF OA\", \"Effusion\", \"Synovitis\", \"Baker's\",\n    \"Contusion\", \"Fracture\",\n]\n\ndef seed_everything(seed=SEED):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.benchmark = True\n\nseed_everything()\n\nINPUT = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\nif not INPUT.exists():\n    candidates = [Path(\"/kaggle/input/rsna-knee-abnormality-detection\")]\n    candidates += list(Path(\"/kaggle/input\").glob(\"*rsna*knee*\"))\n    INPUT = next((path for path in candidates if path.exists()), Path(\"data/raw\"))\nassert (INPUT / \"train.csv\").exists(), f\"Competition data was not attached: {INPUT}\"\nprint({\"data_root\": str(INPUT), \"device\": str(DEVICE), \"torch\": torch.__version__})"},{"cell_type":"markdown","id":"b35213e3","metadata":{},"source":"## Read metadata and build high-precision weak labels\n\nMissing weak labels remain masked in the loss. A report that does not mention an\nabnormality is never treated as a negative label. The 58 competition-provided rows\nalways override weak labels and are the only rows used for validation."},{"cell_type":"code","execution_count":null,"id":"528280c7","metadata":{"lines_to_next_cell":1},"outputs":[],"source":"train = pd.read_csv(INPUT / \"train.csv\")\ntest = pd.read_csv(INPUT / \"test.csv\")\ntrain_series = pd.read_csv(INPUT / \"train_series.csv\")\ntest_series = pd.read_csv(INPUT / \"test_series.csv\")\nsample_submission = pd.read_csv(INPUT / \"sample_submission.csv\")\n\nassert sample_submission.columns.tolist() == [\"StudyInstanceUID\", *LABELS]\ngold_mask = train[LABELS].notna().all(axis=1)\nassert gold_mask.sum() >= N_FOLDS, \"Not enough official labels for cross-validation\"\nprint({\n    \"train_studies\": len(train), \"test_studies_visible\": len(test),\n    \"train_series\": len(train_series), \"officially_labeled\": int(gold_mask.sum()),\n})\n\nNEGATION = re.compile(\n    r\"\\b(?:no|not|without|absent|normal|intact|preserved|unremarkable|sin|\"\n    r\"kein(?:e|en|em|er)?|ohne|intakt(?:e|en|em|er)?|regelrecht|unauffällig|\"\n    r\"δεν|χωρίς|φυσιολογ|няма|без)\\b\",\n    re.IGNORECASE,\n)\nPATHOLOGY = re.compile(\n    r\"tear|rupture|partial|complete|injur|sprain|laxity|discontinu|abnormal|\"\n    r\"lesion|riss|ruptur|teilruptur|läsion|rotura|ruptura|esguince|lesión|\"\n    r\"ρήξη|βλάβ|разкъс\",\n    re.IGNORECASE,\n)\n\n# A condition is assigned only when it is explicitly named. These stems cover the\n# languages observed in the released reports; additional languages simply stay masked.\nPATTERNS = {\n    \"ACL\": r\"\\b(?:acl|lca)\\b|anterior\\s+cruciate|vorder(?:e|en)?\\s+kreuzband|ligamento\\s+cruzado\\s+anterior|προσθ.{0,25}χιαστ\",\n    \"MCL\": r\"\\b(?:mcl|lcm)\\b|medial\\s+collateral|inner(?:e|en)?\\s+seitenband|ligamento\\s+colateral\\s+medial|έσω\\s+πλάγιο\",\n    \"Medial Meniscus\": r\"medial\\s+meniscus|inner(?:e|en)?\\s+menisk|menisco\\s+medial|έσω\\s+μηνίσκ|медиал.{0,15}мениск\",\n    \"Lateral Meniscus\": r\"lateral\\s+meniscus|outer(?:e|en)?\\s+menisk|menisco\\s+lateral|έξω\\s+μηνίσκ|латерал.{0,15}мениск\",\n    \"Medial OA\": r\"(?:medial|inner|medialen|medialer|medial\\w*|έσω|медиал).{0,50}(?:osteoarth|arthros|gonarth|degenerat)|(?:osteoarth|arthros|gonarth).{0,50}(?:medial|inner|έσω|медиал)\",\n    \"Lateral OA\": r\"(?:lateral|outer|lateralen|lateraler|έξω|латерал).{0,50}(?:osteoarth|arthros|gonarth|degenerat)|(?:osteoarth|arthros|gonarth).{0,50}(?:lateral|outer|έξω|латерал)\",\n    \"PF OA\": r\"patello.{0,20}(?:femoral|femorale)|femoropatellar|patelofemoral\",\n    \"Effusion\": r\"effusion|joint\\s+fluid|hydrops|derrame|erguss|υγρ.{0,20}αρθρ|излив\",\n    \"Synovitis\": r\"synovitis|sinovitis|synovial.{0,25}(?:thicken|inflam)|synovialitis|υμενίτιδ|синовит\",\n    \"Baker's\": r\"baker(?:'s)?(?:\\s+(?:cyst|cyste|zyste))?|popliteal\\s+cyst|quiste\\s+popl|κύστη.{0,20}baker|кист.{0,20}бейк\",\n    \"Contusion\": r\"bone\\s+(?:contusion|bruise)|marrow\\s+o?edema|ödem.{0,25}knochenmark|edema.{0,25}óseo|οστικ.{0,25}οίδημα|оток.{0,25}кост\",\n    \"Fracture\": r\"fracture|fraktur|fractura|κάταγμα|счуп|фрактур\",\n}\nPATTERNS = {name: re.compile(pattern, re.IGNORECASE) for name, pattern in PATTERNS.items()}\nPATHOLOGY_REQUIRED = {\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\"}\n\ndef report_target(text, pattern, label):\n    \"\"\"Return 1, 0, or NaN from explicit positive/negated mentions only.\"\"\"\n    text = \"\" if pd.isna(text) else str(text).casefold()\n    positive = negative = False\n    for sentence in re.split(r\"[\\.\\n;:]+\", text):\n        for match in pattern.finditer(sentence):\n            context = sentence[max(0, match.start() - 90):min(len(sentence), match.end() + 90)]\n            if NEGATION.search(context):\n                negative = True\n            elif label not in PATHOLOGY_REQUIRED or PATHOLOGY.search(context):\n                positive = True\n    if positive:\n        return 1.0\n    if negative:\n        return 0.0\n    return np.nan\n\nweak = pd.DataFrame({\n    label: train[\"Report\"].map(lambda text, p=pattern, name=label: report_target(text, p, name))\n    for label, pattern in PATTERNS.items()\n})\ntargets = weak.copy()\ntargets.loc[gold_mask, LABELS] = train.loc[gold_mask, LABELS].astype(float).values\nprint(\"weak-label coverage\", targets.notna().sum().to_dict())\nprint(\"weak positives\", targets.sum(skipna=True).astype(int).to_dict())\n\nquality_rows = []\nfor label in LABELS:\n    observed = weak.loc[gold_mask, label].notna()\n    if observed.any():\n        y_true = train.loc[gold_mask, label].astype(int)[observed]\n        y_pred = weak.loc[gold_mask, label][observed].astype(int)\n        quality_rows.append({\n            \"label\": label,\n            \"report_coverage\": int(observed.sum()),\n            \"precision\": float(((y_pred == 1) & (y_true == 1)).sum() / max((y_pred == 1).sum(), 1)),\n            \"accuracy\": float((y_pred == y_true).mean()),\n        })\ndisplay(pd.DataFrame(quality_rows).round(3))"},{"cell_type":"markdown","id":"a14d4461","metadata":{},"source":"## DICOM study dataset\n\nEach study is represented by up to five informative series. Three evenly spaced slices\nbecome RGB channels, which lets a lightweight 2D CNN model through-plane context without\nmaterializing the 247 GB DICOM archive into a second cache."},{"cell_type":"code","execution_count":null,"id":"7d68bb7f","metadata":{"lines_to_next_cell":1},"outputs":[],"source":"def select_series(series_df):\n    priority = series_df.copy()\n    priority[\"plane_rank\"] = priority[\"Anatomical_Plane\"].map({\"Sagittal\": 0, \"Coronal\": 1, \"Axial\": 2}).fillna(3)\n    priority = priority.sort_values(\n        [\"StudyInstanceUID\", \"Fluid_Sensitive\", \"Fat_Suppression\", \"plane_rank\"],\n        ascending=[True, False, False, True],\n    )\n    return priority.groupby(\"StudyInstanceUID\", sort=False).head(MAX_SERIES)\n\ndef percentile_image(array):\n    array = np.asarray(array, dtype=np.float32)\n    lo, hi = np.percentile(array, [1, 99])\n    if not np.isfinite(lo) or hi <= lo:\n        return np.zeros((IMAGE_SIZE, IMAGE_SIZE), np.float32)\n    array = np.clip((array - lo) / (hi - lo), 0, 1)\n    return cv2.resize(array, (IMAGE_SIZE, IMAGE_SIZE), interpolation=cv2.INTER_AREA).astype(np.float32, copy=False)\n\n@lru_cache(maxsize=32768)\ndef read_series_as_rgb(series_path):\n    files = sorted(Path(series_path).glob(\"*.dcm\"))\n    if not files:\n        return np.zeros((3, IMAGE_SIZE, IMAGE_SIZE), np.float32)\n    positions = np.linspace(0, len(files) - 1, 3).round().astype(int)\n    slices = []\n    for position in positions:\n        try:\n            pixel_array = pydicom.dcmread(files[int(position)]).pixel_array\n            slices.append(percentile_image(pixel_array))\n        except Exception:\n            slices.append(np.zeros((IMAGE_SIZE, IMAGE_SIZE), np.float32))\n    return np.stack(slices, axis=0)\n\nclass KneeStudyDataset(Dataset):\n    def __init__(self, study_ids, series_df, root, target_frame=None, training=False):\n        self.study_ids = list(study_ids)\n        self.root = Path(root)\n        self.training = training\n        selected = select_series(series_df[series_df[\"StudyInstanceUID\"].isin(self.study_ids)])\n        self.series = selected.groupby(\"StudyInstanceUID\")[\"SeriesInstanceUID\"].agg(list).to_dict()\n        self.target_frame = target_frame\n\n    def __len__(self):\n        return len(self.study_ids)\n\n    def __getitem__(self, index):\n        study_id = self.study_ids[index]\n        series_ids = self.series.get(study_id, [])[:MAX_SERIES]\n        images, valid = [], []\n        for series_id in series_ids:\n            path = self.root / \"train_series\" / study_id / series_id\n            if not path.exists():\n                path = self.root / \"test_series\" / study_id / series_id\n            images.append(read_series_as_rgb(str(path)))\n            valid.append(1.0)\n        while len(images) < MAX_SERIES:\n            images.append(np.zeros((3, IMAGE_SIZE, IMAGE_SIZE), np.float32))\n            valid.append(0.0)\n        image = torch.from_numpy(np.stack(images)).float()\n        if self.training and torch.rand(()) < 0.5:\n            image = torch.flip(image, dims=[-1])\n        if self.target_frame is None:\n            return image, torch.tensor(valid, dtype=torch.float32), study_id\n        target = self.target_frame.loc[study_id, LABELS].to_numpy(dtype=np.float32)\n        mask = np.isfinite(target).astype(np.float32)\n        target = np.nan_to_num(target, nan=0.0)\n        return image, torch.tensor(valid, dtype=torch.float32), torch.from_numpy(target), torch.from_numpy(mask)\n\nclass StudyResNet(nn.Module):\n    def __init__(self, n_labels):\n        super().__init__()\n        network = resnet18(weights=None)\n        self.encoder = nn.Sequential(*list(network.children())[:-1])\n        self.head = nn.Sequential(nn.Flatten(), nn.Linear(512, n_labels))\n\n    def forward(self, image, series_mask):\n        batch_size, n_series = image.shape[:2]\n        features = self.encoder(image.flatten(0, 1)).flatten(1).reshape(batch_size, n_series, -1)\n        weights = series_mask.unsqueeze(-1)\n        features = (features * weights).sum(1) / weights.sum(1).clamp_min(1.0)\n        return self.head(features)\n\ndef make_loader(dataset, shuffle):\n    return DataLoader(\n        dataset, batch_size=BATCH_SIZE, shuffle=shuffle, num_workers=NUM_WORKERS,\n        pin_memory=DEVICE.type == \"cuda\", persistent_workers=NUM_WORKERS > 0,\n    )\n\ndef masked_bce(logits, targets, mask, pos_weight):\n    loss = nn.functional.binary_cross_entropy_with_logits(\n        logits, targets, pos_weight=pos_weight, reduction=\"none\"\n    )\n    return (loss * mask).sum() / mask.sum().clamp_min(1.0)\n\ndef validation_auc(prediction, validation_ids, target_frame):\n    truth = target_frame.loc[validation_ids, LABELS].to_numpy(dtype=float)\n    scores = []\n    for column in range(len(LABELS)):\n        valid = np.isfinite(truth[:, column])\n        if valid.sum() and np.unique(truth[valid, column]).size == 2:\n            scores.append(roc_auc_score(truth[valid, column], prediction[valid, column]))\n    return float(np.mean(scores)) if scores else float(\"nan\")"},{"cell_type":"markdown","id":"fb9c4c6e","metadata":{},"source":"## Train three folds on weak labels, validate only on competition truth"},{"cell_type":"code","execution_count":null,"id":"6aa03f88","metadata":{},"outputs":[],"source":"target_frame = targets.copy()\ntarget_frame.index = train[\"StudyInstanceUID\"]\nall_ids = train[\"StudyInstanceUID\"].tolist()\ngold_ids = train.loc[gold_mask, \"StudyInstanceUID\"].to_numpy()\ntest_ids = test[\"StudyInstanceUID\"].tolist()\n\noof = np.full((len(gold_ids), len(LABELS)), np.nan, dtype=np.float32)\ntest_prediction = np.zeros((len(test_ids), len(LABELS)), dtype=np.float32)\nfold_scores = []\n\nfor fold, (gold_train_idx, gold_valid_idx) in enumerate(KFold(N_FOLDS, shuffle=True, random_state=SEED).split(gold_ids)):\n    valid_ids = gold_ids[gold_valid_idx]\n    valid_set = set(valid_ids)\n    train_ids = [study_id for study_id in all_ids if study_id not in valid_set and target_frame.loc[study_id, LABELS].notna().any()]\n    fold_targets = target_frame.loc[train_ids, LABELS]\n    positives = fold_targets.sum(skipna=True).to_numpy(dtype=np.float32)\n    observed = fold_targets.notna().sum().to_numpy(dtype=np.float32)\n    pos_weight = torch.tensor(np.clip((observed - positives) / np.maximum(positives, 1), 1, 15), device=DEVICE)\n\n    train_dataset = KneeStudyDataset(train_ids, train_series, INPUT, target_frame, training=True)\n    valid_dataset = KneeStudyDataset(valid_ids, train_series, INPUT, target_frame, training=False)\n    test_dataset = KneeStudyDataset(test_ids, test_series, INPUT, target_frame=None, training=False)\n    train_loader = make_loader(train_dataset, shuffle=True)\n    valid_loader = make_loader(valid_dataset, shuffle=False)\n    test_loader = make_loader(test_dataset, shuffle=False)\n\n    model = StudyResNet(len(LABELS)).to(DEVICE)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-4)\n    scaler = GradScaler(enabled=DEVICE.type == \"cuda\")\n    for epoch in range(EPOCHS):\n        model.train()\n        losses = []\n        for image, series_mask, target, mask in train_loader:\n            optimizer.zero_grad(set_to_none=True)\n            with autocast(enabled=DEVICE.type == \"cuda\"):\n                logits = model(image.to(DEVICE, non_blocking=True), series_mask.to(DEVICE, non_blocking=True))\n                loss = masked_bce(logits, target.to(DEVICE), mask.to(DEVICE), pos_weight)\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n            losses.append(loss.item())\n        print(f\"fold={fold} epoch={epoch + 1}/{EPOCHS} loss={np.mean(losses):.4f}\")\n\n    model.eval()\n    fold_valid, fold_test = [], []\n    with torch.no_grad():\n        for image, series_mask, _, _ in valid_loader:\n            fold_valid.append(torch.sigmoid(model(image.to(DEVICE), series_mask.to(DEVICE))).cpu().numpy())\n        for image, series_mask, _ in test_loader:\n            fold_test.append(torch.sigmoid(model(image.to(DEVICE), series_mask.to(DEVICE))).cpu().numpy())\n    fold_valid = np.concatenate(fold_valid)\n    oof[gold_valid_idx] = fold_valid\n    test_prediction += np.concatenate(fold_test) / N_FOLDS\n    score = validation_auc(fold_valid, valid_ids, target_frame)\n    fold_scores.append(score)\n    print(f\"fold={fold} official-label macro AUC={score:.5f}\")\n    del model, train_loader, valid_loader, test_loader\n    torch.cuda.empty_cache()\n\nprint(\"CV macro AUC\", np.nanmean(fold_scores), \"per-fold\", fold_scores)"},{"cell_type":"markdown","id":"d85365d8","metadata":{},"source":"## Write and validate the notebook submission"},{"cell_type":"code","execution_count":null,"id":"7297b23a","metadata":{},"outputs":[],"source":"submission = pd.DataFrame(test_prediction, columns=LABELS)\nsubmission.insert(0, \"StudyInstanceUID\", test_ids)\nassert submission.columns.tolist() == sample_submission.columns.tolist()\nassert submission[\"StudyInstanceUID\"].is_unique\nassert np.isfinite(submission[LABELS].to_numpy()).all()\nassert ((submission[LABELS] >= 0) & (submission[LABELS] <= 1)).all().all()\n\noutput_path = Path(\"/kaggle/working/submission.csv\")\noutput_path.parent.mkdir(parents=True, exist_ok=True)\nsubmission.to_csv(output_path, index=False)\nprint({\"submission\": str(output_path), \"rows\": len(submission), \"mean_scores\": submission[LABELS].mean().round(4).to_dict()})\nsubmission.head()"}],"metadata":{"jupytext":{"cell_metadata_filter":"-all","main_language":"python","notebook_metadata_filter":"-all"},"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"}},"nbformat":4,"nbformat_minor":5}