{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import os\nfrom pathlib import Path\n\nprint(\"Contents of /kaggle/input:\")\nprint(os.listdir(\"/kaggle/input\"))\nprint(\"\\n\" + \"=\"*60)\n\n# Show the full structure one level deeper\nfor item in os.listdir(\"/kaggle/input\"):\n    path = Path(\"/kaggle/input\") / item\n    print(f\"\\nInside '{item}':\")\n    print(os.listdir(path)[:20])   ","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:38.28217Z","iopub.execute_input":"2026-09-15T03:30:38.2828Z","iopub.status.idle":"2026-09-15T03:30:38.288986Z","shell.execute_reply.started":"2026-09-15T03:30:38.28277Z","shell.execute_reply":"2026-09-15T03:30:38.287963Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport os\nfrom pathlib import Path\n\n# Correct path\nDATA_PATH = Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\")\n\n# Load the main CSV files\ntrain = pd.read_csv(DATA_PATH / \"train.csv\")\ntrain_series = pd.read_csv(DATA_PATH / \"train_series.csv\")\nsample_sub = pd.read_csv(DATA_PATH / \"sample_submission.csv\")\n\nprint(\"=\"*50)\nprint(\"train.csv shape:\", train.shape)\nprint(\"train_series.csv shape:\", train_series.shape)\nprint(\"sample_submission.csv shape:\", sample_sub.shape)\nprint(\"=\"*50)\n\nprint(\"\\nColumns in train.csv:\")\nprint(train.columns.tolist())\n\nprint(\"\\nFirst 3 rows of train.csv:\")\ndisplay(train.head(3))\n\nprint(\"\\nLabel prevalence (% of positive cases):\")\nlabel_cols = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n              \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n              \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\nprint(train[label_cols].mean().sort_values(ascending=False).round(3) * 100)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:38.417551Z","iopub.execute_input":"2026-09-15T03:30:38.417922Z","iopub.status.idle":"2026-09-15T03:30:38.967018Z","shell.execute_reply.started":"2026-09-15T03:30:38.417898Z","shell.execute_reply":"2026-09-15T03:30:38.966401Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nfor item in os.listdir(\"/kaggle/input\"):\n    print(item)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:38.9685Z","iopub.execute_input":"2026-09-15T03:30:38.968764Z","iopub.status.idle":"2026-09-15T03:30:38.972903Z","shell.execute_reply.started":"2026-09-15T03:30:38.968719Z","shell.execute_reply":"2026-09-15T03:30:38.972246Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"datasets_root = \"/kaggle/input/datasets\"\nfor item in os.listdir(datasets_root):\n    print(item)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:38.9738Z","iopub.execute_input":"2026-09-15T03:30:38.974084Z","iopub.status.idle":"2026-09-15T03:30:38.987369Z","shell.execute_reply.started":"2026-09-15T03:30:38.974052Z","shell.execute_reply":"2026-09-15T03:30:38.986732Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for root, dirs, files in os.walk(datasets_root):\n    for f in files:\n        print(os.path.join(root, f))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:38.988886Z","iopub.execute_input":"2026-09-15T03:30:38.989331Z","iopub.status.idle":"2026-09-15T03:30:39.013873Z","shell.execute_reply.started":"2026-09-15T03:30:38.989308Z","shell.execute_reply":"2026-09-15T03:30:39.013293Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\n\npseudo_labels_path = \"/kaggle/input/datasets/adityazhivam/rsna-knee-pseudo-labels-v1/pseudo_labels_v1.csv\"\nlang_lookup = pd.read_csv(pseudo_labels_path)[[\"StudyInstanceUID\", \"lang\"]]\ntrain = train.merge(lang_lookup, on=\"StudyInstanceUID\", how=\"left\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:39.081776Z","iopub.execute_input":"2026-09-15T03:30:39.082396Z","iopub.status.idle":"2026-09-15T03:30:39.115833Z","shell.execute_reply.started":"2026-09-15T03:30:39.082375Z","shell.execute_reply":"2026-09-15T03:30:39.115311Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(train[\"lang\"].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:39.24137Z","iopub.execute_input":"2026-09-15T03:30:39.241692Z","iopub.status.idle":"2026-09-15T03:30:39.249486Z","shell.execute_reply.started":"2026-09-15T03:30:39.241671Z","shell.execute_reply":"2026-09-15T03:30:39.248579Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# How many studies have labels vs only reports?\nlabel_cols = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n              \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n              \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\n# Count non-null labels per study\ntrain[\"num_labels\"] = train[label_cols].notna().sum(axis=1)\n\nprint(\"Number of studies with at least one label:\")\nprint((train[\"num_labels\"] > 0).sum())\n\nprint(\"\\nDistribution of number of labeled findings per study:\")\nprint(train[\"num_labels\"].value_counts().sort_index())\n\nprint(\"\\n% of studies that have full labels (all 12):\")\nprint(f\"{(train['num_labels'] == 12).mean()*100:.1f}%\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:39.441673Z","iopub.execute_input":"2026-09-15T03:30:39.442371Z","iopub.status.idle":"2026-09-15T03:30:39.453682Z","shell.execute_reply.started":"2026-09-15T03:30:39.442345Z","shell.execute_reply":"2026-09-15T03:30:39.452944Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labeled_only = train[train[\"num_labels\"] == 12]\n\n# Look at a couple of clearly-positive and clearly negative examples per condition\nfor cond in [\"ACL\", \"Fracture\"]:\n    print(f\"\\n{'='*20} {cond} = 1 {'='*20}\")\n    pos = labeled_only[labeled_only[cond] == 1][\"Report\"].head(2)\n    for r in pos:\n        print(r[:400], \"\\n---\")\n    print(f\"\\n{'='*20} {cond} = 0 {'='*20}\")\n    neg = labeled_only[labeled_only[cond] == 0][\"Report\"].head(2)\n    for r in neg:\n        print(r[:400], \"\\n---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:39.641552Z","iopub.execute_input":"2026-09-15T03:30:39.641767Z","iopub.status.idle":"2026-09-15T03:30:39.65056Z","shell.execute_reply.started":"2026-09-15T03:30:39.641748Z","shell.execute_reply":"2026-09-15T03:30:39.649981Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"test = pd.read_csv(DATA_PATH / \"test.csv\")\nprint(test.columns.tolist())\nprint(test.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:39.833099Z","iopub.execute_input":"2026-09-15T03:30:39.833348Z","iopub.status.idle":"2026-09-15T03:30:39.843211Z","shell.execute_reply.started":"2026-09-15T03:30:39.833326Z","shell.execute_reply":"2026-09-15T03:30:39.842391Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def non_ascii_frac(s):\n    if not isinstance(s, str) or len(s) == 0:\n        return 0.0\n    return sum(ord(c) > 127 for c in s) / len(s)\n\ntrain[\"report_len\"] = train[\"Report\"].str.len()\ntrain[\"non_ascii_frac\"] = train[\"Report\"].apply(non_ascii_frac)\nprint(train[\"non_ascii_frac\"].describe())\n\n# eyeball a spread of reports across the non-ascii range\nfor i, r in train.sort_values(\"non_ascii_frac\")[[\"Report\"]].iloc[::500].iterrows():\n    print(r[\"Report\"][:150], \"\\n---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.017578Z","iopub.execute_input":"2026-09-15T03:30:40.018164Z","iopub.status.idle":"2026-09-15T03:30:40.349708Z","shell.execute_reply.started":"2026-09-15T03:30:40.018139Z","shell.execute_reply":"2026-09-15T03:30:40.349089Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#!pip install -q langdetect\n#from langdetect import detect, DetectorFactory\n#DetectorFactory.seed = 0\n\n#def safe_detect(t):\n #   try:\n  #      return detect(t) if isinstance(t, str) and len(t.strip()) > 20 else \"unk\"\n   # except Exception:\n#        return \"unk\"\n\n#train[\"lang\"] = train[\"Report\"].apply(safe_detect)\n#print(train[\"lang\"].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.350667Z","iopub.execute_input":"2026-09-15T03:30:40.35097Z","iopub.status.idle":"2026-09-15T03:30:40.354433Z","shell.execute_reply.started":"2026-09-15T03:30:40.350949Z","shell.execute_reply":"2026-09-15T03:30:40.353589Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labeled_only = train[train[\"num_labels\"] == 12]\nprint(labeled_only[\"lang\"].value_counts())\nprint()\nprint(f\"Gold set size: {len(labeled_only)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.401813Z","iopub.execute_input":"2026-09-15T03:30:40.40238Z","iopub.status.idle":"2026-09-15T03:30:40.409068Z","shell.execute_reply.started":"2026-09-15T03:30:40.402356Z","shell.execute_reply":"2026-09-15T03:30:40.408138Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import re\n\ndef has_negation_before(text, term_pos, window=40):\n    \"\"\"Check for negation cues in a window before the matched term.\"\"\"\n    negation_cues = [\n        \"no \", \"no evidence of\", \"without\", \"negative for\", \"intact\",\n        \"normal\", \"unremarkable\", \"preserved\", \"not seen\", \"not torn\",\n        \"no tear\", \"no fracture\", \"no significant\", \"no acute\"\n    ]\n    start = max(0, term_pos - window)\n    window_text = text[start:term_pos].lower()\n    return any(cue in window_text for cue in negation_cues)\n\n# Condition -> list of positive-indicating phrases (English)\nCONDITION_PHRASES_EN = {\n    \"ACL\": [\"acl tear\", \"acl rupture\", \"anterior cruciate ligament tear\",\n            \"torn acl\", \"acl injury\", \"disruption of the acl\"],\n    \"MCL\": [\"mcl tear\", \"mcl sprain\", \"medial collateral ligament tear\", \"mcl injury\"],\n    \"Medial Meniscus\": [\"medial meniscus tear\", \"medial meniscal tear\",\n                         \"tear of the medial meniscus\"],\n    \"Lateral Meniscus\": [\"lateral meniscus tear\", \"lateral meniscal tear\",\n                          \"tear of the lateral meniscus\"],\n    \"Fracture\": [\"fracture\", \"fx of the\", \"cortical break\"],\n    \"Effusion\": [\"joint effusion\", \"knee effusion\", \"effusion is seen\", \" effusion\"],\n    \"Synovitis\": [\"synovitis\", \"synovial thickening\", \"synovial inflammation\"],\n    \"Baker's\": [\"baker's cyst\", \"bakers cyst\", \"popliteal cyst\"],\n    \"Contusion\": [\"bone contusion\", \"bone bruise\", \"osseous contusion\", \"contusion\"],\n    # OA subtypes are trickier -- often phrased as \"chondromalacia\" /\n    # \"degenerative changes\" / \"osteoarthritis\" localized to a compartment\n    \"Medial OA\": [\"medial compartment osteoarthritis\", \"medial osteoarthritis\",\n                  \"medial compartment degenerative\", \"medial chondromalacia\"],\n    \"Lateral OA\": [\"lateral compartment osteoarthritis\", \"lateral osteoarthritis\",\n                   \"lateral compartment degenerative\", \"lateral chondromalacia\"],\n    \"PF OA\": [\"patellofemoral osteoarthritis\", \"patellofemoral compartment degenerative\",\n              \"patellofemoral chondromalacia\"],\n}\n\ndef label_report_en(text):\n    \"\"\"Returns dict of condition -> (0/1/None, confidence).\"\"\"\n    if not isinstance(text, str):\n        return {c: (None, 0.0) for c in CONDITION_PHRASES_EN}\n    text_lower = text.lower()\n    results = {}\n    for cond, phrases in CONDITION_PHRASES_EN.items():\n        found_positive = False\n        found_negated = False\n        for phrase in phrases:\n            for m in re.finditer(re.escape(phrase), text_lower):\n                if has_negation_before(text_lower, m.start()):\n                    found_negated = True\n                else:\n                    found_positive = True\n        if found_positive:\n            results[cond] = (1, 0.8)\n        elif found_negated:\n            results[cond] = (0, 0.7)\n        else:\n            results[cond] = (None, 0.0)  # no mention found -- ambiguous, leave unlabeled\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.569572Z","iopub.execute_input":"2026-09-15T03:30:40.570248Z","iopub.status.idle":"2026-09-15T03:30:40.579811Z","shell.execute_reply.started":"2026-09-15T03:30:40.570221Z","shell.execute_reply":"2026-09-15T03:30:40.579062Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"en_gold = train[(train[\"lang\"] == \"en\") & (train[\"num_labels\"] == 12)]\n\nrows = []\nfor _, row in en_gold.iterrows():\n    preds = label_report_en(row[\"Report\"])\n    for cond in CONDITION_PHRASES_EN:\n        pred_label, conf = preds[cond]\n        true_label = row[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": true_label, \"conf\": conf})\n\nval_df = pd.DataFrame(rows)\n# accuracy only where the labeler actually made a call (pred is not None)\nmade_call = val_df.dropna(subset=[\"pred\"])\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\nprint(made_call.groupby(\"cond\").agg(\n    n_calls=(\"correct\", \"size\"),\n    accuracy=(\"correct\", \"mean\"),\n))\nprint()\nprint(f\"Coverage (labeler made a call): {len(made_call)} / {len(val_df)} \"\n      f\"({len(made_call)/len(val_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.761632Z","iopub.execute_input":"2026-09-15T03:30:40.761994Z","iopub.status.idle":"2026-09-15T03:30:40.798244Z","shell.execute_reply.started":"2026-09-15T03:30:40.761968Z","shell.execute_reply":"2026-09-15T03:30:40.797401Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"made_call = val_df.dropna(subset=[\"pred\"]).copy()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:40.945516Z","iopub.execute_input":"2026-09-15T03:30:40.9463Z","iopub.status.idle":"2026-09-15T03:30:40.95118Z","shell.execute_reply.started":"2026-09-15T03:30:40.946273Z","shell.execute_reply":"2026-09-15T03:30:40.950356Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# For conditions with 0 coverage or low accuracy, look at actual gold-positive reports\nfor cond in [\"MCL\", \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Synovitis\", \"Effusion\"]:\n    print(f\"\\n{'='*25} {cond} = 1 (English gold) {'='*25}\")\n    pos_reports = en_gold[en_gold[cond] == 1][\"Report\"]\n    for r in pos_reports.head(3):\n        print(r[:500])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:41.137859Z","iopub.execute_input":"2026-09-15T03:30:41.138504Z","iopub.status.idle":"2026-09-15T03:30:41.148464Z","shell.execute_reply.started":"2026-09-15T03:30:41.138474Z","shell.execute_reply":"2026-09-15T03:30:41.147587Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import re\n\ndef show_context(text, keywords, window=100):\n    \"\"\"Find all keyword occurrences and show surrounding context.\"\"\"\n    text_lower = text.lower()\n    for kw in keywords:\n        for m in re.finditer(re.escape(kw.lower()), text_lower):\n            start = max(0, m.start() - window)\n            end = min(len(text), m.end() + window)\n            print(f\"[{kw}] ...{text[start:end]}...\")\n            print()\n\n# Look at full MCL positive reports for the actual injury vocabulary\nmcl_pos = en_gold[en_gold[\"MCL\"] == 1][\"Report\"]\nfor r in mcl_pos:\n    show_context(r, [\"mcl\", \"medial collateral\"])\n    print(\"=\"*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:41.321584Z","iopub.execute_input":"2026-09-15T03:30:41.321801Z","iopub.status.idle":"2026-09-15T03:30:41.329143Z","shell.execute_reply.started":"2026-09-15T03:30:41.321781Z","shell.execute_reply":"2026-09-15T03:30:41.328313Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Look at full Medial OA / PF OA positive reports -- search broadly for\n# any degenerative/cartilage-related vocabulary to find the real trigger words\noa_keywords = [\"chondro\", \"degenerat\", \"osteophyte\", \"arthros\", \"joint space\",\n               \"cartilage loss\", \"thinning\", \"osteoarthr\", \"wear\"]\n\nmed_oa_pos = en_gold[en_gold[\"Medial OA\"] == 1][\"Report\"]\nfor r in med_oa_pos.head(2):\n    show_context(r, oa_keywords, window=80)\n    print(\"=\"*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:41.521823Z","iopub.execute_input":"2026-09-15T03:30:41.522203Z","iopub.status.idle":"2026-09-15T03:30:41.528694Z","shell.execute_reply.started":"2026-09-15T03:30:41.522178Z","shell.execute_reply":"2026-09-15T03:30:41.527859Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import re\n\ndef split_sentences(text):\n    # Split on periods, newlines, and semicolons -- these reports use short,\n    # often colon-delimited findings (\"Medial collateral ligament (MCL): Grade 2 injury.\")\n    parts = re.split(r'[.\\n;]+', text)\n    return [p.strip() for p in parts if p.strip()]\n\nROMAN = {\" i \": \" 1 \", \" ii \": \" 2 \", \" iii \": \" 3 \",\n         \"grade i)\": \"grade 1)\", \"grade ii)\": \"grade 2)\", \"grade iii)\": \"grade 3)\",\n         \"grade i.\": \"grade 1.\", \"grade ii.\": \"grade 2.\", \"grade iii.\": \"grade 3.\"}\n\ndef normalize(text):\n    t = \" \" + text.lower() + \" \"\n    for k, v in ROMAN.items():\n        t = t.replace(k, v)\n    return t\n\nNEGATION_WORDS = [\"no \", \"not \", \"without\", \"intact\", \"normal\", \"unremarkable\",\n                   \"preserved\", \"negative for\"]\n\ndef sentence_has_negation(sent):\n    return any(neg in sent for neg in NEGATION_WORDS)\n\n# --- MCL ---\nMCL_ANATOMY = [\"mcl\", \"medial collateral ligament\"]\nMCL_FINDING = [\"tear\", \"rupture\", \"sprain\", \"injury\", \"grade 1\", \"grade 2\", \"grade 3\",\n               \"high signal\", \"laxity\", \"edema\"]\n\ndef label_mcl(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    for s in sentences:\n        if any(a in s for a in MCL_ANATOMY):\n            has_finding = any(f in s for f in MCL_FINDING)\n            negated = sentence_has_negation(s)\n            if has_finding and not negated:\n                return 1, 0.85\n            if negated and not has_finding:\n                return 0, 0.7\n            if negated and has_finding:\n                # e.g. \"no tear of the mcl\" -- negation wins if it directly precedes finding word\n                return 0, 0.5\n    return None, 0.0\n\n# --- OA compartments ---\nOA_FINDING = [\"chondrosis\", \"chondromalacia\", \"cartilage loss\", \"cartilage thinning\",\n              \"cartilage fissuring\", \"osteophyte\", \"degenerative\", \"cartilage defect\",\n              \"osteoarthritis\", \"osteoarthr\"]\n\nCOMPARTMENT_TERMS = {\n    \"Medial OA\": [\"medial compartment\", \"medial cartilage\", \"medial condyle\",\n                  \"medial patellar facet\"],  # careful: medial patellar facet is PF, remove below\n    \"Lateral OA\": [\"lateral compartment\", \"lateral cartilage\", \"lateral condyle\"],\n    \"PF OA\": [\"patellofemoral\", \"patellar facet\", \"trochlea\", \"patellar cartilage\"],\n}\n# fix overlap: medial/lateral patellar facet belongs to PF OA, not Medial/Lateral OA\nCOMPARTMENT_TERMS[\"Medial OA\"] = [\"medial compartment\", \"medial condyle\"]\nCOMPARTMENT_TERMS[\"Lateral OA\"] = [\"lateral compartment\", \"lateral condyle\"]\n\ndef label_oa(text, condition):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = COMPARTMENT_TERMS[condition]\n\n    # \"tricompartmental\" findings apply to all three OA labels at once\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING):\n            return 1, 0.75\n\n    # sliding window of 2 sentences to catch header + finding split across lines\n    for i in range(len(sentences)):\n        window = sentences[i] + \" \" + (sentences[i+1] if i+1 < len(sentences) else \"\")\n        if any(t in window for t in terms):\n            has_finding = any(f in window for f in OA_FINDING)\n            negated = sentence_has_negation(window)\n            if has_finding and not negated:\n                return 1, 0.75\n    return None, 0.0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:41.721813Z","iopub.execute_input":"2026-09-15T03:30:41.722633Z","iopub.status.idle":"2026-09-15T03:30:41.73523Z","shell.execute_reply.started":"2026-09-15T03:30:41.722599Z","shell.execute_reply":"2026-09-15T03:30:41.734462Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_report_en_v2(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"MCL\"] = label_mcl(text)\n    for cond in [\"Medial OA\", \"Lateral OA\", \"PF OA\"]:\n        results[cond] = label_oa(text, cond)\n    # keep prior phrase-based results for the rest for now\n    prior = label_report_en(text)\n    for cond in [\"ACL\", \"Baker's\", \"Contusion\", \"Effusion\", \"Fracture\",\n                 \"Lateral Meniscus\", \"Medial Meniscus\", \"Synovitis\"]:\n        results[cond] = prior[cond]\n    return results\n\nrows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v2(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"conf\": conf})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\nprint(made_call.groupby(\"cond\").agg(n_calls=(\"correct\",\"size\"), accuracy=(\"correct\",\"mean\")))\nprint(f\"\\nCoverage: {len(made_call)}/{len(val_df)} ({len(made_call)/len(val_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:41.905909Z","iopub.execute_input":"2026-09-15T03:30:41.906774Z","iopub.status.idle":"2026-09-15T03:30:41.947062Z","shell.execute_reply.started":"2026-09-15T03:30:41.906744Z","shell.execute_reply":"2026-09-15T03:30:41.946277Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v2(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond],\n                     \"conf\": conf, \"report\": row[\"Report\"]})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\n\nwrong = made_call[~made_call[\"correct\"]]\nfor cond in [\"Lateral OA\", \"PF OA\"]:\n    print(f\"\\n{'='*20} {cond} WRONG CALLS {'='*20}\")\n    for _, r in wrong[wrong[\"cond\"] == cond].iterrows():\n        print(f\"pred={r['pred']}  true={r['true']}\")\n        print(r[\"report\"][:600])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:42.09829Z","iopub.execute_input":"2026-09-15T03:30:42.09866Z","iopub.status.idle":"2026-09-15T03:30:42.135317Z","shell.execute_reply.started":"2026-09-15T03:30:42.098633Z","shell.execute_reply":"2026-09-15T03:30:42.134659Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"ACL_ANATOMY = [\"acl\", \"anterior cruciate ligament\"]\nACL_FINDING = [\"tear\", \"rupture\", \"sprain\", \"injury\", \"grade 1\", \"grade 2\", \"grade 3\",\n               \"high signal\", \"disruption\", \"torn\"]\n\nMENISCUS_FINDING = [\"tear\", \"torn\", \"degenerative signal\", \"extrusion\", \"complex tear\"]\n\nEFFUSION_ANATOMY = [\"effusion\", \"joint fluid\"]\nEFFUSION_FINDING = [\"small\", \"moderate\", \"large\", \"trace\", \"present\", \"seen\", \"is noted\",\n                     \"\"]  # empty string lets mere presence of \"effusion\" count if not negated\n\ndef label_ligament_or_meniscus(text, anatomy_terms, finding_terms, require_finding=True):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    for s in sentences:\n        if any(a in s for a in anatomy_terms):\n            has_finding = (not require_finding) or any(f in s for f in finding_terms)\n            negated = sentence_has_negation(s)\n            if has_finding and not negated:\n                return 1, 0.85\n            if negated:\n                return 0, 0.7\n    return None, 0.0\n\ndef label_report_en_v3(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"MCL\"] = label_mcl(text)\n    for cond in [\"Medial OA\", \"Lateral OA\", \"PF OA\"]:\n        results[cond] = label_oa(text, cond)\n    results[\"ACL\"] = label_ligament_or_meniscus(text, ACL_ANATOMY, ACL_FINDING)\n    results[\"Medial Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"medial meniscus\", \"medial meniscal\"], MENISCUS_FINDING)\n    results[\"Lateral Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"lateral meniscus\", \"lateral meniscal\"], MENISCUS_FINDING)\n    results[\"Effusion\"] = label_ligament_or_meniscus(\n        text, EFFUSION_ANATOMY, EFFUSION_FINDING, require_finding=False)\n    prior = label_report_en(text)\n    for cond in [\"Baker's\", \"Contusion\", \"Fracture\", \"Synovitis\"]:\n        results[cond] = prior[cond]\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:42.289503Z","iopub.execute_input":"2026-09-15T03:30:42.29026Z","iopub.status.idle":"2026-09-15T03:30:42.297679Z","shell.execute_reply.started":"2026-09-15T03:30:42.290225Z","shell.execute_reply":"2026-09-15T03:30:42.297071Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v3(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond],\n                     \"conf\": conf, \"report\": row[\"Report\"]})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\n\nwrong = made_call[~made_call[\"correct\"]]\nfor cond in [\"Lateral OA\", \"PF OA\"]:\n    print(f\"\\n{'='*20} {cond} WRONG CALLS {'='*20}\")\n    for _, r in wrong[wrong[\"cond\"] == cond].iterrows():\n        print(f\"pred={r['pred']}  true={r['true']}\")\n        print(r[\"report\"][:600])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:42.473885Z","iopub.execute_input":"2026-09-15T03:30:42.474574Z","iopub.status.idle":"2026-09-15T03:30:42.524887Z","shell.execute_reply.started":"2026-09-15T03:30:42.474543Z","shell.execute_reply":"2026-09-15T03:30:42.52411Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_oa_debug(text, condition):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = COMPARTMENT_TERMS[condition]\n\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING):\n            return 1, 0.75, f\"[tricompartmental] {s}\"\n\n    for i in range(len(sentences)):\n        window = sentences[i] + \" \" + (sentences[i+1] if i+1 < len(sentences) else \"\")\n        if any(t in window for t in terms):\n            has_finding = any(f in window for f in OA_FINDING)\n            negated = sentence_has_negation(window)\n            if has_finding and not negated:\n                return 1, 0.75, f\"[compartment+finding] {window}\"\n    return None, 0.0, None\n\n# Re-check the false positives with the trigger sentence visible\nfor cond in [\"Lateral OA\", \"PF OA\"]:\n    print(f\"\\n{'='*20} {cond} {'='*20}\")\n    pos_but_wrong = en_gold[en_gold[cond] == 0]  # true negatives the labeler got wrong\n    for _, row in pos_but_wrong.iterrows():\n        pred, conf, trigger = label_oa_debug(row[\"Report\"], cond)\n        if pred == 1:\n            print(f\"TRIGGER: {trigger}\")\n            print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:42.665687Z","iopub.execute_input":"2026-09-15T03:30:42.666226Z","iopub.status.idle":"2026-09-15T03:30:42.683192Z","shell.execute_reply.started":"2026-09-15T03:30:42.666188Z","shell.execute_reply":"2026-09-15T03:30:42.682362Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Strong OA indicators -- rarely used for acute trauma, safe to trust alone\nOA_FINDING_STRONG = [\"osteoarthritis\", \"chondromalacia\", \"joint space narrowing\",\n                      \"degenerative\", \"osteophyte\"]\n\n# Weak/ambiguous indicators -- also used to describe acute post-traumatic\n# chondral injury, so only trust these if NOT near trauma language\nOA_FINDING_WEAK = [\"chondrosis\", \"cartilage loss\", \"cartilage thinning\",\n                    \"cartilage fissuring\", \"cartilage defect\"]\n\nTRAUMA_CONTEXT = [\"contusion\", \"impaction\", \"bone marrow edema\", \"fracture\",\n                  \"acute\", \"bone bruise\", \"osteochondral impaction\"]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:42.866009Z","iopub.execute_input":"2026-09-15T03:30:42.866479Z","iopub.status.idle":"2026-09-15T03:30:42.871811Z","shell.execute_reply.started":"2026-09-15T03:30:42.866449Z","shell.execute_reply":"2026-09-15T03:30:42.871246Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"MILD_QUALIFIERS = [\"mild\", \"minimal\", \"trace\", \"low-grade\", \"low grade\",\n                    \"small amount of\", \"slight\"]\nMODERATE_PLUS = [\"moderate\", \"severe\", \"high-grade\", \"high grade\",\n                  \"marked\", \"significant\", \"advanced\", \"full thickness\",\n                  \"diffuse\", \"extensive\"]\n\ndef has_severity_downgrade(window):\n    \"\"\"True if mild/minimal language is present without a moderate+ override.\"\"\"\n    has_mild = any(m in window for m in MILD_QUALIFIERS)\n    has_moderate_plus = any(m in window for m in MODERATE_PLUS)\n    return has_mild and not has_moderate_plus\n\ndef label_oa(text, condition):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = COMPARTMENT_TERMS[condition]\n\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING_STRONG):\n            if has_severity_downgrade(s):\n                continue\n            return 1, 0.75\n\n    # single-sentence match only -- no cross-sentence window, to stop\n    # unrelated section bleed (e.g. osseous osteophyte mention leaking\n    # into an unrelated meniscus-tear sentence next to it)\n    for s in sentences:\n        if any(t in s for t in terms):\n            negated = sentence_has_negation(s)\n            if negated:\n                continue\n            if has_severity_downgrade(s):\n                continue\n            if any(f in s for f in OA_FINDING_STRONG):\n                return 1, 0.8\n            if any(f in s for f in OA_FINDING_WEAK) and not any(t in s for t in TRAUMA_CONTEXT):\n                return 1, 0.6\n    return None, 0.0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:43.057322Z","iopub.execute_input":"2026-09-15T03:30:43.058071Z","iopub.status.idle":"2026-09-15T03:30:43.066166Z","shell.execute_reply.started":"2026-09-15T03:30:43.057999Z","shell.execute_reply":"2026-09-15T03:30:43.065449Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_report_en_v4(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"MCL\"] = label_mcl(text)\n    for cond in [\"Medial OA\", \"Lateral OA\", \"PF OA\"]:\n        results[cond] = label_oa(text, cond)\n    results[\"ACL\"] = label_ligament_or_meniscus(text, ACL_ANATOMY, ACL_FINDING)\n    results[\"Medial Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"medial meniscus\", \"medial meniscal\"], MENISCUS_FINDING)\n    results[\"Lateral Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"lateral meniscus\", \"lateral meniscal\"], MENISCUS_FINDING)\n    results[\"Effusion\"] = label_ligament_or_meniscus(\n        text, EFFUSION_ANATOMY, EFFUSION_FINDING, require_finding=False)\n    prior = label_report_en(text)\n    for cond in [\"Baker's\", \"Contusion\", \"Fracture\", \"Synovitis\"]:\n        results[cond] = prior[cond]\n    return results\n\nrows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v4(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"conf\": conf})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\nprint(made_call.groupby(\"cond\").agg(n_calls=(\"correct\",\"size\"), accuracy=(\"correct\",\"mean\")))\nprint(f\"\\nCoverage: {len(made_call)}/{len(val_df)} ({len(made_call)/len(val_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:43.257648Z","iopub.execute_input":"2026-09-15T03:30:43.258329Z","iopub.status.idle":"2026-09-15T03:30:43.310096Z","shell.execute_reply.started":"2026-09-15T03:30:43.258299Z","shell.execute_reply":"2026-09-15T03:30:43.309507Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"SYNOVITIS_TERMS = [\"synovitis\", \"synovial thickening\", \"synovial proliferation\",\n                    \"synovial inflammation\", \"synovial hypertrophy\"]\n\ndef label_synovitis(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    for s in sentences:\n        if any(t in s for t in SYNOVITIS_TERMS):\n            negated = sentence_has_negation(s)\n            if not negated:\n                return 1, 0.8\n            return 0, 0.7\n    return None, 0.0\n\nCONTUSION_TERMS = [\"contusion\", \"bone bruise\"]\n\ndef label_contusion(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    found_positive, found_negated = False, False\n    for s in sentences:\n        if any(t in s for t in CONTUSION_TERMS):\n            if sentence_has_negation(s):\n                found_negated = True\n            else:\n                found_positive = True\n    if found_positive:\n        return 1, 0.8\n    if found_negated:\n        return 0, 0.7\n    return None, 0.0\n\ndef label_pf_oa(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = [\"patellofemoral\", \"patellar facet\", \"trochlea\", \"patellar cartilage\"]\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING_STRONG + OA_FINDING_WEAK):\n            return 1, 0.7\n    for s in sentences:\n        if any(t in s for t in terms):\n            negated = sentence_has_negation(s)\n            if negated:\n                continue\n            if any(f in s for f in OA_FINDING_STRONG):\n                return 1, 0.8\n            if any(f in s for f in OA_FINDING_WEAK) and not any(t in s for t in TRAUMA_CONTEXT):\n                return 1, 0.65\n    return None, 0.0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:43.449852Z","iopub.execute_input":"2026-09-15T03:30:43.450407Z","iopub.status.idle":"2026-09-15T03:30:43.458607Z","shell.execute_reply.started":"2026-09-15T03:30:43.450383Z","shell.execute_reply":"2026-09-15T03:30:43.458014Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_report_en_v5(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"MCL\"] = label_mcl(text)\n    results[\"Medial OA\"] = label_oa(text, \"Medial OA\")\n    results[\"Lateral OA\"] = label_oa(text, \"Lateral OA\")\n    results[\"PF OA\"] = label_pf_oa(text)  # new, more lenient rule\n    results[\"ACL\"] = label_ligament_or_meniscus(text, ACL_ANATOMY, ACL_FINDING)\n    results[\"Medial Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"medial meniscus\", \"medial meniscal\"], MENISCUS_FINDING)\n    results[\"Lateral Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"lateral meniscus\", \"lateral meniscal\"], MENISCUS_FINDING)\n    results[\"Effusion\"] = label_ligament_or_meniscus(\n        text, EFFUSION_ANATOMY, EFFUSION_FINDING, require_finding=False)\n    results[\"Synovitis\"] = label_synovitis(text)      # new\n    results[\"Contusion\"] = label_contusion(text)      # new\n    prior = label_report_en(text)\n    results[\"Baker's\"] = prior[\"Baker's\"]\n    results[\"Fracture\"] = prior[\"Fracture\"]\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:43.633558Z","iopub.execute_input":"2026-09-15T03:30:43.633934Z","iopub.status.idle":"2026-09-15T03:30:43.640388Z","shell.execute_reply.started":"2026-09-15T03:30:43.63391Z","shell.execute_reply":"2026-09-15T03:30:43.639805Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============== ALL LABELER FUNCTIONS ==============\n# Make sure everything is defined before pseudo-label generation\n\nLANG_VOCAB = {\n    \"tr\": {  # Turkish\n        \"ACL\": ([\"acl\", \"ön çapraz bağ\"], [\"yırtık\", \"rüptür\", \"kopma\"]),\n        \"MCL\": ([\"mcl\", \"iç yan bağ\", \"medial kollateral\"], [\"yırtık\", \"rüptür\", \"burkulma\"]),\n        \"Medial Meniscus\": ([\"iç menisküs\", \"medial menisküs\"], [\"yırtık\", \"rüptür\"]),\n        \"Lateral Meniscus\": ([\"dış menisküs\", \"lateral menisküs\"], [\"yırtık\", \"rüptür\"]),\n        \"Effusion\": ([\"efüzyon\", \"sıvı birikimi\"], [\"\"]),\n        \"Fracture\": ([\"kırık\", \"fraktür\"], [\"\"]),\n        \"negation\": [\"normal\", \"doğal\", \"yok\", \"izlenmedi\", \"sağlam\"],\n    },\n    \"hr\": {  # Croatian\n        \"ACL\": ([\"acl\", \"prednji križni ligament\"], [\"ruptura\", \"trganje\", \"kidanje\"]),\n        \"MCL\": ([\"mcl\", \"medijalni kolateralni ligament\"], [\"ruptura\", \"trganje\", \"uganuće\"]),\n        \"Medial Meniscus\": ([\"medijalni meniskus\"], [\"ruptura\", \"trganje\"]),\n        \"Lateral Meniscus\": ([\"lateralni meniskus\"], [\"ruptura\", \"trganje\"]),\n        \"Effusion\": ([\"izljev\", \"efuzija\"], [\"\"]),\n        \"Fracture\": ([\"fraktura\", \"prijelom\"], [\"\"]),\n        \"negation\": [\"uredan\", \"uredno\", \"normalan\", \"bez\", \"očuvan\"],\n    },\n    \"de\": {  # German\n        \"ACL\": ([\"acl\", \"vorderes kreuzband\"], [\"ruptur\", \"riss\"]),\n        \"MCL\": ([\"mcl\", \"innenband\", \"mediales kollateralband\"], [\"ruptur\", \"riss\", \"distorsion\"]),\n        \"Medial Meniscus\": ([\"innenmeniskus\", \"medialer meniskus\"], [\"ruptur\", \"riss\"]),\n        \"Lateral Meniscus\": ([\"außenmeniskus\", \"lateraler meniskus\"], [\"ruptur\", \"riss\"]),\n        \"Effusion\": ([\"erguss\", \"gelenkerguss\"], [\"\"]),\n        \"Fracture\": ([\"fraktur\", \"bruch\"], [\"\"]),\n        \"negation\": [\"regelrecht\", \"intakt\", \"unauffällig\", \"kein\", \"keine\", \"normal\"],\n    },\n    \"el\": {  # Greek\n        \"ACL\": ([\"πρόσθιο χιαστό\"], [\"ρήξη\", \"ρήγμα\"]),\n        \"MCL\": ([\"έσω πλάγιο\", \"mcl\"], [\"ρήξη\", \"διάταση\"]),\n        \"Medial Meniscus\": ([\"έσω μηνίσκος\"], [\"ρήξη\"]),\n        \"Lateral Meniscus\": ([\"έξω μηνίσκος\"], [\"ρήξη\"]),\n        \"Effusion\": ([\"συλλογή υγρού\", \"διόγκωση\"], [\"\"]),\n        \"Fracture\": ([\"κάταγμα\"], [\"\"]),\n        \"negation\": [\"φυσιολογικ\", \"ακέραι\", \"χωρίς\", \"δεν\"],\n    },\n    \"bg\": {  # Bulgarian\n        \"ACL\": ([\"предна кръстна връзка\", \"acl\"], [\"руптура\", \"скъсване\"]),\n        \"MCL\": ([\"медиален колатерален лигамент\", \"mcl\"], [\"руптура\", \"скъсване\", \"разтягане\"]),\n        \"Medial Meniscus\": ([\"медиален менискус\"], [\"руптура\", \"скъсване\"]),\n        \"Lateral Meniscus\": ([\"латерален менискус\"], [\"руптура\", \"скъсване\"]),\n        \"Effusion\": ([\"излив\"], [\"\"]),\n        \"Fracture\": ([\"фрактура\", \"счупване\"], [\"\"]),\n        \"negation\": [\"нормален\", \"запазен\", \"без\", \"не се\"],\n    },\n    \"nl\": {  # Dutch\n        \"ACL\": ([\"acl\", \"voorste kruisband\"], [\"ruptuur\", \"scheur\"]),\n        \"MCL\": ([\"mcl\", \"mediale collaterale band\"], [\"ruptuur\", \"scheur\", \"verstuiking\"]),\n        \"Medial Meniscus\": ([\"mediale meniscus\"], [\"ruptuur\", \"scheur\"]),\n        \"Lateral Meniscus\": ([\"laterale meniscus\"], [\"ruptuur\", \"scheur\"]),\n        \"Effusion\": ([\"effusie\", \"gewrichtsvocht\"], [\"\"]),\n        \"Fracture\": ([\"fractuur\", \"breuk\"], [\"\"]),\n        \"negation\": [\"normaal\", \"intact\", \"geen\", \"onopvallend\"],\n    },\n}\n\ndef label_report_lightweight(text, lang, window=60):\n    if not isinstance(text, str) or lang not in LANG_VOCAB:\n        return {}\n    text_lower = \" \" + text.lower() + \" \"\n    vocab = LANG_VOCAB[lang]\n    negation_terms = vocab[\"negation\"]\n    results = {}\n    for cond, value in vocab.items():\n        if cond == \"negation\":\n            continue\n        anatomy_terms, finding_terms = value\n        found_positive, found_negated = False, False\n        for a in anatomy_terms:\n            for m in re.finditer(re.escape(a), text_lower):\n                start = max(0, m.start() - window)\n                end = min(len(text_lower), m.end() + window)\n                ctx = text_lower[start:end]\n                has_finding = (finding_terms == [\"\"]) or any(f in ctx for f in finding_terms)\n                has_negation = any(n in ctx for n in negation_terms)\n                if has_finding and not has_negation:\n                    found_positive = True\n                elif has_negation:\n                    found_negated = True\n        if found_positive:\n            results[cond] = (1, 0.4)\n        elif found_negated:\n            results[cond] = (0, 0.35)\n        else:\n            results[cond] = (None, 0.0)\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:43.833677Z","iopub.execute_input":"2026-09-15T03:30:43.834122Z","iopub.status.idle":"2026-09-15T03:30:43.848791Z","shell.execute_reply.started":"2026-09-15T03:30:43.834092Z","shell.execute_reply":"2026-09-15T03:30:43.848069Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Define everything that might be missing\nLABEL_COLS_ALL = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n                   \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n                   \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\n# Add French to vocab if not already there\nif \"fr\" not in LANG_VOCAB:\n    LANG_VOCAB[\"fr\"] = {\n        \"ACL\": ([\"lca\", \"ligament croisé antérieur\"], [\"rupture\", \"déchirure\"]),\n        \"MCL\": ([\"lcm\", \"ligament collatéral médial\"], [\"rupture\", \"déchirure\", \"entorse\"]),\n        \"Medial Meniscus\": ([\"ménisque médial\", \"ménisque interne\"], [\"rupture\", \"déchirure\"]),\n        \"Lateral Meniscus\": ([\"ménisque latéral\", \"ménisque externe\"], [\"rupture\", \"déchirure\"]),\n        \"Effusion\": ([\"épanchement\"], [\"\"]),\n        \"Fracture\": ([\"fracture\"], [\"\"]),\n        \"negation\": [\"normal\", \"intact\", \"sans\", \"absence de\"],\n    }\n\nprint(\"✅ All variables defined!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.025821Z","iopub.execute_input":"2026-09-15T03:30:44.026458Z","iopub.status.idle":"2026-09-15T03:30:44.032239Z","shell.execute_reply.started":"2026-09-15T03:30:44.02643Z","shell.execute_reply":"2026-09-15T03:30:44.031599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Define everything that might be missing\n\nimport re\n\ndef split_sentences_es(text):\n    parts = re.split(r'[.\\n;]+', text)\n    return [p.strip() for p in parts if p.strip()]\n\ndef normalize_es(text):\n    return \" \" + text.lower() + \" \"\n\nNEGATION_WORDS_ES = [\"sin alteraciones\", \"sin signos de\", \"dentro de límites normales\",\n                     \"no hay\", \"conservad\", \"sin lesión\", \"sin lesion\", \"íntegr\", \"integr\"]\n\ndef sentence_has_negation_es(sent):\n    return any(neg in sent for neg in NEGATION_WORDS_ES)\n\nMILD_QUALIFIERS_ES = [\"leve\", \"mínim\", \"minim\", \"discreto\", \"discreta\", \"escaso\"]\nMODERATE_PLUS_ES = [\"moderad\", \"sever\", \"grave\", \"extens\", \"importante\"]\n\ndef has_severity_downgrade_es(window):\n    has_mild = any(m in window for m in MILD_QUALIFIERS_ES)\n    has_mod = any(m in window for m in MODERATE_PLUS_ES)\n    return has_mild and not has_mod\n\nACL_ANATOMY_ES = [\"lca\", \"ligamento cruzado anterior\"]\nMCL_ANATOMY_ES = [\"lcm\", \"ligamento colateral medial\"]\nFINDING_ES = [\"rotura\", \"rupture\", \"esguince\", \"lesión\", \"lesion\", \"desgarro\"]\n\ndef label_ligament_es(text, anatomy_terms, finding_terms=FINDING_ES):\n    sentences = [normalize_es(s) for s in split_sentences_es(text)]\n    for s in sentences:\n        if any(a in s for a in anatomy_terms):\n            has_finding = any(f in s for f in finding_terms)\n            negated = sentence_has_negation_es(s)\n            if has_finding and not negated:\n                return 1, 0.8\n            if negated:\n                return 0, 0.7\n    return None, 0.0\n\ndef label_report_es_v1(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"ACL\"] = label_ligament_es(text, ACL_ANATOMY_ES)\n    results[\"MCL\"] = label_ligament_es(text, MCL_ANATOMY_ES)\n    results[\"Medial Meniscus\"] = label_ligament_es(text, [\"menisco interno\", \"menisco medial\"])\n    results[\"Lateral Meniscus\"] = label_ligament_es(text, [\"menisco externo\", \"menisco lateral\"])\n    results[\"Effusion\"] = label_ligament_es(text, [\"derrame\"], finding_terms=[\"\"])\n    results[\"Contusion\"] = label_ligament_es(text, [\"contusion\", \"contusión\"], finding_terms=[\"\"])\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.226116Z","iopub.execute_input":"2026-09-15T03:30:44.226742Z","iopub.status.idle":"2026-09-15T03:30:44.235762Z","shell.execute_reply.started":"2026-09-15T03:30:44.226692Z","shell.execute_reply":"2026-09-15T03:30:44.235058Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Revert: PF OA now uses the same strict severity-gated rule as Medial/Lateral OA,\n# since the looser weak-finding threshold (0.5 conf) likely added mislabeled\n# ambiguous \"mild chondrosis\" studies that hurt real leaderboard performance\n# despite looking fine on local validation (which shares the same label noise).\n\ndef label_report_en_v6(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"MCL\"] = label_mcl(text)\n    results[\"Medial OA\"] = label_oa(text, \"Medial OA\")\n    results[\"Lateral OA\"] = label_oa(text, \"Lateral OA\")\n    results[\"PF OA\"] = label_oa(text, \"PF OA\")  # reverted to tight rule\n    results[\"ACL\"] = label_ligament_or_meniscus(text, ACL_ANATOMY, ACL_FINDING)\n    results[\"Medial Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"medial meniscus\", \"medial meniscal\"], MENISCUS_FINDING)\n    results[\"Lateral Meniscus\"] = label_ligament_or_meniscus(\n        text, [\"lateral meniscus\", \"lateral meniscal\"], MENISCUS_FINDING)\n    results[\"Effusion\"] = label_ligament_or_meniscus(\n        text, EFFUSION_ANATOMY, EFFUSION_FINDING, require_finding=False)\n    results[\"Synovitis\"] = label_synovitis(text)      # keep -- didn't touch ambiguous territory\n    results[\"Contusion\"] = label_contusion(text)      # keep -- same reasoning\n    prior = label_report_en(text)\n    results[\"Baker's\"] = prior[\"Baker's\"]\n    results[\"Fracture\"] = prior[\"Fracture\"]\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.417548Z","iopub.execute_input":"2026-09-15T03:30:44.41789Z","iopub.status.idle":"2026-09-15T03:30:44.423791Z","shell.execute_reply.started":"2026-09-15T03:30:44.417865Z","shell.execute_reply":"2026-09-15T03:30:44.423064Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# def get_pseudo_labels(row):\n#     lang = row[\"lang\"]\n#     text = row[\"Report\"]\n#     if lang == \"en\":\n#         return label_report_en_v6(text)   # reverted PF OA, kept Synovitis/Contusion fixes\n#     elif lang == \"es\":\n#         return label_report_es_v1(text)\n#     elif lang in LANG_VOCAB:\n#         return label_report_lightweight(text, lang)\n#     else:\n#         return {}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.593555Z","iopub.execute_input":"2026-09-15T03:30:44.593953Z","iopub.status.idle":"2026-09-15T03:30:44.597323Z","shell.execute_reply.started":"2026-09-15T03:30:44.593929Z","shell.execute_reply":"2026-09-15T03:30:44.596686Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v5(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"conf\": conf})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\nprint(made_call.groupby(\"cond\").agg(n_calls=(\"correct\",\"size\"), accuracy=(\"correct\",\"mean\")))\nprint(f\"\\nCoverage: {len(made_call)}/{len(val_df)} ({len(made_call)/len(val_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.7876Z","iopub.execute_input":"2026-09-15T03:30:44.788092Z","iopub.status.idle":"2026-09-15T03:30:44.845866Z","shell.execute_reply.started":"2026-09-15T03:30:44.788053Z","shell.execute_reply":"2026-09-15T03:30:44.845315Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# pseudo_rows = []\n# for idx, row in train.iterrows():\n#     preds = get_pseudo_labels(row)\n#     entry = {\"StudyInstanceUID\": row[\"StudyInstanceUID\"], \"lang\": row[\"lang\"],\n#               \"is_gold\": row[\"num_labels\"] == 12}\n#     for cond in LABEL_COLS_ALL:\n#         pred_label, conf = preds.get(cond, (None, 0.0))\n#         entry[f\"{cond}_pseudo\"] = pred_label\n#         entry[f\"{cond}_conf\"] = conf\n#         if entry[\"is_gold\"] and pd.notna(row[cond]):\n#             entry[f\"{cond}_pseudo\"] = row[cond]\n#             entry[f\"{cond}_conf\"] = 1.0\n#     pseudo_rows.append(entry)\n\n# pseudo_df = pd.DataFrame(pseudo_rows)\n# pseudo_df.to_csv(\"/kaggle/working/pseudo_labels_v1.csv\", index=False)\n# print(pseudo_df.shape)\n# for cond in LABEL_COLS_ALL:\n#     n = pseudo_df[f\"{cond}_pseudo\"].notna().sum()\n#     print(f\"{cond:20s}: {n:5d} / {len(pseudo_df)} ({n/len(pseudo_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:44.982151Z","iopub.execute_input":"2026-09-15T03:30:44.982877Z","iopub.status.idle":"2026-09-15T03:30:44.986544Z","shell.execute_reply.started":"2026-09-15T03:30:44.982846Z","shell.execute_reply":"2026-09-15T03:30:44.985771Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v5(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"report\": row[\"Report\"]})\n\nval_df_v5 = pd.DataFrame(rows)\nmade_call_v5 = val_df_v5.dropna(subset=[\"pred\"]).copy()\nmade_call_v5[\"correct\"] = made_call_v5[\"pred\"] == made_call_v5[\"true\"]\nwrong_v5 = made_call_v5[~made_call_v5[\"correct\"]]\n\nfor cond in [\"Synovitis\", \"PF OA\"]:\n    print(f\"\\n{'='*20} {cond} WRONG CALLS (v5) {'='*20}\")\n    for _, r in wrong_v5[wrong_v5[\"cond\"] == cond].iterrows():\n        print(f\"pred={r['pred']} true={r['true']}\")\n        print(r[\"report\"][:600])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:45.18988Z","iopub.execute_input":"2026-09-15T03:30:45.190208Z","iopub.status.idle":"2026-09-15T03:30:45.245928Z","shell.execute_reply.started":"2026-09-15T03:30:45.190167Z","shell.execute_reply":"2026-09-15T03:30:45.245087Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_pf_oa_debug(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = [\"patellofemoral\", \"patellar facet\", \"trochlea\", \"patellar cartilage\"]\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING_STRONG + OA_FINDING_WEAK):\n            return 1, 0.7, f\"[tricompartmental] {s}\"\n    for s in sentences:\n        if any(t in s for t in terms):\n            negated = sentence_has_negation(s)\n            if negated:\n                continue\n            if any(f in s for f in OA_FINDING_STRONG):\n                return 1, 0.8, f\"[strong] {s}\"\n            if any(f in s for f in OA_FINDING_WEAK) and not any(t in s for t in TRAUMA_CONTEXT):\n                return 1, 0.65, f\"[weak] {s}\"\n    return None, 0.0, None\n\npf_oa_wrong = en_gold[en_gold[\"PF OA\"] == 0]  # true negatives\nfor _, row in pf_oa_wrong.iterrows():\n    pred, conf, trigger = label_pf_oa_debug(row[\"Report\"])\n    if pred == 1:\n        print(f\"TRIGGER: {trigger}\")\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:45.365792Z","iopub.execute_input":"2026-09-15T03:30:45.366226Z","iopub.status.idle":"2026-09-15T03:30:45.377112Z","shell.execute_reply.started":"2026-09-15T03:30:45.366202Z","shell.execute_reply":"2026-09-15T03:30:45.376312Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def label_pf_oa(text):\n    sentences = [normalize(s) for s in split_sentences(text)]\n    terms = [\"patellofemoral\", \"patellar facet\", \"trochlea\", \"patellar cartilage\"]\n    for s in sentences:\n        if \"tricompartmental\" in s and any(f in s for f in OA_FINDING_STRONG + OA_FINDING_WEAK):\n            return 1, 0.6  # lowered from 0.7\n    for s in sentences:\n        if any(t in s for t in terms):\n            negated = sentence_has_negation(s)\n            if negated:\n                continue\n            if any(f in s for f in OA_FINDING_STRONG):\n                return 1, 0.75  # lowered from 0.8\n            if any(f in s for f in OA_FINDING_WEAK) and not any(t in s for t in TRAUMA_CONTEXT):\n                return 1, 0.5   # lowered from 0.65 -- this is the ambiguous case we just proved\n    return None, 0.0","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:45.565959Z","iopub.execute_input":"2026-09-15T03:30:45.566225Z","iopub.status.idle":"2026-09-15T03:30:45.571951Z","shell.execute_reply.started":"2026-09-15T03:30:45.566202Z","shell.execute_reply":"2026-09-15T03:30:45.571234Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in en_gold.reset_index(drop=True).iterrows():\n    preds = label_report_en_v4(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"report\": row[\"Report\"]})\n\nval_df = pd.DataFrame(rows)\nmade_call = val_df.dropna(subset=[\"pred\"]).copy()\nmade_call[\"correct\"] = made_call[\"pred\"] == made_call[\"true\"]\nwrong = made_call[~made_call[\"correct\"]]\n\nfor _, r in wrong[wrong[\"cond\"] == \"Synovitis\"].iterrows():\n    print(f\"pred={r['pred']} true={r['true']}\")\n    print(r[\"report\"][:500])\n    print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:45.758869Z","iopub.execute_input":"2026-09-15T03:30:45.759392Z","iopub.status.idle":"2026-09-15T03:30:45.807698Z","shell.execute_reply.started":"2026-09-15T03:30:45.759364Z","shell.execute_reply":"2026-09-15T03:30:45.80685Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_pseudo_labels(row):\n    \"\"\"Reverted to pure v4 -- the known-good baseline labeler.\n    No Synovitis/Contusion/PF OA changes. Isolating epochs as the only variable.\"\"\"\n    lang = row[\"lang\"]\n    text = row[\"Report\"]\n    if lang == \"en\":\n        return label_report_en_v4(text)\n    elif lang == \"es\":\n        return label_report_es_v1(text)\n    elif lang in LANG_VOCAB:\n        return label_report_lightweight(text, lang)\n    else:\n        return {}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:45.959445Z","iopub.execute_input":"2026-09-15T03:30:45.960005Z","iopub.status.idle":"2026-09-15T03:30:45.964594Z","shell.execute_reply.started":"2026-09-15T03:30:45.959977Z","shell.execute_reply":"2026-09-15T03:30:45.963958Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pseudo_rows = []\nfor idx, row in train.iterrows():\n    preds = get_pseudo_labels(row)\n    entry = {\"StudyInstanceUID\": row[\"StudyInstanceUID\"], \"lang\": row[\"lang\"],\n              \"is_gold\": row[\"num_labels\"] == 12}\n    for cond in LABEL_COLS_ALL:\n        pred_label, conf = preds.get(cond, (None, 0.0))\n        entry[f\"{cond}_pseudo\"] = pred_label\n        entry[f\"{cond}_conf\"] = conf\n        if entry[\"is_gold\"] and pd.notna(row[cond]):\n            entry[f\"{cond}_pseudo\"] = row[cond]\n            entry[f\"{cond}_conf\"] = 1.0\n    pseudo_rows.append(entry)\n\npseudo_df = pd.DataFrame(pseudo_rows)\npseudo_df.to_csv(\"/kaggle/working/pseudo_labels_v1.csv\", index=False)\nprint(pseudo_df.shape)\nfor cond in LABEL_COLS_ALL:\n    n = pseudo_df[f\"{cond}_pseudo\"].notna().sum()\n    print(f\"{cond:20s}: {n:5d} / {len(pseudo_df)} ({n/len(pseudo_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:46.142296Z","iopub.execute_input":"2026-09-15T03:30:46.143012Z","iopub.status.idle":"2026-09-15T03:30:48.472628Z","shell.execute_reply.started":"2026-09-15T03:30:46.142987Z","shell.execute_reply":"2026-09-15T03:30:48.471976Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# === Verification: Confirm we are back to pure v4 labels ===\n\nprint(\"=== Pseudo-label coverage check (must match original v4) ===\")\nprint(f\"{'Condition':20s} | Count\")\nprint(\"-\" * 35)\n\nexpected = {\n    \"PF OA\": 372,\n    \"Synovitis\": 346,\n    \"Contusion\": 713,\n}\n\nfor cond in [\"PF OA\", \"Synovitis\", \"Contusion\"]:\n    n = pseudo_df[f\"{cond}_pseudo\"].notna().sum()\n    status = \"✅\" if abs(n - expected[cond]) <= 5 else \"❌\"\n    print(f\"{cond:20s} | {n:5d}  {status}  (expected ~{expected[cond]})\")\n\nprint(\"\\nFull coverage:\")\nfor cond in LABEL_COLS_ALL:\n    n = pseudo_df[f\"{cond}_pseudo\"].notna().sum()\n    print(f\"{cond:20s}: {n:5d} / {len(pseudo_df)} ({n/len(pseudo_df):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:48.473894Z","iopub.execute_input":"2026-09-15T03:30:48.474227Z","iopub.status.idle":"2026-09-15T03:30:48.484532Z","shell.execute_reply.started":"2026-09-15T03:30:48.474204Z","shell.execute_reply":"2026-09-15T03:30:48.483676Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nllm_root = \"/kaggle/input/datasets/stevenleehans/rsna-knee-llm-report-labels\"\nprint(os.listdir(llm_root))  # confirm exact filenames match what you expect","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:48.485377Z","iopub.execute_input":"2026-09-15T03:30:48.485636Z","iopub.status.idle":"2026-09-15T03:30:48.499148Z","shell.execute_reply.started":"2026-09-15T03:30:48.485609Z","shell.execute_reply":"2026-09-15T03:30:48.498513Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import roc_auc_score\n\nllm_full = pd.read_csv(f\"{llm_root}/llm_labels_full.csv\")\nllm_v2 = pd.read_csv(f\"{llm_root}/llm_labels_v2.csv\")\nllm_v4 = pd.read_csv(f\"{llm_root}/llm_labels_v4_blend.csv\")\n\ncandidates = {\"full\": llm_full, \"v2\": llm_v2, \"v4_blend\": llm_v4}\ngold = train[train[\"num_labels\"] == 12].copy()\n\nfor name, df in candidates.items():\n    merged = gold[[\"StudyInstanceUID\"] + label_cols].merge(\n        df, on=\"StudyInstanceUID\", suffixes=(\"_true\", \"_llm\")\n    )\n    print(f\"\\n=== {name} ===\")\n    aucs = []\n    for cond in label_cols:\n        y_true = merged[f\"{cond}_true\"]\n        y_pred = merged[f\"{cond}_llm\"]\n        if y_true.nunique() < 2:\n            continue\n        auc = roc_auc_score(y_true, y_pred)\n        aucs.append(auc)\n        print(f\"  {cond:20s}: AUC {auc:.3f}\")\n    print(f\"  MEAN AUC: {np.mean(aucs):.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:48.500981Z","iopub.execute_input":"2026-09-15T03:30:48.501427Z","iopub.status.idle":"2026-09-15T03:30:49.461837Z","shell.execute_reply.started":"2026-09-15T03:30:48.501398Z","shell.execute_reply":"2026-09-15T03:30:49.461229Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def convert_llm_to_pseudo(df, label_cols):\n    out = df[[\"StudyInstanceUID\"]].copy()\n    for cond in label_cols:\n        vals = df[cond]\n        label = (vals > 0.5).astype(float)\n        conf = (vals - 0.5).abs() * 2\n        label[vals == 0.5] = np.nan  # exact 0.5 = no signal, exclude from training\n        conf[vals == 0.5] = 0.0\n        out[f\"{cond}_pseudo\"] = label\n        out[f\"{cond}_conf\"] = conf\n    return out\n\nBEST = \"v4_blend\"  # <-- set this based on Cell 2's results\nllm_pseudo = convert_llm_to_pseudo(candidates[BEST], label_cols)\n\npseudo_df_llm = train[[\"StudyInstanceUID\", \"lang\", \"num_labels\"]].merge(\n    llm_pseudo, on=\"StudyInstanceUID\", how=\"left\"\n)\npseudo_df_llm[\"is_gold\"] = pseudo_df_llm[\"num_labels\"] == 12\n\ntrue_lookup = train.set_index(\"StudyInstanceUID\")\nfor cond in label_cols:\n    gold_mask = pseudo_df_llm[\"is_gold\"]\n    pseudo_df_llm.loc[gold_mask, f\"{cond}_pseudo\"] = pseudo_df_llm.loc[gold_mask, \"StudyInstanceUID\"].map(true_lookup[cond])\n    pseudo_df_llm.loc[gold_mask, f\"{cond}_conf\"] = 1.0\n\npseudo_df_llm = pseudo_df_llm.drop(columns=[\"num_labels\"])\npseudo_df_llm.to_csv(\"/kaggle/working/pseudo_labels_v1.csv\", index=False)\n\nprint(pseudo_df_llm.shape)\nfor cond in label_cols:\n    n = pseudo_df_llm[f\"{cond}_pseudo\"].notna().sum()\n    print(f\"{cond:20s}: {n:5d} / {len(pseudo_df_llm)} ({n/len(pseudo_df_llm):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.462659Z","iopub.execute_input":"2026-09-15T03:30:49.462977Z","iopub.status.idle":"2026-09-15T03:30:49.603242Z","shell.execute_reply.started":"2026-09-15T03:30:49.462953Z","shell.execute_reply":"2026-09-15T03:30:49.602506Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import inspect\n\nlabeler_source = \"\\n\\n\".join([\n    inspect.getsource(f) for f in [\n        split_sentences, normalize, sentence_has_negation, has_severity_downgrade,\n        label_mcl, label_oa, label_ligament_or_meniscus,\n        label_report_en, label_report_en_v4\n    ]\n])\n\nwith open(\"/kaggle/working/en_labeler_v4.py\", \"w\") as f:\n    f.write(labeler_source)\n\nprint(\"Saved. Line count:\", len(labeler_source.splitlines()))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.604321Z","iopub.execute_input":"2026-09-15T03:30:49.605232Z","iopub.status.idle":"2026-09-15T03:30:49.612745Z","shell.execute_reply.started":"2026-09-15T03:30:49.605197Z","shell.execute_reply":"2026-09-15T03:30:49.611955Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"es_gold = train[(train[\"lang\"] == \"es\") & (train[\"num_labels\"] == 12)]\nprint(f\"Spanish gold set: {len(es_gold)}\")\n\n# Same diagnostic pattern as before -- see real positive/negative phrasing\nfor cond in [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Effusion\"]:\n    print(f\"\\n{'='*20} {cond} = 1 (Spanish gold) {'='*20}\")\n    for r in es_gold[es_gold[cond] == 1][\"Report\"].head(3):\n        print(r[:400])\n        print(\"---\")\n    print(f\"\\n{'='*20} {cond} = 0 (Spanish gold) {'='*20}\")\n    for r in es_gold[es_gold[cond] == 0][\"Report\"].head(2):\n        print(r[:400])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.613574Z","iopub.execute_input":"2026-09-15T03:30:49.613834Z","iopub.status.idle":"2026-09-15T03:30:49.631898Z","shell.execute_reply.started":"2026-09-15T03:30:49.613813Z","shell.execute_reply":"2026-09-15T03:30:49.631085Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import re\n\ndef split_sentences_es(text):\n    parts = re.split(r'[.\\n;]+', text)\n    return [p.strip() for p in parts if p.strip()]\n\ndef normalize_es(text):\n    return \" \" + text.lower() + \" \"\n\nNEGATION_WORDS_ES = [\"sin alteraciones\", \"sin signos de\", \"dentro de límites normales\",\n                     \"no hay\", \"conservad\", \"sin lesión\", \"sin lesion\", \"íntegr\", \"integr\"]\n\ndef sentence_has_negation_es(sent):\n    return any(neg in sent for neg in NEGATION_WORDS_ES)\n\nMILD_QUALIFIERS_ES = [\"leve\", \"mínim\", \"minim\", \"discreto\", \"discreta\", \"escaso\"]\nMODERATE_PLUS_ES = [\"moderad\", \"sever\", \"grave\", \"extens\", \"importante\"]\n\ndef has_severity_downgrade_es(window):\n    has_mild = any(m in window for m in MILD_QUALIFIERS_ES)\n    has_mod = any(m in window for m in MODERATE_PLUS_ES)\n    return has_mild and not has_mod\n\nACL_ANATOMY_ES = [\"lca\", \"ligamento cruzado anterior\"]\nMCL_ANATOMY_ES = [\"lcm\", \"ligamento colateral medial\"]\nFINDING_ES = [\"rotura\", \"rupture\", \"esguince\", \"lesión\", \"lesion\", \"desgarro\"]\n\ndef label_ligament_es(text, anatomy_terms, finding_terms=FINDING_ES):\n    sentences = [normalize_es(s) for s in split_sentences_es(text)]\n    for s in sentences:\n        if any(a in s for a in anatomy_terms):\n            has_finding = any(f in s for f in finding_terms)\n            negated = sentence_has_negation_es(s)\n            if has_finding and not negated:\n                return 1, 0.8\n            if negated:\n                return 0, 0.7\n    return None, 0.0\n\ndef label_report_es_v1(text):\n    if not isinstance(text, str):\n        return {}\n    results = {}\n    results[\"ACL\"] = label_ligament_es(text, ACL_ANATOMY_ES)\n    results[\"MCL\"] = label_ligament_es(text, MCL_ANATOMY_ES)\n    results[\"Medial Meniscus\"] = label_ligament_es(text, [\"menisco interno\", \"menisco medial\"])\n    results[\"Lateral Meniscus\"] = label_ligament_es(text, [\"menisco externo\", \"menisco lateral\"])\n    results[\"Effusion\"] = label_ligament_es(text, [\"derrame\"], finding_terms=[\"\"])\n    results[\"Contusion\"] = label_ligament_es(text, [\"contusion\", \"contusión\"], finding_terms=[\"\"])\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.633632Z","iopub.execute_input":"2026-09-15T03:30:49.634186Z","iopub.status.idle":"2026-09-15T03:30:49.650964Z","shell.execute_reply.started":"2026-09-15T03:30:49.634156Z","shell.execute_reply":"2026-09-15T03:30:49.650096Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in es_gold.reset_index(drop=True).iterrows():\n    preds = label_report_es_v1(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond]})\n\nval_df_es = pd.DataFrame(rows)\nmade_call_es = val_df_es.dropna(subset=[\"pred\"]).copy()\nmade_call_es[\"correct\"] = made_call_es[\"pred\"] == made_call_es[\"true\"]\nprint(made_call_es.groupby(\"cond\").agg(n_calls=(\"correct\",\"size\"), accuracy=(\"correct\",\"mean\")))\nprint(f\"\\nCoverage: {len(made_call_es)}/{len(val_df_es)} ({len(made_call_es)/len(val_df_es):.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.651996Z","iopub.execute_input":"2026-09-15T03:30:49.652305Z","iopub.status.idle":"2026-09-15T03:30:49.682629Z","shell.execute_reply.started":"2026-09-15T03:30:49.652274Z","shell.execute_reply":"2026-09-15T03:30:49.681882Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(es_gold[\"Medial Meniscus\"].value_counts(dropna=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.685149Z","iopub.execute_input":"2026-09-15T03:30:49.685509Z","iopub.status.idle":"2026-09-15T03:30:49.698135Z","shell.execute_reply.started":"2026-09-15T03:30:49.685485Z","shell.execute_reply":"2026-09-15T03:30:49.697323Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"rows = []\nfor idx, row in es_gold.reset_index(drop=True).iterrows():\n    preds = label_report_es_v1(row[\"Report\"])\n    for cond in preds:\n        pred_label, conf = preds[cond]\n        rows.append({\"cond\": cond, \"pred\": pred_label, \"true\": row[cond], \"report\": row[\"Report\"]})\n\nval_df_es = pd.DataFrame(rows)\nmade_call_es = val_df_es.dropna(subset=[\"pred\"]).copy()\nmade_call_es[\"correct\"] = made_call_es[\"pred\"] == made_call_es[\"true\"]\nwrong_es = made_call_es[~made_call_es[\"correct\"]]\n\nfor cond in [\"Effusion\", \"Medial Meniscus\", \"Contusion\"]:\n    print(f\"\\n{'='*20} {cond} {'='*20}\")\n    for _, r in wrong_es[wrong_es[\"cond\"] == cond].iterrows():\n        print(f\"pred={r['pred']} true={r['true']}\")\n        print(r[\"report\"][:400])\n        print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.69906Z","iopub.execute_input":"2026-09-15T03:30:49.699346Z","iopub.status.idle":"2026-09-15T03:30:49.721851Z","shell.execute_reply.started":"2026-09-15T03:30:49.699312Z","shell.execute_reply":"2026-09-15T03:30:49.720969Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Check for duplicate report text across gold studies\ndup_reports = train[train[\"num_labels\"] == 12].groupby(\"Report\")[\"StudyInstanceUID\"].apply(list)\ndup_reports = dup_reports[dup_reports.apply(len) > 1]\nprint(f\"Duplicate report texts among gold studies: {len(dup_reports)}\")\nfor report_text, study_ids in dup_reports.items():\n    print(study_ids)\n    sub = train[train[\"StudyInstanceUID\"].isin(study_ids)]\n    print(sub[[\"StudyInstanceUID\"] + label_cols].to_string())\n    print(\"=\"*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.722724Z","iopub.execute_input":"2026-09-15T03:30:49.722947Z","iopub.status.idle":"2026-09-15T03:30:49.743063Z","shell.execute_reply.started":"2026-09-15T03:30:49.722929Z","shell.execute_reply":"2026-09-15T03:30:49.742197Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"labeler_source_es = \"\\n\\n\".join([\n    inspect.getsource(f) for f in [\n        split_sentences_es, normalize_es, sentence_has_negation_es,\n        has_severity_downgrade_es, label_ligament_es, label_report_es_v1\n    ]\n])\nwith open(\"/kaggle/working/es_labeler_v1.py\", \"w\") as f:\n    f.write(labeler_source_es)\nprint(\"Saved.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.74392Z","iopub.execute_input":"2026-09-15T03:30:49.74442Z","iopub.status.idle":"2026-09-15T03:30:49.750958Z","shell.execute_reply.started":"2026-09-15T03:30:49.744386Z","shell.execute_reply":"2026-09-15T03:30:49.750299Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Minimal per-language vocab: anatomy terms, finding/injury terms, negation cues.\n# Built from cheap machine-translation of the English/Spanish terms we validated,\n# NOT hand-verified against gold examples (too few gold samples per language to check).\n# Confidence is deliberately capped low (0.4) to reflect this.\n\nLANG_VOCAB = {\n    \"tr\": {  # Turkish\n        \"ACL\": ([\"acl\", \"ön çapraz bağ\"], [\"yırtık\", \"rüptür\", \"kopma\"]),\n        \"MCL\": ([\"mcl\", \"iç yan bağ\", \"medial kollateral\"], [\"yırtık\", \"rüptür\", \"burkulma\"]),\n        \"Medial Meniscus\": ([\"iç menisküs\", \"medial menisküs\"], [\"yırtık\", \"rüptür\"]),\n        \"Lateral Meniscus\": ([\"dış menisküs\", \"lateral menisküs\"], [\"yırtık\", \"rüptür\"]),\n        \"Effusion\": ([\"efüzyon\", \"sıvı birikimi\"], [\"\"]),\n        \"Fracture\": ([\"kırık\", \"fraktür\"], [\"\"]),\n        \"negation\": [\"normal\", \"doğal\", \"yok\", \"izlenmedi\", \"sağlam\"],\n    },\n    \"hr\": {  # Croatian\n        \"ACL\": ([\"acl\", \"prednji križni ligament\"], [\"ruptura\", \"trganje\", \"kidanje\"]),\n        \"MCL\": ([\"mcl\", \"medijalni kolateralni ligament\"], [\"ruptura\", \"trganje\", \"uganuće\"]),\n        \"Medial Meniscus\": ([\"medijalni meniskus\"], [\"ruptura\", \"trganje\"]),\n        \"Lateral Meniscus\": ([\"lateralni meniskus\"], [\"ruptura\", \"trganje\"]),\n        \"Effusion\": ([\"izljev\", \"efuzija\"], [\"\"]),\n        \"Fracture\": ([\"fraktura\", \"prijelom\"], [\"\"]),\n        \"negation\": [\"uredan\", \"uredno\", \"normalan\", \"bez\", \"očuvan\"],\n    },\n    \"de\": {  # German\n        \"ACL\": ([\"acl\", \"vorderes kreuzband\"], [\"ruptur\", \"riss\"]),\n        \"MCL\": ([\"mcl\", \"innenband\", \"mediales kollateralband\"], [\"ruptur\", \"riss\", \"distorsion\"]),\n        \"Medial Meniscus\": ([\"innenmeniskus\", \"medialer meniskus\"], [\"ruptur\", \"riss\"]),\n        \"Lateral Meniscus\": ([\"außenmeniskus\", \"lateraler meniskus\"], [\"ruptur\", \"riss\"]),\n        \"Effusion\": ([\"erguss\", \"gelenkerguss\"], [\"\"]),\n        \"Fracture\": ([\"fraktur\", \"bruch\"], [\"\"]),\n        \"negation\": [\"regelrecht\", \"intakt\", \"unauffällig\", \"kein\", \"keine\", \"normal\"],\n    },\n    \"el\": {  # Greek\n        \"ACL\": ([\"πρόσθιο χιαστό\"], [\"ρήξη\", \"ρήγμα\"]),\n        \"MCL\": ([\"έσω πλάγιο\", \"mcl\"], [\"ρήξη\", \"διάταση\"]),\n        \"Medial Meniscus\": ([\"έσω μηνίσκος\"], [\"ρήξη\"]),\n        \"Lateral Meniscus\": ([\"έξω μηνίσκος\"], [\"ρήξη\"]),\n        \"Effusion\": ([\"συλλογή υγρού\", \"διόγκωση\"], [\"\"]),\n        \"Fracture\": ([\"κάταγμα\"], [\"\"]),\n        \"negation\": [\"φυσιολογικ\", \"ακέραι\", \"χωρίς\", \"δεν\"],\n    },\n    \"bg\": {  # Bulgarian\n        \"ACL\": ([\"предна кръстна връзка\", \"acl\"], [\"руптура\", \"скъсване\"]),\n        \"MCL\": ([\"медиален колатерален лигамент\", \"mcl\"], [\"руптура\", \"скъсване\", \"разтягане\"]),\n        \"Medial Meniscus\": ([\"медиален менискус\"], [\"руптура\", \"скъсване\"]),\n        \"Lateral Meniscus\": ([\"латерален менискус\"], [\"руптура\", \"скъсване\"]),\n        \"Effusion\": ([\"излив\"], [\"\"]),\n        \"Fracture\": ([\"фрактура\", \"счупване\"], [\"\"]),\n        \"negation\": [\"нормален\", \"запазен\", \"без\", \"не се\"],\n    },\n    \"nl\": {  # Dutch\n        \"ACL\": ([\"acl\", \"voorste kruisband\"], [\"ruptuur\", \"scheur\"]),\n        \"MCL\": ([\"mcl\", \"mediale collaterale band\"], [\"ruptuur\", \"scheur\", \"verstuiking\"]),\n        \"Medial Meniscus\": ([\"mediale meniscus\"], [\"ruptuur\", \"scheur\"]),\n        \"Lateral Meniscus\": ([\"laterale meniscus\"], [\"ruptuur\", \"scheur\"]),\n        \"Effusion\": ([\"effusie\", \"gewrichtsvocht\"], [\"\"]),\n        \"Fracture\": ([\"fractuur\", \"breuk\"], [\"\"]),\n        \"negation\": [\"normaal\", \"intact\", \"geen\", \"onopvallend\"],\n    },\n}\n\ndef label_report_lightweight(text, lang, window=60):\n    if not isinstance(text, str) or lang not in LANG_VOCAB:\n        return {}\n    text_lower = \" \" + text.lower() + \" \"\n    vocab = LANG_VOCAB[lang]\n    negation_terms = vocab[\"negation\"]\n    results = {}\n    for cond, value in vocab.items():\n        if cond == \"negation\":\n            continue\n        anatomy_terms, finding_terms = value\n        found_positive, found_negated = False, False\n        for a in anatomy_terms:\n            for m in re.finditer(re.escape(a), text_lower):\n                start = max(0, m.start() - window)\n                end = min(len(text_lower), m.end() + window)\n                ctx = text_lower[start:end]\n                has_finding = (finding_terms == [\"\"]) or any(f in ctx for f in finding_terms)\n                has_negation = any(n in ctx for n in negation_terms)\n                if has_finding and not has_negation:\n                    found_positive = True\n                elif has_negation:\n                    found_negated = True\n        if found_positive:\n            results[cond] = (1, 0.4)\n        elif found_negated:\n            results[cond] = (0, 0.35)\n        else:\n            results[cond] = (None, 0.0)\n    return results","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.752155Z","iopub.execute_input":"2026-09-15T03:30:49.75246Z","iopub.status.idle":"2026-09-15T03:30:49.838145Z","shell.execute_reply.started":"2026-09-15T03:30:49.752439Z","shell.execute_reply":"2026-09-15T03:30:49.837364Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"for lang in [\"tr\", \"hr\", \"de\", \"el\", \"bg\", \"nl\"]:\n    gold_lang = train[(train[\"lang\"] == lang) & (train[\"num_labels\"] == 12)]\n    if len(gold_lang) == 0:\n        continue\n    print(f\"\\n{lang}: {len(gold_lang)} gold examples\")\n    for _, row in gold_lang.head(2).iterrows():\n        preds = label_report_lightweight(row[\"Report\"], lang)\n        fires = {k: v for k, v in preds.items() if v[0] is not None}\n        print(f\"  fired on: {fires}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.839167Z","iopub.execute_input":"2026-09-15T03:30:49.839914Z","iopub.status.idle":"2026-09-15T03:30:49.865668Z","shell.execute_reply.started":"2026-09-15T03:30:49.839883Z","shell.execute_reply":"2026-09-15T03:30:49.865087Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# def get_pseudo_labels(row):\n#     \"\"\"Unified entry point: routes to the right labeler by language.\"\"\"\n#     lang = row[\"lang\"]\n#     text = row[\"Report\"]\n#     if lang == \"en\":\n#         return label_report_en_v5(text)\n#     elif lang == \"es\":\n#         return label_report_es_v1(text)\n#     elif lang in LANG_VOCAB:\n#         return label_report_lightweight(text, lang)\n#     else:\n#         return {}\n\n# # Apply across the FULL dataset (gold + unlabeled)\n# LABEL_COLS_ALL = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n#                    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n#                    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\n# LANG_VOCAB[\"fr\"] = {\n#     \"ACL\": ([\"lca\", \"ligament croisé antérieur\"], [\"rupture\", \"déchirure\"]),\n#     \"MCL\": ([\"lcm\", \"ligament collatéral médial\"], [\"rupture\", \"déchirure\", \"entorse\"]),\n#     \"Medial Meniscus\": ([\"ménisque médial\", \"ménisque interne\"], [\"rupture\", \"déchirure\"]),\n#     \"Lateral Meniscus\": ([\"ménisque latéral\", \"ménisque externe\"], [\"rupture\", \"déchirure\"]),\n#     \"Effusion\": ([\"épanchement\"], [\"\"]),\n#     \"Fracture\": ([\"fracture\"], [\"\"]),\n#     \"negation\": [\"normal\", \"intact\", \"sans\", \"absence de\"],\n# }\n\n# pseudo_rows = []\n# for idx, row in train.iterrows():\n#     preds = get_pseudo_labels(row)\n#     entry = {\"StudyInstanceUID\": row[\"StudyInstanceUID\"], \"lang\": row[\"lang\"],\n#               \"is_gold\": row[\"num_labels\"] == 12}\n#     for cond in LABEL_COLS_ALL:\n#         pred_label, conf = preds.get(cond, (None, 0.0))\n#         entry[f\"{cond}_pseudo\"] = pred_label\n#         entry[f\"{cond}_conf\"] = conf\n#         # if gold label exists, use it directly with full confidence,\n#         # overriding the pseudo-label\n#         if entry[\"is_gold\"] and pd.notna(row[cond]):\n#             entry[f\"{cond}_pseudo\"] = row[cond]\n#             entry[f\"{cond}_conf\"] = 1.0\n#     pseudo_rows.append(entry)\n\n# pseudo_df = pd.DataFrame(pseudo_rows)\n# pseudo_df.to_csv(\"/kaggle/working/pseudo_labels_v1.csv\", index=False)\n# print(pseudo_df.shape)\n# print(pseudo_df.head())\n\n# # Coverage summary: how many studies get a usable pseudo-label per condition\n# for cond in LABEL_COLS_ALL:\n#     n = pseudo_df[f\"{cond}_pseudo\"].notna().sum()\n#     print(f\"{cond:20s}: {n:5d} / {len(pseudo_df)} ({n/len(pseudo_df):.1%})\")\n    ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.866537Z","iopub.execute_input":"2026-09-15T03:30:49.866809Z","iopub.status.idle":"2026-09-15T03:30:49.871774Z","shell.execute_reply.started":"2026-09-15T03:30:49.866777Z","shell.execute_reply":"2026-09-15T03:30:49.871071Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pydicom\nimport numpy as np\nimport glob\nimport os\n\ndef load_series_volume(series_dir, target_size=224):\n    \"\"\"\n    Load all DICOM slices in a series directory into a single\n    normalized numpy volume of shape (num_slices, target_size, target_size).\n    Returns None if the series fails to load cleanly.\n    \"\"\"\n    dcm_files = sorted(glob.glob(os.path.join(series_dir, \"*.dcm\")))\n    if len(dcm_files) == 0:\n        return None\n\n    slices = []\n    positions = []\n    for f in dcm_files:\n        try:\n            ds = pydicom.dcmread(f)\n            img = ds.pixel_array.astype(np.float32)\n            # Some DICOMs need rescale slope/intercept applied\n            slope = float(getattr(ds, \"RescaleSlope\", 1.0))\n            intercept = float(getattr(ds, \"RescaleIntercept\", 0.0))\n            img = img * slope + intercept\n            slices.append(img)\n            # Use InstanceNumber or ImagePositionPatient for ordering if available\n            pos = getattr(ds, \"InstanceNumber\", len(slices))\n            positions.append(pos)\n        except Exception as e:\n            print(f\"  Failed to read {f}: {e}\")\n            continue\n\n    if len(slices) == 0:\n        return None\n\n    # Sort by acquisition order\n    order = np.argsort(positions)\n    slices = [slices[i] for i in order]\n\n    # Resize each slice to target_size x target_size\n    import cv2\n    resized = []\n    for img in slices:\n        # Per-slice min-max normalization to [0, 1]\n        img_min, img_max = img.min(), img.max()\n        if img_max > img_min:\n            img = (img - img_min) / (img_max - img_min)\n        else:\n            img = np.zeros_like(img)\n        img_resized = cv2.resize(img, (target_size, target_size), interpolation=cv2.INTER_LINEAR)\n        resized.append(img_resized)\n\n    return np.stack(resized, axis=0)  # (num_slices, H, W)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:49.87265Z","iopub.execute_input":"2026-09-15T03:30:49.872922Z","iopub.status.idle":"2026-09-15T03:30:50.452306Z","shell.execute_reply.started":"2026-09-15T03:30:49.872891Z","shell.execute_reply":"2026-09-15T03:30:50.451666Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import random\n\nSERIES_DIR = os.path.join(DATA_PATH, \"train_series\")\nsample_studies = train_series[\"StudyInstanceUID\"].unique()\nrandom.seed(0)\nsample_studies = random.sample(list(sample_studies), 15)\n\nresults = []\nfor study_id in sample_studies:\n    series_ids = train_series.loc[\n        train_series[\"StudyInstanceUID\"] == study_id, \"SeriesInstanceUID\"\n    ].tolist()\n    for series_id in series_ids:\n        series_path = os.path.join(SERIES_DIR, study_id, series_id)\n        try:\n            vol = load_series_volume(series_path)\n            if vol is None:\n                results.append({\"study\": study_id, \"series\": series_id, \"status\": \"empty\"})\n            else:\n                results.append({\n                    \"study\": study_id, \"series\": series_id, \"status\": \"ok\",\n                    \"n_slices\": vol.shape[0], \"shape\": vol.shape,\n                })\n        except Exception as e:\n            results.append({\"study\": study_id, \"series\": series_id, \"status\": f\"error: {e}\"})\n\nresults_df = pd.DataFrame(results)\nprint(results_df[\"status\"].value_counts())\nprint()\nprint(results_df[results_df[\"status\"] != \"ok\"])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:30:50.453138Z","iopub.execute_input":"2026-09-15T03:30:50.453377Z","iopub.status.idle":"2026-09-15T03:31:15.713204Z","shell.execute_reply.started":"2026-09-15T03:30:50.453356Z","shell.execute_reply":"2026-09-15T03:31:15.712367Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(results_df[\"n_slices\"].describe())\nprint()\nprint(results_df[\"shape\"].value_counts().head(10))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:15.714213Z","iopub.execute_input":"2026-09-15T03:31:15.714611Z","iopub.status.idle":"2026-09-15T03:31:15.723266Z","shell.execute_reply.started":"2026-09-15T03:31:15.714575Z","shell.execute_reply":"2026-09-15T03:31:15.722442Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# For the studies we just loaded, look at their series composition\nsample_series_meta = train_series[train_series[\"StudyInstanceUID\"].isin(sample_studies)]\nprint(sample_series_meta.groupby(\"StudyInstanceUID\").apply(\n    lambda g: g[[\"Anatomical_Plane\", \"Fluid_Sensitive\", \"Fat_Suppression\"]].to_dict(\"records\")\n))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:15.726959Z","iopub.execute_input":"2026-09-15T03:31:15.727272Z","iopub.status.idle":"2026-09-15T03:31:15.769373Z","shell.execute_reply.started":"2026-09-15T03:31:15.727249Z","shell.execute_reply":"2026-09-15T03:31:15.768524Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Fix display truncation + get a cleaner view\npd.set_option('display.max_colwidth', None)\n\nfor study_id in sample_studies[:5]:\n    sub = train_series[train_series[\"StudyInstanceUID\"] == study_id]\n    print(f\"\\n{study_id}\")\n    print(sub[[\"SeriesInstanceUID\", \"Anatomical_Plane\", \"Fluid_Sensitive\", \"Fat_Suppression\"]].to_string(index=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:15.770291Z","iopub.execute_input":"2026-09-15T03:31:15.770549Z","iopub.status.idle":"2026-09-15T03:31:15.795145Z","shell.execute_reply.started":"2026-09-15T03:31:15.770518Z","shell.execute_reply":"2026-09-15T03:31:15.79434Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def pick_best_series(study_id, series_df):\n    \"\"\"Preference order: Sagittal+FluidSensitive > Sagittal > any FluidSensitive > first available.\"\"\"\n    sub = series_df[series_df[\"StudyInstanceUID\"] == study_id]\n    candidates = sub[(sub[\"Anatomical_Plane\"] == \"Sagittal\") & (sub[\"Fluid_Sensitive\"] == 1)]\n    if len(candidates) > 0:\n        return candidates.iloc[0][\"SeriesInstanceUID\"], \"sag_fluid\"\n    candidates = sub[sub[\"Anatomical_Plane\"] == \"Sagittal\"]\n    if len(candidates) > 0:\n        return candidates.iloc[0][\"SeriesInstanceUID\"], \"sag_only\"\n    candidates = sub[sub[\"Fluid_Sensitive\"] == 1]\n    if len(candidates) > 0:\n        return candidates.iloc[0][\"SeriesInstanceUID\"], \"fluid_only\"\n    if len(sub) > 0:\n        return sub.iloc[0][\"SeriesInstanceUID\"], \"fallback\"\n    return None, \"missing\"\n\n# Check selection-rule coverage across ALL studies, not just the sample\nall_study_ids = train_series[\"StudyInstanceUID\"].unique()\nselection_results = []\nfor study_id in all_study_ids:\n    series_id, rule = pick_best_series(study_id, train_series)\n    selection_results.append({\"study\": study_id, \"series\": series_id, \"rule\": rule})\n\nsel_df = pd.DataFrame(selection_results)\nprint(sel_df[\"rule\"].value_counts())\nprint(f\"\\nMissing (no series at all): {(sel_df['rule'] == 'missing').sum()}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:15.796177Z","iopub.execute_input":"2026-09-15T03:31:15.796493Z","iopub.status.idle":"2026-09-15T03:31:27.220644Z","shell.execute_reply.started":"2026-09-15T03:31:15.796461Z","shell.execute_reply":"2026-09-15T03:31:27.219927Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def sample_or_pad_slices(volume, target_slices=32):\n    \"\"\"\n    volume: (N, H, W)\n    Returns (target_slices, H, W) via center-cropping (evenly spaced sampling)\n    if N > target, or zero-padding if N < target.\n    \"\"\"\n    n = volume.shape[0]\n    if n == target_slices:\n        return volume\n    elif n > target_slices:\n        # evenly spaced indices across the volume -- keeps overall anatomical\n        # coverage rather than just taking the first/middle N slices\n        indices = np.linspace(0, n - 1, target_slices).astype(int)\n        return volume[indices]\n    else:\n        pad_width = target_slices - n\n        pad_before = pad_width // 2\n        pad_after = pad_width - pad_before\n        return np.pad(volume, ((pad_before, pad_after), (0, 0), (0, 0)), mode=\"constant\")\n\ndef load_study_tensor(study_id, series_df, series_root, target_size=224, target_slices=32):\n    \"\"\"Full pipeline: pick best series -> load DICOMs -> fixed-length sample -> tensor.\"\"\"\n    series_id, rule = pick_best_series(study_id, series_df)\n    if series_id is None:\n        return None, rule\n    series_path = os.path.join(series_root, study_id, series_id)\n    volume = load_series_volume(series_path, target_size=target_size)\n    if volume is None:\n        return None, \"load_failed\"\n    volume = sample_or_pad_slices(volume, target_slices=target_slices)\n    return volume, rule","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:27.221846Z","iopub.execute_input":"2026-09-15T03:31:27.222159Z","iopub.status.idle":"2026-09-15T03:31:27.22925Z","shell.execute_reply.started":"2026-09-15T03:31:27.222126Z","shell.execute_reply":"2026-09-15T03:31:27.228449Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"tensor_results = []\nfor study_id in sample_studies:\n    vol, rule = load_study_tensor(study_id, train_series, SERIES_DIR)\n    if vol is not None:\n        tensor_results.append({\"study\": study_id, \"rule\": rule, \"shape\": vol.shape,\n                                \"min\": vol.min(), \"max\": vol.max(), \"mean\": vol.mean()})\n    else:\n        tensor_results.append({\"study\": study_id, \"rule\": rule, \"shape\": None})\n\ntensor_df = pd.DataFrame(tensor_results)\nprint(tensor_df)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:27.230269Z","iopub.execute_input":"2026-09-15T03:31:27.230563Z","iopub.status.idle":"2026-09-15T03:31:28.640756Z","shell.execute_reply.started":"2026-09-15T03:31:27.230532Z","shell.execute_reply":"2026-09-15T03:31:28.639916Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pipeline_source = \"\\n\\n\".join([\n    inspect.getsource(f) for f in [\n        load_series_volume, pick_best_series, sample_or_pad_slices, load_study_tensor\n    ]\n])\nwith open(\"/kaggle/working/image_pipeline_v1.py\", \"w\") as f:\n    f.write(pipeline_source)\nprint(\"Saved.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:28.6418Z","iopub.execute_input":"2026-09-15T03:31:28.642607Z","iopub.status.idle":"2026-09-15T03:31:28.648916Z","shell.execute_reply.started":"2026-09-15T03:31:28.64258Z","shell.execute_reply":"2026-09-15T03:31:28.648122Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nfrom torch.utils.data import Dataset, DataLoader\n\nLABEL_COLS_ALL = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n                   \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n                   \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\nclass KneeMRIDataset(Dataset):\n    def __init__(self, pseudo_df, series_df, series_root, target_size=224, target_slices=32):\n        # Drop studies with no usable pseudo-label on ANY condition -- pure noise\n        has_any_label = pseudo_df[[f\"{c}_pseudo\" for c in LABEL_COLS_ALL]].notna().any(axis=1)\n        self.df = pseudo_df[has_any_label].reset_index(drop=True)\n        self.series_df = series_df\n        self.series_root = series_root\n        self.target_size = target_size\n        self.target_slices = target_slices\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        vol, rule = load_study_tensor(\n            study_id, self.series_df, self.series_root,\n            target_size=self.target_size, target_slices=self.target_slices,\n        )\n        if vol is None:\n            # fallback: return a zero volume with zero confidence labels,\n            # so a bad study doesn't crash the whole batch\n            vol = np.zeros((self.target_slices, self.target_size, self.target_size), dtype=np.float32)\n\n        labels = np.array([row[f\"{c}_pseudo\"] if pd.notna(row[f\"{c}_pseudo\"]) else 0.0\n                            for c in LABEL_COLS_ALL], dtype=np.float32)\n        conf = np.array([row[f\"{c}_conf\"] for c in LABEL_COLS_ALL], dtype=np.float32)\n        mask = (conf > 0).astype(np.float32)  # which labels are usable in the loss\n\n        return {\n            \"volume\": torch.from_numpy(vol).unsqueeze(0),  # (1, D, H, W) -- channel dim\n            \"labels\": torch.from_numpy(labels),\n            \"conf\": torch.from_numpy(conf),\n            \"mask\": torch.from_numpy(mask),\n            \"study_id\": study_id,\n        }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:28.650018Z","iopub.execute_input":"2026-09-15T03:31:28.650455Z","iopub.status.idle":"2026-09-15T03:31:33.471724Z","shell.execute_reply.started":"2026-09-15T03:31:28.650419Z","shell.execute_reply":"2026-09-15T03:31:33.471082Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pseudo_df = pd.read_csv(\"/kaggle/working/pseudo_labels_v1.csv\")\ndataset = KneeMRIDataset(pseudo_df, train_series, SERIES_DIR)\nprint(f\"Dataset size: {len(dataset)}\")\n\nsample = dataset[0]\nfor k, v in sample.items():\n    if isinstance(v, torch.Tensor):\n        print(f\"{k}: {v.shape} {v.dtype}\")\n    else:\n        print(f\"{k}: {v}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:33.472662Z","iopub.execute_input":"2026-09-15T03:31:33.473059Z","iopub.status.idle":"2026-09-15T03:31:34.085091Z","shell.execute_reply.started":"2026-09-15T03:31:33.472999Z","shell.execute_reply":"2026-09-15T03:31:34.084404Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"loader = DataLoader(dataset, batch_size=4, shuffle=True, num_workers=2)\nbatch = next(iter(loader))\nfor k, v in batch.items():\n    if isinstance(v, torch.Tensor):\n        print(f\"{k}: {v.shape}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:34.086095Z","iopub.execute_input":"2026-09-15T03:31:34.086356Z","iopub.status.idle":"2026-09-15T03:31:38.436936Z","shell.execute_reply.started":"2026-09-15T03:31:34.086333Z","shell.execute_reply":"2026-09-15T03:31:38.43623Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile /kaggle/working/dataset_v1.py\nimport torch\nimport numpy as np\nimport pandas as pd\nfrom torch.utils.data import Dataset\n\nLABEL_COLS_ALL = [\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n                   \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n                   \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"]\n\nclass KneeMRIDataset(Dataset):\n    def __init__(self, pseudo_df, series_df, series_root, target_size=224, target_slices=32):\n        has_any_label = pseudo_df[[f\"{c}_pseudo\" for c in LABEL_COLS_ALL]].notna().any(axis=1)\n        self.df = pseudo_df[has_any_label].reset_index(drop=True)\n        self.series_df = series_df\n        self.series_root = series_root\n        self.target_size = target_size\n        self.target_slices = target_slices\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        vol, rule = load_study_tensor(\n            study_id, self.series_df, self.series_root,\n            target_size=self.target_size, target_slices=self.target_slices,\n        )\n        if vol is None:\n            vol = np.zeros((self.target_slices, self.target_size, self.target_size), dtype=np.float32)\n\n        labels = np.array([row[f\"{c}_pseudo\"] if pd.notna(row[f\"{c}_pseudo\"]) else 0.0\n                            for c in LABEL_COLS_ALL], dtype=np.float32)\n        conf = np.array([row[f\"{c}_conf\"] for c in LABEL_COLS_ALL], dtype=np.float32)\n        mask = (conf > 0).astype(np.float32)\n\n        return {\n            \"volume\": torch.from_numpy(vol).unsqueeze(0),\n            \"labels\": torch.from_numpy(labels),\n            \"conf\": torch.from_numpy(conf),\n            \"mask\": torch.from_numpy(mask),\n            \"study_id\": study_id,\n        }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:38.438477Z","iopub.execute_input":"2026-09-15T03:31:38.439116Z","iopub.status.idle":"2026-09-15T03:31:38.445853Z","shell.execute_reply.started":"2026-09-15T03:31:38.439068Z","shell.execute_reply":"2026-09-15T03:31:38.444918Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nfor item in os.listdir(\"/kaggle/input\"):\n    print(item)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:38.446864Z","iopub.execute_input":"2026-09-15T03:31:38.447212Z","iopub.status.idle":"2026-09-15T03:31:38.460477Z","shell.execute_reply.started":"2026-09-15T03:31:38.447178Z","shell.execute_reply":"2026-09-15T03:31:38.459723Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"dino_root = \"/kaggle/input\"\nfor root, dirs, files in os.walk(dino_root):\n    if \"dinov2\" in root.lower():\n        for f in files:\n            print(os.path.join(root, f))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:31:38.461513Z","iopub.execute_input":"2026-09-15T03:31:38.462022Z","iopub.status.idle":"2026-09-15T03:36:35.881398Z","shell.execute_reply.started":"2026-09-15T03:31:38.461982Z","shell.execute_reply":"2026-09-15T03:36:35.880728Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\nimport os\nos.environ[\"HF_HUB_OFFLINE\"] = \"1\"\nos.environ[\"TRANSFORMERS_OFFLINE\"] = \"1\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:35.882555Z","iopub.execute_input":"2026-09-15T03:36:35.882858Z","iopub.status.idle":"2026-09-15T03:36:35.887207Z","shell.execute_reply.started":"2026-09-15T03:36:35.882823Z","shell.execute_reply":"2026-09-15T03:36:35.886439Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from transformers import AutoModel, AutoConfig\n\nDINO_PATH = \"/kaggle/input/models/metaresearch/dinov2/pytorch/small/1\"\n\ndino_config = AutoConfig.from_pretrained(DINO_PATH)\nprint(dino_config)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:35.888006Z","iopub.execute_input":"2026-09-15T03:36:35.888381Z","iopub.status.idle":"2026-09-15T03:36:52.948888Z","shell.execute_reply.started":"2026-09-15T03:36:35.888357Z","shell.execute_reply":"2026-09-15T03:36:52.94827Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"dino_backbone = AutoModel.from_pretrained(DINO_PATH)\ndino_backbone.eval()\nprint(dino_backbone.config.hidden_size)\n\n# Quick smoke test: does it produce features on a dummy batch?\nimport torch\nwith torch.no_grad():\n    dummy = torch.randn(2, 3, 224, 224)\n    out = dino_backbone(dummy)\n    print(type(out))\n    print(out.last_hidden_state.shape)       # (B, num_patches+1, hidden_size)\n    print(out.pooler_output.shape if hasattr(out, \"pooler_output\") and out.pooler_output is not None else \"no pooler_output\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:52.949951Z","iopub.execute_input":"2026-09-15T03:36:52.950438Z","iopub.status.idle":"2026-09-15T03:36:56.822439Z","shell.execute_reply.started":"2026-09-15T03:36:52.950411Z","shell.execute_reply":"2026-09-15T03:36:56.821477Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class DinoV2FeatureExtractor(torch.nn.Module):\n    def __init__(self, dino_model):\n        super().__init__()\n        self.dino = dino_model\n\n    def forward(self, x):\n        out = self.dino(x)\n        cls_token = out.last_hidden_state[:, 0, :]  # (B, hidden_size)\n        return cls_token","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:56.823339Z","iopub.execute_input":"2026-09-15T03:36:56.823586Z","iopub.status.idle":"2026-09-15T03:36:56.829515Z","shell.execute_reply.started":"2026-09-15T03:36:56.823561Z","shell.execute_reply":"2026-09-15T03:36:56.828751Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\nimport torch.nn.functional as F\n\nclass Slice25DModel(nn.Module):\n    def __init__(self, num_classes=12, backbone_dim=384, pretrained_backbone=None, freeze_backbone=True):\n        super().__init__()\n        self.backbone = pretrained_backbone\n        self.freeze_backbone = freeze_backbone\n        if freeze_backbone and self.backbone is not None:\n            for p in self.backbone.parameters():\n                p.requires_grad = False\n\n        self.attn = nn.Sequential(\n            nn.Linear(backbone_dim, 128),\n            nn.Tanh(),\n            nn.Linear(128, 1),\n        )\n        self.classifier = nn.Sequential(\n            nn.LayerNorm(backbone_dim),\n            nn.Dropout(0.3),\n            nn.Linear(backbone_dim, num_classes),\n        )\n\n    def forward(self, x):\n        # x: (B, 1, D, H, W)\n        B, C, D, H, W = x.shape\n        x = x.view(B * D, 1, H, W).repeat(1, 3, 1, 1)  # fake RGB for pretrained backbone\n        feats = self.backbone(x)  # (B*D, backbone_dim)\n        feats = feats.view(B, D, -1)  # (B, D, backbone_dim)\n\n        attn_scores = self.attn(feats)  # (B, D, 1)\n        attn_weights = F.softmax(attn_scores, dim=1)\n        pooled = (feats * attn_weights).sum(dim=1)  # (B, backbone_dim)\n\n        logits = self.classifier(pooled)  # (B, num_classes)\n        return logits, attn_weights.squeeze(-1)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:56.830415Z","iopub.execute_input":"2026-09-15T03:36:56.830636Z","iopub.status.idle":"2026-09-15T03:36:56.847418Z","shell.execute_reply.started":"2026-09-15T03:36:56.830614Z","shell.execute_reply":"2026-09-15T03:36:56.846625Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Instantiate the full model\nbackbone = DinoV2FeatureExtractor(dino_backbone)\n\nmodel = Slice25DModel(\n    num_classes=12,\n    backbone_dim=384,\n    pretrained_backbone=backbone,\n    freeze_backbone=True,  # start frozen -- much faster, less GPU memory, good baseline\n)\n\ndevice = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(f\"Using device: {device}\")\nmodel = model.to(device)\nmodel.eval()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:56.848603Z","iopub.execute_input":"2026-09-15T03:36:56.848913Z","iopub.status.idle":"2026-09-15T03:36:57.23551Z","shell.execute_reply.started":"2026-09-15T03:36:56.848881Z","shell.execute_reply":"2026-09-15T03:36:57.234702Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Full pipeline smoke test using a REAL batch from your dataset/loader\nloader = DataLoader(dataset, batch_size=2, shuffle=True, num_workers=2)\nbatch = next(iter(loader))\n\nvolumes = batch[\"volume\"].to(device)  # (B, 1, D, H, W)\nlabels = batch[\"labels\"].to(device)\nmask = batch[\"mask\"].to(device)\n\nwith torch.no_grad():\n    logits, attn_weights = model(volumes)\n\nprint(\"logits shape:\", logits.shape)          # expect (B, 12)\nprint(\"attn_weights shape:\", attn_weights.shape)  # expect (B, 32)\nprint(\"logits sample:\", logits[0])\nprint(\"attn sum per study (should be ~1.0):\", attn_weights.sum(dim=1))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:36:57.236539Z","iopub.execute_input":"2026-09-15T03:36:57.236869Z","iopub.status.idle":"2026-09-15T03:37:00.42177Z","shell.execute_reply.started":"2026-09-15T03:36:57.236844Z","shell.execute_reply":"2026-09-15T03:37:00.420855Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from torch.utils.data import Subset\nimport numpy as np\n\n# Take a small slice of the dataset for a fast test run.\n# We shuffle first so it's not biased toward one language/study type.\nnp.random.seed(0)\nall_indices = np.random.permutation(len(dataset))\n\ntrain_indices = all_indices[:150]   # small training set\nval_indices = all_indices[150:200]  # small validation set (model never trains on these)\n\ntrain_subset = Subset(dataset, train_indices)\nval_subset = Subset(dataset, val_indices)\n\ntrain_loader = DataLoader(train_subset, batch_size=4, shuffle=True, num_workers=2)\nval_loader = DataLoader(val_subset, batch_size=4, shuffle=False, num_workers=2)\n\nprint(f\"Train studies: {len(train_subset)}, Val studies: {len(val_subset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.423307Z","iopub.execute_input":"2026-09-15T03:37:00.423565Z","iopub.status.idle":"2026-09-15T03:37:00.430356Z","shell.execute_reply.started":"2026-09-15T03:37:00.423538Z","shell.execute_reply":"2026-09-15T03:37:00.429631Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\n\ndef masked_confidence_weighted_loss(logits, labels, mask, conf):\n    \"\"\"\n    logits: raw model output, shape (B, 12) -- not yet 0-1 probabilities\n    labels: pseudo-labels (0 or 1), shape (B, 12)\n    mask:   1 if this label should count, 0 if it should be ignored, shape (B, 12)\n    conf:   confidence weight (0 to 1) per label, shape (B, 12)\n    \"\"\"\n    # binary_cross_entropy_with_logits computes \"how wrong\" per single number,\n    # without averaging yet -- we need per-element values so we can weight them\n    per_element_loss = F.binary_cross_entropy_with_logits(logits, labels, reduction=\"none\")\n\n    weights = mask * conf  # zero out missing labels, scale down low-confidence ones\n    weighted_loss = per_element_loss * weights\n\n    # average only over the labels that actually counted (avoid dividing by\n    # a huge number of zeros, which would make the loss artificially tiny)\n    total_weight = weights.sum()\n    if total_weight == 0:\n        return torch.tensor(0.0, device=logits.device, requires_grad=True)\n    return weighted_loss.sum() / total_weight","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.431311Z","iopub.execute_input":"2026-09-15T03:37:00.431598Z","iopub.status.idle":"2026-09-15T03:37:00.444099Z","shell.execute_reply.started":"2026-09-15T03:37:00.431564Z","shell.execute_reply":"2026-09-15T03:37:00.443431Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Only train the parts that AREN'T the frozen DINOv2 backbone --\n# # since freeze_backbone=True, those weights won't change anyway,\n# # but this makes it explicit and slightly faster.\n# trainable_params = [p for p in model.parameters() if p.requires_grad]\n# optimizer = torch.optim.Adam(trainable_params, lr=1e-4)\n\n# model.train()  # tells the model \"we're learning now\" (affects dropout layers)\n\n# num_epochs = 3  # just a few passes, since this is a small test run\n\n# for epoch in range(num_epochs):\n#     epoch_losses = []\n#     for batch in train_loader:\n#         volumes = batch[\"volume\"].to(device)\n#         labels = batch[\"labels\"].to(device)\n#         mask = batch[\"mask\"].to(device)\n#         conf = batch[\"conf\"].to(device)\n\n#         optimizer.zero_grad()          # clear old adjustment signals\n#         logits, _ = model(volumes)     # run the model forward\n#         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#         loss.backward()                # figure out which direction to adjust weights\n#         optimizer.step()               # actually apply the adjustment\n\n#         epoch_losses.append(loss.item())\n\n#     avg_loss = sum(epoch_losses) / len(epoch_losses)\n#     print(f\"Epoch {epoch+1}/{num_epochs} -- train loss: {avg_loss:.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.44503Z","iopub.execute_input":"2026-09-15T03:37:00.445393Z","iopub.status.idle":"2026-09-15T03:37:00.458609Z","shell.execute_reply.started":"2026-09-15T03:37:00.44537Z","shell.execute_reply":"2026-09-15T03:37:00.458092Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# model.eval()  # tells the model \"we're just checking, not learning\" (affects dropout)\n\n# val_losses = []\n# with torch.no_grad():  # don't bother computing adjustment directions, we're not training\n#     for batch in val_loader:\n#         volumes = batch[\"volume\"].to(device)\n#         labels = batch[\"labels\"].to(device)\n#         mask = batch[\"mask\"].to(device)\n#         conf = batch[\"conf\"].to(device)\n\n#         logits, _ = model(volumes)\n#         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#         val_losses.append(loss.item())\n\n# avg_val_loss = sum(val_losses) / len(val_losses)\n# print(f\"Validation loss: {avg_val_loss:.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.459486Z","iopub.execute_input":"2026-09-15T03:37:00.459823Z","iopub.status.idle":"2026-09-15T03:37:00.47274Z","shell.execute_reply.started":"2026-09-15T03:37:00.459799Z","shell.execute_reply":"2026-09-15T03:37:00.4721Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import time\n\n# # Use a bigger sample than before, but still not the full dataset --\n# # enough to get a reliable time-per-batch measurement without waiting forever\n# np.random.seed(1)\n# timing_indices = np.random.permutation(len(dataset))[:400]\n# timing_subset = Subset(dataset, timing_indices)\n# timing_loader = DataLoader(timing_subset, batch_size=4, shuffle=False, num_workers=2)\n\n# model.train()\n# trainable_params = [p for p in model.parameters() if p.requires_grad]\n# optimizer = torch.optim.Adam(trainable_params, lr=1e-4)\n\n# start_time = time.time()\n# n_batches = 0\n\n# for batch in timing_loader:\n#     volumes = batch[\"volume\"].to(device)\n#     labels = batch[\"labels\"].to(device)\n#     mask = batch[\"mask\"].to(device)\n#     conf = batch[\"conf\"].to(device)\n\n#     optimizer.zero_grad()\n#     logits, _ = model(volumes)\n#     loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#     loss.backward()\n#     optimizer.step()\n\n#     n_batches += 1\n\n# elapsed = time.time() - start_time\n# studies_processed = n_batches * timing_loader.batch_size\n\n# print(f\"Processed {studies_processed} studies in {elapsed:.1f} seconds\")\n# print(f\"Time per study: {elapsed / studies_processed:.3f} seconds\")\n\n# # Extrapolate to your real dataset size\n# full_dataset_size = len(dataset)  # ~4056 from earlier\n# est_epoch_time_sec = (elapsed / studies_processed) * full_dataset_size\n# est_epoch_time_min = est_epoch_time_sec / 60\n\n# print(f\"\\nEstimated time for ONE full epoch ({full_dataset_size} studies): \"\n#       f\"{est_epoch_time_min:.1f} minutes\")\n\n# budget_minutes = 9 * 60  # your 9-hour submission cap, in minutes\n# # Leave headroom for data loading overhead, validation, and inference on\n# # ~1300 test studies at submission time -- don't plan to use the full 9 hours\n# # purely on training\n# safe_training_budget = budget_minutes * 0.6\n# max_epochs = safe_training_budget / est_epoch_time_min\n\n# print(f\"Rough estimate of max epochs within a safe training budget: {max_epochs:.1f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.473681Z","iopub.execute_input":"2026-09-15T03:37:00.473967Z","iopub.status.idle":"2026-09-15T03:37:00.486935Z","shell.execute_reply.started":"2026-09-15T03:37:00.473935Z","shell.execute_reply":"2026-09-15T03:37:00.486243Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# np.random.seed(0)\n# all_indices = np.random.permutation(len(dataset))\n\n# # Roughly 85/15 train/val split across your FULL usable dataset now,\n# # not just the earlier small test subset\n# split_point = int(len(dataset) * 0.85)\n# train_indices = all_indices[:split_point]\n# val_indices = all_indices[split_point:]\n\n# train_subset = Subset(dataset, train_indices)\n# val_subset = Subset(dataset, val_indices)\n\n# train_loader = DataLoader(train_subset, batch_size=4, shuffle=True, num_workers=2)\n# val_loader = DataLoader(val_subset, batch_size=4, shuffle=False, num_workers=2)\n\n# print(f\"Train studies: {len(train_subset)}, Val studies: {len(val_subset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.493012Z","iopub.execute_input":"2026-09-15T03:37:00.49332Z","iopub.status.idle":"2026-09-15T03:37:00.502434Z","shell.execute_reply.started":"2026-09-15T03:37:00.493296Z","shell.execute_reply":"2026-09-15T03:37:00.501836Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import roc_auc_score\nimport numpy as np\n\ndef evaluate_auc(model, loader, device, label_names):\n    \"\"\"\n    Runs the model on a loader (no training, just prediction) and computes\n    AUC per condition, using only the samples that actually have a usable\n    label for that condition (mask == 1).\n    \"\"\"\n    model.eval()\n    all_logits = []\n    all_labels = []\n    all_masks = []\n\n    with torch.no_grad():\n        for batch in loader:\n            volumes = batch[\"volume\"].to(device)\n            logits, _ = model(volumes)\n            all_logits.append(logits.cpu().numpy())\n            all_labels.append(batch[\"labels\"].numpy())\n            all_masks.append(batch[\"mask\"].numpy())\n\n    all_logits = np.concatenate(all_logits, axis=0)\n    all_labels = np.concatenate(all_labels, axis=0)\n    all_masks = np.concatenate(all_masks, axis=0)\n\n    probs = 1 / (1 + np.exp(-all_logits))  # convert raw logits to 0-1 probabilities\n\n    auc_scores = {}\n    for i, name in enumerate(label_names):\n        valid = all_masks[:, i] == 1\n        y_true = all_labels[valid, i]\n        y_pred = probs[valid, i]\n        # AUC needs both classes present, and a reasonable number of samples\n        if len(np.unique(y_true)) < 2 or valid.sum() < 5:\n            auc_scores[name] = None  # not enough data to compute a meaningful AUC\n        else:\n            auc_scores[name] = roc_auc_score(y_true, y_pred)\n\n    return auc_scores","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.503277Z","iopub.execute_input":"2026-09-15T03:37:00.503491Z","iopub.status.idle":"2026-09-15T03:37:00.517831Z","shell.execute_reply.started":"2026-09-15T03:37:00.503458Z","shell.execute_reply":"2026-09-15T03:37:00.517017Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# model.train()\n# trainable_params = [p for p in model.parameters() if p.requires_grad]\n# optimizer = torch.optim.Adam(trainable_params, lr=1e-4)\n\n# num_epochs = 16  \n# best_val_loss = float(\"inf\")\n\n# for epoch in range(num_epochs):\n#     # ---- Training pass ----\n#     model.train()\n#     train_losses = []\n#     for batch in train_loader:\n#         volumes = batch[\"volume\"].to(device)\n#         labels = batch[\"labels\"].to(device)\n#         mask = batch[\"mask\"].to(device)\n#         conf = batch[\"conf\"].to(device)\n\n#         optimizer.zero_grad()\n#         logits, _ = model(volumes)\n#         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#         loss.backward()\n#         optimizer.step()\n#         train_losses.append(loss.item())\n\n#     avg_train_loss = sum(train_losses) / len(train_losses)\n\n#     # ---- Validation pass ----\n#     model.eval()\n#     val_losses = []\n#     with torch.no_grad():\n#         for batch in val_loader:\n#             volumes = batch[\"volume\"].to(device)\n#             labels = batch[\"labels\"].to(device)\n#          model.train()\n# trainable_params = [p for p in model.parameters() if p.requires_grad]\n# optimizer = torch.optim.Adam(trainable_params, lr=1e-4)\n\n# num_epochs = 16  \n# best_val_loss = float(\"inf\")\n\n# for epoch in range(num_epochs):\n#     # ---- Training pass ----\n#     model.train()\n#     train_losses = []\n#     for batch in train_loader:\n#         volumes = batch[\"volume\"].to(device)\n#         labels = batch[\"labels\"].to(device)\n#         mask = batch[\"mask\"].to(device)\n#         conf = batch[\"conf\"].to(device)\n\n#         optimizer.zero_grad()\n#         logits, _ = model(volumes)\n#         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#         loss.backward()\n#         optimizer.step()\n#         train_losses.append(loss.item())\n\n#     avg_train_loss = sum(train_losses) / len(train_losses)\n\n    # # ---- Validation pass ----\n    # model.eval()\n    # val_losses = []\n    # with torch.no_grad():\n    #     for batch in val_loader:\n    #         volumes = batch[\"v   mask = batch[\"mask\"].to(device)\n    #         conf = batch[\"conf\"].to(device)\n    #         logits, _ = model(volumes)\n    #         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n    #         val_losses.append(loss.item())\n    # avg_val_loss = sum(val_losses) / len(val_losses)\n\n    # auc_scores = evaluate_auc(model, val_loader, device, LABEL_COLS_ALL)\n    # valid_aucs = [v for v in auc_scores.values() if v is not None]\n    # mean_auc = sum(valid_aucs) / len(valid_aucs) if valid_aucs else float(\"nan\")\n\n    # print(f\"\\nEpoch {epoch+1}/{num_epochs} -- train loss: {avg_train_loss:.4f} | \"\n    #       f\"val loss: {avg_val_loss:.4f} | mean val AUC: {mean_auc:.4f}\")\n    # for name, score in auc_scores.items():\n    #     score_str = f\"{score:.3f}\" if score is not None else \"n/a\"\n    #     print(f\"    {name:20s}: {score_str}\")\n\n    # if avg_val_loss < best_val_loss:\n    #     best_val_loss = avg_val_loss\n    #     torch.save(model.state_dict(), \"/kaggle/working/best_model.pt\")\n    #     print(\"    -> saved new best model\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.518788Z","iopub.execute_input":"2026-09-15T03:37:00.519169Z","iopub.status.idle":"2026-09-15T03:37:00.531789Z","shell.execute_reply.started":"2026-09-15T03:37:00.519138Z","shell.execute_reply":"2026-09-15T03:37:00.531229Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Step 1: rebuild the model architecture and load\n# # the trained weights into it. We need to rebuild the architecture first,\n# # saved weights are just numbers, not the model's structure.\n\n# dino_config = AutoConfig.from_pretrained(DINO_PATH)\n# dino_backbone_for_inference = AutoModel.from_pretrained(DINO_PATH)\n# backbone_for_inference = DinoV2FeatureExtractor(dino_backbone_for_inference)\n\n# inference_model = Slice25DModel(\n#     num_classes=12,\n#     backbone_dim=384,\n#     pretrained_backbone=backbone_for_inference,\n#     freeze_backbone=True,\n# )\n# inference_model.load_state_dict(torch.load(\"/kaggle/working/best_model.pt\", map_location=device))\n# inference_model = inference_model.to(device)\n# inference_model.eval() \n# print(\"Model loaded successfully\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.532575Z","iopub.execute_input":"2026-09-15T03:37:00.532916Z","iopub.status.idle":"2026-09-15T03:37:00.548094Z","shell.execute_reply.started":"2026-09-15T03:37:00.532893Z","shell.execute_reply":"2026-09-15T03:37:00.547395Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Step 2: load the test data. Note test_series.csv/test.csv currently\n# # contain only 3 EXAMPLE studies at actual scoring time.\n\n#test = pd.read_csv(DATA_PATH / \"test.csv\")\n#test_series = pd.read_csv(DATA_PATH / \"test_series.csv\")\n#TEST_SERIES_DIR = os.path.join(DATA_PATH, \"test_series\")\n\n#print(f\"Test studies: {len(test)}\")\n#print(test.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.549369Z","iopub.execute_input":"2026-09-15T03:37:00.550001Z","iopub.status.idle":"2026-09-15T03:37:00.559235Z","shell.execute_reply.started":"2026-09-15T03:37:00.549966Z","shell.execute_reply":"2026-09-15T03:37:00.558333Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Step 3: run inference on every test study, one at a time, using the exact\n# # same series-selection + loading pipeline we already validated during training.\n\n# predictions = []\n\n# inference_model.eval()\n# with torch.no_grad():\n#     for study_id in test[\"StudyInstanceUID\"]:\n#         vol, rule = load_study_tensor(study_id, test_series, TEST_SERIES_DIR)\n\n#         if vol is None:\n#             probs = np.full(12, 0.5, dtype=np.float32)\n#         else:\n#             volume_tensor = torch.from_numpy(vol).unsqueeze(0).unsqueeze(0).to(device)  # (1, 1, D, H, W)\n#             logits, _ = inference_model(volume_tensor)\n#             probs = torch.sigmoid(logits).cpu().numpy()[0]  # convert to 0-1 probabilities\n\n#         row = {\"StudyInstanceUID\": study_id}\n#         for i, cond in enumerate(LABEL_COLS_ALL):\n#             row[cond] = probs[i]\n#         predictions.append(row)\n\n# pred_df = pd.DataFrame(predictions)\n# print(pred_df.shape)\n# # pred_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.560257Z","iopub.execute_input":"2026-09-15T03:37:00.560572Z","iopub.status.idle":"2026-09-15T03:37:00.573929Z","shell.execute_reply.started":"2026-09-15T03:37:00.560548Z","shell.execute_reply":"2026-09-15T03:37:00.573333Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Step 4: match the EXACT column order and naming Kaggle expects,\n# # check against sample_submission.csv rather than assuming\n\n# sample_sub = pd.read_csv(DATA_PATH / \"sample_submission.csv\")\n# print(\"Expected columns:\", sample_sub.columns.tolist())\n# print(\"Our columns:      \", pred_df.columns.tolist())\n\n# # Reorder our columns to match exactly, just in case\n# pred_df = pred_df[sample_sub.columns.tolist()]\n\n# pred_df.to_csv(\"/kaggle/working/submission.csv\", index=False)\n# print(\"Saved submission.csv\")\n# pred_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.575061Z","iopub.execute_input":"2026-09-15T03:37:00.575419Z","iopub.status.idle":"2026-09-15T03:37:00.584687Z","shell.execute_reply.started":"2026-09-15T03:37:00.575387Z","shell.execute_reply":"2026-09-15T03:37:00.584093Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Look at the actual language used around \"synovitis\" mentions across\n# # ALL English reports (not just gold), to see real phrasing variety\n# import re\n\n# def show_context(text, keywords, window=100):\n#     text_lower = text.lower()\n#     for kw in keywords:\n#         for m in re.finditer(re.escape(kw.lower()), text_lower):\n#             start = max(0, m.start() - window)\n#             end = min(len(text), m.end() + window)\n#             print(f\"[{kw}] ...{text[start:end]}...\")\n#             print()\n\n# en_reports = train[train[\"lang\"] == \"en\"][\"Report\"].dropna()\n# sample = en_reports.sample(15, random_state=1)\n# for r in sample:\n#     if \"synov\" in r.lower():\n#         show_context(r, [\"synov\"], window=80)\n#         print(\"=\"*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.586489Z","iopub.execute_input":"2026-09-15T03:37:00.587Z","iopub.status.idle":"2026-09-15T03:37:00.597286Z","shell.execute_reply.started":"2026-09-15T03:37:00.586975Z","shell.execute_reply":"2026-09-15T03:37:00.596627Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# en_gold_check = train[(train[\"lang\"] == \"en\") & (train[\"num_labels\"] == 12)]\n# for cond_val in [1, 0]:\n#     print(f\"\\n{'='*20} Contusion = {cond_val} {'='*20}\")\n#     subset = en_gold_check[en_gold_check[\"Contusion\"] == cond_val][\"Report\"]\n#     for r in subset.head(3):\n#         show_context(r, [\"contusion\", \"bone bruise\", \"osseous contusion\"], window=80)\n#         print(\"---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.598321Z","iopub.execute_input":"2026-09-15T03:37:00.598625Z","iopub.status.idle":"2026-09-15T03:37:00.611703Z","shell.execute_reply.started":"2026-09-15T03:37:00.598588Z","shell.execute_reply":"2026-09-15T03:37:00.610876Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# pf_oa_check = en_gold_check[en_gold_check[\"PF OA\"] == 1][\"Report\"]\n# print(f\"PF OA = 1 gold count: {len(pf_oa_check)}\")\n# for r in pf_oa_check:\n#     show_context(r, [\"patellofemoral\", \"patellar facet\", \"trochlea\"], window=100)\n#     print(\"=\"*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.612862Z","iopub.execute_input":"2026-09-15T03:37:00.613182Z","iopub.status.idle":"2026-09-15T03:37:00.622888Z","shell.execute_reply.started":"2026-09-15T03:37:00.613151Z","shell.execute_reply":"2026-09-15T03:37:00.622139Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# lang_lookup_check = pd.read_csv(\n#     \"/kaggle/input/datasets/adityazhivam/rsna-knee-pseudo-labels-v1/pseudo_labels_v1.csv\"\n# )\n# print(lang_lookup_check[\"Synovitis_pseudo\"].notna().sum())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.623853Z","iopub.execute_input":"2026-09-15T03:37:00.624129Z","iopub.status.idle":"2026-09-15T03:37:00.635589Z","shell.execute_reply.started":"2026-09-15T03:37:00.624108Z","shell.execute_reply":"2026-09-15T03:37:00.634874Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# def unfreeze_last_n_layers(dino_model, n_layers=4):\n#     \"\"\"\n#     Unfreeze only the last n_layers of DINOv2's 12 transformer layers,\n#     plus the final layernorm. Everything before that stays frozen.\n#     \"\"\"\n#     # First, freeze everything\n#     for param in dino_model.parameters():\n#         param.requires_grad = False\n\n#     # Then selectively unfreeze the last n encoder layers\n#     total_layers = len(dino_model.encoder.layer)\n#     for i, layer in enumerate(dino_model.encoder.layer):\n#         if i >= total_layers - n_layers:\n#             for param in layer.parameters():\n#                 param.requires_grad = True\n\n#     # Also unfreeze the final layernorm, since it directly feeds the CLS token we use\n#     for param in dino_model.layernorm.parameters():\n#         param.requires_grad = True\n\n#     # Report what's trainable now\n#     trainable = sum(p.numel() for p in dino_model.parameters() if p.requires_grad)\n#     total = sum(p.numel() for p in dino_model.parameters())\n#     print(f\"Backbone trainable params: {trainable:,} / {total:,} ({trainable/total:.1%})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.636472Z","iopub.execute_input":"2026-09-15T03:37:00.636804Z","iopub.status.idle":"2026-09-15T03:37:00.647847Z","shell.execute_reply.started":"2026-09-15T03:37:00.636767Z","shell.execute_reply":"2026-09-15T03:37:00.647236Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Rebuild backbone and model with partial unfreezing instead of fully frozen\n# backbone_partial = DinoV2FeatureExtractor(dino_backbone)\n# unfreeze_last_n_layers(backbone_partial.dino, n_layers=4)\n\n# model = Slice25DModel(\n#     num_classes=12,\n#     backbone_dim=384,\n#     pretrained_backbone=backbone_partial,\n#     freeze_backbone=False,  # important: don't let Slice25DModel re-freeze everything\n# )\n# model = model.to(device)\n# print(\"Model rebuilt with partial backbone unfreezing\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.6494Z","iopub.execute_input":"2026-09-15T03:37:00.64974Z","iopub.status.idle":"2026-09-15T03:37:00.662951Z","shell.execute_reply.started":"2026-09-15T03:37:00.649715Z","shell.execute_reply":"2026-09-15T03:37:00.6624Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Separate parameter groups: backbone gets a much smaller learning rate,\n# # the attention + classifier head gets the normal (larger) one\n# backbone_params = [p for p in model.backbone.parameters() if p.requires_grad]\n# head_params = list(model.attn.parameters()) + list(model.classifier.parameters())\n\n# optimizer = torch.optim.Adam([\n#     {\"params\": backbone_params, \"lr\": 1e-5},   # gentle nudge to pretrained knowledge\n#     {\"params\": head_params, \"lr\": 1e-4},        # normal learning rate, same as before\n# ])\n\n# print(f\"Backbone params in optimizer: {sum(p.numel() for p in backbone_params):,}\")\n# print(f\"Head params in optimizer: {sum(p.numel() for p in head_params):,}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.663836Z","iopub.execute_input":"2026-09-15T03:37:00.664146Z","iopub.status.idle":"2026-09-15T03:37:00.674857Z","shell.execute_reply.started":"2026-09-15T03:37:00.664124Z","shell.execute_reply":"2026-09-15T03:37:00.674094Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Quick timing re-check with partial unfreezing active\n# np.random.seed(1)\n# timing_indices = np.random.permutation(len(dataset))[:200]  # smaller sample, since this will be slower\n# timing_subset = Subset(dataset, timing_indices)\n# timing_loader = DataLoader(timing_subset, batch_size=4, shuffle=False, num_workers=2)\n\n# model.train()\n# start_time = time.time()\n# n_batches = 0\n# for batch in timing_loader:\n#     volumes = batch[\"volume\"].to(device)\n#     labels = batch[\"labels\"].to(device)\n#     mask = batch[\"mask\"].to(device)\n#     conf = batch[\"conf\"].to(device)\n\n#     optimizer.zero_grad()\n#     logits, _ = model(volumes)\n#     loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#     loss.backward()\n#     optimizer.step()\n#     n_batches += 1\n\n# elapsed = time.time() - start_time\n# studies_processed = n_batches * timing_loader.batch_size\n# time_per_study = elapsed / studies_processed\n# est_epoch_min = (time_per_study * len(dataset)) / 60\n# print(f\"Time per study: {time_per_study:.3f}s -- estimated epoch time: {est_epoch_min:.1f} min\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.675788Z","iopub.execute_input":"2026-09-15T03:37:00.676434Z","iopub.status.idle":"2026-09-15T03:37:00.689744Z","shell.execute_reply.started":"2026-09-15T03:37:00.676393Z","shell.execute_reply":"2026-09-15T03:37:00.688964Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Reload fresh -- the current dino_backbone was mutated during the\n# aborted unfreezing experiment and should not be reused\ndino_backbone_fresh = AutoModel.from_pretrained(DINO_PATH)\ndino_backbone_fresh.eval()\nprint(\"Fresh DINOv2 backbone loaded\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:00.690556Z","iopub.execute_input":"2026-09-15T03:37:00.690779Z","iopub.status.idle":"2026-09-15T03:37:01.023139Z","shell.execute_reply.started":"2026-09-15T03:37:00.690758Z","shell.execute_reply":"2026-09-15T03:37:01.011851Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # How many series of each type do we have?\n# print(\"Anatomical Plane distribution:\")\n# print(train_series[\"Anatomical_Plane\"].value_counts())\n# print()\n\n# print(\"Fluid_Sensitive distribution:\")\n# print(train_series[\"Fluid_Sensitive\"].value_counts())\n# print()\n\n# # How many series per study on average?\n# series_per_study = train_series.groupby(\"StudyInstanceUID\").size()\n# print(\"Series per study statistics:\")\n# print(series_per_study.describe())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.023985Z","iopub.execute_input":"2026-09-15T03:37:01.024312Z","iopub.status.idle":"2026-09-15T03:37:01.03179Z","shell.execute_reply.started":"2026-09-15T03:37:01.024288Z","shell.execute_reply":"2026-09-15T03:37:01.028662Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# def select_best_series(study_series):\n#     \"\"\"\n#     For one study, select up to 3 series:\n#     - Best Sagittal (prefer Fluid_Sensitive=1)\n#     - Best Coronal  (prefer Fluid_Sensitive=1)\n#     - Best Axial    (prefer Fluid_Sensitive=1)\n#     Returns a list of SeriesInstanceUIDs (can be 1, 2, or 3).\n#     \"\"\"\n#     selected = []\n\n#     for plane in [\"Sagittal\", \"Coronal\", \"Axial\"]:\n#         plane_series = study_series[study_series[\"Anatomical_Plane\"] == plane]\n#         if len(plane_series) == 0:\n#             continue\n\n#         # Prefer fluid-sensitive series\n#         fluid = plane_series[plane_series[\"Fluid_Sensitive\"] == 1]\n#         if len(fluid) > 0:\n#             chosen = fluid.iloc[0][\"SeriesInstanceUID\"]\n#         else:\n#             chosen = plane_series.iloc[0][\"SeriesInstanceUID\"]\n\n#         selected.append(chosen)\n\n#     return selected\n\n\n# # Quick test on a few studies\n# sample_studies = train[\"StudyInstanceUID\"].head(5)\n# for sid in sample_studies:\n#     study_series = train_series[train_series[\"StudyInstanceUID\"] == sid]\n#     chosen = select_best_series(study_series)\n#     print(f\"{sid[:40]}... → {len(chosen)} series selected\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.033243Z","iopub.execute_input":"2026-09-15T03:37:01.034003Z","iopub.status.idle":"2026-09-15T03:37:01.052261Z","shell.execute_reply.started":"2026-09-15T03:37:01.033963Z","shell.execute_reply":"2026-09-15T03:37:01.05097Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pydicom\nimport numpy as np\nimport cv2\nfrom pathlib import Path\n\ndef load_series_as_volume(series_path, target_size=224, max_slices=32):\n    \"\"\"\n    Load one series folder and return a volume of shape (max_slices, target_size, target_size).\n    Uses cv2.resize instead of skimage.transform.resize -- meaningfully faster,\n    since cv2 is implemented in optimized C++ with no Python-level overhead per call.\n    \"\"\"\n    dicom_files = sorted(list(Path(series_path).glob(\"*.dcm\")))\n    if len(dicom_files) == 0:\n        return None\n\n    slices = []\n    for f in dicom_files:\n        try:\n            ds = pydicom.dcmread(str(f))\n            img = ds.pixel_array.astype(np.float32)\n            img_min, img_max = img.min(), img.max()\n            if img_max > img_min:\n                img = (img - img_min) / (img_max - img_min)\n            else:\n                img = np.zeros_like(img)\n            # cv2.resize expects (width, height) order, and float32 input works directly\n            img_resized = cv2.resize(img, (target_size, target_size), interpolation=cv2.INTER_LINEAR)\n            slices.append(img_resized)\n        except:\n            continue\n\n    if len(slices) == 0:\n        return None\n\n    volume = np.stack(slices, axis=0)  # (D, H, W), already resized\n\n    # Pad or crop depth to max_slices\n    D = volume.shape[0]\n    if D >= max_slices:\n        start = (D - max_slices) // 2\n        volume = volume[start:start+max_slices]\n    else:\n        pad = max_slices - D\n        volume = np.pad(volume, ((0, pad), (0, 0), (0, 0)), mode=\"constant\")\n\n    return volume.astype(np.float32)\n\ndef load_study_multi(study_id, series_df, series_root, target_size=224, max_slices=32):\n    \"\"\"\n    Load up to 3 series (Sag + Cor + Ax) for one study.\n    Returns a list of volumes (each of shape (max_slices, H, W))\n    \"\"\"\n    study_series = series_df[series_df[\"StudyInstanceUID\"] == study_id]\n    selected_uids = select_best_series(study_series)\n\n    volumes = []\n    for uid in selected_uids:\n        series_path = Path(series_root) / study_id / uid\n        vol = load_series_as_volume(series_path, target_size=target_size, max_slices=max_slices)\n        if vol is not None:\n            volumes.append(vol)\n\n    return volumes","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.058143Z","iopub.execute_input":"2026-09-15T03:37:01.058449Z","iopub.status.idle":"2026-09-15T03:37:01.07523Z","shell.execute_reply.started":"2026-09-15T03:37:01.058413Z","shell.execute_reply":"2026-09-15T03:37:01.073631Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Quick test\n# test_id = train[\"StudyInstanceUID\"].iloc[0]\n# vols = load_study_multi(\n#     test_id, \n#     train_series, \n#     \"/kaggle/input/competitions/rsna-knee-abnormality-detection/train_series\"\n# )\n# print(f\"Loaded {len(vols)} volumes\")\n# for i, v in enumerate(vols):\n#     print(f\"  Volume {i}: shape {v.shape}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.076289Z","iopub.execute_input":"2026-09-15T03:37:01.076532Z","iopub.status.idle":"2026-09-15T03:37:01.091594Z","shell.execute_reply.started":"2026-09-15T03:37:01.076503Z","shell.execute_reply":"2026-09-15T03:37:01.090562Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from torch.utils.data import Dataset\nimport torch\n\nclass MultiSeriesKneeDataset(Dataset):\n    def __init__(self, study_ids, series_df, series_root, pseudo_df, label_cols):\n        self.study_ids = list(study_ids)\n        self.series_df = series_df\n        self.series_root = series_root\n        self.pseudo_df = pseudo_df.set_index(\"StudyInstanceUID\")\n        self.label_cols = label_cols\n\n    def __len__(self):\n        return len(self.study_ids)\n\n    def __getitem__(self, idx):\n        study_id = self.study_ids[idx]\n\n        # Load multiple series\n        volumes = load_study_multi(\n            study_id, \n            self.series_df, \n            self.series_root,\n            target_size=224,\n            max_slices=32\n        )\n\n        # If something failed, create empty volumes\n        if len(volumes) == 0:\n            volumes = [np.zeros((32, 224, 224), dtype=np.float32) for _ in range(3)]\n\n        # Pad to exactly 3 volumes if fewer were found\n        while len(volumes) < 3:\n            volumes.append(np.zeros((32, 224, 224), dtype=np.float32))\n\n        # Stack → (3, 32, 224, 224)\n        volume_tensor = torch.from_numpy(np.stack(volumes, axis=0)).float()\n\n        # Labels\n        row = self.pseudo_df.loc[study_id]\n        labels = []\n        mask = []\n        conf = []\n        for col in self.label_cols:\n            val = row.get(f\"{col}_pseudo\", np.nan)\n            c = row.get(f\"{col}_conf\", 0.0)\n            if pd.isna(val):\n                labels.append(0.0)\n                mask.append(0.0)\n                conf.append(0.0)\n            else:\n                labels.append(float(val))\n                mask.append(1.0)\n                conf.append(float(c))\n\n        return {\n            \"volume\": volume_tensor,          # (3, 32, 224, 224)\n            \"labels\": torch.tensor(labels, dtype=torch.float32),\n            \"mask\": torch.tensor(mask, dtype=torch.float32),\n            \"conf\": torch.tensor(conf, dtype=torch.float32),\n            \"study_id\": study_id\n        }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.09288Z","iopub.execute_input":"2026-09-15T03:37:01.093584Z","iopub.status.idle":"2026-09-15T03:37:01.108472Z","shell.execute_reply.started":"2026-09-15T03:37:01.093445Z","shell.execute_reply":"2026-09-15T03:37:01.107537Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\n\nclass MultiSeriesSlice25DModel(nn.Module):\n    def __init__(self, num_classes=12, backbone_dim=384, pretrained_backbone=None, freeze_backbone=True):\n        super().__init__()\n        \n        self.backbone = pretrained_backbone\n        if freeze_backbone:\n            for param in self.backbone.parameters(): \n                param.requires_grad = False\n\n        # Attention pooling over slices (same idea as before)\n        self.attn = nn.Sequential(\n            nn.Linear(backbone_dim, 128),\n            nn.Tanh(),\n            nn.Linear(128, 1)\n        )\n\n        # Small fusion layer across the 3 series\n        self.series_fusion = nn.Sequential(\n            nn.Linear(backbone_dim * 3, backbone_dim),\n            nn.ReLU(),\n            nn.Dropout(0.2)\n        )\n\n        self.classifier = nn.Linear(backbone_dim, num_classes)\n\n    def encode_one_series(self, volume):\n        \"\"\"\n        volume: (B, D, H, W)\n        Returns: (B, backbone_dim)\n        \"\"\"\n        B, D, H, W = volume.shape\n        # Treat each slice as an image\n        x = volume.reshape(B * D, 1, H, W)       # (B*D, 1, H, W)\n        x = x.repeat(1, 3, 1, 1)                 # (B*D, 3, H, W) for DINOv2\n\n        features = self.backbone(x)              # (B*D, backbone_dim)\n        features = features.view(B, D, -1)       # (B, D, backbone_dim)\n\n        # Attention pooling over slices\n        weights = self.attn(features)            # (B, D, 1)\n        weights = torch.softmax(weights, dim=1)\n        pooled = (features * weights).sum(dim=1) # (B, backbone_dim)\n        return pooled\n\n    def forward(self, volumes):\n        \"\"\"\n        volumes: (B, 3, D, H, W)\n        \"\"\"\n        series_features = []\n        for i in range(3):\n            feat = self.encode_one_series(volumes[:, i])   # (B, backbone_dim)\n            series_features.append(feat)\n\n        # Concatenate the 3 series features\n        fused = torch.cat(series_features, dim=1)          # (B, backbone_dim*3)\n        fused = self.series_fusion(fused)                  # (B, backbone_dim)\n\n        logits = self.classifier(fused)                    # (B, num_classes)\n        return logits, fused","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.109533Z","iopub.execute_input":"2026-09-15T03:37:01.109905Z","iopub.status.idle":"2026-09-15T03:37:01.124669Z","shell.execute_reply.started":"2026-09-15T03:37:01.109883Z","shell.execute_reply":"2026-09-15T03:37:01.123674Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"multi_backbone = DinoV2FeatureExtractor(dino_backbone_fresh)\nmulti_model = MultiSeriesSlice25DModel(\n    num_classes=12,\n    backbone_dim=384,\n    pretrained_backbone=multi_backbone,\n    freeze_backbone=True,  # keep frozen -- isolate multi-series as the only variable\n)\nmulti_model = multi_model.to(device)\nprint(\"Multi-series model built with frozen backbone\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.125722Z","iopub.execute_input":"2026-09-15T03:37:01.126058Z","iopub.status.idle":"2026-09-15T03:37:01.193793Z","shell.execute_reply.started":"2026-09-15T03:37:01.125992Z","shell.execute_reply":"2026-09-15T03:37:01.193088Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\npseudo_df_fresh = pd.read_csv(\"/kaggle/working/pseudo_labels_v1.csv\")\n\nhas_any_label = pseudo_df_fresh[[f\"{c}_pseudo\" for c in LABEL_COLS_ALL]].notna().any(axis=1)\nusable_study_ids = pseudo_df_fresh[has_any_label][\"StudyInstanceUID\"].tolist()\nprint(f\"Usable studies: {len(usable_study_ids)}\")\n\nnp.random.seed(0)\nshuffled = np.random.permutation(usable_study_ids)\nsplit_point = int(len(shuffled) * 0.85)\ntrain_ids = shuffled[:split_point]\nval_ids = shuffled[split_point:]\n\nSERIES_ROOT = \"/kaggle/input/competitions/rsna-knee-abnormality-detection/train_series\"\n\nmulti_train_dataset = MultiSeriesKneeDataset(train_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL)\nmulti_val_dataset = MultiSeriesKneeDataset(val_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL)\n\nmulti_train_loader = DataLoader(multi_train_dataset, batch_size=2, shuffle=True, num_workers=2)\nmulti_val_loader = DataLoader(multi_val_dataset, batch_size=2, shuffle=False, num_workers=2)\nprint(f\"Train: {len(multi_train_dataset)}, Val: {len(multi_val_dataset)}\")\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.194883Z","iopub.execute_input":"2026-09-15T03:37:01.19566Z","iopub.status.idle":"2026-09-15T03:37:01.239934Z","shell.execute_reply.started":"2026-09-15T03:37:01.195636Z","shell.execute_reply":"2026-09-15T03:37:01.239105Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def select_best_series(study_series):\n    \"\"\"\n    For one study, select up to 2 series: Sagittal + Coronal (prefer Fluid_Sensitive=1).\n    Returns a list of SeriesInstanceUIDs (can be 1 or 2).\n    \"\"\"\n    selected = []\n    for plane in [\"Sagittal\", \"Coronal\"]:  # dropped Axial\n        plane_series = study_series[study_series[\"Anatomical_Plane\"] == plane]\n        if len(plane_series) == 0:\n            continue\n        fluid = plane_series[plane_series[\"Fluid_Sensitive\"] == 1]\n        if len(fluid) > 0:\n            chosen = fluid.iloc[0][\"SeriesInstanceUID\"]\n        else:\n            chosen = plane_series.iloc[0][\"SeriesInstanceUID\"]\n        selected.append(chosen)\n    return selected","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.241013Z","iopub.execute_input":"2026-09-15T03:37:01.241628Z","iopub.status.idle":"2026-09-15T03:37:01.247715Z","shell.execute_reply.started":"2026-09-15T03:37:01.241589Z","shell.execute_reply":"2026-09-15T03:37:01.246866Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class MultiSeriesKneeDataset(Dataset):\n    def __init__(self, study_ids, series_df, series_root, pseudo_df, label_cols, n_series=2):\n        self.study_ids = list(study_ids)\n        self.series_df = series_df\n        self.series_root = series_root\n        self.pseudo_df = pseudo_df.set_index(\"StudyInstanceUID\")\n        self.label_cols = label_cols\n        self.n_series = n_series\n\n    def __len__(self):\n        return len(self.study_ids)\n\n    def __getitem__(self, idx):\n        study_id = self.study_ids[idx]\n        volumes = load_study_multi(\n            study_id, self.series_df, self.series_root,\n            target_size=224, max_slices=32\n        )\n        if len(volumes) == 0:\n            volumes = [np.zeros((32, 224, 224), dtype=np.float32) for _ in range(self.n_series)]\n        while len(volumes) < self.n_series:\n            volumes.append(np.zeros((32, 224, 224), dtype=np.float32))\n        volumes = volumes[:self.n_series]  # in case more than n_series somehow got selected\n\n        volume_tensor = torch.from_numpy(np.stack(volumes, axis=0)).float()\n\n        row = self.pseudo_df.loc[study_id]\n        labels, mask, conf = [], [], []\n        for col in self.label_cols:\n            val = row.get(f\"{col}_pseudo\", np.nan)\n            c = row.get(f\"{col}_conf\", 0.0)\n            if pd.isna(val):\n                labels.append(0.0); mask.append(0.0); conf.append(0.0)\n            else:\n                labels.append(float(val)); mask.append(1.0); conf.append(float(c))\n\n        return {\n            \"volume\": volume_tensor,\n            \"labels\": torch.tensor(labels, dtype=torch.float32),\n            \"mask\": torch.tensor(mask, dtype=torch.float32),\n            \"conf\": torch.tensor(conf, dtype=torch.float32),\n            \"study_id\": study_id\n        }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.24859Z","iopub.execute_input":"2026-09-15T03:37:01.248853Z","iopub.status.idle":"2026-09-15T03:37:01.265112Z","shell.execute_reply.started":"2026-09-15T03:37:01.248819Z","shell.execute_reply":"2026-09-15T03:37:01.264263Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class MultiSeriesSlice25DModel(nn.Module):\n    def __init__(self, num_classes=12, backbone_dim=384, pretrained_backbone=None, freeze_backbone=True, n_series=2):\n        super().__init__()\n        self.n_series = n_series\n        self.backbone = pretrained_backbone\n        if freeze_backbone:\n            for param in self.backbone.parameters():\n                param.requires_grad = False\n\n        self.attn = nn.Sequential(\n            nn.Linear(backbone_dim, 128),\n            nn.Tanh(),\n            nn.Linear(128, 1)\n        )\n        self.series_fusion = nn.Sequential(\n            nn.Linear(backbone_dim * n_series, backbone_dim),\n            nn.ReLU(),\n            nn.Dropout(0.2)\n        )\n        self.classifier = nn.Linear(backbone_dim, num_classes)\n\n    def encode_one_series(self, volume):\n        B, D, H, W = volume.shape\n        x = volume.reshape(B * D, 1, H, W)\n        x = x.repeat(1, 3, 1, 1)\n        features = self.backbone(x)\n        features = features.view(B, D, -1)\n        weights = torch.softmax(self.attn(features), dim=1)\n        pooled = (features * weights).sum(dim=1)\n        return pooled\n\n    def forward(self, volumes):\n        series_features = []\n        for i in range(self.n_series):\n            feat = self.encode_one_series(volumes[:, i])\n            series_features.append(feat)\n        fused = torch.cat(series_features, dim=1)\n        fused = self.series_fusion(fused)\n        logits = self.classifier(fused)\n        return logits, fused","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.26617Z","iopub.execute_input":"2026-09-15T03:37:01.266955Z","iopub.status.idle":"2026-09-15T03:37:01.282556Z","shell.execute_reply.started":"2026-09-15T03:37:01.266929Z","shell.execute_reply":"2026-09-15T03:37:01.281717Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"multi_model = MultiSeriesSlice25DModel(\n    num_classes=12, backbone_dim=384,\n    pretrained_backbone=multi_backbone,  # reuse the fresh frozen backbone from before\n    freeze_backbone=True, n_series=2,\n)\nmulti_model = multi_model.to(device)\n\nmulti_train_dataset = MultiSeriesKneeDataset(train_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL, n_series=2)\nmulti_val_dataset = MultiSeriesKneeDataset(val_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL, n_series=2)\nmulti_train_loader = DataLoader(multi_train_dataset, batch_size=3, shuffle=True, num_workers=2)  # can try batch_size=3 now, less memory per study\nmulti_val_loader = DataLoader(multi_val_dataset, batch_size=3, shuffle=False, num_workers=2)\nprint(f\"Train: {len(multi_train_dataset)}, Val: {len(multi_val_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.283602Z","iopub.execute_input":"2026-09-15T03:37:01.284013Z","iopub.status.idle":"2026-09-15T03:37:01.309444Z","shell.execute_reply.started":"2026-09-15T03:37:01.28398Z","shell.execute_reply":"2026-09-15T03:37:01.308755Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"multi_model.load_state_dict(torch.load(\n    \"/kaggle/input/datasets/adityazhivam/best-model-2series-v28/best_model_2series.pt\",\n    map_location=device\n))\nprint(\"Resumed training from Version 28's checkpoint\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:01.310361Z","iopub.execute_input":"2026-09-15T03:37:01.310677Z","iopub.status.idle":"2026-09-15T03:37:02.418425Z","shell.execute_reply.started":"2026-09-15T03:37:01.310644Z","shell.execute_reply":"2026-09-15T03:37:02.417651Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # Confirm weights loaded successfully - if this prints without error, \n# # the model's structure matches the saved checkpoint exactly\n# print(\"Model backbone frozen:\", not any(p.requires_grad for p in multi_model.backbone.parameters()))\n# print(\"Total trainable params:\", sum(p.numel() for p in multi_model.parameters() if p.requires_grad))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.419321Z","iopub.execute_input":"2026-09-15T03:37:02.419607Z","iopub.status.idle":"2026-09-15T03:37:02.423839Z","shell.execute_reply.started":"2026-09-15T03:37:02.419583Z","shell.execute_reply":"2026-09-15T03:37:02.423091Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# optimizer = torch.optim.Adam(multi_model.parameters(), lr=5e-5)\n\n# timing_subset_ids = train_ids[:100]\n# timing_dataset = MultiSeriesKneeDataset(timing_subset_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL)\n# timing_loader = DataLoader(timing_dataset, batch_size=2, shuffle=False, num_workers=2)\n\n# multi_model.train()\n# start_time = time.time()\n# n_batches = 0\n# for batch in timing_loader:\n#     volumes = batch[\"volume\"].to(device)\n#     labels = batch[\"labels\"].to(device)\n#     mask = batch[\"mask\"].to(device)\n#     conf = batch[\"conf\"].to(device)\n\n#     optimizer.zero_grad()\n#     logits, _ = multi_model(volumes)\n#     loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#     loss.backward()\n#     optimizer.step()\n#     n_batches += 1\n\n# elapsed = time.time() - start_time\n# studies_processed = n_batches * timing_loader.batch_size\n# time_per_study = elapsed / studies_processed\n# est_epoch_min = (time_per_study * len(multi_train_dataset)) / 60\n# print(f\"Time per study: {time_per_study:.3f}s -- estimated epoch time: {est_epoch_min:.1f} min\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.424847Z","iopub.execute_input":"2026-09-15T03:37:02.425188Z","iopub.status.idle":"2026-09-15T03:37:02.43574Z","shell.execute_reply.started":"2026-09-15T03:37:02.425154Z","shell.execute_reply":"2026-09-15T03:37:02.435095Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# num_epochs = 6\n# best_val_loss = float(\"inf\")\n \n# for epoch in range(num_epochs):\n#     # ---- Training ----\n#     multi_model.train()\n#     train_losses = []\n    \n#     for batch in multi_train_loader:\n#         volumes = batch[\"volume\"].to(device)\n#         labels = batch[\"labels\"].to(device)\n#         mask = batch[\"mask\"].to(device)\n#         conf = batch[\"conf\"].to(device)\n\n#         optimizer.zero_grad()\n#         logits, _ = multi_model(volumes)\n#         loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#         loss.backward()\n#         optimizer.step()\n#         train_losses.append(loss.item())\n\n#     avg_train_loss = sum(train_losses) / len(train_losses)\n\n#     # ---- Validation ----\n#     multi_model.eval()\n#     val_losses = []\n    \n#     with torch.no_grad():\n#         for batch in multi_val_loader:\n#             volumes = batch[\"volume\"].to(device)\n#             labels = batch[\"labels\"].to(device)\n#             mask = batch[\"mask\"].to(device)\n#             conf = batch[\"conf\"].to(device)\n\n#             logits, _ = multi_model(volumes)\n#             loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n#             val_losses.append(loss.item())\n\n#     avg_val_loss = sum(val_losses) / len(val_losses)\n\n#     # ---- AUC ----\n#     auc_scores = evaluate_auc(multi_model, multi_val_loader, device, LABEL_COLS_ALL)\n#     valid_aucs = [v for v in auc_scores.values() if v is not None]\n#     mean_auc = sum(valid_aucs) / len(valid_aucs) if valid_aucs else float(\"nan\")\n\n#     print(f\"\\nEpoch {epoch+1}/{num_epochs} -- train loss: {avg_train_loss:.4f} | \"\n#           f\"val loss: {avg_val_loss:.4f} | mean val AUC: {mean_auc:.4f}\")\n    \n#     for name, score in auc_scores.items():\n#         score_str = f\"{score:.3f}\" if score is not None else \"n/a\"\n#         print(f\"    {name:20s}: {score_str}\")\n\n#     if avg_val_loss < best_val_loss:\n#         best_val_loss = avg_val_loss\n#         torch.save(multi_model.state_dict(), \"/kaggle/working/best_model_2series.pt\")\n#         print(\"    -> saved new best model\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.436705Z","iopub.execute_input":"2026-09-15T03:37:02.437004Z","iopub.status.idle":"2026-09-15T03:37:02.452135Z","shell.execute_reply.started":"2026-09-15T03:37:02.436982Z","shell.execute_reply":"2026-09-15T03:37:02.451349Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# inference_backbone = DinoV2FeatureExtractor(AutoModel.from_pretrained(DINO_PATH))\n# inference_multi_model = MultiSeriesSlice25DModel(\n#     num_classes=12, backbone_dim=384,\n#     pretrained_backbone=inference_backbone,\n#     freeze_backbone=True, n_series=2,\n# )\n# inference_multi_model.load_state_dict(torch.load(\"/kaggle/working/best_model_2series.pt\", map_location=device))\n# inference_multi_model = inference_multi_model.to(device)\n# inference_multi_model.eval()\n# print(\"2-series model loaded for inference\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.453068Z","iopub.execute_input":"2026-09-15T03:37:02.453532Z","iopub.status.idle":"2026-09-15T03:37:02.466747Z","shell.execute_reply.started":"2026-09-15T03:37:02.453493Z","shell.execute_reply":"2026-09-15T03:37:02.46621Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# test = pd.read_csv(DATA_PATH / \"test.csv\")\n# test_series = pd.read_csv(DATA_PATH / \"test_series.csv\")\n# TEST_SERIES_ROOT = \"/kaggle/input/competitions/rsna-knee-abnormality-detection/test_series\"\n\n# inference_multi_model.eval()\n# predictions = []\n# batch_size = 8\n# study_ids = test[\"StudyInstanceUID\"].tolist()\n\n# with torch.no_grad():\n#     for i in range(0, len(study_ids), batch_size):\n#         batch_ids = study_ids[i:i+batch_size]\n#         batch_volumes = []\n#         for study_id in batch_ids:\n#             volumes = load_study_multi(study_id, test_series, TEST_SERIES_ROOT, target_size=224, max_slices=32)\n#             if len(volumes) == 0:\n#                 volumes = [np.zeros((32, 224, 224), dtype=np.float32) for _ in range(2)]\n#             while len(volumes) < 2:\n#                 volumes.append(np.zeros((32, 224, 224), dtype=np.float32))\n#             volumes = volumes[:2]\n#             batch_volumes.append(np.stack(volumes, axis=0))\n\n#         volume_tensor = torch.from_numpy(np.stack(batch_volumes, axis=0)).float().to(device)  # (B, 2, 32, 224, 224)\n#         logits, _ = inference_multi_model(volume_tensor)\n#         probs = torch.sigmoid(logits).cpu().numpy()  # (B, 12)\n\n#         for j, study_id in enumerate(batch_ids):\n#             row = {\"StudyInstanceUID\": study_id}\n#             for k, cond in enumerate(LABEL_COLS_ALL):\n#                 row[cond] = probs[j][k]\n#             predictions.append(row)\n\n# pred_df = pd.DataFrame(predictions)\n# pred_df = pred_df[sample_sub.columns.tolist()]\n# pred_df.to_csv(\"/kaggle/working/submission.csv\", index=False)\n# print(\"Saved submission.csv\")\n# pred_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.467669Z","iopub.execute_input":"2026-09-15T03:37:02.467949Z","iopub.status.idle":"2026-09-15T03:37:02.479159Z","shell.execute_reply.started":"2026-09-15T03:37:02.467914Z","shell.execute_reply":"2026-09-15T03:37:02.478484Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def select_best_series(study_series):\n    \"\"\"Sagittal + Coronal + Axial, preferring Fluid_Sensitive=1 within each plane.\"\"\"\n    selected = []\n    for plane in [\"Sagittal\", \"Coronal\", \"Axial\"]:\n        plane_series = study_series[study_series[\"Anatomical_Plane\"] == plane]\n        if len(plane_series) == 0:\n            continue\n        fluid = plane_series[plane_series[\"Fluid_Sensitive\"] == 1]\n        chosen = fluid.iloc[0][\"SeriesInstanceUID\"] if len(fluid) > 0 else plane_series.iloc[0][\"SeriesInstanceUID\"]\n        selected.append(chosen)\n    return selected","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.48001Z","iopub.execute_input":"2026-09-15T03:37:02.480405Z","iopub.status.idle":"2026-09-15T03:37:02.496597Z","shell.execute_reply.started":"2026-09-15T03:37:02.48038Z","shell.execute_reply":"2026-09-15T03:37:02.495781Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"dino_backbone_fresh3 = AutoModel.from_pretrained(DINO_PATH)\nmulti_backbone3 = DinoV2FeatureExtractor(dino_backbone_fresh3)\n\npseudo_df_fresh = pd.read_csv(\"/kaggle/working/pseudo_labels_v1.csv\")\nhas_any_label = pseudo_df_fresh[[f\"{c}_pseudo\" for c in LABEL_COLS_ALL]].notna().any(axis=1)\nusable_study_ids = pseudo_df_fresh[has_any_label][\"StudyInstanceUID\"].tolist()\n\nnp.random.seed(0)\nshuffled = np.random.permutation(usable_study_ids)\nsplit_point = int(len(shuffled) * 0.85)\ntrain_ids = shuffled[:split_point]\nval_ids = shuffled[split_point:]\n\nSERIES_ROOT = \"/kaggle/input/competitions/rsna-knee-abnormality-detection/train_series\"\n\nmulti_model3 = MultiSeriesSlice25DModel(\n    num_classes=12, backbone_dim=384,\n    pretrained_backbone=multi_backbone3,\n    freeze_backbone=True, n_series=3,\n)\nmulti_model3 = multi_model3.to(device)\n\nmulti_train_dataset3 = MultiSeriesKneeDataset(train_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL, n_series=3)\nmulti_val_dataset3 = MultiSeriesKneeDataset(val_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL, n_series=3)\nmulti_train_loader3 = DataLoader(multi_train_dataset3, batch_size=2, shuffle=True, num_workers=2)\nmulti_val_loader3 = DataLoader(multi_val_dataset3, batch_size=2, shuffle=False, num_workers=2)\nprint(f\"Train: {len(multi_train_dataset3)}, Val: {len(multi_val_dataset3)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.497584Z","iopub.execute_input":"2026-09-15T03:37:02.497947Z","iopub.status.idle":"2026-09-15T03:37:02.932476Z","shell.execute_reply.started":"2026-09-15T03:37:02.497925Z","shell.execute_reply":"2026-09-15T03:37:02.931455Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"optimizer3 = torch.optim.Adam(multi_model3.parameters(), lr=1e-4)\n\ntiming_ids = train_ids[:100]\ntiming_ds = MultiSeriesKneeDataset(timing_ids, train_series, SERIES_ROOT, pseudo_df_fresh, LABEL_COLS_ALL, n_series=3)\ntiming_loader = DataLoader(timing_ds, batch_size=2, shuffle=False, num_workers=2)\n\nmulti_model3.train()\nstart = time.time()\nn_batches = 0\nfor batch in timing_loader:\n    volumes = batch[\"volume\"].to(device)\n    labels = batch[\"labels\"].to(device)\n    mask = batch[\"mask\"].to(device)\n    conf = batch[\"conf\"].to(device)\n    optimizer3.zero_grad()\n    logits, _ = multi_model3(volumes)\n    loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n    loss.backward()\n    optimizer3.step()\n    n_batches += 1\nelapsed = time.time() - start\ntime_per_study = elapsed / (n_batches * timing_loader.batch_size)\nest_epoch_min = (time_per_study * len(multi_train_dataset3)) / 60\nprint(f\"Time per study: {time_per_study:.3f}s -- estimated epoch time: {est_epoch_min:.1f} min\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T03:37:02.93373Z","iopub.execute_input":"2026-09-15T03:37:02.93409Z","iopub.status.idle":"2026-09-15T03:38:25.777242Z","shell.execute_reply.started":"2026-09-15T03:37:02.934052Z","shell.execute_reply":"2026-09-15T03:38:25.776389Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"num_epochs = 6\nbest_val_loss = float(\"inf\")\n\nfor epoch in range(num_epochs):\n    multi_model3.train()\n    train_losses = []\n    for batch in multi_train_loader3:\n        volumes = batch[\"volume\"].to(device)\n        labels = batch[\"labels\"].to(device)\n        mask = batch[\"mask\"].to(device)\n        conf = batch[\"conf\"].to(device)\n        optimizer3.zero_grad()\n        logits, _ = multi_model3(volumes)\n        loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n        loss.backward()\n        optimizer3.step()\n        train_losses.append(loss.item())\n    avg_train_loss = sum(train_losses) / len(train_losses)\n\n    multi_model3.eval()\n    val_losses = []\n    with torch.no_grad():\n        for batch in multi_val_loader3:\n            volumes = batch[\"volume\"].to(device)\n            labels = batch[\"labels\"].to(device)\n            mask = batch[\"mask\"].to(device)\n            conf = batch[\"conf\"].to(device)\n            logits, _ = multi_model3(volumes)\n            loss = masked_confidence_weighted_loss(logits, labels, mask, conf)\n            val_losses.append(loss.item())\n    avg_val_loss = sum(val_losses) / len(val_losses)\n\n    auc_scores = evaluate_auc(multi_model3, multi_val_loader3, device, LABEL_COLS_ALL)\n    valid_aucs = [v for v in auc_scores.values() if v is not None]\n    mean_auc = sum(valid_aucs) / len(valid_aucs) if valid_aucs else float(\"nan\")\n\n    print(f\"\\nEpoch {epoch+1}/{num_epochs} -- train loss: {avg_train_loss:.4f} | \"\n          f\"val loss: {avg_val_loss:.4f} | mean val AUC: {mean_auc:.4f}\")\n    for name, score in auc_scores.items():\n        score_str = f\"{score:.3f}\" if score is not None else \"n/a\"\n        print(f\"    {name:20s}: {score_str}\")\n\n    if avg_val_loss < best_val_loss:\n        best_val_loss = avg_val_loss\n        torch.save(multi_model3.state_dict(), \"/kaggle/working/best_model_3series_llm.pt\")\n        print(\"    -> saved new best model\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"inference_backbone3 = DinoV2FeatureExtractor(AutoModel.from_pretrained(DINO_PATH))\ninference_model3 = MultiSeriesSlice25DModel(\n    num_classes=12, backbone_dim=384,\n    pretrained_backbone=inference_backbone3,\n    freeze_backbone=True, n_series=3,\n)\ninference_model3.load_state_dict(torch.load(\"/kaggle/working/best_model_3series_llm.pt\", map_location=device))\ninference_model3 = inference_model3.to(device)\ninference_model3.eval()\n\ntest = pd.read_csv(DATA_PATH / \"test.csv\")\ntest_series = pd.read_csv(DATA_PATH / \"test_series.csv\")\nTEST_SERIES_ROOT = \"/kaggle/input/competitions/rsna-knee-abnormality-detection/test_series\"\n\npredictions = []\nbatch_size = 6\nstudy_ids = test[\"StudyInstanceUID\"].tolist()\n\nwith torch.no_grad():\n    for i in range(0, len(study_ids), batch_size):\n        batch_ids = study_ids[i:i+batch_size]\n        batch_volumes = []\n        for study_id in batch_ids:\n            volumes = load_study_multi(study_id, test_series, TEST_SERIES_ROOT, target_size=224, max_slices=32)\n            while len(volumes) < 3:\n                volumes.append(np.zeros((32, 224, 224), dtype=np.float32))\n            volumes = volumes[:3]\n            batch_volumes.append(np.stack(volumes, axis=0))\n        volume_tensor = torch.from_numpy(np.stack(batch_volumes, axis=0)).float().to(device)\n        logits, _ = inference_model3(volume_tensor)\n        probs = torch.sigmoid(logits).cpu().numpy()\n        for j, study_id in enumerate(batch_ids):\n            row = {\"StudyInstanceUID\": study_id}\n            for k, cond in enumerate(LABEL_COLS_ALL):\n                row[cond] = probs[j][k]\n            predictions.append(row)\n\npred_df = pd.DataFrame(predictions)\npred_df = pred_df[sample_sub.columns.tolist()]\npred_df.to_csv(\"/kaggle/working/submission.csv\", index=False)\nprint(\"Saved submission.csv\")\npred_df.head()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}