{"cells":[{"cell_type":"markdown","metadata":{},"source":"# RSNA Knee: simple image baseline\n\nThis notebook is deliberately small and self-contained:\n\n1. find the competition files;\n2. read a few evenly spaced DICOM slices from one representative series;\n3. train a randomly initialized CNN on the 58 complete GT studies;\n4. evaluate a fixed 46/12 study split with macro ROC-AUC;\n5. retrain on all GT studies and write `/kaggle/working/submission.csv`.\n\nThere are no model weights or datasets from other teams. The most useful next\nexperiments are changing `N_SLICES`, using several series, adding weak labels,\nor replacing `SmallStudyCNN` with a stronger architecture.\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Imports and the few knobs students are expected to change.\nfrom __future__ import annotations\n\nimport json\nimport random\nimport shutil\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\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 torch.utils.data import DataLoader, TensorDataset\n\n\nSEED = 42\nIMAGE_SIZE = 64\nN_SLICES = 8\nEPOCHS = 35\nBATCH_SIZE = 8\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(SEED)\n\nprint(\"device:\", DEVICE)\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Locate the competition mount and read the tabular metadata.\ndef find_data_root():\n    candidates = [\n        Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\"),\n        Path(\"/kaggle/input/rsna-knee-abnormality-detection\"),\n    ]\n    for candidate in candidates:\n        if (candidate / \"train.csv\").is_file() and (candidate / \"train_series.csv\").is_file():\n            return candidate\n    for candidate in Path(\"/kaggle/input\").glob(\"**/train.csv\"):\n        if (candidate.parent / \"train_series.csv\").is_file():\n            return candidate.parent\n    raise FileNotFoundError(\"Could not find the competition data mount\")\n\n\nDATA_ROOT = find_data_root()\ntrain_table = pd.read_csv(DATA_ROOT / \"train.csv\", dtype={\"StudyInstanceUID\": str})\ntest_table = pd.read_csv(DATA_ROOT / \"test.csv\", dtype={\"StudyInstanceUID\": str})\nsample_submission = pd.read_csv(DATA_ROOT / \"sample_submission.csv\")\nTARGETS = [column for column in sample_submission.columns if column != \"StudyInstanceUID\"]\n\n# The competition exposes complete labels for 58 studies. All other train rows\n# are kept in train.csv but have missing target values.\nlabeled = train_table.dropna(subset=TARGETS).copy()\nif len(labeled) == 0:\n    raise RuntimeError(\"No complete labeled studies were found\")\nprint(\"data root:\", DATA_ROOT)\nprint(\"complete GT studies:\", len(labeled), \"targets:\", TARGETS)\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Minimal DICOM preprocessing.\n# We select one fluid-sensitive sagittal series when available, then sample\n# evenly spaced slices. This is intentionally easy to replace.\ndef as_int(value, default=0):\n    try:\n        return int(float(value))\n    except (TypeError, ValueError):\n        return default\n\n\ndef header_key(path):\n    try:\n        ds = pydicom.dcmread(\n            str(path), stop_before_pixels=True,\n            specific_tags=[\"InstanceNumber\", \"ImagePositionPatient\"],\n        )\n        instance = getattr(ds, \"InstanceNumber\", None)\n        position = getattr(ds, \"ImagePositionPatient\", None)\n        if instance is not None:\n            return (0, int(instance), path.name)\n        if position is not None and len(position) >= 3:\n            return (1, float(position[2]), path.name)\n    except Exception:\n        pass\n    return (2, path.name)\n\n\ndef read_one_slice(path):\n    try:\n        ds = pydicom.dcmread(str(path))\n        image = ds.pixel_array.astype(np.float32)\n        if getattr(ds, \"PhotometricInterpretation\", \"\") == \"MONOCHROME1\":\n            image = image.max() - image\n        low, high = np.percentile(image, [1.0, 99.0])\n        image = np.clip((image - low) / max(float(high - low), 1e-6), 0.0, 1.0)\n        # A centered square keeps the code simple and avoids assuming a fixed\n        # Rows/Columns resolution across studies.\n        height, width = image.shape[:2]\n        side = min(height, width)\n        top = (height - side) // 2\n        left = (width - side) // 2\n        image = image[top:top + side, left:left + side]\n        return cv2.resize(image, (IMAGE_SIZE, IMAGE_SIZE), interpolation=cv2.INTER_AREA)\n    except Exception:\n        return None\n\n\ndef choose_series(records):\n    def score(record):\n        plane = str(record.get(\"Anatomical_Plane\", \"\"))\n        return (\n            10 * as_int(record.get(\"Fluid_Sensitive\")),\n            3 * as_int(record.get(\"Fat_Suppression\")),\n            int(plane.lower() == \"sagittal\"),\n        )\n    return max(records, key=score)\n\n\ndef index_series(split):\n    table = pd.read_csv(DATA_ROOT / f\"{split}_series.csv\", dtype=str)\n    grouped = {}\n    for record in table.to_dict(\"records\"):\n        grouped.setdefault(str(record[\"StudyInstanceUID\"]), []).append(record)\n    return grouped\n\n\nTRAIN_SERIES = index_series(\"train\")\nTEST_SERIES = index_series(\"test\")\nCACHE = Path(\"/kaggle/working/teaching-baseline-cache\")\nCACHE.mkdir(parents=True, exist_ok=True)\n\n\ndef load_study(split, uid, series_index):\n    target = CACHE / f\"{split}_{uid}.npy\"\n    if target.is_file():\n        return np.load(target)\n    records = series_index.get(str(uid), [])\n    if not records:\n        raise FileNotFoundError(f\"No series metadata for {split}/{uid}\")\n    record = choose_series(records)\n    folder = DATA_ROOT / f\"{split}_series\" / str(uid) / str(record[\"SeriesInstanceUID\"])\n    files = sorted(folder.glob(\"*.dcm\"), key=header_key)\n    if not files:\n        raise FileNotFoundError(f\"No DICOM files in {folder}\")\n    positions = np.linspace(0, len(files) - 1, N_SLICES).round().astype(int)\n    slices = []\n    for position in positions:\n        image = read_one_slice(files[int(position)])\n        if image is None:\n            image = np.zeros((IMAGE_SIZE, IMAGE_SIZE), dtype=np.float32)\n        slices.append(image.astype(np.float32, copy=False))\n    result = np.stack(slices, axis=0)\n    np.save(target, result)\n    return result\n\n\ndef build_images(split, uids, series_index):\n    started = time.time()\n    output = {}\n    failures = []\n    # A small thread pool hides DICOM I/O latency without complicating the\n    # teaching model or data loader.\n    with ThreadPoolExecutor(max_workers=8) as pool:\n        futures = {\n            pool.submit(load_study, split, str(uid), series_index): str(uid)\n            for uid in uids\n        }\n        for future in as_completed(futures):\n            uid = futures[future]\n            try:\n                output[uid] = future.result()\n            except Exception as error:\n                failures.append({\"uid\": uid, \"error\": repr(error)})\n    if failures:\n        print(\"preprocessing failures:\", failures[:3], \"count=\", len(failures))\n        raise RuntimeError(\"A baseline input could not be decoded\")\n    print(f\"{split}: {len(output)} studies, {time.time() - started:.1f}s\")\n    return np.stack([output[str(uid)] for uid in uids]).astype(np.float32)\n\n\ngt_uids = labeled[\"StudyInstanceUID\"].astype(str).tolist()\ntest_uids = test_table[\"StudyInstanceUID\"].astype(str).tolist()\ngt_images = build_images(\"train\", gt_uids, TRAIN_SERIES)\ntest_images = build_images(\"test\", test_uids, TEST_SERIES)\ngt_labels = labeled[TARGETS].to_numpy(dtype=np.float32)\nprint(\"image tensors:\", gt_images.shape, test_images.shape)\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# A small study-level CNN. It starts from random initialization.\nclass SmallStudyCNN(nn.Module):\n    def __init__(self, n_targets):\n        super().__init__()\n        self.encoder = nn.Sequential(\n            nn.Conv2d(1, 16, kernel_size=3, padding=1),\n            nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2),\n            nn.Conv2d(16, 32, kernel_size=3, padding=1),\n            nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2),\n            nn.Conv2d(32, 64, kernel_size=3, padding=1),\n            nn.BatchNorm2d(64), nn.ReLU(inplace=True),\n            nn.AdaptiveAvgPool2d(1),\n        )\n        self.head = nn.Sequential(\n            nn.Linear(64, 64), nn.ReLU(inplace=True), nn.Dropout(0.2),\n            nn.Linear(64, n_targets),\n        )\n\n    def forward(self, images):\n        batch, slices, height, width = images.shape\n        features = self.encoder(images.reshape(batch * slices, 1, height, width))\n        features = features.flatten(1).reshape(batch, slices, -1).mean(dim=1)\n        return self.head(features)\n\n\ndef macro_auc(labels, scores):\n    values = []\n    for column in range(labels.shape[1]):\n        if len(np.unique(labels[:, column])) >= 2:\n            values.append(roc_auc_score(labels[:, column], scores[:, column]))\n    return float(np.mean(values)) if values else float(\"nan\")\n\n\ndef make_loader(images, labels, indices, shuffle):\n    x = torch.from_numpy(np.asarray(images[indices], dtype=np.float32).copy())\n    y = torch.from_numpy(np.asarray(labels[indices], dtype=np.float32).copy())\n    return DataLoader(TensorDataset(x, y), batch_size=BATCH_SIZE, shuffle=shuffle)\n\n\ndef predict(model, loader):\n    model.eval()\n    scores, labels = [], []\n    with torch.no_grad():\n        for images, targets in loader:\n            scores.append(torch.sigmoid(model(images.to(DEVICE))).cpu().numpy())\n            labels.append(targets.numpy())\n    return np.concatenate(labels), np.concatenate(scores)\n\n\ndef fit_with_validation(train_loader, valid_loader, valid_labels):\n    model = SmallStudyCNN(len(TARGETS)).to(DEVICE)\n    positive = gt_labels[train_indices].sum(axis=0)\n    negative = len(train_indices) - positive\n    pos_weight = torch.from_numpy(negative / np.maximum(positive, 1)).float().to(DEVICE)\n    criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-3)\n    best_auc = -1.0\n    best_epoch = 1\n    best_state = None\n    for epoch in range(1, EPOCHS + 1):\n        model.train()\n        for images, targets in train_loader:\n            optimizer.zero_grad(set_to_none=True)\n            logits = model(images.to(DEVICE))\n            loss = criterion(logits, targets.to(DEVICE))\n            loss.backward()\n            optimizer.step()\n        labels, scores = predict(model, valid_loader)\n        current_auc = macro_auc(labels, scores)\n        if current_auc > best_auc:\n            best_auc = current_auc\n            best_epoch = epoch\n            best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}\n        if epoch == 1 or epoch % 5 == 0 or epoch == EPOCHS:\n            print(f\"epoch={epoch:02d} loss={loss.item():.4f} valid_macro_auc={current_auc:.4f}\")\n    model.load_state_dict(best_state)\n    print(f\"best validation macro AUC={best_auc:.4f} at epoch={best_epoch}\")\n    return model, best_epoch, best_auc\n\n\ndef fit_final(images, labels, epochs):\n    model = SmallStudyCNN(len(TARGETS)).to(DEVICE)\n    dataset = TensorDataset(\n        torch.from_numpy(np.asarray(images, dtype=np.float32).copy()),\n        torch.from_numpy(np.asarray(labels, dtype=np.float32).copy()),\n    )\n    loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)\n    positive = labels.sum(axis=0)\n    negative = len(labels) - positive\n    pos_weight = torch.from_numpy(negative / np.maximum(positive, 1)).float().to(DEVICE)\n    criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-3)\n    for _ in range(max(1, epochs)):\n        model.train()\n        for batch_images, batch_labels in loader:\n            optimizer.zero_grad(set_to_none=True)\n            loss = criterion(model(batch_images.to(DEVICE)), batch_labels.to(DEVICE))\n            loss.backward()\n            optimizer.step()\n    return model\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Fixed 46/12 study split for a quick, leakage-safe smoke evaluation.\nrng = np.random.default_rng(SEED)\npermutation = rng.permutation(len(gt_uids))\nvalid_size = max(12, int(round(0.2 * len(gt_uids))))\nvalid_indices = permutation[:valid_size]\ntrain_indices = permutation[valid_size:]\ntrain_loader = make_loader(gt_images, gt_labels, train_indices, shuffle=True)\nvalid_loader = make_loader(gt_images, gt_labels, valid_indices, shuffle=False)\n\nvalidation_model, best_epoch, validation_auc = fit_with_validation(\n    train_loader, valid_loader, gt_labels[valid_indices]\n)\nvalid_gold, valid_scores = predict(validation_model, valid_loader)\nmetrics = {\n    \"train_studies\": int(len(train_indices)),\n    \"validation_studies\": int(len(valid_indices)),\n    \"best_epoch\": int(best_epoch),\n    \"validation_macro_auc\": float(validation_auc),\n    \"per_target_auc\": {\n        target: (float(roc_auc_score(valid_gold[:, i], valid_scores[:, i]))\n                 if len(np.unique(valid_gold[:, i])) >= 2 else None)\n        for i, target in enumerate(TARGETS)\n    },\n}\nprint(json.dumps(metrics, indent=2))\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Retrain on all reviewed studies, predict test, and save the submission.\nfinal_model = fit_final(gt_images, gt_labels, best_epoch)\nfinal_model.eval()\nwith torch.no_grad():\n    test_scores = torch.sigmoid(final_model(torch.from_numpy(test_images).to(DEVICE))).cpu().numpy()\n\nsubmission = pd.DataFrame(test_scores, columns=TARGETS)\nsubmission.insert(0, \"StudyInstanceUID\", test_uids)\nsubmission.to_csv(\"/kaggle/working/submission.csv\", index=False)\ntorch.save({\n    \"model\": final_model.state_dict(),\n    \"targets\": TARGETS,\n    \"image_size\": IMAGE_SIZE,\n    \"n_slices\": N_SLICES,\n    \"public_weights\": False,\n}, \"/kaggle/working/baseline_model.pt\")\nmetrics[\"submission_rows\"] = int(len(submission))\nmetrics[\"public_weights\"] = False\nwith open(\"/kaggle/working/baseline_metrics.json\", \"w\", encoding=\"utf-8\") as handle:\n    json.dump(metrics, handle, indent=2)\n# The cache is useful while the notebook runs, but deleting it keeps the Kaggle\n# output small and makes downloading the three teaching artifacts reliable.\nshutil.rmtree(CACHE, ignore_errors=True)\nprint(\"saved /kaggle/working/submission.csv\", submission.shape)\nprint(submission.head())\n"}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.11"}},"nbformat":4,"nbformat_minor":5}