{"cells":[{"cell_type":"markdown","metadata":{},"source":"# RSNA Knee 推理 (2.5D 异构集成)\n\n**Cell 1** 自动探测 `/kaggle/input` 下所有训练权重（按 state_dict 键识别 ConvNeXt-S / resnet50 / efficientnet_v2_s，按 `plane_emb`/`contrast_emb` 识别 v1(3组)/v2(6组)），打印 `发现 N 个权重` 且含 `resnp0_best.pth: ... resnet50 ...` 即为正确。\n\n**依赖输入（只挂这两个，别挂旧的 0.826 用过的 model1/modelcm/model5fold1，否则权重重复计数）**：\n- 权重数据集 `evelynzhu323/kneebest`（16 个 `*_best.pth`：fix0-4 / fixcm0-4 / fixmx0-4 / resnp0）\n- 比赛数据 `/kaggle/input/rsna-knee-abnormality-detection/`（含 `test_series/`、`test.csv`、`test_series.csv`）\n\n**运行要求**：Accelerator 设为 GPU（T4/P100 用 fp16；无 GPU 会 CPU 推理数小时）。\n\n集成方式：所有模型 12 类 logit 各自 sigmoid 后求概率均值（混合 v1/v2 与 ConvNeXt/resnet50 亦可）。模型/预处理与集群 `train_2d.py` + `preprocess_2d.py` 完全一致。"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"#!/usr/bin/env python3\n\"\"\"RSNA Knee 2.5D 异构集成推理 notebook 源码\n用 #%% 分节，build_kaggle_ipynb.py 按标记拆成多个 cell：\n  cell 1: 环境 + 路径探测 + 权重定位与校验\n  cell 2: 模型定义 (KneeModel2D = ConvNeXt-S / resnet50 / efficientnet_v2_s + AttentionPool)\n  cell 3: 2.5D 预处理 (复刻 preprocess_2d.py)\n  cell 4: 加载权重 + 推理 + 写 submission.csv\n模型 / 预处理与集群 train_2d.py + preprocess_2d.py 完全一致。\n\"\"\"\n# 注意: 本 notebook 用于 Kaggle 代码竞赛提交，必须离线运行(无 internet)。\n# 因此不在此安装任何包，全部依赖 Kaggle 预装环境: torch / torchvision / timm / pydicom / opencv / numpy。\nimport os, sys, subprocess, glob, csv, shutil\nimport numpy as np\nimport torch\nprint(\"torch:\", torch.__version__, \"| cuda:\", torch.version.cuda)\nimport timm\n\n# 竞赛提交环境用 T4(sm_75), 默认 torch 正常; 但 Kaggle 网页 dev 运行可能分到 P100(sm_60),\n# 默认 torch 无对应内核, conv2d 会崩。为让 dev 运行也能跑完并产出 submission.csv(满足提交校验\n# \"selected version must output submission.csv\"), 检测到 P100 时回退到 CPU 推理。\n# dev 测试集仅 3 studies, CPU 几分钟可完成; 竞赛 Submit 走 T4 用 GPU, 不受影响。\nFORCE_CPU = torch.cuda.is_available() and 'P100' in torch.cuda.get_device_name(0)\nif FORCE_CPU:\n    print(\"⚠️ 检测到 P100(sm_60), 默认 torch 无对应内核 → 回退 CPU 推理(dev 模式)。竞赛 Submit 走 T4 用 GPU, 不受影响。\")\n\n\ndef find(kw):\n    base = '/kaggle/input'\n    # 新版 Kaggle: 比赛数据挂在 /kaggle/input/competitions/<slug>/ 下\n    for p in (os.path.join(base, 'competitions', 'rsna-knee-abnormality-detection'),\n              os.path.join(base, 'rsna-knee-abnormality-detection')):\n        if os.path.isdir(p):\n            return p\n    # 兜底: 只扫直接子目录（不深挖 test_series 巨树）\n    try:\n        for d in sorted(os.listdir(base)):\n            if kw in d:\n                return os.path.join(base, d)\n    except OSError:\n        pass\n    return os.path.join(base, kw)\n\n\nCOMP = find('rsna-knee')\nTEST_SERIES_DIR = os.path.join(COMP, 'test_series')\nTEST_SERIES_CSV = os.path.join(COMP, 'test_series.csv')\n\n\ndef find_weights_all():\n    \"\"\"找所有\"训练模型\"checkpoint（*.pth），按 state_dict 键自动识别 backbone。\n    支持 ConvNeXt-S / resnet50 / efficientnet_v2_s。\n    只扫挂载的数据集目录（跳过 competitions 比赛数据——里面是百万级 dcm 文件，\n    全盘 os.walk 会慢几分钟），且深度受限（兼容 model artifact 挂载路径）。\"\"\"\n    found = {}\n    base = '/kaggle/input'\n    try:\n        top_dirs = [os.path.join(base, d) for d in sorted(os.listdir(base))\n                    if os.path.isdir(os.path.join(base, d))]\n    except OSError:\n        return []\n    for top in top_dirs:\n        name = os.path.basename(top)\n        if name == 'competitions' or 'rsna-knee' in name:   # 跳过比赛数据巨树\n            continue\n        stack = [(top, 0)]\n        while stack:                                        # 深度受限扫描(≤8 层)\n            d, dep = stack.pop()\n            if dep > 8:\n                continue\n            try:\n                entries = os.listdir(d)\n            except OSError:\n                continue\n            for e in entries:\n                p = os.path.join(d, e)\n                if os.path.isdir(p):\n                    stack.append((p, dep + 1))\n                elif e.endswith('.pth') and e not in found:\n                    try:\n                        sd = torch.load(p, map_location='cpu')\n                        if isinstance(sd, dict) and 'state_dict' in sd:\n                            sd = sd['state_dict']\n                        ks = list(sd.keys())\n                        has_stem = any('stem.0.weight' in k for k in ks)\n                        has_pool = any(k.startswith('pool.') for k in ks)\n                        has_conv1 = any('conv1.weight' in k for k in ks)\n                        has_resnet_layers = any('layer1.0.conv1.weight' in k for k in ks)\n                        has_eff_features = any('features.0.0.weight' in k for k in ks)\n                        if has_stem and has_pool:\n                            backbone = 'convnext_small'\n                        elif has_conv1 and has_resnet_layers:\n                            backbone = 'resnet50'\n                        elif has_eff_features:\n                            backbone = 'efficientnet_v2_s'\n                        else:\n                            continue\n                        # v2 模型有 plane_emb/contrast_emb 参数(6组输入), v1 没有(3组输入)\n                        is_v2 = 'plane_emb' in ks and 'contrast_emb' in ks\n                        found[e] = (p, len(ks), is_v2, backbone)\n                    except Exception:\n                        pass\n    return sorted(found.items())  # [(fn, (path, nkeys, is_v2, backbone)), ...] 按文件名排序\n\n\nWEIGHTS_ALL = find_weights_all()\nprint(\"CUDA available:\", torch.cuda.is_available())   # Kaggle 需设 Accelerator=GPU\nprint(\"比赛数据:\", COMP, \"| 存在:\", os.path.exists(COMP))\nprint(\"test_series.csv:\", os.path.exists(TEST_SERIES_CSV), \"| test_series/:\", os.path.isdir(TEST_SERIES_DIR))\ntry:\n    with open(os.path.join(COMP, 'test.csv')) as f:\n        n_test = sum(1 for _ in f) - 1\n    print(f\"test.csv study 数: {n_test}（应为 ~1400；若只有 3 说明比赛数据挂载不完整，别急着跑推理！）\")\nexcept OSError as e:\n    print(\"test.csv 读取失败:\", e)\n\nprint(f\"发现 {len(WEIGHTS_ALL)} 个权重:\")\nn_v1 = n_v2 = 0\nfor fn, (p, nk, v2, backbone) in WEIGHTS_ALL:\n    ok = nk >= 80\n    n_v2 += v2\n    n_v1 += (not v2)\n    print(f\"  {fn}: {nk} 键 {backbone} {'v2(6组)' if v2 else 'v1(3组)'} {'✅' if ok else '⚠️'}\")\n\nif not WEIGHTS_ALL:\n    print(\"❌ 没找到任何训练权重 — 运行 print(os.listdir('/kaggle/input')) 看挂载目录，检查数据集\")\nelif len(WEIGHTS_ALL) == 1:\n    print(\"✅ 单权重模式（如 fix0_best.pth）\")\nelse:\n    print(f\"✅ 集成模式: {len(WEIGHTS_ALL)} 个权重求 sigmoid 均值 (v1 x{n_v1} + v2 x{n_v2})\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import torch.nn as nn\nimport torch.nn.functional as F\n\nLABELS = ['ACL', 'MCL', 'Medial Meniscus', 'Lateral Meniscus', 'Medial OA',\n          'Lateral OA', 'PF OA', 'Effusion', 'Synovitis', \"Baker's\",\n          'Contusion', 'Fracture']\nPLANES = ['Sagittal', 'Coronal', 'Axial']\nN_SLICES = 15\nSIZE = 384\n\n\nclass ConvNeXtEncoder(nn.Module):\n    def __init__(self, in_ch=1):\n        super().__init__()\n        self.encoder = timm.create_model('convnext_small', pretrained=False,\n                                         in_chans=in_ch, num_classes=0)\n        self.feat_dim = self.encoder.num_features\n\n    def forward(self, x):\n        return self.encoder(x)\n\n\nclass AttentionPool(nn.Module):\n    def __init__(self, feat_dim, hidden=256):\n        super().__init__()\n        self.attn = nn.Sequential(nn.Linear(feat_dim, hidden), nn.Tanh(),\n                                  nn.Linear(hidden, 1))\n        self.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(feat_dim, 128),\n                                nn.ReLU(), nn.Linear(128, len(LABELS)))\n\n    def forward(self, feats):  # (B, N, D)\n        w = self.attn(feats)\n        w = F.softmax(w, dim=1)\n        return self.fc((feats * w).sum(dim=1))\n\n\nclass TorchVisionEncoder(nn.Module):\n    \"\"\"torchvision 骨干（resnet50 / efficientnet_v2_s），输出 (B, feat_dim) 全局特征。\n    推理时不下载预训练权重（weights=None / pretrained=False），直接加载微调后的 checkpoint。\"\"\"\n    def __init__(self, backbone='resnet50', in_ch=1):\n        super().__init__()\n        import torchvision.models as tvm\n        if backbone == 'resnet50':\n            try:\n                m = tvm.resnet50(weights=None)\n            except Exception:\n                m = tvm.resnet50(pretrained=False)\n            stem = m.conv1\n            setattr(m, 'conv1', _adapt_stem_to_1ch(stem, in_ch))\n            self.feat_dim = m.fc.in_features\n            m.fc = nn.Identity()\n        elif backbone == 'efficientnet_v2_s':\n            try:\n                m = tvm.efficientnet_v2_s(weights=None)\n            except Exception:\n                m = tvm.efficientnet_v2_s(pretrained=False)\n            m.features[0][0] = _adapt_stem_to_1ch(m.features[0][0], in_ch)\n            self.feat_dim = m.classifier[1].in_features\n            m.classifier = nn.Identity()\n        else:\n            raise ValueError(f'TorchVisionEncoder: 未支持的 backbone {backbone}')\n        self.encoder = m\n\n    def forward(self, x):\n        return self.encoder(x)\n\n\ndef _adapt_stem_to_1ch(conv, in_ch):\n    \"\"\"3 通道 stem 卷积 → in_ch 通道（沿输入通道取均值，权重保持预训练语义）。\"\"\"\n    if in_ch == 3:\n        return conv\n    w = conv.weight.data\n    nc, _, k1, k2 = w.shape\n    new_conv = nn.Conv2d(in_ch, nc, (k1, k2), stride=conv.stride,\n                         padding=conv.padding, bias=conv.bias is not None)\n    new_conv.weight.data = w.mean(dim=1, keepdim=True)\n    if conv.bias is not None:\n        new_conv.bias.data = conv.bias.data\n    return new_conv\n\n\nclass KneeModel2D(nn.Module):\n    \"\"\"G=3(v1) 或 6(v2: plane×contrast) 组各自过 encoder →\n    (v2 加 plane_emb/contrast_emb) → concat → AttentionPool。\n    支持 ConvNeXt-S / resnet50 / efficientnet_v2_s。\"\"\"\n    def __init__(self, backbone='convnext_small'):\n        super().__init__()\n        if backbone == 'convnext_small':\n            self.encoder = ConvNeXtEncoder(in_ch=1)\n        elif backbone in ('resnet50', 'efficientnet_v2_s'):\n            self.encoder = TorchVisionEncoder(backbone=backbone, in_ch=1)\n        else:\n            raise ValueError(f'KneeModel2D: 未支持的 backbone {backbone}')\n        D = self.encoder.feat_dim\n        self.pool = AttentionPool(D)\n        self.n_planes = 3\n        # v2 多对比度 embedding: 组序 contrast-major [Sag,Cor,Ax, Sag,Cor,Ax]×[FS,nonFS]\n        self.plane_emb = nn.Parameter(torch.zeros(3, D))\n        self.contrast_emb = nn.Parameter(torch.zeros(2, D))\n        nn.init.trunc_normal_(self.plane_emb, std=0.02)\n        nn.init.trunc_normal_(self.contrast_emb, std=0.02)\n\n    def forward(self, x):  # (B, G, 15, 384, 384), G=3 或 6\n        B, G, N, H, W = x.shape\n        feats = []\n        for g in range(G):\n            xg = x[:, g].reshape(B * N, 1, H, W)\n            fp = self.encoder(xg).reshape(B, N, -1)  # (B, N, D)\n            if G > 3:  # v2: 组 g → plane=g%3, contrast=g//3\n                fp = fp + self.plane_emb[g % 3] + self.contrast_emb[g // 3]\n            feats.append(fp)\n        return self.pool(torch.cat(feats, dim=1))"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import pydicom\nfrom pydicom.pixel_data_handlers.util import apply_voi_lut\nimport cv2\n\n\ndef series_to_slices(dcm_dir, n=N_SLICES, size=SIZE):\n    \"\"\"读一个 series 的 dcm → 排序 → 百分位归一化 → 均匀采 n 张 → resize → (n,size,size)\"\"\"\n    files = sorted(glob.glob(os.path.join(dcm_dir, '*.dcm')))\n    if not files:\n        return None\n    slices = []\n    for f in files:\n        try:\n            ds = pydicom.dcmread(f)\n            arr = apply_voi_lut(ds.pixel_array, ds).astype(np.float32)\n            if hasattr(ds, 'ImagePositionPatient'):\n                z = float(ds.ImagePositionPatient[2])\n            elif hasattr(ds, 'SliceLocation'):\n                z = float(ds.SliceLocation)\n            else:\n                z = float(getattr(ds, 'InstanceNumber', 0)) * 0.001\n            slices.append((z, arr))\n        except Exception:\n            continue\n    if len(slices) < 2:\n        return None\n    slices.sort(key=lambda x: x[0])\n    stack = np.stack([s for _, s in slices], axis=0)  # (N,H,W)\n    lo, hi = np.percentile(stack, 1), np.percentile(stack, 99)\n    if hi > lo:\n        stack = np.clip((stack - lo) / (hi - lo), 0, 1)\n    else:\n        stack = np.zeros_like(stack)\n    # 均匀采 n 张\n    N = stack.shape[0]\n    if N > n:\n        idx = np.linspace(0, N - 1, n).astype(int)\n        stack = stack[idx]\n    elif N < n:\n        stack = np.pad(stack, ((0, n - N), (0, 0), (0, 0)))\n    # resize 到 size²\n    out = np.zeros((n, size, size), np.float32)\n    for i in range(n):\n        out[i] = cv2.resize(stack[i], (size, size))\n    return out\n\n\n# 一次性索引 test_series.csv: (study, plane) -> [(sid, fs, fatsup), ...]（避免推理时反复全表扫描）\nSERIES_INDEX = {}\nwith open(TEST_SERIES_CSV, errors='ignore') as f:\n    for row in csv.DictReader(f):\n        sid = row.get('SeriesInstanceUID', '')\n        if not sid:\n            continue\n        key = (row.get('StudyInstanceUID', ''), row.get('Anatomical_Plane', ''))\n        SERIES_INDEX.setdefault(key, []).append(\n            (sid, row.get('Fluid_Sensitive') == '1', row.get('Fat_Suppression') == '1'))\nprint(f\"series 索引: {len(SERIES_INDEX)} (study,plane) 条\", flush=True)\n\n\ndef pick_series(study, plane, fs_group):\n    \"\"\"与 preprocess_2d_v2.py 一致: FS 组 = Fluid=1&FatSupp=1, nonFS 组 = Fluid=0&FatSupp=0, 各取 CSV 顺序 [0]\"\"\"\n    for sid, fs, fatsup in SERIES_INDEX.get((study, plane), []):\n        if fs_group and fs and fatsup:\n            return os.path.join(TEST_SERIES_DIR, study, sid)\n        if not fs_group and not fs and not fatsup:\n            return os.path.join(TEST_SERIES_DIR, study, sid)\n    return None\n\n\ndef build_vol(study):\n    \"\"\"v2: 6 组 contrast-major [FS(S,C,A), nonFS(S,C,A)] → (6,15,384,384)。\n    缺 (plane,contrast) 留全零（与训练一致）。前 3 组 = v1 的 FS 3-plane，v1 模型直接用 x[:, :3]。\"\"\"\n    vol = np.zeros((6, N_SLICES, SIZE, SIZE), np.float32)\n    for ci, fs_group in enumerate([True, False]):\n        for pi, plane in enumerate(PLANES):\n            sdir = pick_series(study, plane, fs_group)\n            if sdir is None or not os.path.isdir(sdir):\n                continue\n            s = series_to_slices(sdir)\n            if s is not None:\n                vol[ci * 3 + pi] = s\n    return vol"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"dev = torch.device('cpu' if FORCE_CPU else ('cuda:0' if torch.cuda.is_available() else 'cpu'))\nprint(\"  device:\", dev)\nif dev.type == 'cpu':\n    print(\"  ⚠️ dev 运行无 GPU（或 P100 回退）→ CPU 推理（仅前 3 权重快速产出 submission.csv）\")\nelse:\n    print(\"  GPU:\", torch.cuda.get_device_name(0))\n\nprint(\"\\n== 收集 test studies ==\")\ntest_studies = sorted(os.listdir(TEST_SERIES_DIR))\ntest_studies = [s for s in test_studies if os.path.isdir(os.path.join(TEST_SERIES_DIR, s))]\nprint(f\"  {len(test_studies)} test studies\")\n\n# dev 运行测试集很小(仅 3 studies)→ 只跑前 3 权重快速产出 submission.csv(满足提交校验\n# \"selected version must output submission.csv\"); 竞赛 Submit 测试集完整(~1400 studies)走 T4 用全部 16 权重。\n# 用\"测试集大小\"而非\"是否 P100\"判断 dev 模式, 这样 enable_gpu=false 的纯 CPU dev 运行也能正确只跑 3 权重。\nDEV_MODE = len(test_studies) <= 10\nweights_to_run = WEIGHTS_ALL[:3] if DEV_MODE else WEIGHTS_ALL\n\nprint(\"== 加载模型 ==\")\nmodels = []\nfor fn, (p, nk, v2, backbone) in weights_to_run:\n    m = KneeModel2D(backbone=backbone)\n    sd = torch.load(p, map_location='cpu')\n    if isinstance(sd, dict) and 'state_dict' in sd:\n        sd = sd['state_dict']\n    miss, unexp = m.load_state_dict(sd, strict=False)\n    m.eval()\n    m = m.to(dev)\n    models.append((fn, m, v2))\n    print(f\"  {fn} [{'v2' if v2 else 'v1'}]: loaded miss={len(miss)} unexp={len(unexp)}\", flush=True)\n    if len(miss) > 0:\n        print(f\"    [WARN] missing first 8: {miss[:8]}\")\nn_v1 = sum(1 for _, _, v2 in models if not v2)\nn_v2 = len(models) - n_v1\nprint(f\"  {len(models)} 个模型就绪 (v1 x{n_v1} + v2 x{n_v2})\" + (\"（dev 仅 3 权重；竞赛 Submit 用全部 16 个）\" if DEV_MODE else \"（集成求均值）\"))\n\nprint(\"\\n== 推理 ==\")\npred = {}\nfor i, st in enumerate(test_studies):\n    vol6 = build_vol(st)                       # (6,15,384,384), v1 模型用前 3 组\n    x6 = torch.from_numpy(vol6).unsqueeze(0).to(dev)\n    probs = []\n    for _, m, v2 in models:\n        x = x6 if v2 else x6[:, :3]            # v1: 只喂 FS 3-plane\n        with torch.no_grad():\n            try:\n                # fp16: P100/T4 支持 fp16（无 bf16 tensor core）；用经典 autocast 默认 fp16\n                with torch.cuda.amp.autocast(enabled=dev.type == 'cuda'):\n                    logit = m(x)\n            except Exception:\n                logit = m(x)  # 兜底: fp32\n        probs.append(torch.sigmoid(logit)[0].float().cpu().numpy())\n    pred[st] = np.mean(probs, axis=0)  # 集成: sigmoid 概率均值\n    if (i + 1) % 100 == 0 or i == len(test_studies) - 1:\n        print(f\"  {i+1}/{len(test_studies)}\", flush=True)\n\nprint(\"\\n== 写 submission.csv ==\")\nwith open(os.path.join(COMP, 'test.csv')) as f:\n    all_test = [r[0] for r in csv.reader(f)][1:]  # 跳过表头\nwith open('submission.csv', 'w', newline='') as f:\n    w = csv.writer(f)\n    w.writerow(['StudyInstanceUID'] + LABELS)\n    for st in all_test:\n        p = pred.get(st, np.full(len(LABELS), 0.5))\n        w.writerow([st] + [f\"{v:.4f}\" for v in p])\nprint(f\"  写完 {len(all_test)} 行（含 test.csv 里所有 study）\")"},{"cell_type":"markdown","metadata":{},"source":"## 结果\n运行后当前目录会生成 `submission.csv`，下载提交。"}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"}},"nbformat":4,"nbformat_minor":5}