{"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":"# Ячейка 1 — конфиг, загрузка и GOLD check\n\nfrom pathlib import Path\nimport os, re, json, random, math, time, warnings\nimport unicodedata\n\nimport numpy as np\nimport pandas as pd\n\nwarnings.filterwarnings(\"ignore\")\npd.set_option(\"display.max_columns\", 100)\n\nSEEDS = [42, 1337]   # два seed, чтобы не раздувать время\nBASE_SEED = 42\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\nGOLD = train[train[LABELS].notna().all(axis=1)].copy()\nprint(\"GOLD fully annotated studies:\", len(GOLD), flush=True)\nif len(GOLD) == 0:\n    raise RuntimeError(\"Не нашёл gold-строки в train.csv. Ожидались ~58 fully annotated.\")\nprint(\"GOLD positive rate:\", flush=True)\nprint(GOLD[LABELS].mean().round(3), flush=True)\n\n# Конфиг: назад к живому v0.3/v0.6, но с gold weight.\nIMG_SIZE = 320\nSLICES_PER_SERIES = 10\nN_TRAIN_STUDIES = 512\nEPOCHS = 2\nBATCH_SIZE = 8\nLR = 3e-4\nNUM_WORKERS = 0\nAMP = True\nGRAD_CLIP = 1.0\nVAL_FRAC = 0.20\nGOLD_WEIGHT = 3.0\n\nprint(\"CONFIG v0.7:\", {\n    \"SEEDS\": SEEDS, \"IMG_SIZE\": IMG_SIZE, \"SLICES_PER_SERIES\": SLICES_PER_SERIES,\n    \"N_TRAIN_STUDIES\": N_TRAIN_STUDIES, \"EPOCHS\": EPOCHS, \"BATCH_SIZE\": BATCH_SIZE,\n    \"LR\": LR, \"VAL_FRAC\": VAL_FRAC, \"GOLD_WEIGHT\": GOLD_WEIGHT,\n}, flush=True)","metadata":{"trusted":true,"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Ячейка 2 — extractor v2 + agreement на 58 GOLD\n\n_PRE = str.maketrans({\"ı\": \"i\", \"İ\": \"i\", \"I\": \"i\", \"ß\": \"ss\", \"đ\": \"d\", \"Đ\": \"d\", \"ø\": \"o\", \"Ø\": \"o\", \"æ\": \"ae\", \"Æ\": \"ae\"})\n\ndef normalize(text):\n    if not isinstance(text, str):\n        return \"\"\n    text = text.translate(_PRE).lower()\n    text = unicodedata.normalize(\"NFKD\", text)\n    text = \"\".join(ch for ch in text if not unicodedata.combining(ch))\n    text = text.replace(\"\\xad\", \" \")\n    text = re.sub(r\"[_\\-/\\\\]+\", \" \", text)\n    text = re.sub(r\"\\s+\", \" \", text)\n    return text.strip()\n\nNEG_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\"croise anterieur\", r\"vorderes kreuzband\", r\"крестообраз\"],\n    \"MCL\": [r\"\\bmcl\\b\", r\"colateral medial\", r\"collateral medial\", 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\"aussenmeniskus\", r\"латеральн.{0,20}мениск\"],\n    \"Medial OA\": [r\"medial.{0,40}(osteoarth|arthrosis|arthrose)\", r\"(osteoarth|arthrosis|arthrose).{0,40}medial\", r\"artrosis.{0,40}medial\", r\"femorotibial medial\", r\"медиальн.{0,40}остеоартр\"],\n    \"Lateral OA\": [r\"lateral.{0,40}(osteoarth|arthrosis|arthrose)\", r\"(osteoarth|arthrosis|arthrose).{0,40}lateral\", r\"artrosis.{0,40}lateral\", r\"femorotibial lateral\", r\"латеральн.{0,40}остеоартр\"],\n    \"PF OA\": [r\"patellofemoral.{0,40}(osteoarth|arthrosis|arthrose|chondrop|chondros)\", r\"(osteoarth|arthrosis|arthrose).{0,40}patellofemoral\", r\"femoropatelar\", r\"retropatellar\", r\"пателлофеморал\"],\n    \"Effusion\": [r\"effusion\", r\"derrame\", r\"erguss\", r\"epanchement\", r\"versamento\", r\"joint fluid\", r\"выпот\"],\n    \"Synovitis\": [r\"\\bsynovitis\\b\", r\"\\bsinovitis\\b\", r\"синовит\"],\n    \"Baker's\": [r\"baker\", r\"popliteal cyst\", r\"quiste popl\", r\"poplitea\", r\"беккер\", r\"бейкер\"],\n    \"Contusion\": [r\"contusion\", r\"bone bruise\", r\"bone marrow edema\", r\"edema oseo\", r\"костномозгов\"],\n    \"Fracture\": [r\"fracture\", r\"fractura\", r\"fraktur\", r\"перелом\"],\n}\n\ndef clauses(text):\n    t = normalize(text)\n    return [s.strip() for s in re.split(r\"[\\.\\!\\?\\n\\r]+\", t) if s.strip()]\n\ndef weak_one(text):\n    hits = {lab: {\"pos\": 0, \"neg\": 0} for lab in LABELS}\n    for s in clauses(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    return {lab: (1.0 if hits[lab][\"pos\"] > 0 else (0.0 if hits[lab][\"neg\"] > 0 else np.nan)) for lab in LABELS}\n\ncache = Path(\"/kaggle/working/train_weak_v2_gold.csv\")\nif cache.exists():\n    weak = pd.read_csv(cache)\n    print(\"loaded weak cache:\", weak.shape, flush=True)\nelse:\n    print(\"extract weak labels...\", flush=True)\n    t0 = time.time()\n    weak = pd.DataFrame([weak_one(x) for x in train[\"Report\"].fillna(\"\")])\n    weak.insert(0, \"StudyInstanceUID\", train[\"StudyInstanceUID\"].values)\n    weak.to_csv(cache, index=False)\n    print(\"weak done\", time.time() - t0, \"s\", flush=True)\n\n# Agreement на GOLD\nfrom sklearn.metrics import roc_auc_score\nwp = weak.set_index(\"StudyInstanceUID\").loc[GOLD[\"StudyInstanceUID\"], LABELS]\nrows = []\nfor c in LABELS:\n    y = GOLD[c].values.astype(int)\n    p = wp[c].values\n    m = ~np.isnan(p)\n    sil = float(np.isnan(p).mean())\n    auc = np.nan\n    if m.sum() > 0 and len(np.unique(y[m])) == 2:\n        try:\n            auc = roc_auc_score(y[m], p[m])\n        except Exception:\n            pass\n    rows.append({\"label\": c, \"auc_on_gold\": auc, \"silence\": sil, \"gold_pos\": int(y.sum())})\nrep = pd.DataFrame(rows)\nprint(rep.round(4).to_string(index=False), flush=True)\nprint(\"macro agreement AUC:\", float(np.nanmean(rep[\"auc_on_gold\"])), flush=True)\nprint(\"mean silence:\", float(rep[\"silence\"].mean()), flush=True)","metadata":{"trusted":true,"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Ячейка 3 — выбор серий и sample weights с GOLD_WEIGHT\n\nPLANES = [\"Axial\", \"Sagittal\", \"Coronal\"]\n\ndef select_series(sdf, split, desc=\"\"):\n    rows = []\n    studies = list(sdf.groupby(\"StudyInstanceUID\"))\n    t0 = time.time()\n    for i, (study, x) in enumerate(studies):\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        if desc and (i % 1500 == 0 or i == len(studies) - 1):\n            print(f\"[{desc}] {i + 1}/{len(studies)} elapsed={time.time() - t0:.1f}s\", flush=True)\n    return pd.DataFrame(rows)\n\ntrain_sel = select_series(train_series, \"train\", \"select train\")\ntest_sel = select_series(test_series, \"test\", \"select test\")\n\ntrain_df = train_sel.merge(weak, left_on=\"study\", right_on=\"StudyInstanceUID\", how=\"left\").drop(columns=[\"StudyInstanceUID\"])\ngold_ids = set(GOLD[\"StudyInstanceUID\"])\ntrain_df[\"sample_w\"] = np.where(train_df[\"study\"].isin(gold_ids), GOLD_WEIGHT, 1.0).astype(np.float32)\ntest_df = test_sel.copy()\n\nprint(\"train_df:\", train_df.shape, \"test_df:\", test_df.shape, flush=True)\nprint(\"gold rows in train_df:\", int((train_df[\"sample_w\"] > 1).sum()), flush=True)\ntrain_df.to_csv(\"/kaggle/working/train_selected_series_v0_7.csv\", index=False)\ntest_df.to_csv(\"/kaggle/working/test_selected_series_v0_7.csv\", index=False)","metadata":{"trusted":true,"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Ячейка 4 — dataset/model с sample_w, self-contained fallback\n\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport 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\nif \"IMG_SIZE\" not in globals(): IMG_SIZE = 320\nif \"SLICES_PER_SERIES\" not in globals(): SLICES_PER_SERIES = 10\nif \"AMP\" not in globals(): AMP = True\nif \"LABELS\" not in globals():\n    LABELS = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\", \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\nif \"PLANES\" not in globals(): PLANES = [\"Axial\", \"Sagittal\", \"Coronal\"]\nif \"COMP\" not in globals():\n    BASE = Path(\"/kaggle/input\")\n    COMP = None\n    for p in BASE.rglob(\"sample_submission.csv\"):\n        COMP = p.parent\n        break\n    if COMP is None:\n        raise FileNotFoundError(\"Не нашёл sample_submission.csv. Проверь Add Data.\")\n\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nUSE_AMP = bool(AMP and DEVICE == \"cuda\")\ntorch.backends.cudnn.benchmark = True\nprint(\"DEVICE:\", DEVICE, \"USE_AMP:\", USE_AMP, flush=True)\n\ntry:\n    from pydicom.pixel_data_handlers.util import apply_voi_lut\nexcept Exception:\n    apply_voi_lut = None\nRESAMPLE = getattr(getattr(Image, \"Resampling\", Image), \"BILINEAR\")\n\ndef read_dicom(path):\n    try:\n        ds = pydicom.dcmread(str(path))\n        arr = ds.pixel_array\n        if apply_voi_lut is not None:\n            try: arr = apply_voi_lut(arr, ds)\n            except Exception: pass\n        arr = arr.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        h, w = arr.shape\n        m = max(h, w)\n        canvas = np.zeros((m, m), dtype=np.float32)\n        y0 = (m - h) // 2; x0 = (m - w) // 2\n        canvas[y0:y0+h, x0:x0+w] = arr\n        img = Image.fromarray((canvas * 255).astype(np.uint8)).resize((IMG_SIZE, IMG_SIZE), RESAMPLE)\n        return np.asarray(img).astype(np.float32) / 255.0\n    except Exception:\n        return np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.float32)\n\ndef series_tensor(path):\n    zero = torch.zeros((SLICES_PER_SERIES, 1, IMG_SIZE, IMG_SIZE), dtype=torch.float32)\n    if not path: return zero\n    files = sorted(Path(path).glob(\"*.dcm\"))\n    if len(files) == 0: return zero\n    idx = np.linspace(0, len(files) - 1, SLICES_PER_SERIES).round().astype(int)\n    arr = np.stack([read_dicom(files[i]) for i in idx], axis=0)\n    arr = (arr - 0.5) / 0.5\n    return torch.from_numpy(arr).float().unsqueeze(1)\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    def __len__(self): return len(self.df)\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)); pm.append(1.0)\n            else:\n                xs.append(torch.zeros((SLICES_PER_SERIES, 1, IMG_SIZE, IMG_SIZE), dtype=torch.float32)); pm.append(0.0)\n        x = torch.stack(xs, dim=0)\n        plane_mask = torch.tensor(pm, dtype=torch.float32)\n        y = torch.tensor([r.get(c, np.nan) for c in LABELS], dtype=torch.float32) if self.has_labels else torch.full((len(LABELS),), float(\"nan\"))\n        sw = torch.tensor(float(r.get(\"sample_w\", 1.0)), dtype=torch.float32)\n        return x, plane_mask, y, sw\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    def forward(self, x, plane_mask):\n        B, P, S, C, H, W = x.shape\n        slice_valid = (x.abs().sum(dim=(3, 4, 5)) > 0).float()\n        z = x.reshape(B * P * S, C, H, W)\n        f = self.backbone(z).reshape(B, P, S, -1)\n        w = slice_valid.unsqueeze(-1)\n        series_feat = (f * w).sum(dim=2) / w.sum(dim=2).clamp_min(1.0)\n        pm = plane_mask.unsqueeze(-1)\n        study_feat = (series_feat * pm).sum(dim=1) / pm.sum(dim=1).clamp_min(1.0)\n        return self.head(study_feat)\n\ndef weighted_masked_bce(logits, y, sw):\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\")  # B,12\n    per = (loss * m).sum(dim=1) / m.sum(dim=1).clamp_min(1.0)              # B\n    return (per * sw).sum() / sw.sum().clamp_min(1e-6)\n\ndef build_model(seed):\n    torch.manual_seed(seed)\n    model = MILNet(len(LABELS)).to(DEVICE)\n    opt = torch.optim.AdamW(model.parameters(), lr=3e-4)\n    return model, opt\n\nprint(\"model builder ready\", flush=True)","metadata":{"trusted":true,"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Ячейка 5 — train v0.7 fixed: все GOLD в train, val только из non-gold\n\nfrom pathlib import Path\nfrom sklearn.metrics import roc_auc_score\nimport time, random\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom torch.utils.data import DataLoader\n\n# fallback-конфиг\nif \"SEEDS\" not in globals(): SEEDS = [42, 1337]\nif \"N_TRAIN_STUDIES\" not in globals(): N_TRAIN_STUDIES = 512\nif \"EPOCHS\" not in globals(): EPOCHS = 2\nif \"BATCH_SIZE\" not in globals(): BATCH_SIZE = 8\nif \"NUM_WORKERS\" not in globals(): NUM_WORKERS = 0\nif \"VAL_FRAC\" not in globals(): VAL_FRAC = 0.20\nif \"GRAD_CLIP\" not in globals(): GRAD_CLIP = 1.0\nif \"LABELS\" not in globals():\n    LABELS = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\", \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\nneed = [\"KneeStudyDS\", \"weighted_masked_bce\", \"build_model\", \"DEVICE\", \"USE_AMP\"]\nmissing = [x for x in need if x not in globals()]\nif missing:\n    raise NameError(f\"Не хватает объектов из Ячейки 4: {missing}. Сначала выполни Ячейку 4.\")\n\nif \"train_df\" not in globals():\n    p = Path(\"/kaggle/working/train_selected_series_v0_7.csv\")\n    if p.exists():\n        train_df = pd.read_csv(p)\n        print(\"loaded train_df:\", train_df.shape, flush=True)\n    else:\n        raise NameError(\"Нет train_df. Выполни Ячейки 1–3.\")\n\nif \"sample_w\" not in train_df.columns:\n    train_df[\"sample_w\"] = 1.0\n    print(\"WARNING: sample_w не найден, все веса = 1.0\", flush=True)\n\ndef fmt_eta(seconds):\n    seconds = int(max(0, seconds))\n    return f\"{seconds // 60:02d}:{seconds % 60:02d}\"\n\ndef quick_macro(logits_np, y_np):\n    if len(logits_np) == 0:\n        return np.nan\n    prob = 1 / (1 + np.exp(-logits_np))\n    aucs = []\n    for j in range(len(LABELS)):\n        m = ~np.isnan(y_np[:, j])\n        if m.sum() > 0 and len(np.unique(y_np[m, j])) == 2:\n            try:\n                aucs.append(roc_auc_score(y_np[m, j], prob[m, j]))\n            except Exception:\n                pass\n    return float(np.mean(aucs)) if aucs else np.nan\n\nknown_cnt = train_df[LABELS].notna().sum(axis=1)\ngold_all = train_df[train_df[\"sample_w\"] > 1].copy()\nnongold_pool = train_df[(train_df[\"sample_w\"] <= 1) & (known_cnt > 0)].copy()\n\nprint(\"gold_all:\", len(gold_all), flush=True)\nprint(\"nongold with any weak label:\", len(nongold_pool), flush=True)\nif len(gold_all) == 0:\n    print(\"WARNING: gold_all пуст. Проверь sample_w / GOLD_WEIGHT в Ячейке 3.\", flush=True)\n\nfor seed in SEEDS:\n    print(\"\\n\" + \"#\" * 90, flush=True)\n    print(\"SEED\", seed, flush=True)\n    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)\n\n    ng = nongold_pool.sample(frac=1.0, random_state=seed).reset_index(drop=True)\n\n    n_val = int(len(ng) * VAL_FRAC)\n    va_df = ng.iloc[:n_val].copy()\n    ng_train = ng.iloc[n_val:].copy()\n\n    need_ng = max(0, N_TRAIN_STUDIES - len(gold_all))\n    ng_train = ng_train.iloc[:need_ng].copy()\n\n    tr_df = pd.concat([gold_all, ng_train], ignore_index=True)\n    tr_df = tr_df.sample(frac=1.0, random_state=seed).reset_index(drop=True)\n\n    print(\"train/val sanity:\", len(tr_df), len(va_df), flush=True)\n    print(\"gold in train:\", int((tr_df[\"sample_w\"] > 1).sum()), \"| gold in val:\", int((va_df[\"sample_w\"] > 1).sum()), flush=True)\n\n    model, opt = build_model(seed)\n    tr_loader = DataLoader(KneeStudyDS(tr_df, True), batch_size=BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS)\n    va_loader = DataLoader(KneeStudyDS(va_df, True), batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS) if len(va_df) else None\n    scaler = torch.cuda.amp.GradScaler(enabled=USE_AMP)\n\n    for ep in range(EPOCHS):\n        model.train()\n        t0 = time.time()\n        total_loss, total_n = 0.0, 0\n\n        for bi, (x, pm, y, sw) in enumerate(tr_loader):\n            x = x.to(DEVICE); pm = pm.to(DEVICE); y = y.to(DEVICE); sw = sw.to(DEVICE)\n\n            with torch.cuda.amp.autocast(enabled=USE_AMP):\n                logits = model(x, pm)\n                loss = weighted_masked_bce(logits, y, sw)\n\n            opt.zero_grad(set_to_none=True)\n            if USE_AMP:\n                scaler.scale(loss).backward()\n                if GRAD_CLIP:\n                    scaler.unscale_(opt)\n                    torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP)\n                scaler.step(opt)\n                scaler.update()\n            else:\n                loss.backward()\n                if GRAD_CLIP:\n                    torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP)\n                opt.step()\n\n            total_loss += float(loss.item()) * len(y)\n            total_n += len(y)\n\n            if (bi % 10 == 0) or (bi == len(tr_loader) - 1):\n                elapsed = time.time() - t0\n                done = bi + 1\n                eta = elapsed / max(done, 1) * (len(tr_loader) - done)\n                print(f\"[seed{seed}/ep{ep}] {done}/{len(tr_loader)} avg={total_loss / max(total_n, 1):.4f} elapsed={fmt_eta(elapsed)} eta={fmt_eta(eta)}\", flush=True)\n\n        va_auc = np.nan\n        if va_loader is not None:\n            model.eval()\n            all_logits, all_y = [], []\n            with torch.no_grad():\n                for x, pm, y, sw in va_loader:\n                    x = x.to(DEVICE); pm = pm.to(DEVICE)\n                    with torch.cuda.amp.autocast(enabled=USE_AMP):\n                        logits = model(x, pm)\n                    all_logits.append(logits.float().cpu().numpy())\n                    all_y.append(y.numpy())\n            va_auc = quick_macro(np.vstack(all_logits), np.concatenate(all_y, axis=0)) if all_logits else np.nan\n\n        print(f\"[seed{seed}] epoch {ep} train_loss={total_loss / max(total_n, 1):.4f} sanity_val_macro={va_auc:.4f}\", flush=True)\n\n    out = f\"/kaggle/working/milnet_v0_7_seed{seed}.pt\"\n    torch.save(model.state_dict(), out)\n    print(\"saved\", out, flush=True)\n\n    del model, opt\n    torch.cuda.empty_cache()\n\nprint(\"Ячейка 5 завершена.\", flush=True)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Ячейка 6 — rank-average inference и submission.csv\n\nif \"SEEDS\" not in globals(): SEEDS = [42, 1337]\nif \"BATCH_SIZE\" not in globals(): BATCH_SIZE = 8\nif \"NUM_WORKERS\" not in globals(): NUM_WORKERS = 0\nif \"LABELS\" not in globals():\n    LABELS = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\", \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\", \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\nneed = [\"KneeStudyDS\", \"build_model\", \"DEVICE\", \"USE_AMP\", \"test_df\", \"sample_sub\"]\nmissing = [x for x in need if x not in globals()]\nif missing:\n    raise NameError(f\"Не хватает {missing}. Выполни предыдущие ячейки.\")\n\ndef fmt_eta(seconds):\n    seconds = int(max(0, seconds)); return f\"{seconds // 60:02d}:{seconds % 60:02d}\"\n\ndef rank_cols(arr):\n    # arr: N,12 -> per-label ranks in [0,1]\n    return pd.DataFrame(arr, columns=LABELS).rank(pct=True).to_numpy(np.float64)\n\ntest_loader = DataLoader(KneeStudyDS(test_df, False), batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS)\nprint(\"test batches:\", len(test_loader), flush=True)\n\nrank_preds = []\nfor seed in SEEDS:\n    path = f\"/kaggle/working/milnet_v0_7_seed{seed}.pt\"\n    if not Path(path).exists():\n        print(\"missing model:\", path, flush=True)\n        continue\n    model, _ = build_model(seed)\n    model.load_state_dict(torch.load(path, map_location=DEVICE))\n    model.eval()\n\n    preds = []\n    t0 = time.time()\n    with torch.no_grad():\n        for bi, (x, pm, _, _) in enumerate(test_loader):\n            x = x.to(DEVICE); pm = pm.to(DEVICE)\n            with torch.cuda.amp.autocast(enabled=USE_AMP):\n                logits = model(x, pm)\n            preds.append(torch.sigmoid(logits).float().cpu().numpy())\n            if (bi % 20 == 0) or (bi == len(test_loader) - 1):\n                elapsed = time.time() - t0; done = bi + 1\n                eta = elapsed / max(done, 1) * (len(test_loader) - done)\n                print(f\"[infer seed{seed}] {done}/{len(test_loader)} elapsed={fmt_eta(elapsed)} eta={fmt_eta(eta)}\", flush=True)\n\n    p = np.vstack(preds) if preds else np.full((len(test_df), len(LABELS)), 0.5)\n    np.save(f\"/kaggle/working/preds_v0_7_seed{seed}.npy\", p)\n    rank_preds.append(rank_cols(p))\n    print(\"seed done:\", seed, flush=True)\n    del model\n    torch.cuda.empty_cache()\n\nens = np.mean(np.stack(rank_preds, axis=0), axis=0) if rank_preds else np.full((len(test_df), len(LABELS)), 0.5)\n\nsub = sample_sub.copy().set_index(\"StudyInstanceUID\")\npred_df = pd.DataFrame(ens, columns=LABELS)\npred_df.insert(0, \"StudyInstanceUID\", test_df[\"study\"].values)\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)\nsub.to_csv(\"/kaggle/working/submission.csv\", index=False)\n\nprint(\"saved: /kaggle/working/submission.csv\", sub.shape, flush=True)\ndisplay(sub.head())\nassert list(sub.columns) == list(sample_sub.columns)\nassert sub[LABELS].isna().sum().sum() == 0\nprint(\"submission OK\", flush=True)","metadata":{"trusted":true,"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null}]}