{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.13"},"papermill":{"default_parameters":{},"duration":2432.701301,"end_time":"2026-08-06T07:03:07.330627+00:00","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-08-06T06:22:34.629326+00:00","version":"2.7.0"},"widgets":{"application/vnd.jupyter.widget-state+json":{"state":{"3a721b39dbf64900a7b16e468eee2216":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HBoxModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HBoxModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HBoxView","box_style":"","children":["IPY_MODEL_f52e0e8713b642ecbda9e50ad601931c","IPY_MODEL_f8434c2a2a3e44c586c5e8ea13bc87d7","IPY_MODEL_691d22c75fb648169f275974021daf10"],"layout":"IPY_MODEL_6ed6d12e6d4846e2b30ae764e1bc66a6","tabbable":null,"tooltip":null}},"44084f82686f4adf9165810f5c7cbf15":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"49cfe45f18ad4bc6b66ea96aabccf593":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"ProgressStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"ProgressStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","bar_color":null,"description_width":""}},"691d22c75fb648169f275974021daf10":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_74ca351109b14f7785e9cd7f00e0d001","placeholder":"​","style":"IPY_MODEL_a929ee80188445068a874b6e373044a8","tabbable":null,"tooltip":null,"value":" 223/223 [00:00&lt;00:00, 945.58it/s, Materializing param=layernorm.weight]"}},"6ed6d12e6d4846e2b30ae764e1bc66a6":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"74ca351109b14f7785e9cd7f00e0d001":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"a929ee80188445068a874b6e373044a8":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"b59196588e9043d3a7eb0dab129e0027":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"e93c8009dd28401ab81c6fd45d333bba":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"f52e0e8713b642ecbda9e50ad601931c":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_b59196588e9043d3a7eb0dab129e0027","placeholder":"​","style":"IPY_MODEL_e93c8009dd28401ab81c6fd45d333bba","tabbable":null,"tooltip":null,"value":"Loading weights: 100%"}},"f8434c2a2a3e44c586c5e8ea13bc87d7":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"FloatProgressModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"FloatProgressModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"ProgressView","bar_style":"success","description":"","description_allow_html":false,"layout":"IPY_MODEL_44084f82686f4adf9165810f5c7cbf15","max":223,"min":0,"orientation":"horizontal","style":"IPY_MODEL_49cfe45f18ad4bc6b66ea96aabccf593","tabbable":null,"tooltip":null,"value":223}}},"version_major":2,"version_minor":0}}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"md-00","cell_type":"markdown","source":"# RSNA Knee: DINOv2-Base physical-scale soup\n\nPublic challenger derived from Roman Tamrazov's **RSNA Knee | DINOsaur V2** and Pilkwang Kim's public baseline. The material change is a 130 mm physical crop at 280 px (0.464 mm/pixel), chosen to preserve approximately 1 mm meniscal findings while retaining two three-slice views per acquisition. The original multilingual weak-label reader, anatomical slot prior, EMA, ranking loss, checkpoint soup and final full-data fit are retained.\n\nPublic LB will be recorded here after the code submission completes.\n","metadata":{"papermill":{"duration":0.006089,"end_time":"2026-08-06T06:22:37.389546+00:00","exception":false,"start_time":"2026-08-06T06:22:37.383457+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-03","cell_type":"markdown","source":"### Multilingual parser","metadata":{"papermill":{"duration":0.004817,"end_time":"2026-08-06T06:22:37.418619+00:00","exception":false,"start_time":"2026-08-06T06:22:37.413802+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-04","cell_type":"code","source":"from __future__ import annotations\n\nimport re\nimport unicodedata\n\nTARGETS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\",\n]\n\n\n# Turkish dotted/dotless i must be folded before casefolding, otherwise \"İZLENMEZ\"\n# and \"izlenmez\" diverge. ß and the Croatian/Serbian d-with-stroke likewise.\n_PRE = str.maketrans({\n    \"ı\": \"i\", \"İ\": \"i\", \"I\": \"i\", \"ß\": \"ss\", \"đ\": \"d\", \"Đ\": \"d\",\n    \"ø\": \"o\", \"Ø\": \"o\", \"æ\": \"ae\", \"Æ\": \"ae\",\n})\n\n\ndef normalize(text: str) -> str:\n    \"\"\"Fold case, diacritics and separators; keep Greek and Cyrillic letters.\n\n    NFKD decomposition strips Latin accents and Greek tonos alike (ά -> α), which is what\n    we want: reports are inconsistent about accents. It also maps the MICRO SIGN U+00B5\n    to a real mu, which matters because most Greek reports here use the wrong codepoint.\n    \"\"\"\n    if not isinstance(text, str):\n        return \"\"\n    text = text.translate(_PRE).lower()\n    text = unicodedata.normalize(\"NFKD\", text)\n    text = \"\".join(ch for ch in text if not unicodedata.combining(ch))\n    text = text.replace(\"­\", \"\")                    # soft hyphen\n    text = re.sub(r\"[_\\-/\\\\]+\", \" \", text)\n    text = re.sub(r\"[ \\t]+\", \" \", text)\n    return text\n\n\n_SENT_SPLIT = re.compile(r\"(?<=[.;!?])\\s+|\\n+\")\n\n\ndef clauses(text: str):\n    \"\"\"Split into clauses, then attach `header:` lines to the value that follows.\n\n    A report line reading `Fractures :` followed by `Aucune.` is one statement. Splitting\n    on punctuation alone separates the anatomy from its negation and flips the label.\n    \"\"\"\n    norm = normalize(text)\n    raw = [c.strip() for c in _SENT_SPLIT.split(norm) if c and c.strip()]\n\n    merged = []\n    for i, c in enumerate(raw):\n        # A fragment ending in a colon is a heading for the next fragment. Structured\n        # English reports write long ones - \"lateral compartment (meniscus, collateral\n        # ligament complex, cartilage):\" is eight words - so the cap is generous, and\n        # the heading is kept as its own clause too in case it carries the finding.\n        if c.endswith(\":\") and len(c.split()) <= 14 and i + 1 < len(raw):\n            merged.append(c + \" \" + raw[i + 1])\n        merged.append(c)\n    # Comma-separated enumerations inside a long clause hide separate assertions.\n    out = []\n    for c in merged:\n        out.append(c)\n        if len(c.split()) > 25:\n            out.extend(p.strip() for p in c.split(\",\") if len(p.split()) > 2)\n    return out\n\n\ndef _rx(*alts: str) -> re.Pattern:\n    return re.compile(\"|\".join(alts))\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.430249Z","iopub.status.busy":"2026-08-06T06:22:37.429642Z","iopub.status.idle":"2026-08-06T06:22:37.445023Z","shell.execute_reply":"2026-08-06T06:22:37.444413Z"},"papermill":{"duration":0.023311,"end_time":"2026-08-06T06:22:37.446661+00:00","exception":false,"start_time":"2026-08-06T06:22:37.42335+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-05","cell_type":"code","source":"NEGATION = _rx(\n    # en\n    r\"\\bno\\b\", r\"\\bnot\\b\", r\"\\bwithout\\b\", r\"\\bnegative for\\b\", r\"\\babsence\\b\",\n    r\"\\bno evidence\\b\", r\"\\bunremarkable\\b\", r\"\\bfree of\\b\",\n    # es\n    r\"\\bsin\\b\", r\"\\bno hay\\b\", r\"\\bausencia\\b\", r\"\\bausentes?\\b\",\n    # fr\n    r\"\\bpas de\\b\", r\"\\bsans\\b\", r\"\\baucune?\\b\", r\"\\babsence\\b\",\n    # nl\n    r\"\\bgeen\\b\", r\"\\bzonder\\b\", r\"\\bniet\\b\",\n    # de\n    r\"\\bkeine?\\b\", r\"\\bohne\\b\", r\"\\bnicht\\b\",\n    # tr\n    r\"\\byok\\b\", r\"\\byoktur\\b\", r\"izlenmemekte\", r\"saptanmadi\", r\"\\bdegil\\b\",\n    r\"gozlenmemekte\", r\"mevcut degil\", r\"eslik etmiyor\", r\"\\bizlenmedi\\b\",\n    # hr / sr / bs\n    r\"\\bnema\\b\", r\"\\bbez\\b\", r\"\\bnisu\\b\", r\"\\bnije\\b\",\n    # el (accents already stripped)\n    r\"\\bδεν\\b\", r\"\\bχωρις\\b\", r\"ουδεν\",\n    # bg / ru\n    r\"\\bбез\\b\", r\"\\bне\\b\", r\"липсва\", r\"\\bняма\\b\",\n)\n\nNORMALITY = _rx(\n    r\"\\bnormal\", r\"\\bintact\\b\", r\"\\bpreserved\\b\", r\"\\bwithin normal limits\\b\",\n    r\"limites normales\", r\"\\bconservad\", r\"\\bintegr\", r\"\\bnormales\\b\",\n    r\"\\bdoga(l|ll)\\b\", r\"korunmus\", r\"\\bnormaldir\\b\", r\"olagan\",\n    r\"\\buredn\", r\"\\bocuvan\", r\"\\bodrzan\", r\"\\bintakt\",\n    r\"φυσιολογικ\", r\"ακεραι\",\n    r\"unauffallig\", r\"regelrecht\", r\"\\bintakt\\b\",\n    r\"нормал\", r\"запазен\", r\"съхранен\", r\"\\bбез особености\\b\",\n    r\"\\bgaaf\\b\", r\"\\bnormaal\\b\",\n)\n\nUNCERTAIN = _rx(\n    r\"\\bpossible\\b\", r\"\\bprobable\\b\", r\"\\bsuspicious\\b\", r\"\\bsuspected\\b\",\n    r\"cannot (be )?exclude\", r\"\\bmay\\b\", r\"\\bquestionable\\b\", r\"\\bequivocal\\b\",\n    r\"\\bposible\\b\", r\"sin criterios categoricos\", r\"\\bdudos\",\n    r\"\\bmuhtemel\\b\", r\"\\bolasi\\b\", r\"\\bsupheli\\b\", r\"\\bizlenim\",\n    r\"\\bmoguce\\b\", r\"\\bvjerojatno\\b\", r\"\\bsumnja\\b\",\n    r\"πιθαν\", r\"υποπτ\",\n    r\"\\bmoglich\", r\"\\bverdachtig\", r\"\\bfraglich\", r\"\\bV\\.a\\.\\b\",\n    r\"\\bвъзможно\\b\", r\"\\bвероятно\\b\", r\"суспект\",\n    r\"\\bmogelijk\\b\", r\"\\bverdacht\\b\",\n)\n\n# Pathology vocabulary shared by the paired rules.\nTEAR = _rx(\n    r\"\\btear\", r\"\\btorn\\b\", r\"\\brupture\", r\"\\bdisruption\\b\", r\"discontinuit\",\n    r\"\\bavuls\",\n    r\"\\brotura\\b\", r\"\\broturas\\b\", r\"\\bruptura\", r\"\\bdesgarro\", r\"\\broto\\b\",\n    r\"\\bdechirure\", r\"\\bdechire\",\n    r\"\\bscheur\", r\"\\bruptuur\", r\"gescheurd\",\n    r\"\\briss\\b\", r\"einriss\", r\"\\bruptur\", r\"zerreiss\", r\"\\blasion\",\n    r\"\\byirtik\", r\"\\byirtig\", r\"\\bkopma\\b\", r\"butunluk kaybi\", r\"\\brupturu\\b\",\n    r\"\\bpuknuce\", r\"\\bruptur\", r\"\\bprekid\\b\", r\"\\bpukotin\",\n    r\"ρηξη\", r\"ρηξις\", r\"ρηγμα\",\n    r\"руптура\", r\"разкъсв\", r\"разрив\", r\"скъсв\",\n)\n\nDEGEN = _rx(\n    r\"degenerat\", r\"\\bmucoid\\b\", r\"\\bmyxoid\\b\", r\"\\bfray\", r\"\\bfissur\",\n    r\"dejeneratif\", r\"\\bmukoid\\b\", r\"degenerativn\", r\"εκφυλιστ\", r\"дегенерат\",\n    r\"\\bmuco ?ide\\b\", r\"aufgefasert\",\n)\n\nINJURY = _rx(\n    r\"\\binjur\", r\"\\bsprain\", r\"\\blesion\", r\"\\blasion\", r\"\\bedema\\b\", r\"\\boedema\\b\",\n    r\"\\bodem\\b\", r\"\\bedem\\b\", r\"\\bοιδημα\", r\"\\bодем\", r\"\\bедем\", r\"\\bstrain\\b\",\n    r\"\\bhigh signal\\b\", r\"\\bsignal alteration\\b\", r\"\\bhiperintens\", r\"\\bhyperintens\",\n    r\"\\bthicken\", r\"\\bzadebljanje\\b\", r\"\\bverdikking\\b\", r\"\\bdistenzij\",\n    r\"\\blaksite\\b\", r\"\\blaxity\\b\", r\"\\bpartial\\b\", r\"\\bparcijaln\", r\"\\bparcial\",\n    r\"\\bpartiel\", r\"\\bpartiell\",\n)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.458329Z","iopub.status.busy":"2026-08-06T06:22:37.458104Z","iopub.status.idle":"2026-08-06T06:22:37.46896Z","shell.execute_reply":"2026-08-06T06:22:37.468094Z"},"papermill":{"duration":0.018673,"end_time":"2026-08-06T06:22:37.470675+00:00","exception":false,"start_time":"2026-08-06T06:22:37.452002+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-06","cell_type":"code","source":"ANAT = {\n    \"ACL\": _rx(\n        r\"anterior cruciate\", r\"\\bacl\\b\",\n        r\"cruzado anterior\", r\"\\blca\\b\",\n        r\"croise anterieur\",\n        r\"voorste kruisband\", r\"\\bvkb\\b\",\n        r\"vorderes kreuzband\", r\"vorderen kreuzband\", r\"vordere kreuzband\",\n        r\"on capraz\", r\"\\bocb\\b\",\n        r\"prednji krizni\", r\"prednjeg krizn\",\n        r\"προσθι[οα][^ ]* χιαστ\", r\"προσθιου χιαστου\", r\"χιαστο[^ ]* συνδεσμ\",\n        r\"предна кръстна\", r\"предната кръстна\",\n        # Plural, unqualified: reports routinely clear both cruciates in one clause\n        # (\"Ligamentos cruzados y colaterales dentro de limites normales\"). Without\n        # this, Spanish ACL was silent on 88% of its reports and Dutch on 70%.\n        r\"cruciate ligaments\", r\"ligamentos cruzados\", r\"ligaments croises\",\n        r\"kruisbanden\", r\"kreuzbander\", r\"capraz baglar\", r\"krizn[a-z]* ligament[a-z]*\",\n        r\"χιαστοι συνδεσμ\", r\"χιαστων συνδεσμ\", r\"кръстните връзки\", r\"кръстни връзки\",\n    ),\n    \"MCL\": _rx(\n        r\"medial collateral\", r\"\\bmcl\\b\", r\"tibial collateral\",\n        r\"colateral medial\", r\"colateral interno\", r\"\\blcm\\b\",\n        r\"collateral medial\", r\"collateral interne\",\n        r\"mediale collaterale\", r\"binnenband\",\n        r\"innenband\", r\"mediales? kollateral\",\n        r\"\\bic yan bag\", r\"medial kollateral\", r\"\\biyb\\b\",\n        r\"medijalni kolateraln\", r\"medijalnog kolateraln\",\n        r\"εσω πλαγι\", r\"εσωτερικο πλαγι\",\n        r\"медиален колатерал\", r\"вътрешна странична\",\n        # Same plural pattern as the cruciates.\n        # \"Ligamentos cruzados y colaterales\" separates the noun from its adjective, so\n        # the adjective has to stand alone as a cue.\n        r\"\\bcolaterales\\b\", r\"\\bcollateraux\\b\", r\"\\bcollateralen\\b\", r\"\\bkolateralni\\b\",\n        r\"collateral ligaments\", r\"ligamentos colaterales\", r\"ligaments collateraux\",\n        r\"collaterale banden\", r\"kollateralbander\", r\"seitenbander\", r\"yan baglar\",\n        r\"kolateraln[a-z]* ligament[a-z]*\", r\"πλαγιοι συνδεσμ\", r\"πλαγιων συνδεσμ\",\n        r\"колатерални връзки\", r\"страничните връзки\",\n    ),\n    \"Medial Meniscus\": _rx(\n        r\"medial meniscus\", r\"\\bmm\\b(?= tear)\", r\"medial menisc\",\n        r\"menisco medial\", r\"menisco interno\",\n        r\"menisque medial\", r\"menisque interne\",\n        r\"mediale meniscus\", r\"binnenmeniscus\",\n        r\"innenmeniskus\", r\"medialen? meniskus\", r\"innenmeniskushinterhorn\",\n        r\"medyal menisk\", r\"\\bic menisk\",\n        r\"medijalni meniskus\", r\"medijalnog meniskusa\", r\"medijalnom meniskusu\",\n        r\"εσω μηνισκ\", r\"μηνισκ[^ ]* του εσω\", r\"εσω διαμερισμα[^.]{0,40}μηνισκ\",\n        r\"медиалния менискус\", r\"медиален менискус\", r\"вътрешния менискус\",\n    ),\n    \"Lateral Meniscus\": _rx(\n        r\"lateral meniscus\", r\"lateral menisc\",\n        r\"menisco lateral\", r\"menisco externo\",\n        r\"menisque lateral\", r\"menisque externe\",\n        r\"laterale meniscus\", r\"buitenmeniscus\",\n        r\"aussenmeniskus\", r\"lateralen? meniskus\",\n        r\"lateral menisk\", r\"\\bdis menisk\",\n        r\"lateralni meniskus\", r\"lateralnog meniskusa\", r\"lateralnom meniskusu\",\n        r\"εξω μηνισκ\", r\"μηνισκ[^ ]* του εξω\", r\"εξω διαμερισμα[^.]{0,40}μηνισκ\",\n        r\"латералния менискус\", r\"латерален менискус\", r\"външния менискус\",\n    ),\n}\n\n# Osteoarthritis is rarely written as \"osteoarthritis\". It is written as cartilage loss,\n# chondropathy grade, joint space narrowing, or osteophytes - scoped to a compartment.\nOA_EVIDENCE = _rx(\n    r\"osteoarthrit\", r\"\\barthros\", r\"\\bgonarthros\", r\"\\bosteoarthros\",\n    r\"chondropath\", r\"chondromalac\", r\"condropat\", r\"condromalac\",\n    r\"cartilage loss\", r\"cartilage thinning\", r\"chondral (loss|defect|ulcer|thinning)\",\n    r\"osteophyt\", r\"osteofit\", r\"osteofyt\", r\"osteofito\", r\"osteophyten\",\n    r\"joint space narrowing\", r\"pinzamiento articular\",\n    r\"kikirdak kayb\", r\"kikirdak incelme\", r\"kondropati\", r\"kondral\",\n    r\"kraakbeen(lijden|verlies)\", r\"gonartrose\", r\"artrose\",\n    r\"knorpel(verlust|schaden|defekt)\", r\"arthrose\", r\"gonarthrose\",\n    r\"hrskavic\", r\"hondromalac\", r\"artroz\", r\"osteoartrit\",\n    r\"χονδρ[^ ]*παθ\", r\"αρθριτ\", r\"αρθρωσ\", r\"οστεοφυτ\",\n    r\"αρθρικου χονδρου\", r\"εξαλειψη του αρθρικου χονδρου\",\n    r\"артроз\", r\"хондропат\", r\"остеофит\", r\"хрущял[^.]{0,30}(изтън|увред|дефект)\",\n    r\"ulcera[s]? condral\", r\"cartilago[^.]{0,25}(perdida|adelgaz)\",\n    r\"icrs grade\", r\"outerbridge\",\n)\n\nCOMPARTMENT = {\n    \"Medial OA\": _rx(\n        r\"medial (femorotibial|tibiofemoral|compartment)\",\n        r\"compartimento femorotibial medial\", r\"femorotibial interno\",\n        r\"mediaal femorotibiaal\", r\"mediale femorotibial\",\n        r\"medial femorotibial\", r\"medialen kompartiment\", r\"innere[sn]? kompartiment\",\n        r\"medyal femorotibial\", r\"ic kompartman\", r\"medyal kompartman\",\n        r\"medijaln[^ ]* (femorotibi|odjelj|kompartm)\",\n        r\"εσω διαμερισμα\", r\"εσω κνημιαι\", r\"εσω μηριαι\",\n        r\"медиалн[^ ]* (компартм|отдел|тибиал|феморотиб)\",\n        r\"medial (femoral|tibial) (condyle|plateau)\", r\"condilo femoral medial\",\n        r\"medialen? (femurkondyl|tibiaplateau)\", r\"mediale femorale condyl\",\n    ),\n    \"Lateral OA\": _rx(\n        r\"lateral (femorotibial|tibiofemoral|compartment)\",\n        r\"compartimento femorotibial lateral\", r\"femorotibial externo\",\n        r\"lateraal femorotibiaal\", r\"laterale femorotibial\",\n        r\"lateral femorotibial\", r\"lateralen kompartiment\", r\"aussere[sn]? kompartiment\",\n        r\"lateral femorotibial\", r\"dis kompartman\", r\"lateral kompartman\",\n        r\"lateraln[^ ]* (femorotibi|odjelj|kompartm)\",\n        r\"εξω διαμερισμα\", r\"εξω κνημιαι\", r\"εξω μηριαι\",\n        r\"латералн[^ ]* (компартм|отдел|тибиал|феморотиб)\",\n        r\"lateral (femoral|tibial) (condyle|plateau)\", r\"condilo femoral lateral\",\n        r\"lateralen? (femurkondyl|tibiaplateau)\", r\"laterale femorale condyl\",\n    ),\n    \"PF OA\": _rx(\n        r\"patellofemoral\", r\"femoropatellar\", r\"femoropatelar\", r\"patelofemoral\",\n        r\"retropatellar\", r\"retrorotulian\", r\"\\btrochlea\", r\"\\btroclea\", r\"\\btroklea\",\n        r\"\\bpatella\\b\", r\"\\bpatellar\\b\", r\"\\brotulian\", r\"\\brotula\\b\", r\"\\bpatele\\b\",\n        r\"\\bpatellae?\\b\", r\"patellofemoraal\", r\"femoropatellair\",\n        r\"επιγονατιδ\", r\"μηροεπιγονατιδ\", r\"τροχιλ\",\n        r\"пател\", r\"феморопател\", r\"тролх\",\n        r\"anterior compartment\", r\"compartimento anterior\", r\"prednj[^ ]* odjeljk\",\n    ),\n}\n\n# Self-declaring findings: the term itself is the finding.\nDIRECT = {\n    \"Effusion\": _rx(\n        r\"\\beffusion\", r\"joint fluid\", r\"intra ?articular fluid\", r\"\\bhydrops\\b\",\n        r\"derrame articular\", r\"\\bderrame\\b\", r\"liquido articular\",\n        r\"epanchement\",\n        r\"gewrichtsvocht\", r\"\\bvocht\\b\", r\"\\bhydrops\\b\", r\"gewrichtseffusie\",\n        r\"gelenkerguss\", r\"\\berguss\\b\", r\"gelenksergu\",\n        # \"diz eklemi ici sivi miktari ... artmis\" and \"eklem icerisinde yaygin sivi\n        # artisi\" both occur; the noun takes a possessive suffix, so `eklem ` alone\n        # misses. Match the stem plus any suffix.\n        r\"eklem\\w* ic\\w* sivi\", r\"efuzyon\", r\"eklem sivisi\",\n        r\"sivi (miktari|artisi|birikimi)\", r\"sivi artis\", r\"\\bsivi\\b[^.]{0,25}artmis\",\n        r\"\\bizljev\", r\"\\bizliv\", r\"zglobn[^ ]* tekucin\", r\"\\bhidrops\\b\",\n        r\"αρθρικ[^ ]* υγρ\", r\"υγρου ενδαρθρικα\", r\"ενδαρθρικ[^ ]* υγρ\", r\"ποσοτητα υγρου\",\n        r\"ενδαρθρικ\", r\"αρθρικη συλλογη\", r\"υγρο στην αρθρωση\", r\"υγρου στην αρθρωση\",\n        r\"ставен излив\", r\"излив\", r\"ставна течност\", r\"синовиална течност\",\n    ),\n    \"Synovitis\": _rx(\n        r\"synovit\", r\"sinovit\", r\"synovial (thickening|proliferation|hypertroph)\",\n        r\"synovitis\", r\"synoviale? (verdikking|proliferatie)\",\n        r\"synovialitis\", r\"synovialis(verdickung|proliferation)\",\n        r\"sinovijalitis\", r\"sinovitis\", r\"zadebljanje sinovij\",\n        r\"υμενιτιδα\", r\"συνοβιτιδα\", r\"υμενικ[^ ]* υπερτροφ\", r\"αρθρικου υμεν\",\n        r\"синовит\", r\"синовиал[^ ]* (задебел|пролифер)\",\n        r\"verdikkingen van (het )?synovium\", r\"pannus\",\n    ),\n    \"Baker's\": _rx(\n        r\"baker\", r\"popliteal cyst\", r\"quiste popliteo\", r\"quistes popliteos\",\n        r\"kyste poplite\", r\"popliteale? cyst\", r\"poplitealzyste\", r\"bakerzyste\",\n        r\"popliteal kist\", r\"\\bbakerova\\b\", r\"poplitealn[^ ]* cist\",\n        r\"κυστη baker\", r\"πολυχωρη συνοβιακη κυστη\", r\"κυστη του baker\",\n        r\"киста на бейкър\", r\"бейкърова киста\", r\"поплитеална киста\",\n        r\"gastrocnemio ?semimembranos\", r\"gastrocnemius semimembranosus burs\",\n    ),\n    \"Contusion\": _rx(\n        r\"\\bcontusion\", r\"bone bruise\", r\"bone marrow (o?edema|contusion)\",\n        r\"\\bkontuz\", r\"medular bone o?edema\", r\"marrow o?edema\",\n        r\"contusion osea\", r\"edema oseo\", r\"edema de medula osea\",\n        r\"oedeme osseux\", r\"contusion osseuse\",\n        r\"botcontusie\", r\"botoedeem\", r\"beenmergoedeem\", r\"botmergoedeem\",\n        r\"knochenmarkodem\", r\"knochenodem\", r\"kontusion\", r\"bone bruise\",\n        r\"kemik kontuzyonu\", r\"kemik iligi odemi\", r\"kemik odemi\",\n        r\"kostani edem\", r\"edem kosti\", r\"kontuzij\",\n        r\"οστεομυελικ[^ ]* οιδημα\", r\"οστικο οιδημα\", r\"μυελικο οιδημα\",\n        r\"костномозъчен едем\", r\"костен едем\", r\"контузионен\",\n    ),\n    \"Fracture\": _rx(\n        r\"\\bfractur\", r\"\\bfract\\b\",\n        r\"\\bfractura\", r\"\\bfracturas\\b\",\n        r\"\\bfractuur\", r\"\\bbreuk\\b\",\n        r\"\\bfraktur\", r\"\\bbruch\\b\",\n        r\"\\bkirik\\b\", r\"\\bkirigi\\b\", r\"\\bkirik\\b\",\n        r\"\\bfraktur\", r\"\\bprijelom\", r\"impresijsk[^ ]* fraktur\",\n        r\"καταγμα\", r\"καταγματ\",\n        r\"фрактур\", r\"счупван\", r\"фисур\",\n        r\"insufficiency fracture\", r\"stress fracture\", r\"avulsion fracture\",\n        r\"subchondral fracture\", r\"subkondral kiri\",\n    ),\n}\n\n# Terms that look like a finding but are not the finding being scored.\nDECOY = {\n    \"Fracture\": _rx(r\"no fracture\", r\"microfractur\", r\"\\bfracture (risk|prophyla)\"),\n    \"Baker's\": _rx(r\"meniscal cyst\", r\"quiste meniscal\", r\"ganglion\"),\n}\n\nPAIRED = {\"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\"}\nOA_TARGETS = {\"Medial OA\", \"Lateral OA\", \"PF OA\"}\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.482209Z","iopub.status.busy":"2026-08-06T06:22:37.481915Z","iopub.status.idle":"2026-08-06T06:22:37.508573Z","shell.execute_reply":"2026-08-06T06:22:37.507823Z"},"papermill":{"duration":0.034937,"end_time":"2026-08-06T06:22:37.510227+00:00","exception":false,"start_time":"2026-08-06T06:22:37.47529+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-07","cell_type":"code","source":"STEM_MENISCUS = _rx(r\"menisc\\w*\", r\"menisk\\w*\", r\"μηνισκ\\w*\", r\"мениск\\w*\")\nSTEM_CRUCIATE = _rx(r\"cruciate\", r\"cruzado\", r\"croise\", r\"kruisband\", r\"kreuzband\",\n                    r\"capraz bag\\w*\", r\"krizn\\w*\", r\"χιαστ\\w*\", r\"кръстн\\w*\",\n                    r\"\\bacl\\b\", r\"\\bpcl\\b\", r\"\\blca\\b\", r\"\\blcp\\b\", r\"\\bvkb\\b\",\n                    r\"\\bhkb\\b\", r\"\\bocb\\b\", r\"\\bacb\\b\")\nSTEM_COLLATERAL = _rx(r\"collateral\\w*\", r\"colateral\\w*\", r\"kollateral\\w*\",\n                      r\"collaterale\\w*\", r\"kolateraln\\w*\", r\"yan bag\\w*\",\n                      r\"πλαγι\\w*\", r\"колатерал\\w*\", r\"странич\\w*\",\n                      r\"innenband\\w*\", r\"aussenband\\w*\", r\"binnenband\\w*\",\n                      r\"\\bmcl\\b\", r\"\\blcl\\b\", r\"\\blcm\\b\", r\"\\biyb\\b\")\n\nSIDE_MEDIAL = _rx(r\"\\bmedial\\w*\", r\"\\bmedyal\\w*\", r\"\\bmedijaln\\w*\", r\"\\bmediaal\\w*\",\n                  r\"\\bmediale\\w*\", r\"\\binterno\\w*\", r\"\\binterne\\w*\", r\"\\binnen\\w*\",\n                  r\"\\bic\\b\", r\"\\bunutarnj\\w*\", r\"\\bεσω\\w*\", r\"\\bεσωτερικ\\w*\",\n                  r\"\\bмедиал\\w*\", r\"\\bвътреш\\w*\", r\"\\btibial collateral\\b\",\n                  r\"\\bbinnen\\w*\", r\"\\bmediaal\\b\")\nSIDE_LATERAL = _rx(r\"\\blateral\\w*\", r\"\\bexterno\\w*\", r\"\\bexterne\\w*\", r\"\\bdis\\b\",\n                   r\"\\blateraln\\w*\", r\"\\baussen\\w*\", r\"\\bbuiten\\w*\", r\"\\bεξω\\w*\",\n                   r\"\\bεξωτερικ\\w*\", r\"\\bлатерал\\w*\", r\"\\bвъншн\\w*\",\n                   r\"\\bfibular collateral\\b\", r\"\\bvanjsk\\w*\")\nSIDE_ANTERIOR = _rx(r\"\\banterior\\w*\", r\"\\bant\\b\", r\"\\bon\\b\", r\"\\bprednj\\w*\",\n                    r\"\\bvorder\\w*\", r\"\\bvoorste\\b\", r\"\\bπροσθι\\w*\", r\"\\bпредн\\w*\",\n                    r\"\\banteriyor\\w*\", r\"\\bavant\\b\", r\"\\bant[eé]rieur\\w*\")\n\n# Fracture is the target whose stem varies most across the corpus.\nSTEM_FRACTURE = _rx(r\"fractur\\w*\", r\"fraktur\\w*\", r\"fractuur\\w*\", r\"\\bfract\\b\",\n                    r\"kiri[kgğ]\\w*\", r\"prijelom\\w*\", r\"lom kosti\", r\"\\bbreuk\\w*\",\n                    r\"\\bbruch\\w*\", r\"καταγμα\\w*\", r\"καταγματ\\w*\", r\"фрактур\\w*\",\n                    # NOT a bare `fissur\\w*`: \"fisuras condrales\" and \"full thickness\n                    # fissures in the articular cartilage\" describe cartilage, not bone.\n                    # The stem has to be anchored to a bone word to mean a fracture.\n                    r\"счупван\\w*\", r\"fisur\\w* (osea|oseas|kost)\", r\"fissur\\w* kost\")\n\nSTEM_OA_COMPARTMENT = _rx(r\"compartment\\w*\", r\"compartimento\\w*\", r\"compartiment\\w*\",\n                          r\"kompartman\\w*\", r\"kompartiment\\w*\", r\"odjelj\\w*\",\n                          r\"διαμερισμα\\w*\", r\"компартм\\w*\", r\"\\bотдел\\w*\",\n                          r\"femorotibial\\w*\", r\"femorotibiaal\\w*\", r\"tibiofemoral\\w*\",\n                          r\"femoro tibial\\w*\", r\"κνημιαι\\w*\", r\"μηριαι\\w*\",\n                          r\"femoral condyl\\w*\", r\"tibial plateau\\w*\",\n                          r\"condilo femoral\", r\"platillo tibial\", r\"tibiaplateau\\w*\",\n                          r\"femurkondyl\\w*\", r\"femoralne? kondil\\w*\",\n                          r\"tibijaln\\w* plato\", r\"femoral kondil\\w*\",\n                          r\"tibia plato\", r\"tibyal plato\")\n\n\ndef _near(clause: str, stem_rx: re.Pattern, qual_rx: re.Pattern, window: int = 55):\n    \"\"\"True if a stem match has a qualifier within `window` characters either side.\n\n    Character windows rather than token windows, because word order differs: English\n    puts the side before the noun, Greek and Bulgarian often after, and Turkish\n    attaches it as a separate preceding adjective.\n    \"\"\"\n    for m in stem_rx.finditer(clause):\n        lo = max(0, m.start() - window)\n        hi = min(len(clause), m.end() + window)\n        if qual_rx.search(clause[lo:hi]):\n            return True\n    return False\n\n\n# concept -> (stem, side) pairs used in addition to the phrase lexicons above\nSTEM_RULES = {\n    \"ACL\": (STEM_CRUCIATE, SIDE_ANTERIOR),\n    \"MCL\": (STEM_COLLATERAL, SIDE_MEDIAL),\n    \"Medial Meniscus\": (STEM_MENISCUS, SIDE_MEDIAL),\n    \"Lateral Meniscus\": (STEM_MENISCUS, SIDE_LATERAL),\n    \"Medial OA\": (STEM_OA_COMPARTMENT, SIDE_MEDIAL),\n    \"Lateral OA\": (STEM_OA_COMPARTMENT, SIDE_LATERAL),\n}\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.522179Z","iopub.status.busy":"2026-08-06T06:22:37.521923Z","iopub.status.idle":"2026-08-06T06:22:37.535019Z","shell.execute_reply":"2026-08-06T06:22:37.534312Z"},"papermill":{"duration":0.021238,"end_time":"2026-08-06T06:22:37.536665+00:00","exception":false,"start_time":"2026-08-06T06:22:37.515427+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-08","cell_type":"code","source":"SEV_LOW = _rx(\n    r\"\\bsmall\\b\", r\"\\bminimal\\b\", r\"\\btrace\\b\", r\"\\bmild\\b\", r\"\\bslight\\b\",\n    r\"\\btiny\\b\", r\"\\bscant\\b\", r\"\\bmimimal\\b\", r\"\\bdiscrete\\b\", r\"\\bfocal\\b\",\n    r\"\\bleve\\b\", r\"\\bminim\", r\"\\bpeque\", r\"\\bligero\\b\", r\"\\bescaso\\b\", r\"\\bdiscreto\\b\",\n    r\"\\bhafif\\b\", r\"\\bminimal\\b\", r\"\\baz miktarda\\b\", r\"\\bsilik\\b\",\n    r\"\\bmanja\\b\", r\"\\bmanji\\b\", r\"\\bblago\\b\", r\"\\bdiskretn\", r\"\\bmalo\\b\",\n    r\"\\bgering\", r\"\\bdiskret\", r\"\\bkleine?r?\\b\", r\"\\bwenig\\b\", r\"\\bzarte?\\b\",\n    r\"\\bbeperkte?\\b\", r\"\\bgeringe\\b\", r\"\\bweinig\\b\", r\"\\blichte?\\b\",\n    r\"\\bηπι\", r\"\\bμικρ\", r\"\\bελαχιστ\",\n    r\"\\bминимал\", r\"\\bлек\", r\"\\bмалк\", r\"\\bнеголям\",\n)\n\nSEV_HIGH = _rx(\n    r\"\\blarge\\b\", r\"\\bmarked\\b\", r\"\\bmassive\\b\", r\"\\bsevere\\b\", r\"\\bextensive\\b\",\n    r\"\\bmoderate\\b\", r\"\\bgross\\b\", r\"\\bsignificant\\b\", r\"\\babundant\\b\", r\"\\btense\\b\",\n    r\"\\bmoderad\", r\"\\bimportante\\b\", r\"\\bsevera?\\b\", r\"\\bmarcad\", r\"\\bcuantios\",\n    r\"\\bbelirgin\\b\", r\"\\byaygin\\b\", r\"\\bileri\\b\", r\"\\bciddi\\b\", r\"\\bbol\\b\",\n    r\"\\bopsezan\\b\", r\"\\bveliki\\b\", r\"\\bizrazit\", r\"\\bznacajn\", r\"\\bumjeren\",\n    r\"\\bausgepragt\", r\"\\bdeutlich\", r\"\\bmassiv\", r\"\\bmassig\", r\"\\bgross\",\n    r\"\\buitgebreid\", r\"\\bgevorderd\", r\"\\bveel\\b\", r\"\\bmatige?\\b\",\n    r\"\\bμετρι\", r\"\\bμεγαλ\", r\"\\bεκτεταμεν\", r\"\\bευμεγεθ\", r\"\\bσοβαρ\",\n    r\"\\bголям\", r\"\\bизразен\", r\"\\bзначим\", r\"\\bумерен\", r\"\\bобилен\",\n)\n\n# OA is often asserted for the whole joint rather than per compartment\n# (\"tricompartmental osteoarthritis\", \"gonarthrose\", \"incipient OA of all three\n# compartments\"). Those statements are evidence for all three OA targets.\nGLOBAL_OA = _rx(\n    r\"tri ?compartment\", r\"all three compartment\", r\"global(ised)? (oa|osteoarthrit)\",\n    r\"\\bgonarthros\", r\"\\bgonartros\", r\"\\bgonarthrose\", r\"\\bgonartrose\",\n    r\"osteoarthritis of the knee\", r\"artrosis (de |)(la )?rodilla\", r\"knee osteoarthrit\",\n    r\"\\bdiz osteoartrit\", r\"\\bgonartroz\", r\"artroza koljena\",\n    r\"οστεοαρθριτιδα\", r\"αρθριτιδα του γονατος\",\n    r\"артроза на колянната\", r\"гонартроз\",\n    r\"degenerative joint disease\", r\"\\bdjd\\b\",\n)\n\n# A bare \"bone marrow oedema\" is not a contusion when it sits under a cartilage\n# defect: subchondral oedema beneath a worn compartment is reactive degenerative signal,\n# and reading it as a bruise turns every osteoarthritic knee into a trauma case.\nDEGENERATIVE_MARROW = _rx(\n    r\"subchondral\", r\"subcondral\", r\"subkondral\", r\"supkondraln\", r\"subchondraln\",\n    r\"υποχονδρι\", r\"субхондрал\", r\"subchondrale?\",\n    r\"\\bcyst\", r\"\\bquist\", r\"\\bzyste\\b\", r\"\\bcistic\", r\"reactive\", r\"reactivo\",\n)\n\nTRAUMA = _rx(\n    r\"\\bbruise\\b\", r\"\\bcontusion\", r\"\\bkontuz\", r\"\\bcontusion osea\\b\",\n    r\"\\btrauma\", r\"\\bimpaction\\b\", r\"\\bpivot shift\\b\", r\"\\bkissing\\b\",\n    r\"\\bacute\\b\", r\"\\bagudo\\b\", r\"\\bakut\", r\"\\bpivot kaymasi\\b\",\n    r\"\\bcontusion osseuse\\b\", r\"\\bbone bruise\\b\", r\"\\bbotcontusie\\b\",\n    r\"\\bконтузион\", r\"\\bμωλωπ\", r\"\\bkontuzij\",\n)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.548617Z","iopub.status.busy":"2026-08-06T06:22:37.548292Z","iopub.status.idle":"2026-08-06T06:22:37.559253Z","shell.execute_reply":"2026-08-06T06:22:37.558628Z"},"papermill":{"duration":0.018996,"end_time":"2026-08-06T06:22:37.560881+00:00","exception":false,"start_time":"2026-08-06T06:22:37.541885+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-09","cell_type":"code","source":"def _polarity(clause: str, anchor_end: int) -> str:\n    \"\"\"Classify one clause as positive, negative or uncertain for a matched term.\n\n    Scope is the whole clause. Clause segmentation already keeps statements short, and\n    a window in characters mis-scopes badly across languages with different word orders -\n    Turkish puts its negator at the end of the sentence, English at the front.\n    \"\"\"\n    if UNCERTAIN.search(clause):\n        return \"uncertain\"\n    if NEGATION.search(clause):\n        return \"negative\"\n    if NORMALITY.search(clause):\n        # \"meniscus normal\" negates; \"normal ... but tear\" does not.\n        if TEAR.search(clause) or re.search(r\"\\bgrade [34]\\b\", clause):\n            return \"positive\"\n        return \"negative\"\n    return \"positive\"\n\n\nclass _Matcher:\n    \"\"\"Phrase lexicon first, stem+side proximity as the fallback.\n\n    Exposes `.search` so it drops into the same slot as a compiled pattern.\n    \"\"\"\n\n    def __init__(self, phrase_rx, stem=None, side=None, window=55):\n        self.phrase_rx = phrase_rx\n        self.stem = stem\n        self.side = side\n        self.window = window\n\n    def search(self, clause):\n        m = self.phrase_rx.search(clause)\n        if m is not None:\n            return m\n        if self.stem is not None and _near(clause, self.stem, self.side, self.window):\n            return self.stem.search(clause)\n        return None\n\n\nANAT_MATCH = {\n    tgt: _Matcher(ANAT[tgt], *STEM_RULES[tgt]) for tgt in PAIRED\n}\nCOMPARTMENT_MATCH = {\n    \"Medial OA\": _Matcher(COMPARTMENT[\"Medial OA\"], *STEM_RULES[\"Medial OA\"]),\n    \"Lateral OA\": _Matcher(COMPARTMENT[\"Lateral OA\"], *STEM_RULES[\"Lateral OA\"]),\n    \"PF OA\": _Matcher(COMPARTMENT[\"PF OA\"]),\n}\nDIRECT_MATCH = {\n    tgt: _Matcher(_rx(rx.pattern, STEM_FRACTURE.pattern) if tgt == \"Fracture\" else rx)\n    for tgt, rx in DIRECT.items()\n}\n\n\ndef _severity(clause: str) -> float:\n    \"\"\"Weight one positive mention by how emphatic the sentence is.\n\n    Ordered, not calibrated. A \"moderate effusion\" must outrank a \"trace effusion\" and\n    both must outrank silence; the absolute numbers do not matter to AUC.\n    \"\"\"\n    high = SEV_HIGH.search(clause) is not None\n    low = SEV_LOW.search(clause) is not None\n    if high and not low:\n        return 1.0\n    if low and not high:\n        return 0.45\n    return 0.75                       # unqualified mention\n\n\ndef _score_clauses(cls, anat_rx, path_rx=None, decoy_rx=None, context_penalty=None,\n                   context_bonus=None):\n    \"\"\"Accumulate graded evidence over clauses for one target.\n\n    Returns (score, confidence, n_pos, n_neg). Positives are graded by severity and by\n    optional context regexes; negatives only matter when nothing positive was found,\n    because reports assert normality for every structure they check.\n    \"\"\"\n    n_pos = n_neg = n_unc = 0\n    best = 0.0\n    for c in cls:\n        m = anat_rx.search(c)\n        if not m:\n            continue\n        if decoy_rx is not None and decoy_rx.search(c):\n            continue\n        if path_rx is not None and not path_rx.search(c):\n            if NORMALITY.search(c) and not NEGATION.search(c):\n                n_neg += 1\n            continue\n        pol = _polarity(c, m.end())\n        if pol == \"positive\":\n            n_pos += 1\n            w = _severity(c)\n            if context_penalty is not None and context_penalty.search(c):\n                w *= 0.45\n            if context_bonus is not None and context_bonus.search(c):\n                w = min(1.0, w * 1.35)\n            best = max(best, w)\n        elif pol == \"negative\":\n            n_neg += 1\n        else:\n            n_unc += 1\n            best = max(best, 0.30)\n\n    if n_pos or n_unc:\n        # 0.52 .. 0.95, ordered by the strongest single mention, nudged by repetition.\n        score = min(0.95, 0.50 + 0.42 * best + 0.03 * min(n_pos, 3))\n        conf = min(1.0, 0.55 + 0.15 * n_pos)\n    elif n_neg:\n        score = max(0.04, 0.20 - 0.04 * n_neg)\n        conf = min(0.9, 0.45 + 0.12 * n_neg)\n    else:\n        score, conf = 0.28, 0.05          # silence sits above asserted-negative\n    return score, conf, n_pos, n_neg\n\n\ndef extract(report: str) -> dict:\n    \"\"\"Extract twelve (score, confidence) pairs from one report.\"\"\"\n    cls = clauses(report)\n    out = {}\n    path_paired = _rx(TEAR.pattern, DEGEN.pattern, INJURY.pattern)\n\n    for tgt in TARGETS:\n        if tgt in PAIRED:\n            s, c, npos, nneg = _score_clauses(cls, ANAT_MATCH[tgt], path_paired)\n        elif tgt in OA_TARGETS:\n            s, c, npos, nneg = _score_clauses(cls, COMPARTMENT_MATCH[tgt], OA_EVIDENCE)\n        elif tgt == \"Contusion\":\n            # Reactive subchondral oedema under a cartilage defect is osteoarthritis,\n            # not a bruise. Explicit trauma wording pushes the other way.\n            s, c, npos, nneg = _score_clauses(cls, DIRECT_MATCH[tgt], None, DECOY.get(tgt),\n                                              context_penalty=DEGENERATIVE_MARROW,\n                                              context_bonus=TRAUMA)\n        else:\n            s, c, npos, nneg = _score_clauses(cls, DIRECT_MATCH[tgt], None, DECOY.get(tgt))\n        out[tgt] = s\n        out[tgt + \"__conf\"] = c\n        out[tgt + \"__npos\"] = npos\n        out[tgt + \"__nneg\"] = nneg\n\n    # --- cross-target corrections ------------------------------------------ #\n    # A whole-joint osteoarthritis statement is evidence for every compartment that was\n    # not separately assessed. Without this, \"incipient OA of all three compartments\"\n    # scores zero on all three OA targets.\n    g_hits = [c for c in cls if GLOBAL_OA.search(c) and _polarity(c, 0) == \"positive\"]\n    if g_hits:\n        gscore = 0.50 + 0.42 * max(_severity(c) for c in g_hits)\n        for tgt in OA_TARGETS:\n            if out[tgt + \"__npos\"] == 0 and out[tgt + \"__nneg\"] == 0:\n                out[tgt] = max(out[tgt], gscore * 0.92)\n                out[tgt + \"__conf\"] = max(out[tgt + \"__conf\"], 0.4)\n\n    # Synovitis is frequently visible on the images and absent from the text, so silence\n    # is weak evidence of absence here in a way it is not for other findings. Effusion is\n    # its most reliable textual proxy - the two share a mechanism - so a silent synovitis\n    # inherits a fraction of the effusion evidence instead of falling to the floor.\n    if out[\"Synovitis__npos\"] == 0 and out[\"Synovitis__nneg\"] == 0:\n        out[\"Synovitis\"] = max(out[\"Synovitis\"], 0.28 + 0.45 * (out[\"Effusion\"] - 0.28))\n\n    return out\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.573071Z","iopub.status.busy":"2026-08-06T06:22:37.572831Z","iopub.status.idle":"2026-08-06T06:22:37.592824Z","shell.execute_reply":"2026-08-06T06:22:37.591963Z"},"papermill":{"duration":0.028333,"end_time":"2026-08-06T06:22:37.594636+00:00","exception":false,"start_time":"2026-08-06T06:22:37.566303+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-10","cell_type":"markdown","source":"## DICOM series","metadata":{"papermill":{"duration":0.005069,"end_time":"2026-08-06T06:22:37.604978+00:00","exception":false,"start_time":"2026-08-06T06:22:37.599909+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-11","cell_type":"code","source":"from __future__ import annotations\n\nimport os\nfor _v in (\"OMP_NUM_THREADS\", \"OPENBLAS_NUM_THREADS\", \"MKL_NUM_THREADS\"):\n    os.environ.setdefault(_v, \"4\")\n\nimport gc\nimport hashlib\nimport re\nimport time\nimport traceback\nfrom concurrent.futures import ThreadPoolExecutor\nfrom copy import deepcopy\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\nif \"extract\" not in dir():\n    import sys\n    for _p in (\"src\", \"../src\", \"../../src\"):\n        if (Path(_p) / \"report_labeler.py\").is_file():\n            sys.path.insert(0, _p)\n            break\n    from report_labeler import extract\n\nT0 = time.time()\nSEED = 2026\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nTARGETS = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\",\n]\n\nIMG = 280\nCROP_MM = 130.0\nGROUP = 3\nN_GROUP_MAX = 3\nCACHE_BUDGET_GB = 12.0\nHDR_THREADS = 16\nPIX_THREADS = 12\nPHYSICAL_SORT = True\n\nEPOCHS = 13\nFINAL_EPOCHS = 3\nBATCH_STUDIES = 8\nGOLD_PER_BATCH = 1\nLR_BACKBONE = 7e-6\nLR_HEAD = 9e-4\nFINAL_LR_SCALE = 0.30\nLAYER_LR_DECAY = 0.72\nWEIGHT_DECAY = 0.02\nUNFREEZE_LAST = 6\nEVAL_BATCH = 12\nEMA_DECAY = 0.995\nRANK_LOSS_WEIGHT = 0.045\nSLOT_DROPOUT = 0.10\nN_SOUP = 3\nTIME_BUDGET = 8.35 * 3600\n\nSLOTS_RECOVERED = [\n    (\"SAG_FLUID_FS\", \"Sagittal\", True, True),\n    (\"COR_FLUID_FS\", \"Coronal\", True, True),\n    (\"AX_FLUID_FS\", \"Axial\", True, True),\n    (\"SAG_FLUID_NOFS\", \"Sagittal\", True, False),\n    (\"COR_T1\", \"Coronal\", False, False),\n    (\"SAG_T1\", \"Sagittal\", False, False),\n]\n\nSLOTS_PUBLIC = [\n    (\"SAG_FLUID\", \"Sagittal\", None, True),\n    (\"COR_FLUID\", \"Coronal\", None, True),\n    (\"AX_FLUID\", \"Axial\", None, True),\n    (\"SAG_STRUCT\", \"Sagittal\", None, False),\n    (\"COR_STRUCT\", \"Coronal\", None, False),\n    (\"AX_STRUCT\", \"Axial\", None, False),\n]\n\nSLOT_SCHEME = os.environ.get(\"SLOT_SCHEME\", \"recovered\")\nSLOTS = SLOTS_PUBLIC if SLOT_SCHEME == \"public\" else SLOTS_RECOVERED\nN_SLOT = len(SLOTS)\n\nFATSAT_OPTS = {\"FS\", \"FATSAT\", \"FAT_SAT\", \"FSAT\"}\n_SEP = re.compile(r\"[_\\-.]\")\n_FATSAT_RX = re.compile(\n    r\"\\bfs\\b|fatsat|fat sat|\\bstir\\b|\\bspair\\b|\\bspir\\b|\\bwe\\b|\"\n    r\"water excit|\\btirm\\b|\\bsting\\b|\\bfatsup\\b\"\n)\n_T1_RX = re.compile(r\"\\bt1\\b|\\bt1w\\b\")\n_T2_RX = re.compile(r\"\\bt2\\b|\\bt2w\\b\")\n_PD_RX = re.compile(r\"\\bpd\\b|\\bpdw\\b|proton|\\bdp\\b|dens\")\n\ntorch.backends.cuda.matmul.allow_tf32 = True\ntorch.backends.cudnn.allow_tf32 = True\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:37.616521Z","iopub.status.busy":"2026-08-06T06:22:37.616165Z","iopub.status.idle":"2026-08-06T06:22:44.086809Z","shell.execute_reply":"2026-08-06T06:22:44.086105Z"},"papermill":{"duration":6.478817,"end_time":"2026-08-06T06:22:44.088774+00:00","exception":false,"start_time":"2026-08-06T06:22:37.609957+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-12","cell_type":"code","source":"def log(msg):\n    print(f\"[{time.time() - T0:7.1f}s] {msg}\", flush=True)\n\n\ndef find_root():\n    for c in [\n        Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\"),\n        Path(\"/kaggle/input/rsna-knee-abnormality-detection\"),\n        Path(\"data\"), Path(\".\"),\n    ]:\n        if (c / \"test.csv\").is_file() and (c / \"test_series\").is_dir():\n            return c\n    base = Path(\"/kaggle/input\")\n    if base.is_dir():\n        for depth1 in sorted(p for p in base.iterdir() if p.is_dir()):\n            for cand in [depth1] + sorted(p for p in depth1.iterdir() if p.is_dir()):\n                if (cand / \"test.csv\").is_file() and (cand / \"test_series\").is_dir():\n                    return cand\n    raise FileNotFoundError(\"competition mount not found\")\n\n\ndef find_dinov2(variant=\"small\"):\n    base = Path(\"/kaggle/input\")\n    if not base.is_dir():\n        return None\n    hits = []\n    for root, dirs, files in os.walk(base):\n        dirs[:] = [d for d in dirs if d not in (\"train_series\", \"test_series\")]\n        if \"config.json\" in files and \"dinov2\" in root.lower():\n            hits.append(Path(root))\n    for h in hits:\n        if variant in str(h).lower():\n            return h\n    return hits[0] if hits else None\n\n\nROOT = find_root()\nlog(f\"input root: {ROOT}\")\n\n\ndef plan_cache(n_study):\n    per_slice = n_study * N_SLOT * IMG * IMG\n    afford = int(CACHE_BUDGET_GB * 1024 ** 3 // max(per_slice, 1))\n    groups = max(1, min(N_GROUP_MAX, afford // GROUP))\n    if groups < N_GROUP_MAX:\n        log(f\"cache budget allows {groups} group(s), not {N_GROUP_MAX}\")\n    return groups\n\n\nN_GROUP = plan_cache(len(pd.read_csv(ROOT / \"train.csv\")))\nCACHE_SLICES = GROUP * N_GROUP\nlog(f\"cache layout: {N_GROUP} x {GROUP} = {CACHE_SLICES} slices per slot\")\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.10105Z","iopub.status.busy":"2026-08-06T06:22:44.100666Z","iopub.status.idle":"2026-08-06T06:22:44.306202Z","shell.execute_reply":"2026-08-06T06:22:44.305481Z"},"papermill":{"duration":0.213771,"end_time":"2026-08-06T06:22:44.308052+00:00","exception":false,"start_time":"2026-08-06T06:22:44.094281+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-13","cell_type":"code","source":"HDR_TAGS = [\n    \"SeriesDescription\", \"SequenceName\", \"ScanOptions\", \"ScanningSequence\",\n    \"RepetitionTime\", \"EchoTime\", \"Laterality\", \"ImageLaterality\",\n    \"PixelSpacing\", \"Rows\", \"Columns\", \"SliceThickness\",\n    \"SpacingBetweenSlices\", \"RescaleSlope\", \"RescaleIntercept\",\n]\n\n\ndef probe(item):\n    split, study, series, path = item\n    row = {\n        \"split\": split,\n        \"StudyInstanceUID\": study,\n        \"SeriesInstanceUID\": series,\n        \"dir\": path,\n    }\n    try:\n        files = sorted(e.name for e in os.scandir(path) if e.name.endswith(\".dcm\"))\n        row[\"files\"] = files\n        row[\"n_slices\"] = len(files)\n        if not files:\n            return row\n        ds = pydicom.dcmread(\n            os.path.join(path, files[len(files) // 2]),\n            stop_before_pixels=True,\n            force=True,\n        )\n        for t in HDR_TAGS:\n            v = getattr(ds, t, None)\n            if v is None:\n                row[t] = None\n            elif isinstance(v, (list, tuple)) or type(v).__name__ == \"MultiValue\":\n                row[t] = \"|\".join(str(x) for x in v)\n            else:\n                row[t] = str(v)\n    except Exception as exc:\n        row[\"err\"] = str(exc)[:120]\n    return row\n\n\ndef walk(split):\n    base = ROOT / split\n    items = []\n    if not base.is_dir():\n        return pd.DataFrame()\n    for study in os.scandir(base):\n        if study.is_dir():\n            for series in os.scandir(study.path):\n                if series.is_dir():\n                    items.append((split, study.name, series.name, series.path))\n    with ThreadPoolExecutor(max_workers=HDR_THREADS) as pool:\n        return pd.DataFrame(list(pool.map(probe, items)))\n\n\ndef annotate(df):\n    desc = df[\"SeriesDescription\"].fillna(\"\") + \" \" + df[\"SequenceName\"].fillna(\"\")\n    desc = desc.str.lower().str.replace(_SEP, \" \", regex=True)\n\n    opts = df[\"ScanOptions\"].fillna(\"\").str.upper().str.split(\"|\")\n    opts_fs = opts.apply(lambda ts: any(t.strip() in FATSAT_OPTS for t in ts))\n    df[\"fatsat\"] = desc.str.contains(_FATSAT_RX) | opts_fs\n\n    tr = pd.to_numeric(df[\"RepetitionTime\"], errors=\"coerce\")\n    te = pd.to_numeric(df[\"EchoTime\"], errors=\"coerce\")\n    gre = df[\"ScanningSequence\"].fillna(\"\").str.upper().str.contains(\"GR\")\n    t1 = desc.str.contains(_T1_RX)\n    t2 = desc.str.contains(_T2_RX)\n    pdw = desc.str.contains(_PD_RX)\n\n    df[\"weight\"] = np.where(\n        t1 & ~t2 & ~pdw, \"T1\",\n        np.where(\n            t2 & ~pdw, \"T2\",\n            np.where(\n                pdw, \"PD\",\n                np.where(\n                    gre, \"GRE\",\n                    np.where(\n                        tr < 800, \"T1\",\n                        np.where(te > 60, \"T2\", np.where(tr >= 800, \"PD\", \"UNK\")),\n                    ),\n                ),\n            ),\n        ),\n    )\n    df[\"fluid\"] = np.isin(df[\"weight\"], [\"PD\", \"T2\"])\n    df[\"px\"] = pd.to_numeric(\n        df[\"PixelSpacing\"].fillna(\"\").str.split(\"|\").str[0].replace(\"\", np.nan),\n        errors=\"coerce\",\n    )\n    df[\"rows\"] = pd.to_numeric(df[\"Rows\"], errors=\"coerce\")\n    df[\"cols\"] = pd.to_numeric(df[\"Columns\"], errors=\"coerce\")\n    return df\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.321119Z","iopub.status.busy":"2026-08-06T06:22:44.320824Z","iopub.status.idle":"2026-08-06T06:22:44.335153Z","shell.execute_reply":"2026-08-06T06:22:44.334306Z"},"papermill":{"duration":0.02318,"end_time":"2026-08-06T06:22:44.336928+00:00","exception":false,"start_time":"2026-08-06T06:22:44.313748+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-14","cell_type":"code","source":"def _series_quality(cand, exact=True):\n    n = pd.to_numeric(cand[\"n_slices\"], errors=\"coerce\").fillna(0).clip(lower=1)\n    px = pd.to_numeric(cand[\"px\"], errors=\"coerce\").fillna(cand[\"px\"].median())\n    px = px.fillna(1.0).clip(lower=0.2, upper=3.0)\n    rows = pd.to_numeric(cand[\"rows\"], errors=\"coerce\").fillna(256).clip(lower=32)\n    cols = pd.to_numeric(cand[\"cols\"], errors=\"coerce\").fillna(256).clip(lower=32)\n    matrix = np.minimum(rows, cols)\n    score = np.log1p(n) + 0.45 * np.log1p(matrix) + 0.55 / px\n    score += (n >= 12).astype(float) * 0.35\n    if exact:\n        score += 0.50\n    return score\n\n\ndef pick_slots(series_df, plane_map):\n    series_df = series_df.copy()\n    series_df[\"plane\"] = series_df[\"SeriesInstanceUID\"].map(plane_map)\n    out = {}\n\n    for study, g in series_df.groupby(\"StudyInstanceUID\"):\n        chosen = {}\n        for name, plane, fluid, fs in SLOTS:\n            base = g[(g[\"plane\"] == plane) & (g[\"fatsat\"] == fs)]\n            exact = True\n            cand = base\n            if fluid is not None:\n                cand = base[base[\"fluid\"] == fluid]\n\n            if len(cand) == 0 and fluid is False:\n                cand = g[(g[\"plane\"] == plane) & (~g[\"fatsat\"])]\n                exact = False\n\n            if len(cand):\n                score = _series_quality(cand, exact=exact)\n                chosen[name] = cand.loc[score.idxmax()]\n        out[study] = chosen\n\n    return out\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.349748Z","iopub.status.busy":"2026-08-06T06:22:44.34948Z","iopub.status.idle":"2026-08-06T06:22:44.356124Z","shell.execute_reply":"2026-08-06T06:22:44.355321Z"},"papermill":{"duration":0.015151,"end_time":"2026-08-06T06:22:44.35783+00:00","exception":false,"start_time":"2026-08-06T06:22:44.342679+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-15","cell_type":"markdown","source":"## Preprocessing","metadata":{"papermill":{"duration":0.005514,"end_time":"2026-08-06T06:22:44.368867+00:00","exception":false,"start_time":"2026-08-06T06:22:44.363353+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-16","cell_type":"code","source":"ORDER_TAGS = [\n    \"ImagePositionPatient\", \"ImageOrientationPatient\",\n    \"SliceLocation\", \"InstanceNumber\",\n]\n\n\ndef _float_list(v):\n    try:\n        return np.asarray([float(x) for x in v], dtype=np.float64)\n    except Exception:\n        return None\n\n\ndef _ordered_files(rec):\n    files, d = list(rec[\"files\"]), rec[\"dir\"]\n    if not PHYSICAL_SORT or len(files) <= 1:\n        return files\n\n    rows = []\n    for name in files:\n        path = os.path.join(d, name)\n        try:\n            ds = pydicom.dcmread(\n                path,\n                stop_before_pixels=True,\n                force=True,\n                specific_tags=ORDER_TAGS,\n            )\n            pos = _float_list(getattr(ds, \"ImagePositionPatient\", None))\n            ori = _float_list(getattr(ds, \"ImageOrientationPatient\", None))\n            coord = None\n            if pos is not None and ori is not None and len(pos) >= 3 and len(ori) >= 6:\n                normal = np.cross(ori[:3], ori[3:6])\n                if np.linalg.norm(normal) > 1e-6:\n                    coord = float(np.dot(pos[:3], normal))\n            if coord is None:\n                v = getattr(ds, \"SliceLocation\", None)\n                coord = float(v) if v is not None else None\n            inst = getattr(ds, \"InstanceNumber\", None)\n            inst = float(inst) if inst is not None else None\n        except Exception:\n            coord, inst = None, None\n        rows.append((name, coord, inst))\n\n    n_coord = sum(v is not None and np.isfinite(v) for _, v, _ in rows)\n    n_inst = sum(v is not None and np.isfinite(v) for _, _, v in rows)\n\n    if n_coord >= max(3, int(0.7 * len(rows))):\n        return [x[0] for x in sorted(\n            rows,\n            key=lambda x: (\n                x[1] is None or not np.isfinite(x[1]),\n                0.0 if x[1] is None else x[1],\n                x[0],\n            ),\n        )]\n    if n_inst >= max(3, int(0.7 * len(rows))):\n        return [x[0] for x in sorted(\n            rows,\n            key=lambda x: (\n                x[2] is None or not np.isfinite(x[2]),\n                0.0 if x[2] is None else x[2],\n                x[0],\n            ),\n        )]\n    return files\n\n\ndef read_slot(rec, n_slice=None, out_size=None):\n    n_slice = CACHE_SLICES if n_slice is None else n_slice\n    out_size = IMG if out_size is None else out_size\n    files = _ordered_files(rec)\n    d, px = rec[\"dir\"], rec[\"px\"]\n    n = len(files)\n    if n == 0:\n        return None\n\n    lo, hi = int(0.18 * (n - 1)), int(0.82 * (n - 1))\n    idx = np.unique(np.linspace(lo, hi, n_slice).round().astype(int))\n    while len(idx) < n_slice:\n        idx = np.append(idx, idx[-1])\n\n    planes = []\n    for i in idx[:n_slice]:\n        try:\n            ds = pydicom.dcmread(os.path.join(d, files[int(i)]), force=True)\n            a = ds.pixel_array.astype(np.float32)\n            sl = float(getattr(ds, \"RescaleSlope\", 1) or 1)\n            ic = float(getattr(ds, \"RescaleIntercept\", 0) or 0)\n            a = a * sl + ic\n            if str(getattr(ds, \"PhotometricInterpretation\", \"\")).upper() == \"MONOCHROME1\":\n                a = a.max() - a\n        except Exception:\n            a = np.zeros((out_size, out_size), dtype=np.float32)\n        planes.append(a)\n\n    shp = planes[0].shape\n    planes = [p if p.shape == shp else np.zeros(shp, np.float32) for p in planes]\n    vol = np.stack(planes)\n\n    if px and np.isfinite(px) and px > 0:\n        want = int(round(CROP_MM / px))\n        h, w = shp\n        if 16 < want < min(h, w):\n            cy, cx = h // 2, w // 2\n            half = want // 2\n            vol = vol[:, max(0, cy - half):cy + half, max(0, cx - half):cx + half]\n\n    finite = vol[np.isfinite(vol)]\n    if finite.size == 0:\n        return None\n    lo_v, hi_v = np.percentile(finite, [1, 99])\n    vol = np.nan_to_num(vol, nan=lo_v, posinf=hi_v, neginf=lo_v)\n    vol = np.clip((vol - lo_v) / max(hi_v - lo_v, 1e-6), 0, 1)\n\n    t = torch.from_numpy(np.ascontiguousarray(vol)).unsqueeze(0)\n    t = F.interpolate(t, size=(out_size, out_size), mode=\"bilinear\", align_corners=False)\n    return (t.squeeze(0) * 255).round().clamp(0, 255).to(torch.uint8)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.381535Z","iopub.status.busy":"2026-08-06T06:22:44.381207Z","iopub.status.idle":"2026-08-06T06:22:44.391981Z","shell.execute_reply":"2026-08-06T06:22:44.391249Z"},"papermill":{"duration":0.019197,"end_time":"2026-08-06T06:22:44.39367+00:00","exception":false,"start_time":"2026-08-06T06:22:44.374473+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-17","cell_type":"markdown","source":"## Laterality","metadata":{"papermill":{"duration":0.0054,"end_time":"2026-08-06T06:22:44.404513+00:00","exception":false,"start_time":"2026-08-06T06:22:44.399113+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-18","cell_type":"code","source":"def normalise_laterality(img, plane, lat):\n    if lat != \"R\":\n        return img\n    if plane in (\"Coronal\", \"Axial\"):\n        return torch.flip(img, dims=[-1])\n    return torch.flip(img, dims=[0])\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.417623Z","iopub.status.busy":"2026-08-06T06:22:44.416955Z","iopub.status.idle":"2026-08-06T06:22:44.421754Z","shell.execute_reply":"2026-08-06T06:22:44.421128Z"},"papermill":{"duration":0.013238,"end_time":"2026-08-06T06:22:44.423498+00:00","exception":false,"start_time":"2026-08-06T06:22:44.41026+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-19","cell_type":"markdown","source":"## Cache","metadata":{"papermill":{"duration":0.005446,"end_time":"2026-08-06T06:22:44.43428+00:00","exception":false,"start_time":"2026-08-06T06:22:44.428834+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-20","cell_type":"code","source":"def build_cache(slot_map, plane_map, lat_map, tag):\n    studies = sorted(slot_map)\n    sidx = {s: i for i, s in enumerate(studies)}\n    cache = np.zeros((len(studies), N_SLOT, CACHE_SLICES, IMG, IMG), np.uint8)\n    mask = np.zeros((len(studies), N_SLOT), np.float32)\n    log(f\"{tag}: cache {cache.shape} = {cache.nbytes / 1024 ** 3:.1f} GB\")\n\n    jobs = [\n        (st, k, plane, slot_map[st][name])\n        for st in studies\n        for k, (name, plane, _, _) in enumerate(SLOTS)\n        if name in slot_map[st]\n    ]\n    log(f\"{tag}: decoding {len(jobs)} slot-series\")\n\n    chunk = 256 if PHYSICAL_SORT else 512\n    done = 0\n    with ThreadPoolExecutor(max_workers=PIX_THREADS) as pool:\n        for c0 in range(0, len(jobs), chunk):\n            block = jobs[c0:c0 + chunk]\n            decoded = pool.map(lambda j: read_slot(j[3], CACHE_SLICES, IMG), block)\n            for (st, k, plane, _), img in zip(block, decoded):\n                done += 1\n                if img is None:\n                    continue\n                cache[sidx[st], k] = normalise_laterality(\n                    img, plane, lat_map.get(st)\n                ).numpy()\n                mask[sidx[st], k] = 1.0\n            if done % 4096 < chunk:\n                log(f\"  {tag} {done}/{len(jobs)}\")\n            if time.time() - T0 > TIME_BUDGET:\n                log(f\"  {tag}: time budget reached during decode\")\n                break\n    gc.collect()\n    return studies, cache, mask\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.446525Z","iopub.status.busy":"2026-08-06T06:22:44.446171Z","iopub.status.idle":"2026-08-06T06:22:44.454991Z","shell.execute_reply":"2026-08-06T06:22:44.454301Z"},"papermill":{"duration":0.017052,"end_time":"2026-08-06T06:22:44.45665+00:00","exception":false,"start_time":"2026-08-06T06:22:44.439598+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-21","cell_type":"markdown","source":"## Model","metadata":{"papermill":{"duration":0.005532,"end_time":"2026-08-06T06:22:44.467674+00:00","exception":false,"start_time":"2026-08-06T06:22:44.462142+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-22","cell_type":"code","source":"def _slot_prior():\n    p = np.zeros((len(TARGETS), N_SLOT), np.float32)\n    slot_index = {name: i for i, (name, _, _, _) in enumerate(SLOTS)}\n\n    def add(target, names, value):\n        ti = TARGETS.index(target)\n        for name in names:\n            if name in slot_index:\n                p[ti, slot_index[name]] = value\n\n    add(\"ACL\", [\"SAG_FLUID_FS\", \"SAG_FLUID_NOFS\"], 0.8)\n    add(\"ACL\", [\"COR_FLUID_FS\"], 0.35)\n    add(\"MCL\", [\"COR_FLUID_FS\", \"COR_T1\"], 0.8)\n    for t in (\"Medial Meniscus\", \"Lateral Meniscus\"):\n        add(t, [\"SAG_FLUID_FS\", \"COR_FLUID_FS\", \"SAG_FLUID_NOFS\"], 0.55)\n    for t in (\"Medial OA\", \"Lateral OA\"):\n        add(t, [\"COR_FLUID_FS\", \"COR_T1\", \"SAG_T1\"], 0.45)\n    add(\"PF OA\", [\"AX_FLUID_FS\"], 0.9)\n    add(\"PF OA\", [\"SAG_FLUID_FS\"], 0.3)\n    for t in (\"Effusion\", \"Synovitis\"):\n        add(t, [\"AX_FLUID_FS\", \"SAG_FLUID_FS\"], 0.6)\n    add(\"Baker's\", [\"SAG_FLUID_FS\", \"COR_FLUID_FS\"], 0.65)\n    for t in (\"Contusion\", \"Fracture\"):\n        add(t, [\"SAG_FLUID_FS\", \"COR_FLUID_FS\", \"AX_FLUID_FS\"], 0.45)\n    return p\n\n\nclass SlotHead(nn.Module):\n    def __init__(self, dim, n_slot, n_out, hidden=320, p=0.18):\n        super().__init__()\n        self.proj = nn.Sequential(\n            nn.LayerNorm(dim),\n            nn.Linear(dim, hidden),\n            nn.GELU(),\n            nn.Dropout(0.08),\n        )\n        self.slot_emb = nn.Parameter(torch.randn(n_slot, hidden) * 0.02)\n        self.query = nn.Parameter(torch.randn(n_out, hidden) * 0.02)\n        self.prior = nn.Parameter(torch.from_numpy(_slot_prior()))\n        self.drop = nn.Dropout(p)\n        self.out = nn.Linear(hidden, n_out)\n        self.global_gate = nn.Parameter(torch.full((n_out,), -1.2))\n        self.hidden = hidden\n\n    def forward(self, x, mask):\n        h = self.proj(x) + self.slot_emb\n        att = torch.einsum(\"bsh,oh->bos\", h, self.query) / self.hidden ** 0.5\n        att = att + self.prior.unsqueeze(0)\n        att = att.masked_fill(mask.unsqueeze(1) < 0.5, -1e4).softmax(-1)\n        local = torch.einsum(\"bos,bsh->boh\", att, h)\n\n        denom = mask.sum(1, keepdim=True).clamp_min(1.0)\n        global_ctx = (h * mask.unsqueeze(-1)).sum(1) / denom\n        gate = torch.sigmoid(self.global_gate).view(1, -1, 1)\n        ctx = self.drop((1.0 - gate) * local + gate * global_ctx.unsqueeze(1))\n        return (ctx * self.out.weight.unsqueeze(0)).sum(-1) + self.out.bias\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.480637Z","iopub.status.busy":"2026-08-06T06:22:44.480244Z","iopub.status.idle":"2026-08-06T06:22:44.48786Z","shell.execute_reply":"2026-08-06T06:22:44.486895Z"},"papermill":{"duration":0.016285,"end_time":"2026-08-06T06:22:44.489603+00:00","exception":false,"start_time":"2026-08-06T06:22:44.473318+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-23","cell_type":"code","source":"class Model(nn.Module):\n    def __init__(self, backbone, dim):\n        super().__init__()\n        self.backbone = backbone\n        self.head = SlotHead(dim, N_SLOT, len(TARGETS))\n        self.register_buffer(\n            \"mean\", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)\n        )\n        self.register_buffer(\n            \"std\", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)\n        )\n\n    def forward(self, imgs, mask):\n        b, s = imgs.shape[:2]\n        x = imgs.reshape(b * s, *imgs.shape[2:]).float().div_(255.0)\n        x = (x - self.mean) / self.std\n        out = self.backbone(pixel_values=x).last_hidden_state\n        patch = out[:, 1:]\n        feat = torch.cat(\n            [out[:, 0], patch.mean(1), patch.max(1).values],\n            dim=1,\n        ).reshape(b, s, -1)\n        return self.head(feat, mask)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.502162Z","iopub.status.busy":"2026-08-06T06:22:44.501604Z","iopub.status.idle":"2026-08-06T06:22:44.508745Z","shell.execute_reply":"2026-08-06T06:22:44.507843Z"},"papermill":{"duration":0.015597,"end_time":"2026-08-06T06:22:44.510674+00:00","exception":false,"start_time":"2026-08-06T06:22:44.495077+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-24","cell_type":"markdown","source":"### DINOv2 fine-tuning","metadata":{"papermill":{"duration":0.005871,"end_time":"2026-08-06T06:22:44.522013+00:00","exception":false,"start_time":"2026-08-06T06:22:44.516142+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-25","cell_type":"code","source":"def build_model():\n    from transformers import AutoModel\n\n    p = find_dinov2(\"small\")\n    if p is None:\n        raise FileNotFoundError(\"DINOv2 weights not attached\")\n\n    bb = AutoModel.from_pretrained(str(p))\n    n_layer = len(bb.encoder.layer)\n\n    for prm in bb.parameters():\n        prm.requires_grad = False\n    for blk in bb.encoder.layer[max(0, n_layer - UNFREEZE_LAST):]:\n        for prm in blk.parameters():\n            prm.requires_grad = True\n    for prm in bb.layernorm.parameters():\n        prm.requires_grad = True\n\n    dim = bb.config.hidden_size * 3\n    trainable = sum(p.numel() for p in bb.parameters() if p.requires_grad)\n    log(\n        f\"backbone: {n_layer} blocks, last {UNFREEZE_LAST} trainable \"\n        f\"({trainable / 1e6:.1f}M), feature dim {dim}\"\n    )\n    return Model(bb, dim)\n\n\ndef unwrap(model):\n    return model.module if isinstance(model, nn.DataParallel) else model\n\n\ndef build_optimizer(model, lr_scale=1.0):\n    core = unwrap(model)\n    bb = core.backbone\n    groups = []\n    layers = list(bb.encoder.layer)\n    start = max(0, len(layers) - UNFREEZE_LAST)\n\n    for layer_idx in range(start, len(layers)):\n        depth = len(layers) - 1 - layer_idx\n        lr = LR_BACKBONE * (LAYER_LR_DECAY ** depth) * lr_scale\n        params = [p for p in layers[layer_idx].parameters() if p.requires_grad]\n        if params:\n            groups.append({\"params\": params, \"lr\": lr})\n\n    norm_params = [p for p in bb.layernorm.parameters() if p.requires_grad]\n    if norm_params:\n        groups.append({\"params\": norm_params, \"lr\": LR_BACKBONE * lr_scale})\n\n    groups.append({\"params\": core.head.parameters(), \"lr\": LR_HEAD * lr_scale})\n    return torch.optim.AdamW(groups, weight_decay=WEIGHT_DECAY)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.537174Z","iopub.status.busy":"2026-08-06T06:22:44.536334Z","iopub.status.idle":"2026-08-06T06:22:44.543739Z","shell.execute_reply":"2026-08-06T06:22:44.542732Z"},"papermill":{"duration":0.015885,"end_time":"2026-08-06T06:22:44.545447+00:00","exception":false,"start_time":"2026-08-06T06:22:44.529562+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-26","cell_type":"code","source":"def take_group(cache_rows, g):\n    return cache_rows[:, :, g * GROUP:(g + 1) * GROUP]\n\n\ndef augment(imgs):\n    x = imgs.float()\n    scale = 0.90 + 0.20 * torch.rand((x.shape[0], 1, 1, 1, 1), device=x.device)\n    gamma = 0.88 + 0.24 * torch.rand((x.shape[0], 1, 1, 1, 1), device=x.device)\n    x = (x.div(255.0).clamp(0, 1) ** gamma) * scale\n    if torch.rand(1).item() < 0.35:\n        x = x + torch.randn_like(x) * 0.008\n    return x.mul(255.0).clamp(0, 255).to(imgs.dtype)\n\n\ndef drop_slots(mask):\n    if SLOT_DROPOUT <= 0:\n        return mask\n    out = mask.clone()\n    drop = (torch.rand_like(out) < SLOT_DROPOUT) & (out > 0.5)\n    out = out * (~drop)\n    empty = out.sum(1) < 0.5\n    out[empty] = mask[empty]\n    return out\n\n\ndef weighted_bce(logits, y, w, pos_weight):\n    loss = F.binary_cross_entropy_with_logits(logits, y, reduction=\"none\")\n    mult = 1.0 + (pos_weight.view(1, -1) - 1.0) * y\n    weight = w * mult\n    return (loss * weight).sum() / weight.sum().clamp_min(1.0)\n\n\ndef pairwise_auc_loss(logits, y, w):\n    losses = []\n    for j in range(logits.shape[1]):\n        pos = logits[(y[:, j] >= 0.60) & (w[:, j] >= 0.40), j]\n        neg = logits[(y[:, j] <= 0.40) & (w[:, j] >= 0.40), j]\n        if len(pos) and len(neg):\n            losses.append(F.softplus(-(pos[:, None] - neg[None, :])).mean())\n    if not losses:\n        return logits.sum() * 0.0\n    return torch.stack(losses).mean()\n\n\ndef positive_weights(y, w, idx):\n    yy, ww = y[idx], w[idx]\n    pos = (yy * ww).sum(0)\n    neg = ((1.0 - yy) * ww).sum(0)\n    return np.clip(np.sqrt((neg + 1.0) / (pos + 1.0)), 1.0, 2.75).astype(np.float32)\n\n\ndef epoch_batches(indices, gold_indices, rng):\n    indices = np.asarray(indices, dtype=int)\n    gold_indices = np.asarray(gold_indices, dtype=int)\n    gold_set = set(gold_indices.tolist())\n    pseudo = np.asarray([i for i in indices if i not in gold_set], dtype=int)\n    n_steps = max(len(indices) // BATCH_STUDIES, 1)\n\n    for _ in range(n_steps):\n        ng = min(GOLD_PER_BATCH, BATCH_STUDIES, len(gold_indices))\n        npseudo = BATCH_STUDIES - ng\n        parts = []\n        if ng:\n            parts.append(rng.choice(gold_indices, ng, replace=len(gold_indices) < ng))\n        pool = pseudo if len(pseudo) else indices\n        parts.append(rng.choice(pool, npseudo, replace=len(pool) < npseudo))\n        batch = np.concatenate(parts)\n        rng.shuffle(batch)\n        yield batch\n\n\n@torch.no_grad()\ndef update_ema(ema_model, model, decay):\n    src = unwrap(model).state_dict()\n    for k, v in ema_model.state_dict().items():\n        s = src[k].detach()\n        if torch.is_floating_point(v):\n            v.mul_(decay).add_(s, alpha=1.0 - decay)\n        else:\n            v.copy_(s)\n\n\ndef train_epoch(model, ema_model, cache, mask, y, w, indices, gold_indices,\n                optimizer, scheduler, scaler, pos_weight, dev, seed):\n    model.train()\n    rng = np.random.default_rng(seed)\n    total = 0.0\n    steps = 0\n\n    for sel in epoch_batches(indices, gold_indices, rng):\n        rows = torch.from_numpy(cache[sel]).to(dev, non_blocking=True)\n        g = int(rng.integers(N_GROUP))\n        imgs = augment(take_group(rows, g))\n        m = drop_slots(torch.from_numpy(mask[sel]).to(dev, non_blocking=True))\n        yt = torch.from_numpy(y[sel]).to(dev, non_blocking=True)\n        wt = torch.from_numpy(w[sel]).to(dev, non_blocking=True)\n\n        with torch.autocast(\"cuda\", enabled=dev.type == \"cuda\"):\n            logits = model(imgs, m)\n            bce = weighted_bce(logits, yt, wt, pos_weight)\n            rank = pairwise_auc_loss(logits, yt, wt)\n            loss = bce + RANK_LOSS_WEIGHT * rank\n\n        optimizer.zero_grad(set_to_none=True)\n        scaler.scale(loss).backward()\n        scaler.unscale_(optimizer)\n        torch.nn.utils.clip_grad_norm_(unwrap(model).parameters(), 2.0)\n        scaler.step(optimizer)\n        scaler.update()\n        if scheduler is not None:\n            scheduler.step()\n        update_ema(ema_model, model, EMA_DECAY)\n\n        total += float(loss.item())\n        steps += 1\n\n    return total / max(steps, 1)\n\n\n@torch.no_grad()\ndef predict_group_logits(model, cache, mask, idx, dev):\n    model.eval()\n    chunks = []\n    for b in range(0, len(idx), EVAL_BATCH):\n        sel = idx[b:b + EVAL_BATCH]\n        rows = torch.from_numpy(cache[sel]).to(dev, non_blocking=True)\n        m = torch.from_numpy(mask[sel]).to(dev, non_blocking=True)\n        gl = []\n        for g in range(N_GROUP):\n            with torch.autocast(\"cuda\", enabled=dev.type == \"cuda\"):\n                gl.append(model(take_group(rows, g), m).float().cpu())\n        chunks.append(torch.stack(gl, dim=1))\n    if not chunks:\n        return np.zeros((0, N_GROUP, len(TARGETS)), np.float32)\n    return torch.cat(chunks, dim=0).numpy()\n\n\ndef pooling_candidates(group_logits):\n    p = 1.0 / (1.0 + np.exp(-group_logits))\n    out = {\n        \"mean_logit\": 1.0 / (1.0 + np.exp(-group_logits.mean(1))),\n        \"mean_prob\": p.mean(1),\n        \"max_prob\": p.max(1),\n    }\n    if group_logits.shape[1] > 1:\n        top2 = np.sort(p, axis=1)[:, -2:, :].mean(1)\n        z = np.log(np.exp(2.0 * group_logits).mean(1) + 1e-8) / 2.0\n        out[\"top2_prob\"] = top2\n        out[\"lse2_logit\"] = 1.0 / (1.0 + np.exp(-z))\n    return out\n\n\ndef auc_vector(y, p):\n    from sklearn.metrics import roc_auc_score\n    out = np.full(y.shape[1], np.nan, np.float64)\n    for j in range(y.shape[1]):\n        if len(np.unique(y[:, j])) > 1:\n            out[j] = roc_auc_score(y[:, j], p[:, j])\n    return out\n\n\ndef choose_pooling(group_logits, y, exact_mask_rows=None, exact_y=None):\n    candidates = pooling_candidates(group_logits)\n    base = auc_vector(y, candidates[\"mean_logit\"])\n    modes = [\"mean_logit\"] * len(TARGETS)\n    scores = base.copy()\n\n    for name, pred in candidates.items():\n        derived = auc_vector(y, pred)\n        if exact_mask_rows is not None and exact_y is not None and len(exact_y):\n            gold = auc_vector(exact_y, pred[exact_mask_rows])\n        else:\n            gold = np.full(len(TARGETS), np.nan)\n\n        combined = derived.copy()\n        valid = np.isfinite(gold)\n        combined[valid] = 0.72 * derived[valid] + 0.28 * gold[valid]\n\n        for j in range(len(TARGETS)):\n            threshold = scores[j] + (0.0015 if modes[j] == \"mean_logit\" else 0.0005)\n            if np.isfinite(combined[j]) and combined[j] > threshold:\n                scores[j] = combined[j]\n                modes[j] = name\n\n    pred = np.column_stack([candidates[modes[j]][:, j] for j in range(len(TARGETS))])\n    return pred, modes, scores\n\n\ndef apply_pooling(group_logits, modes):\n    candidates = pooling_candidates(group_logits)\n    return np.column_stack([candidates[modes[j]][:, j] for j in range(len(TARGETS))])\n\n\ndef state_cpu(model):\n    return {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}\n\n\ndef average_states(items):\n    weights = np.asarray([max(float(score), 1e-6) for score, _, _ in items], np.float64)\n    weights = np.exp((weights - weights.max()) * 25.0)\n    weights /= weights.sum()\n    states = [state for _, state, _ in items]\n    out = {}\n    for k in states[0]:\n        if torch.is_floating_point(states[0][k]):\n            acc = states[0][k].float() * float(weights[0])\n            for wi, st in zip(weights[1:], states[1:]):\n                acc.add_(st[k].float(), alpha=float(wi))\n            out[k] = acc.to(states[0][k].dtype)\n        else:\n            out[k] = states[0][k].clone()\n    return out\n\n\ndef rank_columns(p):\n    return pd.DataFrame(p).rank(method=\"average\", pct=True).values.astype(np.float32)\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.559058Z","iopub.status.busy":"2026-08-06T06:22:44.55826Z","iopub.status.idle":"2026-08-06T06:22:44.567782Z","shell.execute_reply":"2026-08-06T06:22:44.567046Z"},"papermill":{"duration":0.018066,"end_time":"2026-08-06T06:22:44.569317+00:00","exception":false,"start_time":"2026-08-06T06:22:44.551251+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"md-27","cell_type":"markdown","source":"## Validation, final fit and submission","metadata":{"papermill":{"duration":0.005381,"end_time":"2026-08-06T06:22:44.580201+00:00","exception":false,"start_time":"2026-08-06T06:22:44.57482+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-28","cell_type":"code","source":"def write_benchmark_submission():\n    t = pd.read_csv(ROOT / \"test.csv\")\n    for c in TARGETS:\n        t[c] = 0.5\n    t.to_csv(\"submission.csv\", index=False)\n\n\ndef main():\n    write_benchmark_submission()\n    test_df = pd.read_csv(ROOT / \"test.csv\")\n    test_series = pd.read_csv(ROOT / \"test_series.csv\")\n    train_df = pd.read_csv(ROOT / \"train.csv\")\n    train_series = pd.read_csv(ROOT / \"train_series.csv\")\n    log(f\"train {train_df.shape} test {test_df.shape}\")\n\n    both = pd.concat([train_series, test_series])\n    plane_map = dict(zip(both[\"SeriesInstanceUID\"], both[\"Anatomical_Plane\"]))\n\n    log(\"header pass: test\")\n    hte = annotate(walk(\"test_series\"))\n    log(f\"  {len(hte)} test series\")\n    log(\"header pass: train\")\n    htr = annotate(walk(\"train_series\"))\n    log(f\"  {len(htr)} train series\")\n\n    def lat_of(h):\n        d = {}\n        for st, g in h.groupby(\"StudyInstanceUID\"):\n            vals = []\n            for col in (\"ImageLaterality\", \"Laterality\"):\n                if col not in g:\n                    continue\n                vals.extend(str(x).strip().upper() for x in g[col].dropna())\n            vals = [x[0] for x in vals if x and x[0] in (\"L\", \"R\")]\n            d[st] = vals[0] if vals else None\n        return d\n\n    slots_te = pick_slots(hte, plane_map)\n    slots_tr = pick_slots(htr, plane_map)\n    cov = pd.Series([len(v) for v in slots_tr.values()]).describe()\n    log(\n        f\"train slots per study: mean {cov['mean']:.2f} \"\n        f\"min {cov['min']:.0f} max {cov['max']:.0f}\"\n    )\n\n    st_tr, ctr, mtr = build_cache(slots_tr, plane_map, lat_of(htr), \"train\")\n    st_te, cte, mte = build_cache(slots_te, plane_map, lat_of(hte), \"test\")\n\n    t_lab = time.time()\n    lab = pd.DataFrame([extract(r) for r in train_df[\"Report\"].fillna(\"\")])\n    lab[\"StudyInstanceUID\"] = train_df[\"StudyInstanceUID\"].values\n    lab = lab.set_index(\"StudyInstanceUID\")\n    log(f\"derived labels for {len(lab)} studies in {time.time() - t_lab:.1f}s\")\n\n    gold = train_df.set_index(\"StudyInstanceUID\")[TARGETS]\n    gold = gold[gold.notna().all(axis=1)]\n\n    y = np.zeros((len(st_tr), len(TARGETS)), np.float32)\n    w = np.zeros_like(y)\n    exact_row = np.zeros(len(st_tr), bool)\n\n    for i, st in enumerate(st_tr):\n        if st in gold.index:\n            y[i] = gold.loc[st].values.astype(np.float32)\n            w[i] = 2.75\n            exact_row[i] = True\n        elif st in lab.index:\n            row = lab.loc[st]\n            y[i] = row[TARGETS].values.astype(np.float32)\n            conf = row[[t + \"__conf\" for t in TARGETS]].values.astype(np.float32)\n            w[i] = 0.20 + 0.80 * conf\n\n    keep = np.where(w.sum(1) > 0)[0]\n    log(f\"supervised {len(keep)} / {len(st_tr)}; exact {int(exact_row.sum())}\")\n\n    reports = train_df.set_index(\"StudyInstanceUID\")[\"Report\"].fillna(\"\")\n    group = np.array([\n        int(hashlib.md5(reports.get(s, s).encode()).hexdigest()[:8], 16) % 5\n        for s in st_tr\n    ])\n    va = np.array([i for i in keep if group[i] == 0], dtype=int)\n    tr = np.array([i for i in keep if group[i] != 0], dtype=int)\n    if len(va) == 0 or len(tr) < BATCH_STUDIES:\n        cut = max(1, len(keep) // 5)\n        va, tr = keep[:cut], keep[cut:]\n    log(f\"selection train {len(tr)} / holdout {len(va)}\")\n\n    tr_gold = tr[exact_row[tr]]\n    va_gold_rows = np.where(exact_row[va])[0]\n    va_gold_y = y[va[va_gold_rows]].astype(int)\n    yv = (y[va] > 0.5).astype(int)\n\n    dev = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    base_model = build_model().to(dev)\n    ema_model = deepcopy(base_model).to(dev).eval()\n    for p in ema_model.parameters():\n        p.requires_grad = False\n\n    model = base_model\n    if torch.cuda.device_count() > 1:\n        model = nn.DataParallel(model)\n        log(f\"training on {torch.cuda.device_count()} GPUs\")\n\n    optimizer = build_optimizer(model)\n    steps_per_epoch = max(len(tr) // BATCH_STUDIES, 1)\n    total_steps = EPOCHS * steps_per_epoch\n    max_lrs = [g[\"lr\"] for g in optimizer.param_groups]\n    scheduler = torch.optim.lr_scheduler.OneCycleLR(\n        optimizer,\n        max_lr=max_lrs,\n        total_steps=total_steps,\n        pct_start=0.15,\n        div_factor=5.0,\n        final_div_factor=30.0,\n    )\n    scaler = torch.amp.GradScaler(\"cuda\", enabled=dev.type == \"cuda\")\n    pos_weight = torch.from_numpy(positive_weights(y, w, tr)).to(dev)\n\n    snapshots = []\n    best_modes = [\"mean_logit\"] * len(TARGETS)\n\n    for ep in range(EPOCHS):\n        loss = train_epoch(\n            model, ema_model, ctr, mtr, y, w, tr, tr_gold,\n            optimizer, scheduler, scaler, pos_weight, dev,\n            SEED + 1009 * ep,\n        )\n\n        gl = predict_group_logits(ema_model, ctr, mtr, va, dev)\n        pv, modes, score_vec = choose_pooling(\n            gl, yv, va_gold_rows, va_gold_y\n        )\n        derived_auc = float(np.nanmean(auc_vector(yv, pv)))\n        gold_auc = float(\"nan\")\n        if len(va_gold_rows):\n            gold_auc = float(np.nanmean(auc_vector(va_gold_y, pv[va_gold_rows])))\n        selection = derived_auc if not np.isfinite(gold_auc) else (\n            0.78 * derived_auc + 0.22 * gold_auc\n        )\n\n        log(\n            f\"epoch {ep + 1}/{EPOCHS} loss {loss:.4f} \"\n            f\"holdout {derived_auc:.4f} gold-holdout {gold_auc:.4f} \"\n            f\"select {selection:.4f}\"\n        )\n\n        snapshots.append((selection, state_cpu(ema_model), list(modes)))\n        snapshots = sorted(snapshots, key=lambda x: x[0], reverse=True)[:N_SOUP]\n        best_modes = snapshots[0][2]\n\n        if time.time() - T0 > TIME_BUDGET:\n            log(\"time budget reached\")\n            break\n\n    if not snapshots:\n        snapshots = [(0.0, state_cpu(ema_model), best_modes)]\n\n    soup_state = average_states(snapshots)\n    ema_model.load_state_dict(soup_state)\n    unwrap(model).load_state_dict(soup_state)\n    prefinal_state = {k: v.clone() for k, v in soup_state.items()}\n    best_modes = snapshots[0][2]\n    log(\n        \"pooling: \" + \", \".join(\n            f\"{target}={mode}\" for target, mode in zip(TARGETS, best_modes)\n        )\n    )\n\n    all_gold = keep[exact_row[keep]]\n    optimizer = build_optimizer(model, lr_scale=FINAL_LR_SCALE)\n    final_steps = max(FINAL_EPOCHS * (len(keep) // BATCH_STUDIES), 1)\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(\n        optimizer, T_max=final_steps, eta_min=LR_BACKBONE * 0.02\n    )\n    pos_weight = torch.from_numpy(positive_weights(y, w, keep)).to(dev)\n\n    log(f\"final fit on all {len(keep)} studies\")\n    for ep in range(FINAL_EPOCHS):\n        loss = train_epoch(\n            model, ema_model, ctr, mtr, y, w, keep, all_gold,\n            optimizer, scheduler, scaler, pos_weight, dev,\n            SEED + 50000 + ep,\n        )\n        log(f\"final epoch {ep + 1}/{FINAL_EPOCHS} loss {loss:.4f}\")\n        if time.time() - T0 > TIME_BUDGET:\n            break\n\n    final_logits = predict_group_logits(\n        ema_model, cte, mte, np.arange(len(st_te)), dev\n    )\n    p_final = apply_pooling(final_logits, best_modes)\n\n    pre_model = build_model().to(dev)\n    pre_model.load_state_dict(prefinal_state)\n    pre_logits = predict_group_logits(\n        pre_model, cte, mte, np.arange(len(st_te)), dev\n    )\n    p_pre = apply_pooling(pre_logits, best_modes)\n\n    p = 0.72 * rank_columns(p_final) + 0.28 * rank_columns(p_pre)\n    sub = pd.DataFrame(p, columns=TARGETS)\n    sub.insert(0, \"StudyInstanceUID\", st_te)\n    sub = test_df[[\"StudyInstanceUID\"]].merge(\n        sub, on=\"StudyInstanceUID\", how=\"left\"\n    )\n    sub[TARGETS] = sub[TARGETS].fillna(0.5)\n    sub.to_csv(\"submission.csv\", index=False)\n    log(f\"submission.csv {sub.shape}; nulls {int(sub[TARGETS].isna().sum().sum())}\")\n    print(sub.head().to_string())\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.592996Z","iopub.status.busy":"2026-08-06T06:22:44.592502Z","iopub.status.idle":"2026-08-06T06:22:44.618619Z","shell.execute_reply":"2026-08-06T06:22:44.617576Z"},"papermill":{"duration":0.034591,"end_time":"2026-08-06T06:22:44.6204+00:00","exception":false,"start_time":"2026-08-06T06:22:44.585809+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"id":"code-29","cell_type":"code","source":"try:\n    main()\nexcept Exception:\n    traceback.print_exc()\n    t = pd.read_csv(find_root() / \"test.csv\")\n    for c in TARGETS:\n        t[c] = 0.5\n    t.to_csv(\"submission.csv\", index=False)\n    print(\"wrote fallback submission.csv\")\nlog(\"done\")\n","metadata":{"_kg_hide-input":true,"_kg_hide-output":true,"execution":{"iopub.execute_input":"2026-08-06T06:22:44.633629Z","iopub.status.busy":"2026-08-06T06:22:44.633278Z","iopub.status.idle":"2026-08-06T07:03:03.311174Z","shell.execute_reply":"2026-08-06T07:03:03.310133Z"},"papermill":{"duration":2418.689863,"end_time":"2026-08-06T07:03:03.315909+00:00","exception":false,"start_time":"2026-08-06T06:22:44.626046+00:00","status":"completed"},"tags":[]},"outputs":[],"execution_count":null}]}