{"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":"from pathlib import Path\nimport os, re, json, random, math, time, warnings\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\n\nwarnings.filterwarnings(\"ignore\")\npd.set_option(\"display.max_columns\", 100)\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\n\nBASE = Path(\"/kaggle/input\")\nCOMP = None\nfor p in BASE.rglob(\"sample_submission.csv\"):\n    COMP = p.parent\n    break\nif COMP is None:\n    raise FileNotFoundError(\"Не нашёл sample_submission.csv. Проверь Add Data.\")\n\ntrain = pd.read_csv(COMP / \"train.csv\")\ntest = pd.read_csv(COMP / \"test.csv\")\ntrain_series = pd.read_csv(COMP / \"train_series.csv\")\ntest_series = pd.read_csv(COMP / \"test_series.csv\")\nsample_sub = pd.read_csv(COMP / \"sample_submission.csv\")\n\nLABELS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\",\n]\n\n# SMOKE-конфиг: сначала проверяем, что всё работает. Потом увеличим.\nIMG_SIZE = 320\nSLICES_PER_SERIES = 10\nN_TRAIN_STUDIES = 256\nEPOCHS = 1\nBATCH_SIZE = 8\nLR = 3e-4\n\nprint(\"COMP:\", COMP)\nprint(\"train/test:\", train.shape, test.shape)\nprint(\"train_series/test_series:\", train_series.shape, test_series.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:23:12.807723Z","iopub.execute_input":"2026-08-14T20:23:12.808243Z","iopub.status.idle":"2026-08-14T20:23:13.380118Z","shell.execute_reply.started":"2026-08-14T20:23:12.808211Z","shell.execute_reply":"2026-08-14T20:23:13.379252Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"NEG_RE = re.compile(\n    r\"(\\bno\\b|\\bnot\\b|\\bwithout\\b|\\bsin\\b|\\bno hay\\b|\\bkein\\b|\\bkeine\\b|\\baucun\\b|\\baucune\\b|\"\n    r\"\\bgeen\\b|\\bniet\\b|\\bsem\\b|\\bnon\\b|\\bнет\\b|\\bбез\\b|\\bintact\\b|\\bnormal\\b|\\bwithin normal\\b|\"\n    r\"\\bunremarkable\\b|\\bconservad)\",\n    re.I\n)\n\nRULES = {\n    \"ACL\": [r\"\\bacl\\b\", r\"\\blca\\b\", r\"cruzado anterior\", r\"croisé antérieur\", r\"vorderes kreuzband\", r\"крестообраз\"],\n    \"MCL\": [r\"\\bmcl\\b\", r\"colateral medial\", r\"collatéral médial\", r\"mediales kollateral\", r\"медиальн.{0,20}коллатерал\"],\n    \"Medial Meniscus\": [r\"medial meniscus\", r\"meniscus medial\", r\"menisco medial\", r\"menisco interno\", r\"innenmeniskus\", r\"медиальн.{0,20}мениск\"],\n    \"Lateral Meniscus\": [r\"lateral meniscus\", r\"meniscus lateral\", r\"menisco lateral\", r\"menisco externo\", r\"außenmeniskus\", r\"латеральн.{0,20}мениск\"],\n    \"Medial OA\": [r\"medial.{0,30}osteoarth\", r\"medial.{0,30}arthrose\", r\"artrosis.{0,30}medial\", r\"femorotibial medial\", r\"медиальн.{0,30}остеоартр\"],\n    \"Lateral OA\": [r\"lateral.{0,30}osteoarth\", r\"lateral.{0,30}arthrose\", r\"artrosis.{0,30}lateral\", r\"femorotibial lateral\", r\"латеральн.{0,30}остеоартр\"],\n    \"PF OA\": [r\"patellofemoral\", r\"patellofemor\", r\"femoropatelar\", r\"rétropatellaire\", r\"пателлофеморал\"],\n    \"Effusion\": [r\"effusion\", r\"derrame\", r\"erguss\", r\"épanchement\", r\"versamento\", r\"joint fluid\", r\"выпот\"],\n    \"Synovitis\": [r\"synovitis\", r\"sinovitis\", r\"синовит\"],\n    \"Baker's\": [r\"baker\", r\"popliteal cyst\", r\"quiste popl\", r\"poplitea\", r\"беккер\", r\"бейкер\"],\n    \"Contusion\": [r\"contusion\", r\"contusión\", r\"bone bruise\", r\"bone marrow edema\", r\"edema óseo\", r\"костномозгов\"],\n    \"Fracture\": [r\"fracture\", r\"fractura\", r\"fraktur\", r\"перелом\"],\n}\n\ndef split_sentences(text):\n    return [s.strip() for s in re.split(r\"[\\.\\!\\?\\n\\r]+\", str(text).lower()) if s.strip()]\n\ndef weak_one(text):\n    hits = {lab: {\"pos\": 0, \"neg\": 0} for lab in LABELS}\n    for s in split_sentences(text):\n        neg = bool(NEG_RE.search(s))\n        for lab in LABELS:\n            if any(re.search(p, s, flags=re.I) for p in RULES[lab]):\n                hits[lab][\"neg\" if neg else \"pos\"] += 1\n\n    out = {}\n    for lab in LABELS:\n        if hits[lab][\"pos\"] > 0:\n            out[lab] = 1.0\n        elif hits[lab][\"neg\"] > 0:\n            out[lab] = 0.0\n        else:\n            out[lab] = np.nan\n    return out\n\nweak = pd.DataFrame([weak_one(x) for x in train[\"Report\"].fillna(\"\")])\nweak.insert(0, \"StudyInstanceUID\", train[\"StudyInstanceUID\"].values)\nweak.to_csv(\"/kaggle/working/train_weak_v1.csv\", index=False)\n\nstats = pd.DataFrame({\n    \"known_frac\": weak[LABELS].notna().mean(),\n    \"pos_rate_among_known\": weak[LABELS].mean(skipna=True),\n}).sort_values(\"known_frac\", ascending=False)\n\nprint(stats.round(4))\nprint(\"\\nknown labels per study:\")\nprint(weak[LABELS].notna().sum(axis=1).describe())\n\nfor i in range(3):\n    print(\"\\n--- example\", i, \"---\")\n    print(str(train.loc[i, \"Report\"])[:700].replace(\"\\n\", \" \"))\n    print({k: (None if pd.isna(v) else int(v)) for k, v in weak.loc[i, LABELS].items()})","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:23:13.381912Z","iopub.execute_input":"2026-08-14T20:23:13.38217Z","iopub.status.idle":"2026-08-14T20:23:20.748879Z","shell.execute_reply.started":"2026-08-14T20:23:13.382148Z","shell.execute_reply":"2026-08-14T20:23:20.748109Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"PLANES = [\"Axial\", \"Sagittal\", \"Coronal\"]\n\ndef select_series(sdf, split):\n    rows = []\n    for study, x in sdf.groupby(\"StudyInstanceUID\"):\n        rec = {\"study\": study, \"split\": split}\n        for plane in PLANES:\n            xp = x[x[\"Anatomical_Plane\"] == plane].copy()\n            if len(xp) == 0:\n                rec[plane] = \"\"\n                continue\n            xp[\"score\"] = xp[\"Fluid_Sensitive\"].fillna(0) * 2 + xp[\"Fat_Suppression\"].fillna(0)\n            r = xp.sort_values(\"score\", ascending=False).iloc[0]\n            rec[plane] = str(COMP / f\"{split}_series\" / str(study) / str(r[\"SeriesInstanceUID\"]))\n        rows.append(rec)\n    return pd.DataFrame(rows)\n\ntrain_sel = select_series(train_series, \"train\")\ntest_sel = select_series(test_series, \"test\")\n\ntrain_df = train_sel.merge(weak, left_on=\"study\", right_on=\"StudyInstanceUID\", how=\"left\").drop(columns=[\"StudyInstanceUID\"])\ntest_df = test_sel.copy()\n\nprint(\"train_df:\", train_df.shape, \"test_df:\", test_df.shape)\ndisplay(train_df.head())\n\ntrain_df.to_csv(\"/kaggle/working/train_selected_series.csv\", index=False)\ntest_df.to_csv(\"/kaggle/working/test_selected_series.csv\", index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:23:20.749994Z","iopub.execute_input":"2026-08-14T20:23:20.750396Z","iopub.status.idle":"2026-08-14T20:23:36.200905Z","shell.execute_reply.started":"2026-08-14T20:23:20.750369Z","shell.execute_reply":"2026-08-14T20:23:36.200281Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models\nfrom PIL import Image\nimport pydicom\n\ntorch.manual_seed(SEED)\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(\"DEVICE:\", DEVICE)\n\ndef read_dicom(path):\n    try:\n        ds = pydicom.dcmread(str(path))\n        arr = ds.pixel_array.astype(np.float32)\n        if arr.ndim == 3:\n            arr = arr[..., 0] if arr.shape[-1] in [3, 4] else arr[0]\n        if str(getattr(ds, \"PhotometricInterpretation\", \"\")).upper() == \"MONOCHROME1\":\n            arr = arr.max() - arr\n        arr = (arr - arr.min()) / (arr.max() - arr.min() + 1e-6)\n\n        h, w = arr.shape\n        m = max(h, w)\n        canvas = np.zeros((m, m), dtype=np.float32)\n        y0 = (m - h) // 2\n        x0 = (m - w) // 2\n        canvas[y0:y0+h, x0:x0+w] = arr\n\n        img = Image.fromarray((canvas * 255).astype(np.uint8)).resize((IMG_SIZE, IMG_SIZE), Image.BILINEAR)\n        arr = np.asarray(img).astype(np.float32) / 255.0\n        return arr\n    except Exception:\n        return np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.float32)\n\ndef series_tensor(path):\n    if not path:\n        return torch.zeros((SLICES_PER_SERIES, 1, IMG_SIZE, IMG_SIZE), dtype=torch.float32)\n\n    files = sorted(Path(path).glob(\"*.dcm\"))\n    if len(files) == 0:\n        return torch.zeros((SLICES_PER_SERIES, 1, IMG_SIZE, IMG_SIZE), dtype=torch.float32)\n\n    idx = np.linspace(0, len(files) - 1, SLICES_PER_SERIES).round().astype(int)\n    imgs = [read_dicom(files[i]) for i in idx]\n    arr = np.stack(imgs, axis=0)  # S,H,W\n    arr = (arr - 0.5) / 0.5\n    return torch.from_numpy(arr).float().unsqueeze(1)  # S,1,H,W\n\nclass KneeStudyDS(Dataset):\n    def __init__(self, df, has_labels=True):\n        self.df = df.reset_index(drop=True)\n        self.has_labels = has_labels\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, i):\n        r = self.df.iloc[i]\n        xs, pm = [], []\n        for plane in PLANES:\n            path = r.get(plane, \"\")\n            if isinstance(path, str) and len(path) > 0 and Path(path).exists():\n                xs.append(series_tensor(path))\n                pm.append(1.0)\n            else:\n                xs.append(torch.zeros((SLICES_PER_SERIES, 1, IMG_SIZE, IMG_SIZE), dtype=torch.float32))\n                pm.append(0.0)\n\n        x = torch.stack(xs, dim=0)  # 3,S,1,H,W\n        plane_mask = torch.tensor(pm, dtype=torch.float32)\n\n        if self.has_labels:\n            y = torch.tensor([r.get(c, np.nan) for c in LABELS], dtype=torch.float32)\n        else:\n            y = torch.full((len(LABELS),), float(\"nan\"))\n\n        return x, plane_mask, y\n\nclass MILNet(nn.Module):\n    def __init__(self, n_labels=12):\n        super().__init__()\n        self.backbone = models.resnet18(weights=None)\n        self.backbone.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)\n        feat_dim = self.backbone.fc.in_features\n        self.backbone.fc = nn.Identity()\n        self.head = nn.Linear(feat_dim, n_labels)\n\n    def forward(self, x, plane_mask):\n        # x: B,3,S,1,H,W\n        B, P, S, C, H, W = x.shape\n        valid_slice = (x.abs().sum(dim=(3, 4, 5)) > 0).float()  # B,P,S\n\n        z = x.view(B * P * S, C, H, W)\n        f = self.backbone(z)  # BPS,512\n        f = f.view(B, P, S, -1)\n\n        denom = valid_slice.sum(dim=2, keepdim=True).clamp_min(1.0)  # B,P,1\n        series_feat = (f * valid_slice.unsqueeze(-1)).sum(dim=2) / denom.squeeze(-1).clamp_min(1.0).unsqueeze(-1)\n        # ↑ намеренно максимально просто: если формула вдруг даст warning, заменим ниже\n        series_feat = (f * valid_slice.unsqueeze(-1)).sum(dim=2) / valid_slice.sum(dim=2, keepdim=True).clamp_min(1.0)\n\n        plane_mask = plane_mask.unsqueeze(-1)  # B,3,1\n        study_feat = (series_feat * plane_mask).sum(dim=1) / plane_mask.sum(dim=1).clamp_min(1.0)\n        return self.head(study_feat)\n\ndef masked_bce(logits, y):\n    m = ~torch.isnan(y)\n    y2 = torch.nan_to_num(y, nan=0.0)\n    loss = F.binary_cross_entropy_with_logits(logits, y2, reduction=\"none\")\n    return (loss * m).sum() / m.sum().clamp_min(1.0)\n\nmodel = MILNet(len(LABELS)).to(DEVICE)\nopt = torch.optim.AdamW(model.parameters(), lr=LR)\nprint(model)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:23:36.201781Z","iopub.execute_input":"2026-08-14T20:23:36.202246Z","iopub.status.idle":"2026-08-14T20:23:46.889299Z","shell.execute_reply.started":"2026-08-14T20:23:36.202222Z","shell.execute_reply":"2026-08-14T20:23:46.888531Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import roc_auc_score\n\n# Берём только исследования, где есть хотя бы одна known weak-метка\nknown_cnt = train_df[LABELS].notna().sum(axis=1)\npool = train_df[known_cnt > 0].copy()\nprint(\"studies with any weak label:\", len(pool))\n\nif len(pool) > N_TRAIN_STUDIES:\n    pool = pool.sample(N_TRAIN_STUDIES, random_state=SEED).reset_index(drop=True)\n\ncut = int(len(pool) * 0.9)\ntr_df = pool.iloc[:cut].copy()\nva_df = pool.iloc[cut:].copy()\nprint(\"smoke train/val:\", len(tr_df), len(va_df))\n\ntr_loader = DataLoader(KneeStudyDS(tr_df, True), batch_size=BATCH_SIZE, shuffle=True, num_workers=0)\nva_loader = DataLoader(KneeStudyDS(va_df, True), batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\n\ndef run_epoch(loader, train_mode=True):\n    model.train() if train_mode else model.eval()\n    total_loss, total_n = 0.0, 0\n    all_logits, all_y = [], []\n\n    with torch.set_grad_enabled(train_mode):\n        for x, pm, y in loader:\n            x = x.to(DEVICE, non_blocking=True)\n            pm = pm.to(DEVICE, non_blocking=True)\n            y = y.to(DEVICE, non_blocking=True)\n\n            logits = model(x, pm)\n            loss = masked_bce(logits, y)\n\n            if train_mode:\n                opt.zero_grad()\n                loss.backward()\n                opt.step()\n\n            total_loss += float(loss.item()) * len(y)\n            total_n += len(y)\n            all_logits.append(logits.detach().cpu())\n            all_y.append(y.detach().cpu())\n\n    logits = torch.cat(all_logits).numpy()\n    y = np.concatenate([t.numpy() for t in all_y], axis=0)\n    prob = 1 / (1 + np.exp(-logits))\n\n    aucs = []\n    for j, c in enumerate(LABELS):\n        m = ~np.isnan(y[:, j])\n        if m.sum() > 0 and len(np.unique(y[m, j])) == 2:\n            try:\n                aucs.append((c, roc_auc_score(y[m, j], prob[m, j])))\n            except Exception:\n                pass\n\n    macro = float(np.mean([a for _, a in aucs])) if aucs else float(\"nan\")\n    return total_loss / max(total_n, 1), macro, aucs\n\nfor ep in range(EPOCHS):\n    t0 = time.time()\n    tr_loss, _, _ = run_epoch(tr_loader, True)\n    va_loss, va_auc, va_aucs = run_epoch(va_loader, False)\n    print(f\"epoch {ep}: train_loss={tr_loss:.4f} val_loss={va_loss:.4f} val_macroAUC={va_auc:.4f} time={time.time()-t0:.1f}s\")\n    print(\"val AUCs:\", [(c, round(a, 3)) for c, a in va_aucs])\n\ntorch.save(model.state_dict(), \"/kaggle/working/milnet_smoke.pt\")\nprint(\"saved: /kaggle/working/milnet_smoke.pt\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:23:46.890934Z","iopub.execute_input":"2026-08-14T20:23:46.891286Z","iopub.status.idle":"2026-08-14T20:26:05.177193Z","shell.execute_reply.started":"2026-08-14T20:23:46.891262Z","shell.execute_reply":"2026-08-14T20:26:05.176484Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Если обучение упало, всё равно сделаем валидный сабмит константой.\nuse_model = Path(\"/kaggle/working/milnet_smoke.pt\").exists()\nprint(\"use_model:\", use_model)\n\nif use_model:\n    try:\n        model.load_state_dict(torch.load(\"/kaggle/working/milnet_smoke.pt\", map_location=DEVICE))\n    except Exception as e:\n        print(\"load failed, fallback to 0.5:\", repr(e))\n        use_model = False\n\ntest_loader = DataLoader(KneeStudyDS(test_df, False), batch_size=BATCH_SIZE, shuffle=False, num_workers=0)\npreds = []\n\nif use_model:\n    model.eval()\n    with torch.no_grad():\n        for x, pm, _ in test_loader:\n            x = x.to(DEVICE)\n            pm = pm.to(DEVICE)\n            logits = model(x, pm)\n            preds.append(torch.sigmoid(logits).cpu().numpy())\n    preds = np.vstack(preds)\nelse:\n    preds = np.full((len(test_df), len(LABELS)), 0.5)\n\nsub = sample_sub.copy().set_index(\"StudyInstanceUID\")\npred_df = pd.DataFrame(preds, columns=LABELS)\npred_df.insert(0, \"StudyInstanceUID\", test_df[\"study\"].values)\n\nfor _, r in pred_df.iterrows():\n    if r[\"StudyInstanceUID\"] in sub.index:\n        sub.loc[r[\"StudyInstanceUID\"], LABELS] = r[LABELS].values\n\nsub = sub.reset_index()[[\"StudyInstanceUID\"] + LABELS]\nsub[LABELS] = sub[LABELS].fillna(0.5).clip(0.0, 1.0)\n\nsub.to_csv(\"/kaggle/working/submission.csv\", index=False)\nprint(\"saved: /kaggle/working/submission.csv\", sub.shape)\ndisplay(sub.head())\n\nassert list(sub.columns) == list(sample_sub.columns)\nassert sub[LABELS].isna().sum().sum() == 0\nprint(\"submission OK\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-14T20:30:12.03367Z","iopub.execute_input":"2026-08-14T20:30:12.034001Z","iopub.status.idle":"2026-08-14T20:30:12.867794Z","shell.execute_reply.started":"2026-08-14T20:30:12.03398Z","shell.execute_reply":"2026-08-14T20:30:12.867074Z"}},"outputs":[],"execution_count":null}]}