{"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 numpy as np\nimport pandas as pd\nimport os\n\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T09:26:48.97525Z","iopub.execute_input":"2026-08-09T09:26:48.975484Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install -q iterative-stratification","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T09:38:02.752624Z","iopub.execute_input":"2026-08-09T09:38:02.752978Z","iopub.status.idle":"2026-08-09T09:38:07.952467Z","shell.execute_reply.started":"2026-08-09T09:38:02.752947Z","shell.execute_reply":"2026-08-09T09:38:07.951185Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\"\"\"\nRSNA Knee Abnormality Detection — ADVANCED Multimodal Pipeline\n================================================================\nHackathon-grade upgrade over the baseline. Key differences from a naive\napproach:\n\n  1. IMAGE:  Attention-based MIL pooling over MRI slices (learns which\n             slices matter) instead of crudely averaging them.\n  2. TEXT:   Bio_ClinicalBERT with a learned [CLS]-vs-mean-pool blend.\n  3. FUSION: Cross-attention transformer block (image tokens attend to\n             text tokens and vice versa) instead of naive concatenation.\n  4. LOSS:   Class-balanced Focal Loss — handles the heavy label\n             imbalance (fracture/Baker's cyst will be rare; OA won't be).\n  5. TRAIN:  Mixed precision (AMP), gradient accumulation, EMA weights,\n             OneCycle LR, multilabel-stratified folds (iterative-stratification).\n  6. INFER:  Test-time augmentation (flips) + 2-backbone ensemble\n             (ConvNeXt-Tiny + EfficientNetV2-S) blended with image+text.\n  7. EFFICIENCY: A `LIGHT_MODE` flag swaps in a smaller backbone/fewer\n             slices for a second, fast submission aimed at the\n             Efficiency Track.\n\nINSTALL (run once, with internet ON, before disabling it for submission):\n    pip install -q iterative-stratification timm transformers pydicom\n\nUSAGE\n-----\n1. Run explore_data() first and fix CFG paths/columns to match reality.\n2. run_cv() trains N_FOLDS x N_BACKBONES models, saves checkpoints + EMA weights.\n3. run_inference() loads all checkpoints, applies TTA, blends, writes submission.csv.\n\"\"\"\n\nimport os\nimport glob\nimport copy\nimport shutil\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.amp import autocast, GradScaler\nfrom sklearn.metrics import roc_auc_score\nimport torchvision.transforms as T\nimport timm\nfrom transformers import AutoTokenizer, AutoModel\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n\n# ----------------------------------------------------------------------\n# CONFIG\n# ----------------------------------------------------------------------\nclass CFG:\n    # Auto-detect environment: Kaggle mounts the dataset under /kaggle/input,\n    # which only exists inside a Kaggle Notebook. The exact mount path\n    # varies by how the dataset was attached — confirmed from a real\n    # os.walk('/kaggle/input') listing that it can appear as either\n    # /kaggle/input/rsna-knee-abnormality-detection or nested one level\n    # deeper under /kaggle/input/competitions/rsna-knee-abnormality-detection\n    # — so check both rather than assuming one.\n    #\n    # Running this script locally (e.g. on Windows for quick debugging)\n    # needs a local copy of the dataset instead — set the\n    # RSNA_KNEE_DATA_DIR environment variable, or drop the files under\n    # ./data next to this script.\n    # NOTE: the actual scored submission must still run inside a Kaggle\n    # Notebook per the competition's Code Requirements (internet disabled,\n    # 9h runtime cap) — local runs are for development/debugging only.\n    _CANDIDATE_KAGGLE_DIRS = [\n        \"/kaggle/input/rsna-knee-abnormality-detection\",\n        \"/kaggle/input/competitions/rsna-knee-abnormality-detection\",\n    ]\n    _found = next((p for p in _CANDIDATE_KAGGLE_DIRS if os.path.isdir(p)), None)\n    if _found:\n        DATA_DIR = _found\n    else:\n        # __file__ isn't defined when this script is pasted into a\n        # notebook cell (as opposed to run as a .py file) — os.getcwd()\n        # is the right fallback for that case, and matches what a\n        # Kaggle notebook's \"current directory\" paste-and-run workflow\n        # actually needs.\n        _base_dir = os.path.dirname(os.path.abspath(__file__)) if \"__file__\" in globals() else os.getcwd()\n        DATA_DIR = os.environ.get(\"RSNA_KNEE_DATA_DIR\", os.path.join(_base_dir, \"data\"))\n    TRAIN_CSV = f\"{DATA_DIR}/train.csv\"\n    # Report text lives directly in train.csv (column \"Report\"), NOT in a\n    # separate reports file — confirmed from actual columns:\n    # train.csv: StudyInstanceUID, Report, ACL, MCL, ... Fracture\n    # train_series.csv is series-LEVEL sequence metadata (Fluid_Sensitive,\n    # Fat_Suppression, Anatomical_Plane, SeriesInstanceUID), not report\n    # text. A study has ~5.5 series on average (24371 series / 4407\n    # studies), each presumably in its own SeriesInstanceUID-named folder\n    # under train_series/ — NOT one folder per StudyInstanceUID as\n    # originally assumed.\n    TRAIN_SERIES_CSV = f\"{DATA_DIR}/train_series.csv\"\n    TRAIN_IMG_DIR = f\"{DATA_DIR}/train_series\"        # holds per-SERIES subfolders\n    TEST_CSV = f\"{DATA_DIR}/test.csv\"\n    TEST_SERIES_CSV = f\"{DATA_DIR}/test_series.csv\"\n    TEST_IMG_DIR = f\"{DATA_DIR}/test_series\"\n\n    TARGET_COLS = [\n        \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n        \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n        \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\",\n    ]\n\n    IMG_SIZE = 256\n    N_SLICES = 24\n    BATCH_SIZE = 2                 # small due to N_SLICES per sample; use grad accumulation\n    ACCUM_STEPS = 8                # effective batch = BATCH_SIZE * ACCUM_STEPS\n    EPOCHS = 15\n    WARMUP_PCT = 0.1\n    MAX_LR = 3e-4\n    WEIGHT_DECAY = 1e-4\n    N_FOLDS = 5\n    EMA_DECAY = 0.999\n\n    REPORT_TEXT_COL = \"Report\"   # column in train.csv itself — confirmed from real data\n\n    TEXT_MODEL = \"emilyalsentzer/Bio_ClinicalBERT\"\n    IMG_BACKBONES = [\"convnext_tiny\", \"tf_efficientnetv2_s\"]   # ensemble members\n    FUSION_DIM = 512\n    N_FUSION_LAYERS = 2\n    N_HEADS = 8\n\n    LIGHT_MODE = False              # flip to True for the Efficiency Track submission\n    LIGHT_BACKBONE = \"efficientnet_b0\"\n    LIGHT_N_SLICES = 10\n\n    # ---- Hackathon-mode knobs ----\n    DEBUG = False                   # flip to True for a <5 min smoke test of the whole pipeline\n    DEBUG_N_ROWS = 64                # rows sampled from train/test when DEBUG=True\n    DEBUG_EPOCHS = 2\n    DEBUG_FOLDS = 1                 # only run this many of the N_FOLDS splits\n    DEBUG_BACKBONES = 1             # only run this many of IMG_BACKBONES\n\n    TIME_BUDGET_HOURS = 8.0         # stop starting new folds once this much wall-clock has elapsed\n                                     # (Kaggle code notebooks hard-cap at 9h; leave headroom for inference)\n\n    CACHE_DIR = \"/kaggle/working/slice_cache\"   # preprocessed DICOM stacks cached here as .npy\n    # /kaggle/working is a small quota disk (~20GB on most Kaggle sessions) —\n    # NOT the same as the 570GB read-only /kaggle/input mount. With the full\n    # dataset (hundreds of thousands of studies), caching every stack would\n    # blow that quota and crash the notebook mid-run. MIN_FREE_DISK_GB stops\n    # writing new cache entries once free space drops below this line;\n    # training keeps running, it just falls back to re-reading DICOMs\n    # for studies that didn't make it into the cache.\n    CACHE_ENABLED = True\n    MIN_FREE_DISK_GB = 2.0\n\n    # If a session gets interrupted (GPU quota, timeout, manual stop),\n    # re-running run_cv() skips any (backbone, fold) that already has a\n    # saved checkpoint instead of retraining it from scratch. Set to\n    # False to force a full clean retrain of everything.\n    RESUME_SKIP_EXISTING = True\n\n    DEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n    SEED = 42\n\n\n# AMP is only meaningful on CUDA; on CPU we fall back to full precision\n# instead of crashing (old `torch.cuda.amp.autocast()` assumed a CUDA\n# device unconditionally).\nAMP_ENABLED = CFG.DEVICE == \"cuda\"\n\n\ndef amp_autocast():\n    return autocast(device_type=CFG.DEVICE, enabled=AMP_ENABLED)\n\n\ndef seed_everything(seed=CFG.SEED):\n    import random\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\n\n# ----------------------------------------------------------------------\n# STEP 0: EXPLORE (run by hand first, fix CFG to match real schema)\n# ----------------------------------------------------------------------\ndef check_data_dir(require_images=True):\n    # Shared guard so every entry point (explore_data, run_cv,\n    # run_inference) fails with a clear, actionable message instead of a\n    # raw pandas FileNotFoundError traceback pointing at a path the user\n    # never set themselves.\n    #\n    # require_images=False (used by explore_data): only checks the CSVs,\n    # since label/report/series-count inspection doesn't need the (huge)\n    # image folders downloaded yet. run_cv/run_inference need the images\n    # and pass the default require_images=True.\n    if not os.path.isdir(CFG.DATA_DIR):\n        raise FileNotFoundError(\n            f\"CFG.DATA_DIR does not exist: {CFG.DATA_DIR}\\n\"\n            f\"You're not running inside a Kaggle Notebook (no /kaggle/input mount \"\n            f\"found), and no local dataset was found either.\\n\"\n            f\"Fix: download the competition data locally, e.g.\\n\"\n            f\"    kaggle competitions download -c rsna-knee-abnormality-detection -p data\\n\"\n            f\"then unzip it into a 'data' folder next to this script, or set the\\n\"\n            f\"RSNA_KNEE_DATA_DIR environment variable to wherever you put it.\\n\"\n            f\"(The graded submission must still run inside a Kaggle Notebook — \"\n            f\"local runs are for development only.)\"\n        )\n    required = [CFG.TRAIN_CSV, CFG.TRAIN_SERIES_CSV]\n    if require_images:\n        required.append(CFG.TRAIN_IMG_DIR)\n    missing = [p for p in required if not os.path.exists(p)]\n    if missing:\n        raise FileNotFoundError(\n            f\"CFG.DATA_DIR exists ({CFG.DATA_DIR}) but is missing expected files/folders:\\n\"\n            + \"\\n\".join(f\"  - {p}\" for p in missing)\n            + f\"\\nList what's actually inside {CFG.DATA_DIR} and either finish the \"\n              f\"download/unzip, or update the CFG path constants to match the real \"\n              f\"names.\"\n        )\n\n\ndef build_study_to_series(series_csv):\n    # train_series.csv / test_series.csv: one row per SERIES, with\n    # StudyInstanceUID + SeriesInstanceUID + sequence metadata. Group into\n    # study_id -> [series_id, ...] so the dataset can pull DICOMs from\n    # every one of a study's series subfolders instead of assuming a\n    # single flat per-study folder.\n    series_df = pd.read_csv(series_csv)\n    return series_df.groupby(\"StudyInstanceUID\")[\"SeriesInstanceUID\"].apply(list).to_dict()\n\n\ndef explore_data():\n    check_data_dir(require_images=False)\n    train = pd.read_csv(CFG.TRAIN_CSV)\n    print(\"train.csv shape:\", train.shape)\n    print(\"train.csv columns:\", list(train.columns))\n\n    # Report text lives directly in train.csv (column CFG.REPORT_TEXT_COL) —\n    # confirm it's actually there before a multi-hour run.\n    if CFG.REPORT_TEXT_COL not in train.columns:\n        print(f\"\\nWARNING: CFG.REPORT_TEXT_COL='{CFG.REPORT_TEXT_COL}' not found \"\n              f\"in train.csv. Update it to match one of: {list(train.columns)}\")\n    else:\n        n_missing = train[CFG.REPORT_TEXT_COL].isna().sum()\n        print(f\"\\nReport text column OK. {n_missing}/{len(train)} rows have no report text.\")\n\n    print(\"\\nLabel value counts (dropna=False — check whether NaN means \"\n          \"'negative' or 'not annotated' before training on it):\")\n    for col in CFG.TARGET_COLS:\n        print(f\"  {col}: {train[col].value_counts(dropna=False).to_dict()}\")\n\n    # Every label above showed the identical NaN count (4349) — check\n    # whether that's the SAME 4349 rows across all 12 columns (a clean\n    # \"study is either fully labeled or not labeled at all\" pattern) or a\n    # coincidence where different rows are missing different labels. This\n    # directly determines whether run_cv()'s \"keep only fully-labeled\n    # rows\" filter is discarding usable partial labels.\n    all_nan = train[CFG.TARGET_COLS].isna().all(axis=1).sum()\n    any_nan = train[CFG.TARGET_COLS].isna().any(axis=1).sum()\n    fully_labeled = train[CFG.TARGET_COLS].notna().all(axis=1).sum()\n    print(f\"\\nRow-level NaN alignment check:\")\n    print(f\"  rows with ALL 12 labels NaN:        {all_nan}\")\n    print(f\"  rows with AT LEAST ONE label NaN:    {any_nan}\")\n    print(f\"  rows FULLY labeled (all 12 present): {fully_labeled}\")\n    if all_nan == any_nan:\n        print(f\"  -> Confirmed: NaN is row-aligned. Every row is either fully \"\n              f\"labeled ({fully_labeled} rows) or fully unlabeled ({all_nan} \"\n              f\"rows) — no partial-label rows exist. run_cv()'s filter is safe.\")\n    else:\n        partial = any_nan - all_nan\n        print(f\"  -> WARNING: {partial} rows have SOME but not all labels — \"\n              f\"run_cv()'s current filter drops these along with the fully \"\n              f\"unlabeled rows, discarding usable partial labels. Consider \"\n              f\"per-label masking in the loss instead of row-level filtering.\")\n\n    # train_series.csv is series-level metadata, not reports — one row per\n    # SeriesInstanceUID, several series per study.\n    series_df = pd.read_csv(CFG.TRAIN_SERIES_CSV)\n    print(\"\\ntrain_series.csv shape:\", series_df.shape)\n    print(\"train_series.csv columns:\", list(series_df.columns))\n    if \"StudyInstanceUID\" not in series_df.columns:\n        print(\"\\nWARNING: 'StudyInstanceUID' not found in train_series.csv — \"\n              \"build_study_to_series() will fail.\")\n        return\n\n    study_to_series = build_study_to_series(CFG.TRAIN_SERIES_CSV)\n    n_series_per_study = np.mean([len(v) for v in study_to_series.values()])\n    print(f\"\\nAvg series per study: {n_series_per_study:.2f}\")\n\n    studies_without_series = set(train[\"StudyInstanceUID\"]) - set(study_to_series.keys())\n    print(f\"Studies in train.csv with NO series listed: {len(studies_without_series)}\")\n\n    # Confirmed real layout from an actual Kaggle os.walk listing:\n    # TRAIN_IMG_DIR/<StudyInstanceUID>/<SeriesInstanceUID>/*.dcm\n    sample_study = train[\"StudyInstanceUID\"].iloc[0]\n    sample_series_ids = study_to_series.get(sample_study, [])\n    if not sample_series_ids:\n        print(f\"\\nWARNING: no series found for sample study {sample_study}\")\n        return\n    sample_dir = f\"{CFG.TRAIN_IMG_DIR}/{sample_study}/{sample_series_ids[0]}\"\n    if not os.path.isdir(CFG.TRAIN_IMG_DIR):\n        print(f\"\\nNote: {CFG.TRAIN_IMG_DIR} not downloaded yet — skipping the \"\n              f\"folder-layout check (this is expected if you only pulled the \"\n              f\"CSVs so far). Verify it once you've downloaded some image data.\")\n    elif os.path.isdir(sample_dir):\n        n_dcm = len(glob.glob(f\"{sample_dir}/*.dcm\"))\n        print(f\"\\nOK: found {sample_dir} with {n_dcm} .dcm files \"\n              f\"(study/series nested folder layout confirmed)\")\n    else:\n        print(f\"\\nWARNING: {sample_dir} does not exist even though \"\n              f\"{CFG.TRAIN_IMG_DIR} is present. The real folder layout doesn't \"\n              f\"match <StudyInstanceUID>/<SeriesInstanceUID>/ nesting — inspect \"\n              f\"it manually and update load_dicom_series().\")\n\n\n# ----------------------------------------------------------------------\n# STEP 1: DICOM loading with windowing (better contrast than raw normalize)\n# ----------------------------------------------------------------------\ndef load_dicom_series(img_dir, study_id, series_ids, n_slices):\n    # A study is spread across multiple series (~5.5 on average — different\n    # planes/sequences: sagittal, axial, coronal, fat-suppressed, etc.),\n    # each in its own SeriesInstanceUID-named subfolder. The original\n    # function assumed one flat folder of DICOMs per study, which doesn't\n    # match the real layout (train_series.csv / train_series/<series_id>/).\n    #\n    # Strategy: sample slices proportionally across each of the study's\n    # series rather than picking one series arbitrarily or pooling\n    # everything into one undifferentiated list — different planes carry\n    # different diagnostic signal (e.g. sagittal is typically better for\n    # ACL/meniscus, axial for patellofemoral), so dropping series loses\n    # information a radiologist would actually use.\n    use_cache = CFG.CACHE_ENABLED\n    study_key = \"_\".join(sorted(series_ids)) if series_ids else \"empty\"\n    cache_path = None\n    if use_cache:\n        os.makedirs(CFG.CACHE_DIR, exist_ok=True)\n        # hash long combined keys to keep filenames within OS limits\n        import hashlib\n        key_hash = hashlib.md5(study_key.encode()).hexdigest()[:16]\n        cache_path = os.path.join(CFG.CACHE_DIR, f\"{key_hash}_{n_slices}.npy\")\n        if os.path.exists(cache_path):\n            try:\n                return np.load(cache_path)\n            except Exception:\n                pass  # corrupt cache entry — fall through and regenerate\n\n    if not series_ids:\n        stack = np.zeros((n_slices, CFG.IMG_SIZE, CFG.IMG_SIZE), dtype=np.float32)\n        if use_cache:\n            _maybe_cache(cache_path, stack)\n        return stack\n\n    n_per_series = max(1, n_slices // len(series_ids))\n    slices = []\n    for series_id in series_ids:\n        # Real layout confirmed from an actual /kaggle/input os.walk\n        # listing: train_series/<StudyInstanceUID>/<SeriesInstanceUID>/*.dcm\n        # — one level deeper than a flat per-series folder. Every\n        # earlier \"series folder\" check was against CSVs alone and\n        # couldn't have caught this.\n        series_dir = f\"{img_dir}/{study_id}/{series_id}\"\n        files = sorted(glob.glob(f\"{series_dir}/*.dcm\"))\n        if len(files) == 0:\n            continue\n        idxs = np.linspace(0, len(files) - 1, min(n_per_series, len(files))).astype(int)\n        for i in idxs:\n            try:\n                dcm = pydicom.dcmread(files[i])\n                arr = dcm.pixel_array.astype(np.float32)\n            except Exception:\n                continue\n            # percentile-based windowing is more robust than min/max for MRI noise\n            lo, hi = np.percentile(arr, [1, 99])\n            # np.percentile returns float64 scalars even for a float32\n            # input array, so `(arr - lo) / (hi - lo)` silently upcasts\n            # the whole slice to float64. That mismatches the model's\n            # float32 weights downstream and crashes with \"expected\n            # scalar type Double but found Float\" deep inside the CNN\n            # backbone — a real bug caught by actually running the\n            # pipeline, not something visible from reading the code.\n            lo, hi = np.float32(lo), np.float32(hi)\n            arr = np.clip(arr, lo, hi)\n            arr = (arr - lo) / (hi - lo + 1e-6)\n            # Belt-and-suspenders: numpy promotes float32 to float64 on\n            # almost any mixed-type arithmetic (a bare Python float\n            # literal like 1e-6 is float64, so even after casting lo/hi\n            # above, this division still upcasts). Force back to float32\n            # explicitly rather than chasing every promotion site\n            # individually — this is what a real run caught, not\n            # something obvious from reading the arithmetic.\n            arr = arr.astype(np.float32)\n\n            # Real MRI series don't share a common pixel matrix size —\n            # a sagittal series and an axial series (or even different\n            # series of the \"same\" plane) can have different acquisition\n            # resolutions. Since load_dicom_series pools slices across\n            # a study's multiple series, np.stack() below crashes with\n            # \"all input arrays must have the same shape\" unless every\n            # slice is resized to a common size FIRST. Doing the resize\n            # here (per-slice, at raw resolution) rather than later on\n            # the whole batched tensor is what actually fixes it — this\n            # only surfaced once real DICOMs with real varying\n            # resolutions were used, not from synthetic same-size test\n            # data.\n            if arr.shape != (CFG.IMG_SIZE, CFG.IMG_SIZE):\n                arr_t = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0)  # (1,1,H,W)\n                arr_t = F.interpolate(arr_t, size=(CFG.IMG_SIZE, CFG.IMG_SIZE),\n                                       mode=\"bilinear\", align_corners=False)\n                arr = arr_t.squeeze(0).squeeze(0).numpy()\n\n            slices.append(arr)\n\n    if len(slices) == 0:\n        stack = np.zeros((n_slices, CFG.IMG_SIZE, CFG.IMG_SIZE), dtype=np.float32)\n    else:\n        # pad by repeating the last slice, or trim, to hit exactly n_slices\n        while len(slices) < n_slices:\n            slices.append(slices[-1])\n        slices = slices[:n_slices]\n        stack = np.stack(slices)\n\n    if use_cache:\n        _maybe_cache(cache_path, stack)\n    return stack\n\n\ndef _maybe_cache(cache_path, stack):\n    try:\n        free_gb = shutil.disk_usage(CFG.CACHE_DIR).free / (1024 ** 3)\n        if free_gb < CFG.MIN_FREE_DISK_GB:\n            return  # quota getting tight — skip caching this one, keep training\n        np.save(cache_path, stack)\n    except OSError:\n        pass  # disk full / read-only — just skip caching, still return the array\n    return stack\n\n\n# ----------------------------------------------------------------------\n# STEP 2: DATASET with light train-time augmentation\n# ----------------------------------------------------------------------\nclass KneeDataset(Dataset):\n    def __init__(self, df, img_dir, tokenizer, n_slices, study_to_series,\n                 targets_present=True, train=True):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = img_dir\n        self.tokenizer = tokenizer\n        self.n_slices = n_slices\n        # study_id -> list[SeriesInstanceUID], built from train_series.csv /\n        # test_series.csv. A study's DICOMs live under img_dir/<series_id>/,\n        # spread across ~5.5 series on average — NOT one flat folder per\n        # study as originally assumed.\n        self.study_to_series = study_to_series\n        self.targets_present = targets_present\n        self.train = train\n        self.resize = T.Resize((CFG.IMG_SIZE, CFG.IMG_SIZE))\n        # timm's pretrained ConvNeXt/EfficientNetV2 checkpoints expect\n        # ImageNet-normalized RGB input. The original pipeline windowed\n        # each slice to [0, 1] and stopped there, which is a mismatch\n        # with what the pretrained backbones were trained on and quietly\n        # hurts transfer-learning performance.\n        self.normalize = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        study_id = row[\"StudyInstanceUID\"]\n        series_ids = self.study_to_series.get(study_id, [])\n\n        stack = load_dicom_series(self.img_dir, study_id, series_ids, self.n_slices)\n        img = torch.from_numpy(stack).unsqueeze(1)            # (N, 1, H, W)\n        img = self.resize(img)\n        img = img.repeat(1, 3, 1, 1)                           # (N, 3, H, W) per-slice, kept separate (no averaging!)\n\n        if self.train:\n            if torch.rand(1).item() < 0.5:\n                img = torch.flip(img, dims=[3])                # horizontal flip augmentation\n            if torch.rand(1).item() < 0.3:\n                img = img + torch.randn_like(img) * 0.02        # slight noise for robustness\n\n        img = self.normalize(img)\n\n        text_col = CFG.REPORT_TEXT_COL\n        if text_col not in row.index:\n            if not getattr(KneeDataset, \"_warned_missing_text_col\", False):\n                print(f\"WARNING: Report text column '{text_col}' not found \"\n                      f\"(columns: {list(row.index)}). Falling back to empty \"\n                      f\"text for all rows — the text tower gets no signal. \"\n                      f\"Confirm this is expected (e.g. test set withholds \"\n                      f\"reports) rather than a wrong column name.\")\n                KneeDataset._warned_missing_text_col = True\n            text = \"\"\n        else:\n            text = str(row[text_col]) if pd.notna(row[text_col]) else \"\"\n        enc = self.tokenizer(text, truncation=True, padding=\"max_length\",\n                              max_length=256, return_tensors=\"pt\")\n        input_ids = enc[\"input_ids\"].squeeze(0)\n        attn_mask = enc[\"attention_mask\"].squeeze(0)\n\n        if self.targets_present:\n            labels = torch.tensor(row[CFG.TARGET_COLS].values.astype(np.float32))\n            return img, input_ids, attn_mask, labels\n        return img, input_ids, attn_mask, study_id\n\n\n# ----------------------------------------------------------------------\n# STEP 3: MODEL\n#   (a) Attention MIL pooling over slice features\n#   (b) Cross-attention fusion between image tokens and text tokens\n# ----------------------------------------------------------------------\nclass AttentionPool(nn.Module):\n    \"\"\"Learns a weight per slice instead of naive averaging (MIL-style).\"\"\"\n    def __init__(self, dim):\n        super().__init__()\n        self.attn = nn.Sequential(\n            nn.Linear(dim, dim // 2), nn.Tanh(), nn.Linear(dim // 2, 1)\n        )\n\n    def forward(self, slice_feats):  # (B, N, dim)\n        weights = torch.softmax(self.attn(slice_feats), dim=1)   # (B, N, 1)\n        pooled = (slice_feats * weights).sum(dim=1)                # (B, dim)\n        return pooled, weights.squeeze(-1)   # also return weights for interpretability\n\n\nclass CrossAttentionFusion(nn.Module):\n    \"\"\"Image summary token and text tokens cross-attend, then pool.\"\"\"\n    def __init__(self, img_dim, text_dim, fused_dim, n_layers=2, n_heads=8):\n        super().__init__()\n        self.img_proj = nn.Linear(img_dim, fused_dim)\n        self.text_proj = nn.Linear(text_dim, fused_dim)\n        layer = nn.TransformerDecoderLayer(\n            d_model=fused_dim, nhead=n_heads, dim_feedforward=fused_dim * 2,\n            batch_first=True, dropout=0.1,\n        )\n        self.decoder = nn.TransformerDecoder(layer, num_layers=n_layers)\n        self.cls_token = nn.Parameter(torch.randn(1, 1, fused_dim))\n\n    def forward(self, img_feat, text_seq):\n        # img_feat: (B, img_dim) -> treat as single memory token\n        # text_seq: (B, T, text_dim) -> query sequence the image attends into\n        img_tok = self.img_proj(img_feat).unsqueeze(1)          # (B, 1, fused_dim)\n        text_tok = self.text_proj(text_seq)                     # (B, T, fused_dim)\n        B = img_feat.size(0)\n        cls = self.cls_token.expand(B, -1, -1)\n        query = torch.cat([cls, text_tok], dim=1)                # (B, T+1, fused_dim)\n        fused_seq = self.decoder(tgt=query, memory=img_tok)      # cross-attend to image\n        return fused_seq[:, 0, :]                                  # pooled CLS-like output\n\n\nclass MultimodalKneeModel(nn.Module):\n    def __init__(self, img_backbone=\"convnext_tiny\", n_targets=len(CFG.TARGET_COLS),\n                 pretrained=True):\n        super().__init__()\n        # `pretrained=False` skips the ImageNet/BERT weight download entirely.\n        # This matters because (a) code-competition submissions run with\n        # internet disabled, so a checkpoint-loading path that still tries\n        # to hit the hub will crash at inference time, and (b) even during\n        # training, any \"eval-only\" copy of the model doesn't need its own\n        # pretrained download since its weights get overwritten immediately.\n        self.slice_encoder = timm.create_model(img_backbone, pretrained=pretrained, num_classes=0)\n        self.img_feat_dim = self.slice_encoder.num_features\n        self.slice_pool = AttentionPool(self.img_feat_dim)\n\n        if pretrained:\n            self.text_encoder = AutoModel.from_pretrained(CFG.TEXT_MODEL)\n        else:\n            from transformers import AutoConfig\n            text_config = AutoConfig.from_pretrained(CFG.TEXT_MODEL)\n            self.text_encoder = AutoModel.from_config(text_config)\n        self.text_dim = self.text_encoder.config.hidden_size\n\n        self.fusion = CrossAttentionFusion(\n            self.img_feat_dim, self.text_dim, CFG.FUSION_DIM,\n            n_layers=CFG.N_FUSION_LAYERS, n_heads=CFG.N_HEADS,\n        )\n        self.head = nn.Sequential(\n            nn.LayerNorm(CFG.FUSION_DIM),\n            nn.Linear(CFG.FUSION_DIM, 256),\n            nn.GELU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, n_targets),\n        )\n\n    def forward(self, img, input_ids, attn_mask, return_attn=False):\n        B, N, C, H, W = img.shape\n        flat = img.view(B * N, C, H, W)\n        slice_feats = self.slice_encoder(flat).view(B, N, -1)      # (B, N, img_feat_dim)\n        pooled_img, slice_weights = self.slice_pool(slice_feats)    # (B, img_feat_dim)\n\n        text_out = self.text_encoder(input_ids=input_ids, attention_mask=attn_mask)\n        text_seq = text_out.last_hidden_state                        # (B, T, text_dim)\n\n        fused = self.fusion(pooled_img, text_seq)                    # (B, fused_dim)\n        logits = self.head(fused)\n        if return_attn:\n            return logits, slice_weights\n        return logits\n\n\n# ----------------------------------------------------------------------\n# LOSS: class-balanced focal loss for multilabel imbalance\n# ----------------------------------------------------------------------\nclass FocalLoss(nn.Module):\n    def __init__(self, pos_weight=None, gamma=2.0):\n        super().__init__()\n        self.gamma = gamma\n        self.pos_weight = pos_weight\n\n    def forward(self, logits, targets):\n        bce = F.binary_cross_entropy_with_logits(\n            logits, targets, pos_weight=self.pos_weight, reduction=\"none\"\n        )\n        p = torch.sigmoid(logits)\n        p_t = p * targets + (1 - p) * (1 - targets)\n        focal_term = (1 - p_t) ** self.gamma\n        return (focal_term * bce).mean()\n\n\ndef compute_pos_weight(df, max_weight=20.0):\n    pos = df[CFG.TARGET_COLS].sum()\n    neg = len(df) - pos\n    pw = (neg / pos.clip(lower=1)).values\n    # Cap pos_weight. Rare classes like Fracture/Baker's cyst can have\n    # neg/pos ratios in the hundreds; combined with the focal loss's own\n    # (1 - p_t)^gamma up-weighting of hard/rare examples, an uncapped\n    # pos_weight can make single positive examples dominate the batch\n    # loss and destabilize training (loss spikes / NaN gradients).\n    pw = np.clip(pw, a_min=None, a_max=max_weight)\n    return torch.tensor(pw, dtype=torch.float32)\n\n\n# ----------------------------------------------------------------------\n# EMA (Exponential Moving Average of weights — smoother, usually higher AUC)\n# ----------------------------------------------------------------------\nclass EMA:\n    def __init__(self, model, decay=CFG.EMA_DECAY):\n        self.decay = decay\n        self.shadow = copy.deepcopy(model.state_dict())\n\n    def update(self, model):\n        for k, v in model.state_dict().items():\n            self.shadow[k] = self.shadow[k] * self.decay + v.detach() * (1 - self.decay)\n\n    def apply_to(self, model):\n        model.load_state_dict(self.shadow)\n\n\n# ----------------------------------------------------------------------\n# METRIC\n# ----------------------------------------------------------------------\ndef macro_auc(y_true, y_pred):\n    # Return one AUC entry per target column, using NaN (not \"skip\") for\n    # labels with a single class in this split. The original version\n    # dropped skipped labels from the list entirely, which is fine for\n    # np.mean() but silently desynchronizes any code that zips this list\n    # against CFG.TARGET_COLS by position (e.g. per-label logging) —\n    # label N's printed AUC could actually belong to label N+1.\n    aucs = []\n    for i in range(y_true.shape[1]):\n        if len(np.unique(y_true[:, i])) < 2:\n            aucs.append(float(\"nan\"))\n            continue\n        aucs.append(roc_auc_score(y_true[:, i], y_pred[:, i]))\n    macro = float(np.nanmean(aucs))\n    return macro, aucs\n\n\n# ----------------------------------------------------------------------\n# STEP 4: TRAIN ONE FOLD (AMP + grad accumulation + EMA + OneCycle)\n# ----------------------------------------------------------------------\ndef train_one_fold(train_df, val_df, tokenizer, fold, backbone_name, study_to_series):\n    seed_everything()\n    n_slices = CFG.LIGHT_N_SLICES if CFG.LIGHT_MODE else CFG.N_SLICES\n    backbone = CFG.LIGHT_BACKBONE if CFG.LIGHT_MODE else backbone_name\n    # CFG.DEBUG_EPOCHS was defined but never actually applied here —\n    # a prior \"debug\" run still trained the full CFG.EPOCHS (15) schedule,\n    # defeating the point of debug mode being fast. Fixed.\n    n_epochs = CFG.DEBUG_EPOCHS if CFG.DEBUG else CFG.EPOCHS\n\n    train_ds = KneeDataset(train_df, CFG.TRAIN_IMG_DIR, tokenizer, n_slices, study_to_series, train=True)\n    val_ds = KneeDataset(val_df, CFG.TRAIN_IMG_DIR, tokenizer, n_slices, study_to_series, train=False)\n    train_dl = DataLoader(train_ds, batch_size=CFG.BATCH_SIZE, shuffle=True, num_workers=2, drop_last=True)\n    val_dl = DataLoader(val_ds, batch_size=CFG.BATCH_SIZE, shuffle=False, num_workers=2)\n\n    model = MultimodalKneeModel(img_backbone=backbone, pretrained=True).to(CFG.DEVICE)\n    ema = EMA(model)\n\n    # Build the EMA-eval model ONCE, outside the epoch loop, with\n    # pretrained=False (its weights get fully overwritten by ema.apply_to\n    # every epoch anyway). The original code re-instantiated this model\n    # from scratch every epoch, which re-downloaded/re-initialized the\n    # ImageNet + Bio_ClinicalBERT backbones up to EPOCHS * N_FOLDS *\n    # len(IMG_BACKBONES) times (15*5*2 = 150) — a large, unnecessary\n    # source of both wall-clock time and network calls.\n    eval_model = MultimodalKneeModel(img_backbone=backbone, pretrained=False).to(CFG.DEVICE)\n\n    pos_weight = compute_pos_weight(train_df).to(CFG.DEVICE)\n    criterion = FocalLoss(pos_weight=pos_weight, gamma=2.0)\n\n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.MAX_LR, weight_decay=CFG.WEIGHT_DECAY)\n    steps_per_epoch = len(train_dl) // CFG.ACCUM_STEPS\n    if steps_per_epoch == 0:\n        # len(train_dl) < ACCUM_STEPS: no optimizer.step() will ever fire\n        # this fold. max(steps_per_epoch, 1) below stops OneCycleLR's\n        # constructor from dividing by zero, but the LR schedule itself\n        # will never actually advance — flag it loudly rather than\n        # training silently at a frozen warmup LR.\n        print(f\"WARNING: fold {fold} ({backbone}) has only {len(train_dl)} \"\n              f\"train batches, fewer than ACCUM_STEPS={CFG.ACCUM_STEPS}. \"\n              f\"No gradient step will fire this epoch; reduce ACCUM_STEPS \"\n              f\"or BATCH_SIZE for this fold.\")\n    scheduler = torch.optim.lr_scheduler.OneCycleLR(\n        optimizer, max_lr=CFG.MAX_LR, epochs=n_epochs,\n        steps_per_epoch=max(steps_per_epoch, 1), pct_start=CFG.WARMUP_PCT,\n    )\n    scaler = GradScaler(enabled=AMP_ENABLED)\n\n    best_auc = 0.0\n    for epoch in range(n_epochs):\n        model.train()\n        optimizer.zero_grad()\n        running_loss = 0.0\n        for step, (img, ids, mask, labels) in enumerate(train_dl):\n            img, ids, mask, labels = (img.to(CFG.DEVICE), ids.to(CFG.DEVICE),\n                                       mask.to(CFG.DEVICE), labels.to(CFG.DEVICE))\n            with amp_autocast():\n                logits = model(img, ids, mask)\n                loss = criterion(logits, labels) / CFG.ACCUM_STEPS\n\n            scaler.scale(loss).backward()\n            running_loss += loss.item() * CFG.ACCUM_STEPS\n\n            if (step + 1) % CFG.ACCUM_STEPS == 0:\n                scaler.unscale_(optimizer)\n                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                scaler.step(optimizer)\n                scaler.update()\n                optimizer.zero_grad()\n                scheduler.step()\n                ema.update(model)\n\n        # ---- validate with EMA weights (usually stronger) ----\n        ema.apply_to(eval_model)\n        eval_model.eval()\n\n        all_preds, all_labels = [], []\n        with torch.no_grad():\n            for img, ids, mask, labels in val_dl:\n                img, ids, mask = img.to(CFG.DEVICE), ids.to(CFG.DEVICE), mask.to(CFG.DEVICE)\n                with amp_autocast():\n                    logits = eval_model(img, ids, mask)\n                all_preds.append(torch.sigmoid(logits).float().cpu().numpy())\n                all_labels.append(labels.numpy())\n        all_preds = np.concatenate(all_preds)\n        all_labels = np.concatenate(all_labels)\n        val_auc, per_label_aucs = macro_auc(all_labels, all_preds)\n\n        print(f\"[{backbone} fold {fold}] epoch {epoch+1}/{n_epochs} \"\n              f\"loss={running_loss/len(train_dl):.4f} val_macro_auc={val_auc:.4f}\")\n        # Per-label breakdown: in a hackathon this is what actually tells\n        # you where to spend the next hour (e.g. \"Fracture AUC is 0.51,\n        # everything else is 0.8+\" points you at class imbalance/loss\n        # weighting, not architecture). The original code computed these\n        # per-label AUCs in macro_auc() and then threw them away.\n        label_auc_str = \", \".join(\n            f\"{name}={auc:.3f}\" for name, auc in zip(CFG.TARGET_COLS, per_label_aucs)\n        )\n        print(f\"    per-label AUC: {label_auc_str}\")\n\n        if val_auc > best_auc:\n            best_auc = val_auc\n            torch.save(ema.shadow, f\"model_{backbone}_fold{fold}_ema.pt\")\n\n    return best_auc\n\n\ndef run_cv():\n    check_data_dir()\n    import time\n    start_time = time.time()\n    budget_seconds = CFG.TIME_BUDGET_HOURS * 3600\n\n    train = pd.read_csv(CFG.TRAIN_CSV)\n    # Report text lives directly in train.csv now — no merge needed.\n    # train_series.csv is series-level metadata; build the study->series\n    # lookup separately so KneeDataset can find each study's DICOMs.\n    study_to_series = build_study_to_series(CFG.TRAIN_SERIES_CSV)\n\n    # Confirmed from explore_data(): every one of the 12 target columns\n    # has exactly 4349 NaN out of 4407 rows — i.e. ~98.7% of studies have\n    # NO labels at all, not \"confirmed negative\" ones. Only ~58 studies\n    # are actually annotated. fillna(0) here would have told the model\n    # that 4349 unlabeled studies are confidently negative for every\n    # finding — a serious, silent label-corruption bug. Instead: keep\n    # only fully-labeled rows for supervised training.\n    fully_labeled_mask = train[CFG.TARGET_COLS].notna().all(axis=1)\n    n_labeled = fully_labeled_mask.sum()\n    n_partial = (~fully_labeled_mask & train[CFG.TARGET_COLS].notna().any(axis=1)).sum()\n    print(f\"Labeled rows (all 12 targets present): {n_labeled} / {len(train)}\")\n    if n_partial > 0:\n        print(f\"WARNING: {n_partial} rows have SOME but not all labels present \"\n              f\"— this breaks the 'all-or-nothing' assumption above. Inspect \"\n              f\"these rows manually; they're currently being dropped along \"\n              f\"with the fully-unlabeled rows, which may be discarding usable \"\n              f\"partial labels.\")\n    train = train[fully_labeled_mask].reset_index(drop=True)\n\n    if n_labeled < 200:\n        # With ~58 labeled studies, a 5-fold stratified split leaves ~11-12\n        # studies per fold — far too few to reliably represent 12 targets,\n        # several of which have single-digit positive counts overall\n        # (e.g. MCL: 9 positives across the WHOLE labeled set). Folds will\n        # likely have zero positives for some rare labels, which macro_auc()\n        # already handles (NaN-skips that label) but the resulting CV score\n        # will be extremely high-variance and not a reliable model-selection\n        # signal. Consider this a smoke test of the pipeline, not a\n        # trustworthy leaderboard estimate, until more labels are released.\n        print(f\"WARNING: only {n_labeled} fully-labeled studies available. \"\n              f\"CV scores from this few examples will be extremely noisy — \"\n              f\"treat results as a pipeline sanity check, not a reliable \"\n              f\"estimate of leaderboard performance. If the competition \"\n              f\"releases more labels later, re-run with the fuller set.\")\n\n    if CFG.DEBUG:\n        # Fast smoke test of the full pipeline (data -> model -> loss ->\n        # metric -> checkpoint) on a tiny slice of data. Use this to catch\n        # shape mismatches, NaN losses, or column-name typos in minutes\n        # instead of discovering them hours into a full 5-fold x\n        # 2-backbone x 15-epoch run.\n        train = train.sample(min(CFG.DEBUG_N_ROWS, len(train)), random_state=CFG.SEED).reset_index(drop=True)\n        print(f\"[DEBUG] subsampled train to {len(train)} rows\")\n\n    tokenizer = AutoTokenizer.from_pretrained(CFG.TEXT_MODEL)\n\n    # Multilabel-stratified folds — preserves the positive rate of every\n    # one of the 12 targets in each fold, crucial since several classes\n    # (fracture, Baker's cyst) will be rare.\n    # With very few labeled rows, CFG.N_FOLDS may be larger than what the\n    # rarest positive class can support (a fold needs at least 1 positive\n    # example of every label to avoid a fully-degenerate split). Cap folds\n    # at the rarest class's positive count as a safety net.\n    min_positive_count = train[CFG.TARGET_COLS].sum().min()\n    n_folds = int(min(CFG.N_FOLDS, max(2, min_positive_count)))\n    if n_folds < CFG.N_FOLDS:\n        print(f\"WARNING: reducing N_FOLDS from {CFG.N_FOLDS} to {n_folds} — \"\n              f\"the rarest label only has {int(min_positive_count)} positive \"\n              f\"examples in the labeled set.\")\n    mskf = MultilabelStratifiedKFold(n_splits=n_folds, shuffle=True, random_state=CFG.SEED)\n    y = train[CFG.TARGET_COLS].values\n\n    backbones = [CFG.LIGHT_BACKBONE] if CFG.LIGHT_MODE else CFG.IMG_BACKBONES\n    if CFG.DEBUG:\n        backbones = backbones[:CFG.DEBUG_BACKBONES]\n\n    results = {}\n    for backbone in backbones:\n        fold_scores = []\n        for fold, (tr_idx, val_idx) in enumerate(mskf.split(train, y)):\n            if CFG.DEBUG and fold >= CFG.DEBUG_FOLDS:\n                break\n\n            elapsed = time.time() - start_time\n            if elapsed > budget_seconds:\n                # Time-budget guard: Kaggle code notebooks hard-cap runtime\n                # at 9 hours. The original code had no awareness of this —\n                # if training overran, the whole run (including\n                # run_inference() afterward) would simply get killed with\n                # no submission.csv ever written. Bailing out of remaining\n                # folds/backbones here, while leaving whatever checkpoints\n                # already saved, guarantees inference still gets to run.\n                print(f\"WARNING: time budget ({CFG.TIME_BUDGET_HOURS}h) exceeded \"\n                      f\"after {elapsed/3600:.2f}h — stopping CV early to leave \"\n                      f\"time for run_inference(). Remaining folds/backbones skipped.\")\n                results[backbone] = fold_scores\n                print(\"Full results (partial):\", results)\n                return\n\n            tr_df, val_df = train.iloc[tr_idx], train.iloc[val_idx]\n\n            ckpt_path = f\"model_{backbone}_fold{fold}_ema.pt\"\n            if CFG.RESUME_SKIP_EXISTING and os.path.exists(ckpt_path):\n                # Kaggle's weekly GPU quota can run out mid-run, or a\n                # session can just get interrupted. Without this check,\n                # re-running run_cv() blindly retrains every fold from\n                # scratch — including ones that already finished and\n                # have a saved checkpoint sitting on disk — wasting the\n                # next batch of quota redoing work you already have.\n                print(f\"SKIPPING {backbone} fold {fold}: checkpoint already \"\n                      f\"exists at {ckpt_path}. Delete it manually if you \"\n                      f\"want to force a retrain of this fold.\")\n                continue\n\n            score = train_one_fold(tr_df, val_df, tokenizer, fold, backbone, study_to_series)\n            fold_scores.append(score)\n        results[backbone] = fold_scores\n        if fold_scores:\n            print(f\"{backbone} CV mean macro AUC: {np.mean(fold_scores):.4f}\")\n        else:\n            print(f\"{backbone}: no folds were trained this run (all skipped \"\n                  f\"via existing checkpoints, or none completed).\")\n\n    print(\"Full results:\", results)\n\n\n# ----------------------------------------------------------------------\n# STEP 5: INFERENCE — TTA (flips) + multi-backbone + multi-fold blend\n# ----------------------------------------------------------------------\ndef predict_with_tta(model, img, ids, mask):\n    preds = []\n    with amp_autocast():\n        preds.append(torch.sigmoid(model(img, ids, mask)))\n        preds.append(torch.sigmoid(model(torch.flip(img, dims=[4]), ids, mask)))  # flip W\n    return torch.stack(preds).mean(0)\n\n\ndef run_inference():\n    check_data_dir()\n    test = pd.read_csv(CFG.TEST_CSV)\n    # Report text lives in test.csv directly (same as train.csv) — no\n    # separate reports file. test_series.csv is the series-metadata\n    # lookup, same shape as train_series.csv.\n    study_to_series = build_study_to_series(CFG.TEST_SERIES_CSV)\n\n    tokenizer = AutoTokenizer.from_pretrained(CFG.TEXT_MODEL)\n    n_slices = CFG.LIGHT_N_SLICES if CFG.LIGHT_MODE else CFG.N_SLICES\n    test_ds = KneeDataset(test, CFG.TEST_IMG_DIR, tokenizer, n_slices, study_to_series,\n                           targets_present=False, train=False)\n    test_dl = DataLoader(test_ds, batch_size=CFG.BATCH_SIZE, shuffle=False, num_workers=2)\n\n    backbones = [CFG.LIGHT_BACKBONE] if CFG.LIGHT_MODE else CFG.IMG_BACKBONES\n    all_model_preds = []\n    ids_final = None\n\n    for backbone in backbones:\n        for fold in range(CFG.N_FOLDS):\n            ckpt_path = f\"model_{backbone}_fold{fold}_ema.pt\"\n            if not os.path.exists(ckpt_path):\n                continue\n            # pretrained=False: this is the critical fix for submission.\n            # Code-competition notebooks run with internet access disabled,\n            # but the original code always built the backbone with\n            # pretrained=True, which tries to download ImageNet/BERT\n            # weights from the hub — that call would fail/hang exactly\n            # when it matters (the scored inference run). The checkpoint\n            # loaded on the next line overwrites every weight anyway.\n            model = MultimodalKneeModel(img_backbone=backbone, pretrained=False).to(CFG.DEVICE)\n            state_dict = torch.load(ckpt_path, map_location=CFG.DEVICE, weights_only=True)\n            model.load_state_dict(state_dict)\n            model.eval()\n\n            preds, ids = [], []\n            with torch.no_grad():\n                for img, input_ids, mask, study_ids in test_dl:\n                    img, input_ids, mask = img.to(CFG.DEVICE), input_ids.to(CFG.DEVICE), mask.to(CFG.DEVICE)\n                    batch_pred = predict_with_tta(model, img, input_ids, mask)\n                    preds.append(batch_pred.float().cpu().numpy())\n                    ids.extend(study_ids)\n            all_model_preds.append(np.concatenate(preds))\n            ids_final = ids\n\n    if not all_model_preds:\n        # No checkpoint files were found at all. Writing a well-formed\n        # fallback submission (0.5 everywhere, matching the sample\n        # format) is safer than letting np.mean([]) raise on an empty\n        # list and killing the whole submission with no output file.\n        print(\"WARNING: no checkpoints found (model_*_ema.pt) — \"\n              \"writing a neutral 0.5 fallback submission instead of failing.\")\n        sub = pd.DataFrame(0.5, index=range(len(test)), columns=CFG.TARGET_COLS)\n        sub.insert(0, \"StudyInstanceUID\", test[\"StudyInstanceUID\"].values)\n    else:\n        blended = np.mean(all_model_preds, axis=0)   # simple average ensemble across backbones+folds\n        sub = pd.DataFrame(blended, columns=CFG.TARGET_COLS)\n        sub.insert(0, \"StudyInstanceUID\", ids_final)\n\n    sub.to_csv(\"submission.csv\", index=False)\n    print(\"Saved submission.csv:\", sub.shape)\n    print(sub.head())\n\n\n# ----------------------------------------------------------------------\n# ENTRY POINT\n# ----------------------------------------------------------------------\nif __name__ == \"__main__\":\n    # Deliberately left empty. In a Jupyter/Kaggle notebook cell,\n    # __name__ == \"__main__\" is true for EVERY cell execution — not just\n    # \"running this file directly\" like it means for a normal .py script.\n    # Auto-calling run_cv()/run_inference() here means simply running this\n    # script cell silently kicks off a full training run using whatever\n    # CFG defaults are active, even if you intended to first set\n    # CFG.EPOCHS/CFG.IMG_BACKBONES/etc. in a later cell. Call\n    # explore_data() / run_cv() / run_inference() explicitly, in their own\n    # cells, after any CFG overrides you want.\n    pass\n\n# Explicit, unconditional confirmation this cell actually finished\n# defining everything successfully — without this, a fast \"1 sec\" run\n# in a notebook is ambiguous (did it finish cleanly, or did the visible\n# timer just tick to 1 while still mid-import?). Check for this exact\n# line in the cell's output before moving to the next cell.\nprint(\"=\" * 60)\nprint(\"Script loaded successfully.\")\nprint(f\"CFG.DATA_DIR = {CFG.DATA_DIR}\")\nprint(f\"CFG.DEVICE = {CFG.DEVICE}\")\nprint(\"Next: call explore_data(), then set any CFG overrides, then run_cv().\")\nprint(\"=\" * 60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T09:39:25.986664Z","iopub.execute_input":"2026-08-09T09:39:25.98706Z","iopub.status.idle":"2026-08-09T09:39:26.101691Z","shell.execute_reply.started":"2026-08-09T09:39:25.986995Z","shell.execute_reply":"2026-08-09T09:39:26.100643Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"CFG.EPOCHS = 3\nCFG.IMG_BACKBONES = [\"convnext_tiny\"]\nCFG.IMG_SIZE = 128\nCFG.N_SLICES = 12\nCFG.BATCH_SIZE = 2\nrun_cv()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T09:39:34.433556Z","iopub.execute_input":"2026-08-09T09:39:34.433889Z","iopub.status.idle":"2026-08-09T10:22:08.316519Z","shell.execute_reply.started":"2026-08-09T09:39:34.433861Z","shell.execute_reply":"2026-08-09T10:22:08.31464Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"run_inference()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T10:22:20.754678Z","iopub.execute_input":"2026-08-09T10:22:20.755267Z","iopub.status.idle":"2026-08-09T10:23:13.423234Z","shell.execute_reply.started":"2026-08-09T10:22:20.755205Z","shell.execute_reply":"2026-08-09T10:23:13.42188Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nprint([f for f in os.listdir('.') if f.endswith('_ema.pt')])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T10:23:19.802405Z","iopub.execute_input":"2026-08-09T10:23:19.802798Z","iopub.status.idle":"2026-08-09T10:23:19.810485Z","shell.execute_reply.started":"2026-08-09T10:23:19.802758Z","shell.execute_reply":"2026-08-09T10:23:19.809614Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\ncount = 0\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n        count += 1\n        if count >= 20:\n            break\n    if count >= 20:\n        break","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T10:23:23.40939Z","iopub.execute_input":"2026-08-09T10:23:23.409759Z","iopub.status.idle":"2026-08-09T10:23:23.424086Z","shell.execute_reply.started":"2026-08-09T10:23:23.409721Z","shell.execute_reply":"2026-08-09T10:23:23.423174Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nsample = pd.read_csv('/kaggle/input/competitions/rsna-knee-abnormality-detection/sample_submission.csv')\nprint(sample.shape)\nprint(sample.columns.tolist())\nprint(sample.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T07:08:55.651994Z","iopub.execute_input":"2026-08-09T07:08:55.652466Z","iopub.status.idle":"2026-08-09T07:08:55.671897Z","shell.execute_reply.started":"2026-08-09T07:08:55.652425Z","shell.execute_reply":"2026-08-09T07:08:55.670607Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nprint([f for f in os.listdir('.') if f.endswith('_ema.pt')])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-09T10:23:29.093652Z","iopub.execute_input":"2026-08-09T10:23:29.093999Z","iopub.status.idle":"2026-08-09T10:23:29.10136Z","shell.execute_reply.started":"2026-08-09T10:23:29.093969Z","shell.execute_reply":"2026-08-09T10:23:29.10011Z"}},"outputs":[],"execution_count":null}]}