{"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":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nimport re\nimport random\nimport warnings\nfrom pathlib import Path\nfrom functools import lru_cache\n\nimport joblib\nimport numpy as np\nimport pandas as pd\nimport pydicom\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\n\nimport timm\n\nwarnings.filterwarnings(\"ignore\")\n\n\nclass CFG:\n\n    targets = [\n        \"ACL\",\n        \"MCL\",\n        \"Medial Meniscus\",\n        \"Lateral Meniscus\",\n        \"Medial OA\",\n        \"Lateral OA\",\n        \"PF OA\",\n        \"Effusion\",\n        \"Synovitis\",\n        \"Baker's\",\n        \"Contusion\",\n        \"Fracture\",\n    ]\n\n    model_dir = Path(\n        \"/kaggle/input/models/jek1wantaufik/buddy/pytorch/rsna/1\"\n    )\n\n    output_dir = Path(\"/kaggle/working\")\n\n\nclass IMG:\n\n    size = 192\n    triplets = 2\n\n    batch = 3\n    workers = 2\n\n    backbone = \"resnet18\"\n\n    device = torch.device(\n        \"cuda\" if torch.cuda.is_available() else \"cpu\"\n    )\n\n    amp = torch.cuda.is_available()\n\n\nTARGETS = CFG.targets\n\nPLANES = [\n    \"Sagittal\",\n    \"Coronal\",\n    \"Axial\",\n]\n\n\ndef find_root():\n\n    candidates = [\n        Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\"),\n        Path(\"/kaggle/input/rsna-knee-abnormality-detection\"),\n        Path(\"work/competition-csv\"),\n    ]\n\n    if Path(\"/kaggle/input\").exists():\n        candidates += list(\n            Path(\"/kaggle/input\").glob(\"*knee*\")\n        )\n\n    for p in candidates:\n        if (p / \"test.csv\").exists():\n            return p\n\n    raise FileNotFoundError(\n        \"Competition dataset not found.\"\n    )\n\n\nROOT = find_root()\n\n\nMODEL_PATH = CFG.model_dir / \"knee_v5.pt\"\nRIDGE_PATH = CFG.model_dir / \"ridge_models.pkl\"\n\nassert CFG.model_dir.exists(), (\n    f\"Model directory not found:\\n{CFG.model_dir}\"\n)\n\nassert MODEL_PATH.exists(), (\n    f\"Missing model:\\n{MODEL_PATH}\"\n)\n\nassert RIDGE_PATH.exists(), (\n    f\"Missing ridge models:\\n{RIDGE_PATH}\"\n)\n\nCFG.output_dir.mkdir(\n    parents=True,\n    exist_ok=True,\n)\n\nprint(\"=\" * 60)\nprint(\"Competition :\", ROOT)\nprint(\"Model Dir   :\", CFG.model_dir)\nprint(\"Output Dir  :\", CFG.output_dir)\nprint(\"Device      :\", IMG.device)\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:08.332344Z","iopub.execute_input":"2026-08-07T06:47:08.332817Z","iopub.status.idle":"2026-08-07T06:47:25.650716Z","shell.execute_reply.started":"2026-08-07T06:47:08.33277Z","shell.execute_reply":"2026-08-07T06:47:25.649578Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"test = pd.read_csv(ROOT / \"test.csv\")\n\ntest_series = pd.read_csv(ROOT / \"test_series.csv\")\n\nsample = pd.read_csv(ROOT / \"sample_submission.csv\")\n\n\ndef protocol_features(df, study_ids=None):\n\n    df = df.copy()\n\n    df[\"slot\"] = (\n        df.Anatomical_Plane.str[0]\n        + \"_\"\n        + df.Fluid_Sensitive.astype(int).astype(str)\n    )\n\n    slots = [\n        \"A_0\", \"A_1\",\n        \"C_0\", \"C_1\",\n        \"S_0\", \"S_1\",\n    ]\n\n    protocol = pd.crosstab(\n        df.StudyInstanceUID,\n        df.slot,\n    ).reindex(\n        columns=slots,\n        fill_value=0,\n    )\n\n    summary = df.groupby(\n        \"StudyInstanceUID\"\n    ).agg(\n        n_series=(\"SeriesInstanceUID\", \"size\"),\n        n_planes=(\"Anatomical_Plane\", \"nunique\"),\n        n_fluid=(\"Fluid_Sensitive\", \"sum\"),\n    )\n\n    features = (\n        protocol\n        .join(summary)\n        .astype(np.float32)\n    )\n\n    if study_ids is not None:\n        features = features.reindex(\n            study_ids,\n            fill_value=0,\n        )\n\n    return features\n\n\ntest_meta = protocol_features(\n    test_series,\n    test.StudyInstanceUID,\n)\n\n\nassert list(sample.columns) == [\n    \"StudyInstanceUID\",\n    *TARGETS,\n]\n\nassert len(test) == len(sample)\n\nassert test.StudyInstanceUID.equals(\n    sample.StudyInstanceUID\n)\n\nassert test_meta.shape[0] == len(test)\n\nprint(\"=\" * 60)\nprint(\"Test studies :\", len(test))\nprint(\"Test series  :\", len(test_series))\nprint(\"Meta shape   :\", test_meta.shape)\nprint(\"Targets      :\", len(TARGETS))\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:25.652369Z","iopub.execute_input":"2026-08-07T06:47:25.652902Z","iopub.status.idle":"2026-08-07T06:47:25.770909Z","shell.execute_reply.started":"2026-08-07T06:47:25.652871Z","shell.execute_reply":"2026-08-07T06:47:25.76971Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@lru_cache(maxsize=18000)\ndef ordered_paths(folder_text):\n\n    folder = Path(folder_text)\n\n    rows = []\n\n    for i, path in enumerate(folder.glob(\"*.dcm\")):\n\n        try:\n\n            ds = pydicom.dcmread(\n                path,\n                stop_before_pixels=True,\n                force=True,\n            )\n\n            if (\n                hasattr(ds, \"ImageOrientationPatient\")\n                and hasattr(ds, \"ImagePositionPatient\")\n            ):\n\n                orient = np.asarray(\n                    ds.ImageOrientationPatient,\n                    dtype=float,\n                )\n\n                normal = np.cross(\n                    orient[:3],\n                    orient[3:],\n                )\n\n                key = float(\n                    np.dot(\n                        np.asarray(\n                            ds.ImagePositionPatient,\n                            dtype=float,\n                        ),\n                        normal,\n                    )\n                )\n\n            else:\n\n                key = float(\n                    getattr(ds, \"InstanceNumber\", i)\n                )\n\n        except Exception:\n\n            key = float(i)\n\n        rows.append((key, str(path)))\n\n    return tuple(\n        p for _, p in sorted(rows)\n    )\n\n\ndef decode_triplets(\n    folder,\n    count=IMG.triplets,\n    size=IMG.size,\n):\n\n    paths = ordered_paths(str(folder))\n\n    if len(paths) == 0:\n        raise FileNotFoundError(folder)\n\n    centers = (\n        np.linspace(\n            0,\n            len(paths) - 1,\n            count + 2,\n        )[1:-1]\n        .round()\n        .astype(int)\n    )\n\n    images = []\n\n    for center in centers:\n\n        ids = [\n            max(0, center - 1),\n            center,\n            min(len(paths) - 1, center + 1),\n        ]\n\n        stack = []\n\n        for idx in ids:\n\n            ds = pydicom.dcmread(\n                paths[idx],\n                force=True,\n            )\n\n            x = ds.pixel_array.astype(np.float32)\n\n            x = (\n                x\n                * float(getattr(ds, \"RescaleSlope\", 1.0))\n                + float(getattr(ds, \"RescaleIntercept\", 0.0))\n            )\n\n            stack.append(x)\n\n        x = np.stack(stack)\n\n        lo, hi = np.percentile(\n            x,\n            [0.5, 99.5],\n        )\n\n        x = np.clip(\n            (x - lo) / (hi - lo + 1e-6),\n            0,\n            1,\n        ).astype(np.float32)\n\n        x = (\n            F.interpolate(\n                torch.from_numpy(x).unsqueeze(0),\n                (size, size),\n                mode=\"bilinear\",\n                align_corners=False,\n            )\n            .squeeze(0)\n        )\n\n        images.append(x)\n\n    return torch.stack(images)\n\n\ndef choose_series(frame):\n\n    if frame.empty:\n        return [None] * len(PLANES)\n\n    selected = []\n\n    for plane in PLANES:\n\n        q = (\n            frame[\n                frame.Anatomical_Plane.eq(plane)\n            ]\n            .sort_values(\n                [\n                    \"Fluid_Sensitive\",\n                    \"SeriesInstanceUID\",\n                ],\n                ascending=[False, True],\n            )\n        )\n\n        selected.append(\n            None if q.empty else q.iloc[0]\n        )\n\n    return selected\n\n\nprint(\"✓ DICOM utilities ready\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:25.772208Z","iopub.execute_input":"2026-08-07T06:47:25.772579Z","iopub.status.idle":"2026-08-07T06:47:25.792327Z","shell.execute_reply.started":"2026-08-07T06:47:25.772542Z","shell.execute_reply":"2026-08-07T06:47:25.791453Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class KneeDataset(Dataset):\n\n    def __init__(\n        self,\n        frame,\n        series_frame,\n        image_root,\n    ):\n\n        self.frame = frame.reset_index(drop=True)\n\n        self.groups = {\n            k: v\n            for k, v in series_frame.groupby(\n                \"StudyInstanceUID\"\n            )\n        }\n\n        self.image_root = Path(image_root)\n\n    def __len__(self):\n        return len(self.frame)\n\n    def __getitem__(self, idx):\n\n        uid = self.frame.iloc[idx].StudyInstanceUID\n\n        images = []\n        plane_ids = []\n        masks = []\n\n        selected = choose_series(\n            self.groups.get(\n                uid,\n                pd.DataFrame(),\n            )\n        )\n\n        for plane_id, item in enumerate(selected):\n\n            try:\n\n                folder = (\n                    self.image_root\n                    / uid\n                    / item.SeriesInstanceUID\n                )\n\n                x = decode_triplets(folder)\n\n                valid = True\n\n            except Exception:\n\n                x = torch.zeros(\n                    IMG.triplets,\n                    3,\n                    IMG.size,\n                    IMG.size,\n                    dtype=torch.float32,\n                )\n\n                valid = False\n\n            images.append(x)\n\n            plane_ids.extend(\n                [plane_id] * IMG.triplets\n            )\n\n            masks.extend(\n                [valid] * IMG.triplets\n            )\n\n        return (\n            torch.cat(images, dim=0),\n            torch.tensor(\n                plane_ids,\n                dtype=torch.long,\n            ),\n            torch.tensor(\n                masks,\n                dtype=torch.bool,\n            ),\n            uid,\n        )\n\n\nclass KneeNet(nn.Module):\n\n    def __init__(self):\n\n        super().__init__()\n\n        self.encoder = timm.create_model(\n            IMG.backbone,\n            pretrained=False,\n            num_classes=0,\n            global_pool=\"avg\",\n        )\n\n        dim = self.encoder.num_features\n\n        self.plane = nn.Embedding(\n            len(PLANES),\n            dim,\n        )\n\n        self.attn = nn.Sequential(\n            nn.Linear(dim, 128),\n            nn.Tanh(),\n            nn.Linear(128, 1),\n        )\n\n        self.head = nn.Sequential(\n            nn.Dropout(0.30),\n            nn.Linear(\n                dim,\n                len(TARGETS),\n            ),\n        )\n\n    def forward(\n        self,\n        x,\n        plane,\n        mask,\n    ):\n\n        b, k, c, h, w = x.shape\n\n        feat = self.encoder(\n            x.reshape(\n                b * k,\n                c,\n                h,\n                w,\n            )\n        )\n\n        feat = feat.reshape(\n            b,\n            k,\n            -1,\n        )\n\n        feat = feat + self.plane(plane)\n\n        attn = (\n            self.attn(feat)\n            .squeeze(-1)\n            .masked_fill(\n                ~mask,\n                -1e4,\n            )\n        )\n\n        feat = (\n            feat\n            * attn.softmax(-1).unsqueeze(-1)\n        ).sum(dim=1)\n\n        return self.head(feat)\n\n\nprint(\"=\" * 60)\nprint(\"Inference Dataset Ready\")\nprint(\"Backbone :\", IMG.backbone)\nprint(\"Image Size :\", IMG.size)\nprint(\"Triplets :\", IMG.triplets)\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:25.79467Z","iopub.execute_input":"2026-08-07T06:47:25.79504Z","iopub.status.idle":"2026-08-07T06:47:25.838544Z","shell.execute_reply.started":"2026-08-07T06:47:25.794999Z","shell.execute_reply":"2026-08-07T06:47:25.837351Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"ridge_models = joblib.load(RIDGE_PATH)\n\nmodel = KneeNet().to(IMG.device)\n\nstate = torch.load(\n    MODEL_PATH,\n    map_location=IMG.device,\n)\n\nmodel.load_state_dict(state)\n\nmodel.eval()\n\nprint(\"✓ Image model loaded\")\nprint(\"✓ Ridge models loaded\")\n\n\n@torch.no_grad()\ndef predict_image(\n    model,\n    flip=True,\n):\n\n    dataset = KneeDataset(\n        frame=test,\n        series_frame=test_series,\n        image_root=ROOT / \"test_series\",\n    )\n\n    loader = DataLoader(\n        dataset,\n        batch_size=IMG.batch,\n        shuffle=False,\n        num_workers=IMG.workers,\n        pin_memory=True,\n        persistent_workers=IMG.workers > 0,\n    )\n\n    predictions = []\n    study_ids = []\n\n    model.eval()\n\n    for x, plane, mask, uid in loader:\n\n        x = x.to(\n            IMG.device,\n            non_blocking=True,\n        )\n\n        plane = plane.to(\n            IMG.device,\n            non_blocking=True,\n        )\n\n        mask = mask.to(\n            IMG.device,\n            non_blocking=True,\n        )\n\n        with torch.cuda.amp.autocast(\n            enabled=IMG.amp,\n        ):\n\n            pred = torch.sigmoid(\n                model(\n                    x,\n                    plane,\n                    mask,\n                )\n            )\n\n            if flip:\n\n                pred_flip = torch.sigmoid(\n                    model(\n                        torch.flip(\n                            x,\n                            dims=[-1],\n                        ),\n                        plane,\n                        mask,\n                    )\n                )\n\n                pred = (\n                    pred + pred_flip\n                ) * 0.5\n\n        predictions.append(\n            pred.cpu().float().numpy()\n        )\n\n        study_ids.extend(uid)\n\n    predictions = np.concatenate(\n        predictions,\n        axis=0,\n    )\n\n    assert (\n        study_ids\n        == test.StudyInstanceUID.tolist()\n    )\n\n    return predictions\n\n\nprint(\"=\" * 60)\nprint(\"Inference model ready\")\nprint(f\"Device : {IMG.device}\")\nprint(f\"Flip TTA : Enabled\")\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:25.839937Z","iopub.execute_input":"2026-08-07T06:47:25.840429Z","iopub.status.idle":"2026-08-07T06:47:27.934597Z","shell.execute_reply.started":"2026-08-07T06:47:25.840395Z","shell.execute_reply":"2026-08-07T06:47:27.933503Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"=\" * 60)\nprint(\"Running image inference...\")\nprint(\"=\" * 60)\n\nimage_pred = predict_image(\n    model=model,\n    flip=True,\n)\n\nprint(\"Image prediction shape :\", image_pred.shape)\n\n\nXt = test_meta.values.astype(np.float32)\n\nmeta_pred = np.zeros(\n    (\n        len(test),\n        len(TARGETS),\n    ),\n    dtype=np.float32,\n)\n\nfor j, ridge in enumerate(ridge_models):\n\n    meta_pred[:, j] = ridge.predict(Xt)\n\nmeta_pred = np.clip(\n    meta_pred,\n    0.02,\n    0.98,\n)\n\nprint(\"Metadata prediction shape :\", meta_pred.shape)\n\n\nIMAGE_WEIGHT = 0.88\nMETA_WEIGHT = 0.12\n\nfinal_pred = (\n    IMAGE_WEIGHT * image_pred\n    + META_WEIGHT * meta_pred\n)\n\nfinal_pred = np.clip(\n    final_pred,\n    1e-5,\n    1 - 1e-5,\n)\n\nprint(\"=\" * 60)\nprint(\"Blend completed\")\nprint(\"Final prediction shape :\", final_pred.shape)\nprint(\n    \"Prediction range : \"\n    f\"{final_pred.min():.6f} ~ \"\n    f\"{final_pred.max():.6f}\"\n)\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:27.935843Z","iopub.execute_input":"2026-08-07T06:47:27.93638Z","iopub.status.idle":"2026-08-07T06:47:34.078505Z","shell.execute_reply.started":"2026-08-07T06:47:27.936339Z","shell.execute_reply":"2026-08-07T06:47:34.077165Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def save_submission(\n    pred,\n    filename=\"submission.csv\",\n):\n\n    pred = np.asarray(\n        pred,\n        dtype=np.float64,\n    )\n\n    assert pred.shape == (\n        len(test),\n        len(TARGETS),\n    )\n\n    assert np.isfinite(pred).all()\n\n    submission = sample[\n        [\"StudyInstanceUID\"]\n    ].copy()\n\n    submission[TARGETS] = np.clip(\n        pred,\n        1e-5,\n        1 - 1e-5,\n    )\n\n    output_path = (\n        CFG.output_dir\n        / filename\n    )\n\n    submission.to_csv(\n        output_path,\n        index=False,\n    )\n\n    print(\"=\" * 60)\n    print(\"Submission created successfully\")\n    print(\"=\" * 60)\n    print(f\"Saved to : {output_path}\")\n    print(f\"Shape    : {submission.shape}\")\n    print(\n        \"Prediction range : \"\n        f\"{submission[TARGETS].min().min():.6f} ~ \"\n        f\"{submission[TARGETS].max().max():.6f}\"\n    )\n    print(\"=\" * 60)\n\n    return submission\n\n\nsubmission = save_submission(\n    final_pred,\n    filename=\"submission.csv\",\n)\n\ndisplay(submission.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-07T06:47:34.08024Z","iopub.execute_input":"2026-08-07T06:47:34.080711Z","iopub.status.idle":"2026-08-07T06:47:34.138477Z","shell.execute_reply.started":"2026-08-07T06:47:34.080657Z","shell.execute_reply":"2026-08-07T06:47:34.137321Z"}},"outputs":[],"execution_count":null}]}