{"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":"# # This Python 3 environment comes with many helpful analytics libraries installed\n# # It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# # For example, here's several helpful packages to load\n\n# import numpy as np # linear algebra\n# import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# # Input data files are available in the read-only \"../input/\" directory\n# # For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\n# import os\n# for dirname, _, filenames in os.walk('/kaggle/input'):\n#     for filename in filenames:\n#         print(os.path.join(dirname, filename))\n\n# # You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# # You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session\n\n# # Use the kagglehub client library to attach Kaggle resources like competitions, datasets, and models to your session\n# # Learn more about kagglehub: https://github.com/Kaggle/kagglehub/blob/main/README.md\n\n# import kagglehub\n# # kagglehub.dataset_download('<owner>/<dataset-slug>')","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:22.366295Z","iopub.execute_input":"2026-09-16T03:40:22.366644Z","iopub.status.idle":"2026-09-16T03:40:22.371911Z","shell.execute_reply.started":"2026-09-16T03:40:22.366586Z","shell.execute_reply":"2026-09-16T03:40:22.370951Z"}},"outputs":[],"execution_count":1},{"cell_type":"code","source":"# ============================== 0. Setup ====================================\nfrom __future__ import annotations\n\nimport os\nimport re\nimport sys\nimport unicodedata\nfrom collections import namedtuple\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\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\nPAIRED = ['ACL', 'MCL', 'Medial Meniscus', 'Lateral Meniscus']\nOA_TARGETS = ['Medial OA', 'Lateral OA', 'PF OA']\nDIRECT_TARGETS = ['Effusion', 'Synovitis', \"Baker's\", 'Contusion', 'Fracture']\n\nFEATURES = {\n    'unwrap': True,                 # rejoin hard-wrapped report lines\n    'sections': True,               # history / technique down-weighting\n    'dedupe_clauses': True,         # do not count the same sentence twice\n    'directional_negation': True,   # negation must sit on the right side of the finding\n    'local_uncertainty': True,      # hedge must be near the finding\n    'local_severity': True,         # \"severe\"/\"minimal\" must be near the finding\n    'span_scoped_grade': True,      # \"grade 3\" must be near the finding\n    'postop_suppression': True,     # s/p reconstruction, graft, meniscectomy\n    'oa_inherit': True,             # gonarthrosis -> all three compartments\n    'graded_pathology': True,\n    'synovitis_backoff': True,\n}\n\n# Context window sizes, in WORDS (not characters).\nWIN_NEG = 12       # how far a negation may reach\nWIN_HEDGE = 9      # how far \"possible/suspected\" may reach\nWIN_SEV = 8        # how far \"severe/minimal\" may reach\nWIN_POSTOP = 12\nWIN_GRADE = 9\nWIN_SIDE = 9       # stem <-> laterality proximity (\"meniscus\" ... \"medial\")\n\nprint('targets:', len(TARGETS))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:22.372797Z","iopub.execute_input":"2026-09-16T03:40:22.373188Z","iopub.status.idle":"2026-09-16T03:40:24.404644Z","shell.execute_reply.started":"2026-09-16T03:40:22.373153Z","shell.execute_reply":"2026-09-16T03:40:24.40383Z"}},"outputs":[{"name":"stdout","text":"targets: 12\n","output_type":"stream"}],"execution_count":2},{"cell_type":"code","source":"# =================== 1. Normalisation, unwrapping, sections =================\n_PRE = str.maketrans({\n    'ı': 'i', 'İ': 'i', 'I': 'i', 'ß': 'ss',\n    'đ': 'd', 'Đ': 'd', 'ø': 'o', 'Ø': 'o',\n    'æ': 'ae', 'Æ': 'ae', 'œ': 'oe', 'Œ': 'oe',\n})\n\n\ndef normalize(text: str) -> str:\n    \"\"\"Case-fold, strip diacritics, unify separators. Accent-free text lets one\n    pattern cover 'lésion/lesion/läsion' without listing every variant.\"\"\"\n    if not isinstance(text, str):\n        return ''\n    text = text.translate(_PRE).lower()\n    text = unicodedata.normalize('NFKD', text)\n    text = ''.join(ch for ch in text if not unicodedata.combining(ch))\n    text = text.replace('\\xad', '')                 # soft hyphen from PDF exports\n    text = re.sub(r'(\\w)-\\s*\\n\\s*(\\w)', r'\\1\\2', text)   # de-hyphenate line breaks\n    text = re.sub(r'[_/\\\\]+', ' ', text)\n    text = re.sub(r'(?<=\\w)-(?=\\w)', ' ', text)\n    text = re.sub(r'[ \\t]+', ' ', text)\n    return text\n\n\ndef unwrap(text: str) -> str:\n    \"\"\"Rejoin lines that a report editor hard-wrapped mid-sentence: a continuation\n    line starts lower-case and the previous line has no terminal punctuation.\"\"\"\n    if not isinstance(text, str):\n        return ''\n    out = []\n    for line in text.split('\\n'):\n        s = line.strip()\n        if (out and out[-1] and not re.search(r'[.;:!?>*\\u2022]$', out[-1])\n                and len(out[-1].split()) >= 4 and s and not s[:1].isupper()):\n            out[-1] = out[-1] + ' ' + s\n        else:\n            out.append(s)\n    return '\\n'.join(out)\n\n\n# --- Section headers, multilingual. -----------------------------------------\n# WHY THIS MATTERS: the single biggest false-positive source in these reports is the\n# clinical-history line (\"knee pain, r/o meniscal tear\"). It names the finding the\n# referrer suspects, not the finding the radiologist saw.\nSECTION_RX = [\n    ('impression', re.compile(\n        r'\\b(impression|conclusion|assessment|summary|opinion|'\n        r'impresion|conclusion(es)?|juicio|'\n        r'conclusie|besluit|beurteilung|zusammenfassung|'\n        r'sonuc|yorum|zakljucak|misljenje|'\n        r'sympera|συμπερασμ|γνωματευση|заключение|извод)\\b')),\n    ('findings', re.compile(\n        r'\\b(findings|report|description|hallazgos|informe|descripcion|'\n        r'bevindingen|verslag|befund(e)?|bulgular|nalaz|ευρηματα|περιγραφη|'\n        r'резултат|описание|constatari)\\b')),\n    ('history', re.compile(\n        r'\\b(history|clinical|indication|reason for exam|question|complaint|'\n        r'anamnesis|antecedentes|motivo|indicacion|sospecha de|'\n        r'klinische angaben|fragestellung|verdachtsdiagnose|'\n        r'klinik|endikasyon|on tani|anamneza|uputna|'\n        r'ιστορικο|κλινικ|клинич|анамнез|насочваща)\\b')),\n    ('technique', re.compile(\n        r'\\b(technique|protocol|sequences?|comparison|prior study|tecnica|'\n        r'protocolo|comparacion|techniek|vergelijking|technik|'\n        r'untersuchungstechnik|voraufnahme|teknik|inceleme protokol|'\n        r'tehnika|usporedba|τεχνικη|πρωτοκολλο|методика|сравнение)\\b')),\n]\n\n# How much a clause in each section is allowed to contribute.\nSECTION_WEIGHT = {\n    'impression': 1.15,   # the radiologist's own conclusion: strongest evidence\n    'findings': 1.00,\n    'unknown': 1.00,\n    'history': 0.40,      # referral text: heavily discounted AND forced to \"uncertain\"\n    'technique': 0.00,    # never contributes\n}\n\n_SENT_SPLIT = re.compile(r'(?<=[.;!?])\\s+|\\n+|(?:^|\\s)[-\\u2022*]\\s+')\n\nClause = namedtuple('Clause', 'text section derived')\n\n\ndef _section_of(line: str, current: str) -> str:\n    \"\"\"A header only re-sections the document when it looks like a header:\n    short, and usually followed by a colon.\"\"\"\n    head = line[:60]\n    if not (':' in head or len(line.split()) <= 6):\n        return current\n    for name, rx in SECTION_RX:\n        if rx.search(head):\n            return name\n    return current\n\n\ndef clauses(text: str):\n    \"\"\"Split a report into scoring units, each tagged with its section.\n\n    Returns a list of Clause(text, section, derived). `derived` marks the\n    comma-split fragments of very long sentences: they are used to resolve\n    laterality precisely but only count half, so one sentence cannot vote twice.\n    \"\"\"\n    norm = normalize(unwrap(text) if FEATURES['unwrap'] else text)\n    out, seen = [], set()\n    section = 'unknown'\n    for raw_line in norm.split('\\n'):\n        if FEATURES['sections']:\n            section = _section_of(raw_line, section)\n        for part in _SENT_SPLIT.split(raw_line):\n            if not part:\n                continue\n            c = part.strip(' .;:-\\u2022*')\n            if not c or len(c) < 3:\n                continue\n            sec = _section_of(c, section) if FEATURES['sections'] else 'unknown'\n            key = (c, sec)\n            if FEATURES['dedupe_clauses'] and key in seen:\n                continue\n            seen.add(key)\n            out.append(Clause(c, sec, False))\n            if len(c.split()) > 22:\n                for frag in re.split(r',| and | y | et | und | en | ve | i ', c):\n                    frag = frag.strip()\n                    if len(frag.split()) > 2 and (frag, sec) not in seen:\n                        seen.add((frag, sec))\n                        out.append(Clause(frag, sec, True))\n    return out\n\n\nprint(clauses(\"FINDINGS:\\nThe anterior cruciate ligament is intact.\\n\"\n              \"Horizontal tear of the posterior horn of the medial meniscus.\")[:3])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:24.406412Z","iopub.execute_input":"2026-09-16T03:40:24.406844Z","iopub.status.idle":"2026-09-16T03:40:24.43331Z","shell.execute_reply.started":"2026-09-16T03:40:24.406811Z","shell.execute_reply":"2026-09-16T03:40:24.432401Z"}},"outputs":[{"name":"stdout","text":"[Clause(text='findings', section='findings', derived=False), Clause(text='the anterior cruciate ligament is intact', section='findings', derived=False), Clause(text='horizontal tear of the posterior horn of the medial meniscus', section='findings', derived=False)]\n","output_type":"stream"}],"execution_count":3},{"cell_type":"code","source":"# ============ 2. Polarity lexicon: negation, hedging, normality =============\ndef _rx(*alts: str) -> re.Pattern:\n    return re.compile('|'.join(alts))\n\n\n# Negation that appears BEFORE the finding (English, Romance, Germanic, Slavic, Greek, Bulgarian)\nPRE_NEG = _rx(r'\\bno\\b', r'\\bnot\\b', r'\\bwithout\\b', r'\\bnegative for\\b', r'\\babsence\\b',\n              r'\\bno evidence\\b', r'\\bfree of\\b', r'\\bnone\\b', r'\\bneither\\b', r'\\bnor\\b',\n              r'\\bsin\\b', r'\\bno hay\\b', r'\\bausencia\\b', r'\\bausentes?\\b', r'\\bno se\\b',\n              r'\\bnao ha\\b', r'\\bsem\\b', r'\\bassenza\\b', r'\\bnon si\\b', r'\\bnessun',\n              r'\\bpas de\\b', r'\\bsans\\b', r'\\baucune?\\b', r'\\bgeen\\b', r'\\bzonder\\b',\n              r'\\bniet\\b', r'\\bkeine?[nmrs]?\\b', r'\\bohne\\b', r'\\bnicht\\b', r'\\bkein\\b',\n              r'\\bnema\\b', r'\\bbez\\b', r'\\bnisu\\b', r'\\bnije\\b', r'\\bfara\\b',\n              r'\\bδεν\\b', r'\\bχωρις\\b', 'ουδεν', r'\\bουτε\\b',\n              r'\\bбез\\b', r'\\bне\\b', 'липсва', r'\\bняма\\b', r'\\bотсутств')\n# Negation that appears AFTER the finding (Turkish, Serbo-Croatian)\nPOST_NEG = _rx(r'\\byok\\b', r'\\byoktur\\b', 'izlenmemekte', 'saptanmadi', r'\\bdegil\\b',\n               'gozlenmemekte', 'mevcut degil', 'eslik etmiyor', r'\\bizlenmedi\\b',\n               'izlenmemistir', 'saptanmamistir', 'gorulmemistir', r'\\bnema znakova\\b',\n               'bez znakova')\nNEGATION = _rx(PRE_NEG.pattern, POST_NEG.pattern, r'\\bunremarkable\\b')\n\n# Statements of normality (\"intact\", \"preserved\") — negative unless a tear is also named.\nNORMALITY = _rx(r'\\bnormal', r'\\bintact\\b', r'\\bpreserved\\b', r'\\bwithin normal limits\\b',\n                'limites normales', r'\\bconservad', r'\\bintegr', r'\\bnormales\\b',\n                r'\\bdoga(l|ll)\\b', 'korunmus', r'\\bnormaldir\\b', 'olagan', r'\\buredn',\n                r'\\bocuvan', r'\\bodrzan', r'\\bintakt', r'\\bprimjeren', 'odrzanog kontinuiteta',\n                'φυσιολογικ', 'ακεραι', 'δεν παρατηρουνται', 'δεν σημειωνονται',\n                'unauffallig', 'regelrecht', r'\\bo\\.?b\\.?\\b', 'нормал', 'запазен', 'съхранен',\n                r'\\bбез особености\\b', 'интактн', r'\\bgaaf\\b', r'\\bnormaal\\b')\nNORMAL_PHRASE = _rx(r'\\bsin alteracion', r'\\bsin cambios\\b', r'\\bsin particularidad',\n                    r'\\bsin hallazgos\\b', r'\\bsin lesion', r'\\bsin signos de (rotura|lesion)',\n                    r'\\bcontinu[oa]s?\\b', 'continuidad conservada', r'\\bno abnormalit',\n                    r'\\bno significant abnormalit', r'\\bunremarkable\\b',\n                    r'\\bno evidence of (tear|injury|abnormalit)', 'ohne auffalligkeit',\n                    r'\\bkein nachweis\\b', r'\\bohne befund\\b', 'geen afwijking',\n                    'zonder afwijking', 'sans anomalie', r\"pas d[e']anomalie\",\n                    r'\\bbez osobitosti\\b', r'\\bbez znakova (rupture|lezije)\\b',\n                    r'\\bbez patoloskih\\b', 'χωρις αλλοιωσ', 'χωρις παθολογ',\n                    'δεν παρατηρουνται (αξιολογα|παθολογ)', r'\\bбез особености\\b',\n                    r'\\bбез патологич', r'\\bбез данни за\\b', 'ozel bir ozellik yok',\n                    'patolojik bulgu (yok|izlenmemis)')\n# Hedging / uncertainty\nUNCERTAIN = _rx(r'\\bpossible\\b', r'\\bprobable\\b', r'\\bsuspicious\\b', r'\\bsuspected?\\b',\n                'cannot (be )?exclude', r'\\bmay\\b', r'\\bquestionable\\b', r'\\bequivocal\\b',\n                r'\\br/o\\b', r'\\brule out\\b', r'\\bdd\\b', r'\\blikely\\b', r'\\bsuggest',\n                r'\\bcompatible with\\b', r'\\bconcerning for\\b', r'\\bposible\\b',\n                'sin criterios categoricos', r'\\bdudos', r'\\bsugier', r'\\bprobabile\\b',\n                r'\\bmuhtemel\\b', r'\\bolasi\\b', r'\\bsupheli\\b', r'\\bizlenim', r'\\bdusundur',\n                r'\\bmoguce\\b', r'\\bvjerojatno\\b', r'\\bsumnja\\b', r'\\bmoze odgovarati\\b',\n                'πιθαν', 'υποπτ', r'\\bmoglich', r'\\bverdachtig', r'\\bfraglich',\n                r'\\bv\\.?a\\.?\\b', r'\\bwohl\\b', r'\\bвъзможно\\b', r'\\bвероятно\\b', 'суспект',\n                r'\\bmogelijk\\b', r'\\bverdacht\\b')\n\n# Pathology vocabularies\nTEAR = _rx(r'\\btear', r'\\btorn\\b', r'\\brupture', r'\\bdisruption\\b', 'discontinuit',\n           r'\\bavuls', r'\\bmacerat', r'\\bbuckethandle\\b', 'bucket handle', r'\\brotura\\b',\n           r'\\broturas\\b', r'\\bruptura', r'\\bdesgarro', r'\\broto\\b', 'dechirure', 'dechire',\n           r'\\bscheur', r'\\bruptuur', 'gescheurd', r'\\briss\\b', 'einriss', r'\\bruptur',\n           'zerreiss', r'\\blasion', r'\\bausriss', r'\\byirtik', r'\\byirtig', r'\\bkopma\\b',\n           'butunluk kaybi', r'\\brupturu\\b', 'devamsizlik', r'\\brupture\\b',\n           'devamliligi secilememis', 'puknuce', r'\\bprekid\\b', 'pukotin', 'ρηξη', 'ρηξις',\n           'ρηγμα', 'ασυνεχεια', 'руптура', 'разкъсв', 'разрив', 'скъсв', r'\\bлезия\\b')\nDEGEN = _rx('degenerat', r'\\bmucoid\\b', r'\\bmyxoid\\b', r'\\bfray', r'\\bfissur', 'dejeneratif',\n            r'\\bmukoid\\b', 'degenerativn', 'εκφυλ', 'дегенерат', 'μυξοειδ', 'μυξωδ',\n            'meniskopat', 'meniscopath', r'muco ?ide\\b', 'aufgefasert', 'dejenerasyon')\nINJURY = _rx(r'\\binjur', r'\\bsprain', r'\\blesion', r'\\blasion', r'\\bedema\\b', r'\\boedema\\b',\n             r'\\bodem\\b', r'\\bedem\\b', 'οιδημα', 'одем', 'едем', r'\\bstrain\\b',\n             r'\\bhigh signal\\b', r'\\bsignal alteration\\b', 'hiperintens', 'hyperintens',\n             'aumento de senal', 'alteracion de senal', 'cambio de senal', 'signalanhebung',\n             'signalalteration', 'verhoogd signaal', 'sinyal artis', 'αυξημενο σημα',\n             'повишен сигнал', r'\\besguince\\b', r'\\bthicken', 'zadebljanje', 'verdikking',\n             'distenzij', 'laksite', r'\\blaxity\\b', r'\\bpartial\\b', 'parcijaln', 'parcial',\n             'partiel', 'partiell')\n\n# Post-operative / graft context: a repaired structure is not a fresh tear.\nPOSTOP = _rx(r's\\W?p\\b', r'\\bstatus post\\b', r'\\bpost[- ]?operative\\b', r'\\bpostoperative\\b',\n             r'\\breconstruct', r'\\bgraft\\b', r'\\bhardware\\b', r'\\bscrew(s)?\\b',\n             r'\\bfixation\\b', r'\\bsutur', r'\\brepair(ed)?\\b', r'\\bmeniscectom',\n             r'\\bplasty\\b', r'\\bprevious surgery\\b', r'\\bprior surgery\\b', 'reconstruccion',\n             r'plastia\\b', 'operado', 'reconstruction ligamentaire', 'operiert',\n             'zustand nach', r'op\\.? von', 'rekonstruksiyon', 'ameliyat', r'greft\\b',\n             'menisektomi', 'rekonstrukcij')\nRETEAR = re.compile(r're ?tear|recurrent tear|new tear|graft (tear|rupture|failure)|'\n                    r'nueva rotura|re ?ruptur')\n\n# Severity and explicit grades\nSEV_LOW = _rx(r'\\bsmall\\b', r'\\bminimal\\b', r'\\btrace\\b', r'\\bmild\\b', r'\\bslight\\b',\n              r'\\btiny\\b', r'\\bscant\\b', r'\\bdiscrete\\b', r'\\blow ?grade\\b', r'\\bincipient\\b',\n              r'\\bleve\\b', r'\\bminim', r'\\bpeque', r'\\bfina\\b', r'\\bfino\\b', r'\\bligero\\b',\n              r'\\bescaso\\b', r'\\bdiscreto\\b', r'\\bhafif\\b', r'\\baz miktarda\\b', r'\\bsilik\\b',\n              r'\\bmanj\\w*', r'\\bblago\\b', r'\\bdiskretn', r'\\bmalo\\b', r'\\bpocetn',\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', r'\\blicht\\b',\n              r'\\bηπι', r'\\bμικρ', r'\\bελαχιστ', r'\\bαρχομεν', r'\\bминимал', r'\\bлек',\n              r'\\bмалк', r'\\bнеголям')\nSEV_HIGH = _rx(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',\n               r'\\btense\\b', r'\\bcomplete\\b', r'\\bfull ?thickness\\b', r'\\bhigh ?grade\\b',\n               r'\\badvanced\\b', r'\\bmoderad', r'\\bimportante\\b', r'\\bsevera?\\b',\n               r'\\bmarcad', r'\\bcuantios', r'\\bespesor total\\b', r'\\bcompleta?\\b',\n               r'\\bbelirgin\\b', r'\\byaygin\\b', r'\\bileri\\b', r'\\bciddi\\b', r'\\bbol\\b',\n               r'\\bkomplet', r'\\bopsezan\\b', r'\\bveliki\\b', r'\\bizrazit', r'\\bznacajn',\n               r'\\bumjeren', r'\\buznapredoval', r'\\bpotpun', r'\\bkompleksn', r'\\bausgepragt',\n               r'\\bdeutlich', r'\\bmassiv', r'\\bmassig', r'\\bgross', r'\\buitgebreid',\n               r'\\bgevorderd', r'\\bveel\\b', r'\\bmatige?\\b', r'\\bvolledig', r'\\bμετρι',\n               r'\\bμεγαλ', r'\\bεκτεταμεν', r'\\bευμεγεθ', r'\\bσοβαρ', r'\\bπληρη', r'\\bголям',\n               r'\\bизразен', r'\\bзначим', r'\\bумерен', r'\\bобилен', r'\\bпълн')\n_GRADE_RX = re.compile(\n    r'(?:grade|grad|grado|grau|derece|stupnja|stupanj|βαθμ|степен|icrs|outerbridge)'\n    r'[\\s:]*(?:grade\\s*)?([1-4]|iv|iii|ii|i)\\b')\n_ROMAN = {'i': 1, 'ii': 2, 'iii': 3, 'iv': 4}\n\nprint('polarity lexicon ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:36.412306Z","iopub.execute_input":"2026-09-16T03:40:36.412549Z","iopub.status.idle":"2026-09-16T03:40:36.435418Z","shell.execute_reply.started":"2026-09-16T03:40:36.412529Z","shell.execute_reply":"2026-09-16T03:40:36.43468Z"}},"outputs":[{"name":"stdout","text":"polarity lexicon ready\n","output_type":"stream"}],"execution_count":4},{"cell_type":"code","source":"# ================ 3. Anatomy, sites, direct findings, decoys ===============\nANAT = {\n    'ACL': _rx('anterior cruciate', r'\\bacl\\b', 'cruzado anterior', r'\\blca\\b',\n               'croise anterieur', 'voorste kruisband', r'\\bvkb\\b', 'vorderes kreuzband',\n               'vorderen kreuzband', 'vordere kreuzband', 'on capraz', r'\\bocb\\b',\n               'anterior capraz', 'prednji krizni', 'prednjeg krizn',\n               r'προσθι[οα][^ ]* χιαστ', 'προσθιου χιαστου', r'χιαστο[^ ]* συνδεσμ',\n               r'\\bχιαστ\\w*', 'предна кръстна', 'предната кръстна', 'предна кръста',\n               'cruciate ligaments', 'ligamentos cruzados', 'ligaments croises',\n               'kruisbanden', 'kreuzbander', 'capraz baglar', r'krizn[a-z]* ligament[a-z]*',\n               'χιαστοι συνδεσμ', 'χιαστων συνδεσμ', 'кръстните връзки', 'кръстни връзки'),\n    'MCL': _rx('medial collateral', r'\\bmcl\\b', 'tibial collateral', 'colateral medial',\n               'colateral interno', r'\\blcm\\b', 'collateral medial', 'collateral interne',\n               'mediale collaterale', 'binnenband', r'\\b(mediale|laterale) banden\\b',\n               r'\\bcollaterale banden\\b', 'innenband', 'mediales? kollateral',\n               'ic yan bag', 'medial kollateral', r'\\biyb\\b', 'medyal kollateral',\n               'medijalni kolateraln', 'medijalnog kolateraln', 'εσω πλαγι',\n               'εσωτερικο πλαγι', r'\\bπλαγι\\w* συνδεσμ', r'\\bπλαγιοι\\b',\n               'медиален колатерал', 'вътрешна странична', r'\\bколатерал\\w*',\n               r'\\bcolaterales\\b', r'\\bcollateraux\\b', r'\\bcollateralen\\b',\n               r'\\bkolateralni\\b', 'collateral ligaments', 'ligamentos colaterales',\n               'ligaments collateraux', 'collaterale banden', 'kollateralbander',\n               'seitenbander', 'yan baglar', r'kolateraln[a-z]* ligament[a-z]*',\n               'πλαγιοι συνδεσμ', 'πλαγιων συνδεσμ', 'колатерални връзки',\n               'страничните връзки'),\n    'Medial Meniscus': _rx('medial meniscus', r'\\bmm\\b(?= tear)', 'medial menisc',\n                           'menisco medial', 'menisco interno', 'menisque medial',\n                           'menisque interne', 'mediale meniscus', 'binnenmeniscus',\n                           'innenmeniskus', 'medialen? meniskus', 'innenmeniskushinterhorn',\n                           'medyal menisk', 'ic menisk', 'medijalni meniskus',\n                           'medijalnog meniskusa', 'medijalnom meniskusu',\n                           r'medijaln\\w* menisk\\w*', r'\\bmedijalnog meniska\\b',\n                           'medijalni menisk', 'εσω μηνισκ', r'μηνισκ[^ ]* του εσω',\n                           r'εσω διαμερισμα[^.]{0,40}μηνισκ', 'медиалния менискус',\n                           'медиален менискус', 'вътрешния менискус', 'oba meniska',\n                           'both menisci', 'ambos meniscos', 'beide menisci',\n                           'her iki menisku', r'αμφοτερ\\w* μηνισκ', 'двата менискуса',\n                           'medial (and|&) lateral menisc'),\n    'Lateral Meniscus': _rx('lateral meniscus', 'lateral menisc', 'menisco lateral',\n                            'menisco externo', 'menisque lateral', 'menisque externe',\n                            'laterale meniscus', 'buitenmeniscus', 'aussenmeniskus',\n                            'lateralen? meniskus', 'aussenmeniskushinterhorn',\n                            'lateral menisk', 'dis menisk', 'lateralni meniskus',\n                            'lateralnog meniskusa', 'lateralnom meniskusu',\n                            r'lateraln\\w* menisk\\w*', r'\\blateralnog meniska\\b',\n                            'εξω μηνισκ', r'μηνισκ[^ ]* του εξω',\n                            r'εξω διαμερισμα[^.]{0,40}μηνισκ', 'латералния менискус',\n                            'латерален менискус', 'външния менискус', 'oba meniska',\n                            'both menisci', 'ambos meniscos', 'beide menisci',\n                            'her iki menisku', r'αμφοτερ\\w* μηνισκ', 'двата менискуса',\n                            'medial (and|&) lateral menisc'),\n}\n\nOA_EVIDENCE = _rx('osteoarthrit', r'\\barthros', r'\\bgonarthros', r'\\bosteoarthros',\n                  'chondropath', 'chondromalac', 'condropat', 'condromalac', r'\\bchondros',\n                  'chondral (loss|defect|ulcer|thinning|injury|fissur|wear)',\n                  'cartilage (loss|thinning|defect|fissur|wear|damage|heterogeneity|irregularit)',\n                  '(loss|thinning|fissur|defect|ulcer|erosion|denudation) of[^.]{0,20}cartilage',\n                  'articular cartilage[^.]{0,30}(loss|thin|fissur|defect|erosion|wear|irregular)',\n                  'osteophyt', 'osteofit', 'osteofyt', 'osteofito', 'osteophyten', 'spurring',\n                  'joint space narrowing', 'pinzamiento articular', 'reduced joint space',\n                  'kikirdak kayb', 'kikirdak incelme', 'kondropati', 'kondral',\n                  'kikirdak dejener', r'eklem aralig\\w* daral', 'eklem mesafesi daral',\n                  r'kikirdak kalinlig\\w* azal', 'kraakbeen', 'gonartrose', 'artrose',\n                  r'\\bknorpel', 'arthrose', 'gonarthrose', 'hrskavic', 'hondromalac',\n                  'artroz', 'osteoartrit', 'artrotsk', 'artrotick', r'\\boa promjen',\n                  r'\\boa\\b', 'degenerativne promjene hrskav', r'χονδρ[^ ]*παθ', 'αρθριτ',\n                  'αρθρωσ', 'οστεοφυτ', 'χονδρομαλακ', 'αρθρικου χονδρου',\n                  'εξαλειψη του αρθρικου χονδρου', 'διαβρωση του αρθρικου χονδρ',\n                  r'λεπτυνση[^.]{0,30}χονδρ', r'φθορα[^.]{0,20}χονδρ', 'артроз',\n                  'хондропат', 'остеофит', r'хрущял[^.]{0,40}(изтън|увред|дефект|липс)',\n                  r'изтъняване[^.]{0,30}хрущял', 'хондромалац', 'ulcera[s]? condral',\n                  r'cartilago[^.]{0,25}(perdida|adelgaz)', 'icrs grade', r'icrs\\b',\n                  'outerbridge', r'\\bdenudation\\b', 'denudacij', 'erozivne promjene',\n                  r'\\berosion of[^.]{0,20}cartilage', 'kraakbeenlijden', 'kraakbeenverlies')\n\nTF_SITE = _rx('compartment', 'compartimento', 'compartiment', 'kompartman', 'kompartiment',\n              'kompartment', 'odjelj', 'διαμερισμα', 'компартм', r'\\bотдел', 'femorotibial',\n              'tibiofemoral', 'femoro tibial', 'femorotibiaal', 'femorotibijaln',\n              'феморотибиал', r'\\bft zglob', 'tibiofemoraln', 'condyle', 'condilo', 'kondyl',\n              'kondil', 'condyl', 'κονδυλ', 'кондил', r'\\bplateau', r'\\bplato\\b', 'platillo',\n              'meseta', 'плато', 'tibiaplateau', r'tibijaln\\w* plato', 'tibyal plato',\n              'tibia plato', 'κνημιαι', 'μηριαι', 'weightbearing', 'weightbaring',\n              'zona de carga', 'dragende deel', 'agirlik tasiyan', r'\\bfemur\\b', r'\\btibia\\b',\n              r'\\bfemoral\\b', r'\\btibial\\b', r'\\bfemura\\b', r'\\btibije\\b', 'μεσαρθριο')\nPF_SITE = _rx('patellofemoral', 'femoropatellar', 'femoropatelar', 'patelofemoral',\n              'retropatellar', 'retrorotulian', 'trochlea', 'troclea', 'troklea',\n              'trochlear', 'trohlej', 'τροχιλ', r'\\bpatella', r'\\bpatellar', 'rotulian',\n              r'\\brotula\\b', r'\\bpatele\\b', 'patellofemoraal', 'femoropatellair',\n              'επιγονατιδ', 'μηροεπιγονατιδ', 'пател', 'феморопател', 'anterior compartment',\n              'compartimento anterior', r'prednj\\w* odjeljk', r'\\bfp zglob', r'\\bpf zglob',\n              r'\\bfaset', r'\\bfacet', 'patellofemoraln')\n\nSIDE_MEDIAL = _rx(r'\\bmedial\\w*', r'\\bmedyal\\w*', r'\\bmedijaln\\w*', r'\\bmediaal\\w*',\n                  r'\\bmediale\\w*', r'\\binterno\\b', r'\\binterna\\b', r'\\binternos\\b',\n                  r'\\binterne\\b', r'\\binnen\\w*', r'\\bic\\b', r'\\bunutarnj\\w*', r'\\bεσω\\w*',\n                  r'\\bεσωτερικ\\w*', r'\\bмедиал\\w*', r'\\bвътреш\\w*', r'\\bbinnen\\w*')\nSIDE_LATERAL = _rx(r'\\blateral\\w*', r'\\bexterno\\b', r'\\bexterna\\b', r'\\bexternos\\b',\n                   r'\\bexterne\\b', r'\\bdis\\b', r'\\blateraln\\w*', r'\\baussen\\w*',\n                   r'\\bbuiten\\w*', r'\\bεξω\\w*', r'\\bεξωτερικ\\w*', r'\\bлатерал\\w*',\n                   r'\\bвъншн\\w*', r'\\bvanjsk\\w*')\nSIDE_ANTERIOR = _rx(r'\\banterior\\w*', r'\\bant\\b', r'\\bon\\b', r'\\bprednj\\w*', r'\\bvorder\\w*',\n                    r'\\bvoorste\\b', r'\\bπροσθι\\w*', r'\\bпредн\\w*', r'\\banteriyor\\w*',\n                    r'\\bavant\\b', r'\\banterieur\\w*')\nGLOBAL_OA = _rx(r'tri ?compartment', 'all three compartment', 'global(ised)? (oa|osteoarthrit)',\n                r'\\bgonarthros', r'\\bgonartros', r'\\bgonarthrose', r'\\bgonartrose',\n                'gonartro', 'gonartrot', 'osteoarthritis of the knee',\n                'artrosis (de |)(la )?rodilla', 'knee osteoarthrit', r'\\bdiz osteoartrit',\n                r'\\bgonartroz', 'artroza koljena', 'οστεοαρθριτιδα', 'αρθριτιδα του γονατος',\n                'εκφυλιστικη οστεοαρθριτ', 'артроза на колянната', 'гонартроз',\n                'degenerative joint disease', r'\\bdjd\\b', 'three compartments', 'compartments')\n\nDIRECT = {\n    'Effusion': _rx(r'\\beffusion', 'joint fluid', 'intra ?articular fluid', r'\\bhydrops\\b',\n                    r'\\bhemarthros', r'\\bhaemarthros', 'derrame articular', r'\\bderrame\\b',\n                    'liquido articular', 'hemartrosis', 'epanchement', 'gewrichtsvocht',\n                    r'\\bvocht\\b', 'gewrichtseffusie', 'opzetting van suprapatell',\n                    'gelenkerguss', r'\\berguss\\b', 'gelenksergu', 'gelenksflussigkeit',\n                    r'eklem\\w* ic\\w* sivi', 'efuzyon', 'eklem sivisi',\n                    'eklem mesafesinde sivi', 'sivi (miktari|artisi|birikimi)', 'sivi artis',\n                    r'\\bsivi\\b[^.]{0,25}artmis', r'\\bizljev', r'\\bizliv',\n                    r'zglobn[^ ]* tekucin', r'\\bhidrops\\b', r'αρθρικ[^ ]* υγρ',\n                    'υγρου ενδαρθρικα', r'ενδαρθρικ[^ ]* υγρ', 'ποσοτητα υγρου', 'ενδαρθρικ',\n                    'αρθρικη συλλογη', 'υγρο στην αρθρωση', 'συλλογη υγρου', 'ставен излив',\n                    'излив', 'ставна течност', 'синовиална течност'),\n    'Synovitis': _rx('synovit', 'sinovit', 'synovial (thickening|proliferation|hypertroph)',\n                     r'thicken\\w* synovial', r'hypertroph\\w* of the synovium',\n                     'synoviale? (verdikking|proliferatie)', 'verdikkingen van (het )?synovium',\n                     'synovialitis', 'synovialis(verdickung|proliferation)', 'reizsynovial',\n                     'sinovijalitis', 'sinovitis', 'zadebljanje sinovij',\n                     r'proliferacij\\w* sinovij', r'sinovijaln\\w* proliferacij', 'υμενιτιδα',\n                     'συνοβιτιδα', r'υμενικ[^ ]* υπερτροφ', 'αρθρικου υμεν',\n                     r'παχυνση[^.]{0,20}υμεν', 'синовит',\n                     r'синовиал[^ ]* (задебел|пролифер)', r'\\bpannus\\b', r'\\bhoffit',\n                     r'sinovyal\\w* (kalinlas|proliferas)', 'sinovyal hipertrof'),\n    \"Baker's\": _rx('baker', 'popliteal cyst', 'quiste popliteo', 'quistes popliteos',\n                   'kyste poplite', 'popliteale? cyst', 'poplitealzyste', 'bakerzyste',\n                   'popliteal kist', r'\\bbakerova\\b', r'poplitealn[^ ]* cist',\n                   r'popliteal\\w* cist', 'κυστη baker', 'κυστη του baker',\n                   'πολυχωρη συνοβιακη κυστη', 'συνοβιακη κυστη', 'κυστη τυπου baker',\n                   'киста на бейкър', 'бейкърова киста', 'поплитеална киста', 'бекеров',\n                   r'gastrocnemio ?semimembranos', 'gastrocnemius semimembranosus burs'),\n    'Contusion': _rx(r'\\bcontusion', 'bone bruise', 'bone marrow (o?edema|contusion)',\n                     r'marrow o?edema', r'\\bkontuz', 'medular bone o?edema',\n                     'osseous contusion', 'contusion osea', 'edema oseo',\n                     'edema de medula osea', 'contusiones oseas', 'oedeme osseux',\n                     'contusion osseuse', 'botcontusie', 'botoedeem', 'beenmergoedeem',\n                     'botmergoedeem', 'knochenmarkodem', 'knochenodem', 'knochenmarksodem',\n                     'kontusion', 'kemik kontuzyonu', 'kemik iligi odemi', 'kemik odemi',\n                     'kemik iliginde odem', 'kontuzyonel kemik', 'kemik iligi odemleri',\n                     'kostani edem', 'edem kosti', 'kontuzij', r'kostane srzi[^.]{0,20}edem',\n                     r'οστεομυελικ[^ ]* οιδημα', 'οστικο οιδημα', 'μυελικο οιδημα',\n                     'οστικο μωλωπ', 'костномозъчен едем', 'костен едем', 'контузионен'),\n    'Fracture': _rx(r'\\bfractur', r'\\bfract\\b', r'\\bfractura', r'\\bfracturas\\b',\n                    r'\\bfractuur', r'\\bbreuk\\b', r'\\bfraktur', r'\\bbruch\\b', r'\\bkirik\\b',\n                    r'\\bkirigi\\b', r'\\bkiri[kg]\\w*', r'\\bprijelom', r'impresijsk[^ ]* fraktur',\n                    'impaktcij', 'καταγμα', 'καταγματ', 'фрактур', 'счупван', 'фисур',\n                    'insufficiency fracture', 'stress fracture', 'avulsion fracture',\n                    'subchondral fracture', 'subkondral kiri', 'impaction (fracture|injury)',\n                    'osteochondral (fracture|impaction)', r'\\bsegond\\b', 'impactiefractuur',\n                    'subchondrale impression', 'subchondraler? impress'),\n}\n\n# Decoys: look like the finding, are not the finding.\nDECOY = {\n    'Fracture': _rx('microfractur', r'\\bfracture (risk|prophyla)', 'mikrofraktur'),\n    \"Baker's\": _rx('meniscal cyst', 'quiste meniscal', 'parameniscal', 'ganglion'),\n    'Medial Meniscus': _rx(r'\\bdiscoid meniscus\\b', 'menisco discoideo', 'diskoid menisk'),\n    'Lateral Meniscus': _rx(r'\\bdiscoid meniscus\\b', 'menisco discoideo', 'diskoid menisk'),\n    'Effusion': _rx(r'\\bbursal fluid only\\b',),\n}\n\nPLURAL_MENISCI = _rx(r'\\bmenisci\\b', r'\\bmeniscos\\b', r'\\bmenisques\\b', r'\\bmenisken\\b',\n                     r'\\bmeniskusi\\b', r'\\bmenisk\\w*ler\\b', r'\\bμηνισκοι\\b', r'\\bμηνισκων\\b',\n                     r'\\bменискуси\\b', r'\\bменискусите\\b')\nANY_SIDE = _rx(SIDE_MEDIAL.pattern, SIDE_LATERAL.pattern)\nSTEM_MENISCUS = _rx(r'menisc\\w*', r'menisk\\w*', r'μηνισκ\\w*', r'мениск\\w*')\nSTEM_CRUCIATE = _rx('cruciate', 'cruzado', 'croise', 'kruisband', 'kreuzband',\n                    r'capraz bag\\w*', r'krizn\\w*', r'χιαστ\\w*', r'кръстн\\w*', r'\\bacl\\b',\n                    r'\\blca\\b', r'\\bvkb\\b', r'\\bocb\\b', r'\\bacb\\b')\nSTEM_COLLATERAL = _rx(r'collateral\\w*', r'colateral\\w*', r'kollateral\\w*', r'collaterale\\w*',\n                      r'kolateraln\\w*', r'yan bag\\w*', r'πλαγι\\w*', r'колатерал\\w*',\n                      r'странич\\w*', r'innenband\\w*', r'binnenband\\w*', r'\\bmcl\\b',\n                      r'\\blcm\\b', r'\\biyb\\b')\nSTEM_FRACTURE = _rx(r'fractur\\w*', r'fraktur\\w*', r'fractuur\\w*', r'\\bfract\\b',\n                    r'kiri[kg]\\w*', r'prijelom\\w*', 'lom kosti', r'\\bbreuk\\w*',\n                    r'\\bbruch\\w*', r'καταγμα\\w*', r'καταγματ\\w*', r'фрактур\\w*',\n                    r'счупван\\w*', r'fisur\\w* (osea|oseas|kost)', r'fissur\\w* kost')\n\n# Contexts that re-interpret a marrow signal\nDEGENERATIVE_MARROW = _rx('subchondral', 'subcondral', 'subkondral', 'supkondraln',\n                          'subchondraln', 'υποχονδρι', 'υπαρθρικ', 'субхондрал',\n                          'subartikuler', r'\\bcyst', r'\\bquist', r'\\bzyste\\b', r'\\bcistic',\n                          'reactive', 'reactivo', 'degenerative', 'degenerativ', 'reaktiv')\nTRAUMA = _rx(r'\\bbruise\\b', r'\\bcontusion', r'\\bkontuz', r'\\btrauma', r'\\bimpaction\\b',\n             r'\\bpivot shift\\b', r'\\bkissing\\b', r'\\bacute\\b', r'\\bagudo\\b', r'\\bakut',\n             r'\\bpivot kaymasi\\b', r'\\bbone bruise\\b', r'\\bконтузион', r'\\bμωλωπ',\n             r'\\bkontuzij', r'\\bimpaktcij', r'\\bfall\\b', r'\\binjury\\b')\nSYNOVIAL_PROXY = _rx('bursit', 'burzit', r'\\bbursa\\b[^.]{0,30}(fluid|distend|sivi|tekucin)',\n                     'suprapatellar (bursitis|effusion|recess)', 'suprapatellar bursa',\n                     'hoffa', 'hoffit', 'plica', 'plika', 'πλικα',\n                     r'fat pad[^.]{0,20}(edema|oedema)', 'kapsul', 'capsul', 'καψ', 'капсул',\n                     r'\\bpannus\\b', r'\\bsinov', r'\\bsynov')\n\nprint('anatomy lexicon ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:43.635103Z","iopub.execute_input":"2026-09-16T03:40:43.635368Z","iopub.status.idle":"2026-09-16T03:40:43.673103Z","shell.execute_reply.started":"2026-09-16T03:40:43.635347Z","shell.execute_reply":"2026-09-16T03:40:43.672209Z"}},"outputs":[{"name":"stdout","text":"anatomy lexicon ready\n","output_type":"stream"}],"execution_count":5},{"cell_type":"code","source":"# ============ 4. Token-aware windows, polarity, severity, grade ============\n_BOUNDARY = re.compile(\n    r'\\b(but|however|although|though|whereas|otherwise|'\n    r'ancak|fakat|ama|pero|aunque|sin embargo|maar|echter|aber|jedoch|'\n    r'ali|no i|ωστοσο|αλλα|но|обаче)\\b|[;:]')\n\n\ndef left_ctx(text: str, start: int, words: int) -> str:\n    \"\"\"Words immediately before `start`, stopping at the last clause boundary.\n    A character window would happily read across a semicolon; this does not.\"\"\"\n    seg = text[:start]\n    last = None\n    for m in _BOUNDARY.finditer(seg):\n        last = m.end()\n    if last is not None:\n        seg = seg[last:]\n    return ' '.join(seg.split()[-words:])\n\n\ndef right_ctx(text: str, end: int, words: int) -> str:\n    seg = text[end:]\n    m = _BOUNDARY.search(seg)\n    if m:\n        seg = seg[:m.start()]\n    return ' '.join(seg.split()[:words])\n\n\ndef around(text, span, words):\n    if span is None:\n        return text\n    return left_ctx(text, span[0], words) + ' \\u00b6 ' + right_ctx(text, span[1], words)\n\n\ndef _near(text: str, stem_rx: re.Pattern, qual_rx: re.Pattern, words: int = WIN_SIDE):\n    \"\"\"True when a qualifier (e.g. 'medial') sits within `words` of a stem\n    (e.g. 'meniscus'), without crossing a clause boundary.\"\"\"\n    for m in stem_rx.finditer(text):\n        if qual_rx.search(left_ctx(text, m.start(), words)) or \\\n           qual_rx.search(right_ctx(text, m.end(), words)):\n            return m\n    return None\n\n\nclass Matcher:\n    \"\"\"Phrase first ('medial meniscus'); otherwise stem + nearby laterality\n    ('the meniscus of the medial compartment ... ').\"\"\"\n\n    def __init__(self, phrase_rx, stem=None, side=None, words=WIN_SIDE):\n        self.phrase_rx, self.stem, self.side, self.words = phrase_rx, stem, side, words\n\n    def search(self, text):\n        m = self.phrase_rx.search(text)\n        if m is not None:\n            return m\n        if self.stem is not None:\n            return _near(text, self.stem, self.side, self.words)\n        return None\n\n\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}\nANAT_MATCH = {t: Matcher(ANAT[t], *STEM_RULES[t]) for t in PAIRED}\nDIRECT_MATCH = {\n    t: Matcher(_rx(rx.pattern, STEM_FRACTURE.pattern) if t == 'Fracture' else rx)\n    for t, rx in DIRECT.items()\n}\n\n\ndef _negated(text: str, span) -> bool:\n    if span is None or not FEATURES['directional_negation']:\n        return bool(NEGATION.search(text))\n    return bool(PRE_NEG.search(left_ctx(text, span[0], WIN_NEG)) or\n                POST_NEG.search(right_ctx(text, span[1], WIN_NEG)))\n\n\ndef _uncertain(text: str, span) -> bool:\n    if not FEATURES['local_uncertainty'] or span is None:\n        return bool(UNCERTAIN.search(text))\n    return bool(UNCERTAIN.search(around(text, span, WIN_HEDGE)))\n\n\ndef _postop(text: str, span) -> bool:\n    if not FEATURES['postop_suppression']:\n        return False\n    if RETEAR.search(text):           # a graft can re-tear; that IS a positive\n        return False\n    return bool(POSTOP.search(around(text, span, WIN_POSTOP)))\n\n\ndef polarity(text: str, span=None, section: str = 'unknown') -> str:\n    \"\"\"positive | uncertain | negative, judged in the neighbourhood of `span`.\"\"\"\n    if _uncertain(text, span):\n        return 'uncertain'\n    if _negated(text, span):\n        return 'negative'\n    if NORMAL_PHRASE.search(text):\n        return 'negative'\n    if NORMALITY.search(text):\n        # \"intact\" plus an explicit tear word = the tear wins (e.g. \"MCL intact, ACL torn\")\n        if TEAR.search(around(text, span, WIN_NEG)) or _grade_near(text, span) is not None:\n            return 'positive'\n        return 'negative'\n    # Referral text states a suspicion, never an observation.\n    if section == 'history':\n        return 'uncertain'\n    return 'positive'\n\n\ndef severity(text: str, span=None) -> float:\n    scope = around(text, span, WIN_SEV) if (FEATURES['local_severity'] and span) else text\n    high, low = SEV_HIGH.search(scope) is not None, SEV_LOW.search(scope) is not None\n    if high and not low:\n        return 1.0\n    if low and not high:\n        return 0.45\n    if high and low:\n        return 0.8\n    return 0.75\n\n\ndef _grade_near(text: str, span, words: int = WIN_GRADE):\n    \"\"\"Highest explicit grade that belongs to THIS finding. Prevents a\n    'chondromalacia grade 4' in the same sentence promoting a meniscus mention.\"\"\"\n    scope = around(text, span, words) if (FEATURES['span_scoped_grade'] and span) else text\n    best = None\n    for m in _GRADE_RX.finditer(scope):\n        v = m.group(1)\n        n = int(v) if v.isdigit() else _ROMAN.get(v)\n        if n is not None and (best is None or n > best):\n            best = n\n    return best\n\n\nprint('window helpers ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:50.645283Z","iopub.execute_input":"2026-09-16T03:40:50.645672Z","iopub.status.idle":"2026-09-16T03:40:50.711778Z","shell.execute_reply.started":"2026-09-16T03:40:50.645645Z","shell.execute_reply":"2026-09-16T03:40:50.711058Z"}},"outputs":[{"name":"stdout","text":"window helpers ready\n","output_type":"stream"}],"execution_count":6},{"cell_type":"code","source":"# ============================ 5. Scoring ===================================\ndef _aggregate(pos: float, neg: float, unc: float, best: float, unc_hist: float = 0.0):\n    \"\"\"Turn per-clause votes into (score, confidence).\n\n    score ~ P(finding present); confidence ~ how much the network should trust it.\n    `unc_hist` is hedging that came only from the clinical-history line: if the\n    findings section explicitly denied the finding, the referral suspicion is\n    discarded outright, otherwise it counts half.\"\"\"\n    if unc_hist:\n        if neg and not pos and not unc:\n            unc_hist = 0.0\n        else:\n            unc += 0.5 * unc_hist\n    if pos or unc:\n        score = min(0.97, 0.40 + 0.55 * best + 0.015 * min(pos, 3))\n        conf = min(1.0, 0.55 + 0.15 * pos + 0.04 * unc)\n        if pos == 0:                       # hedged only\n            score = min(score, 0.72)\n            conf = min(conf, 0.45)\n    elif neg:\n        score = max(0.04, 0.20 - 0.04 * neg)\n        conf = min(0.90, 0.45 + 0.12 * neg)\n    else:\n        score, conf = 0.28, 0.05           # finding never mentioned: weak prior, no weight\n    return float(score), float(conf)\n\n\ndef _paired_weight(text, span, meniscus: bool) -> float:\n    g = _grade_near(text, span) if FEATURES['graded_pathology'] else None\n    tear = TEAR.search(around(text, span, WIN_NEG)) is not None\n    if meniscus:\n        if tear:\n            base = 1.0\n        elif g is not None:\n            base = 0.95 if g >= 3 else 0.30      # grade 1-2 = intrasubstance, not a tear\n        elif DEGEN.search(text):\n            base = 0.35\n        else:\n            base = 0.45\n    else:\n        if tear:\n            base = 1.0\n        elif g is not None:\n            base = 0.85 if g >= 2 else 0.30\n        elif DEGEN.search(text):\n            base = 0.40\n        else:\n            base = 0.55\n    s = severity(text, span)\n    if s >= 1.0:\n        base = min(1.0, base * 1.2)\n    elif s <= 0.45:\n        base *= 0.70\n    return base\n\n\nPATH_RX = _rx(TEAR.pattern, DEGEN.pattern, INJURY.pattern)\n\n\ndef score_paired(cls, tgt):\n    \"\"\"ACL / MCL / medial & lateral meniscus.\"\"\"\n    anat, decoy = ANAT_MATCH[tgt], DECOY.get(tgt)\n    meniscus = 'Meniscus' in tgt\n    pos = neg = unc = unc_hist = 0.0\n    best = 0.0\n    for c in cls:\n        w = SECTION_WEIGHT.get(c.section, 1.0)\n        if w == 0.0:\n            continue\n        if c.derived:\n            w *= 0.5\n        hit = anat.search(c.text)\n        generic_hit = False\n        # FIX (v4): \"no meniscal tear\" / \"the meniscus is intact\" / \"menisci intact\"\n        # have no laterality word at all. The old fallback only matched literal\n        # PLURAL forms (\"menisci\"), so the much more common adjective form\n        # (\"meniscal\") and generic singular (\"meniscus\") were invisible -- these\n        # reports silently fell back to the uninformative 0.28 default, which is\n        # exactly what destroys AUC (a true pos/neg tied with never-mentioned).\n        # A side-unspecified mention is credited to BOTH compartments, at reduced\n        # weight on the positive side only (a true bilateral tear is rarer than a\n        # reporter simply omitting laterality; negativity for both sides is safe).\n        if hit is None and meniscus:\n            cand = STEM_MENISCUS.search(c.text)\n            if cand is not None and not ANY_SIDE.search(c.text):\n                hit, generic_hit = cand, True\n        if hit is None:\n            continue\n        if decoy is not None and decoy.search(c.text):\n            continue\n        anat_span = (hit.start(), hit.end())\n        if _postop(c.text, anat_span):\n            continue\n        pm = PATH_RX.search(c.text)\n        if pm is None and _grade_near(c.text, anat_span) is None:\n            # anatomy named, no pathology word: an explicit normality statement is a negative\n            if NORMAL_PHRASE.search(c.text) or (NORMALITY.search(c.text)\n                                                and not NEGATION.search(c.text)):\n                neg += w\n            continue\n        span = (pm.start(), pm.end()) if pm is not None else anat_span\n        pol = polarity(c.text, span, c.section)\n        if pol == 'positive':\n            pos += w\n            wpos = _paired_weight(c.text, span, meniscus)\n            if generic_hit:\n                wpos *= 0.75\n            best = max(best, wpos)\n        elif pol == 'negative':\n            neg += w\n        elif c.section == 'history':\n            unc_hist += w\n        else:\n            unc += w\n            wunc = 0.45 * _paired_weight(c.text, span, meniscus)\n            if generic_hit:\n                wunc *= 0.75\n            best = max(best, wunc)\n    s, cf = _aggregate(pos, neg, unc, best, unc_hist)\n    return s, cf, pos, neg\n\n\ndef score_direct(cls, tgt, penalty=None, bonus=None):\n    \"\"\"Effusion / Synovitis / Baker's / Contusion / Fracture.\"\"\"\n    anat, decoy = DIRECT_MATCH[tgt], DECOY.get(tgt)\n    pos = neg = unc = unc_hist = 0.0\n    best = 0.0\n    for c in cls:\n        w = SECTION_WEIGHT.get(c.section, 1.0)\n        if w == 0.0:\n            continue\n        if c.derived:\n            w *= 0.5\n        m = anat.search(c.text)\n        if m is None:\n            continue\n        if decoy is not None and decoy.search(c.text):\n            continue\n        span = (m.start(), m.end())\n        if tgt == 'Fracture' and _postop(c.text, span):\n            continue\n        pol = polarity(c.text, span, c.section)\n        if pol == 'positive':\n            pos += w\n            v = severity(c.text, span)\n            if penalty is not None and penalty.search(around(c.text, span, WIN_SEV)):\n                v *= 0.45          # subchondral/degenerative marrow signal is not trauma\n            if bonus is not None and bonus.search(c.text):\n                v = min(1.0, v * 1.35)\n            best = max(best, v)\n        elif pol == 'negative':\n            neg += w\n        elif c.section == 'history':\n            unc_hist += w\n        else:\n            unc += w\n            best = max(best, 0.30)\n    s, cf = _aggregate(pos, neg, unc, best, unc_hist)\n    return s, cf, pos, neg\n\n\ndef score_oa(cls):\n    \"\"\"Route each OA statement to the compartment it names; a global statement\n    ('gonarthrosis') is inherited by every compartment at reduced confidence.\"\"\"\n    acc = {t: {'pos': 0.0, 'neg': 0.0, 'unc': 0.0, 'best': 0.0} for t in OA_TARGETS}\n    g_pos = g_neg = 0.0\n    g_best = 0.0\n    for c in cls:\n        w = SECTION_WEIGHT.get(c.section, 1.0)\n        if w == 0.0:\n            continue\n        if c.derived:\n            w *= 0.5\n        m = OA_EVIDENCE.search(c.text)\n        if m is None:\n            continue\n        span = (m.start(), m.end())\n        pol = polarity(c.text, span, c.section)\n        if pol == 'uncertain' and c.section == 'history':\n            continue          # referral suspicion of arthrosis is not evidence\n        sev = severity(c.text, span)\n        g = _grade_near(c.text, span)\n        if g is not None:\n            sev = max(sev, 0.45 if g <= 1 else 0.75 if g == 2 else 1.0)\n        hits = []\n        if _near(c.text, TF_SITE, SIDE_MEDIAL, WIN_SIDE) is not None:\n            hits.append('Medial OA')\n        if _near(c.text, TF_SITE, SIDE_LATERAL, WIN_SIDE) is not None:\n            hits.append('Lateral OA')\n        if PF_SITE.search(c.text) is not None:\n            hits.append('PF OA')\n        if not hits:\n            if pol == 'positive':\n                g_pos += w\n                g_best = max(g_best, sev if GLOBAL_OA.search(c.text) else sev * 0.7)\n            elif pol == 'negative':\n                g_neg += w\n            continue\n        for t in hits:\n            if pol == 'positive':\n                acc[t]['pos'] += w\n                acc[t]['best'] = max(acc[t]['best'], sev)\n            elif pol == 'negative':\n                acc[t]['neg'] += w\n            else:\n                acc[t]['unc'] += w\n                acc[t]['best'] = max(acc[t]['best'], 0.30)\n    out = {}\n    for t in OA_TARGETS:\n        a = acc[t]\n        if not (a['pos'] or a['unc']) and g_pos and FEATURES['oa_inherit']:\n            if a['neg']:\n                score, conf = _aggregate(0, a['neg'], 0, 0.0)\n                score, conf = max(score, 0.35), conf * 0.7\n            else:\n                score, conf = _aggregate(g_pos, 0, 0, g_best * 0.92)\n                conf *= 0.75\n        else:\n            score, conf = _aggregate(a['pos'], a['neg'] + g_neg, a['unc'], a['best'])\n        out[t] = (score, conf, a['pos'], a['neg'])\n    return out\n\n\ndef extract(report: str) -> dict:\n    \"\"\"Full pipeline for one report -> {target, target__conf, __npos, __nneg}.\"\"\"\n    cls = clauses(report)\n    out = {}\n\n    def put(t, s, c, p, n):\n        out[t] = s\n        out[t + '__conf'] = c\n        out[t + '__npos'] = p\n        out[t + '__nneg'] = n\n\n    for t in PAIRED:\n        put(t, *score_paired(cls, t))\n    for t, v in score_oa(cls).items():\n        put(t, *v)\n    for t in DIRECT_TARGETS:\n        if t == 'Contusion':\n            put(t, *score_direct(cls, t, penalty=DEGENERATIVE_MARROW, bonus=TRAUMA))\n        else:\n            put(t, *score_direct(cls, t))\n\n    # Synovitis back-off: it is almost never written down, but effusion + a synovial\n    # proxy (bursitis, Hoffa, capsular thickening) makes it likely. Low confidence on purpose.\n    if FEATURES['synovitis_backoff'] and out['Synovitis__npos'] == 0 \\\n            and out['Synovitis__nneg'] == 0:\n        proxy = sum(1 for c in cls\n                    if SECTION_WEIGHT.get(c.section, 1.0) > 0\n                    and SYNOVIAL_PROXY.search(c.text)\n                    and polarity(c.text, None, c.section) == 'positive')\n        eff = out['Effusion']\n        prior = 0.30 + 0.30 * max(0.0, (eff - 0.5) / 0.45) + 0.06 * min(proxy, 3)\n        out['Synovitis'] = float(min(0.72, prior))\n        out['Synovitis__conf'] = 0.18\n    return out\n\n\nprint('scoring ready')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:40:57.961503Z","iopub.execute_input":"2026-09-16T03:40:57.96235Z","iopub.status.idle":"2026-09-16T03:40:57.992001Z","shell.execute_reply.started":"2026-09-16T03:40:57.962307Z","shell.execute_reply":"2026-09-16T03:40:57.991247Z"}},"outputs":[{"name":"stdout","text":"scoring ready\n","output_type":"stream"}],"execution_count":7},{"cell_type":"code","source":"# =============== 6. Self-test: does it behave on hand-made cases? ==========\nCASES = [\n    (\"Sagittal images show a complete tear of the anterior cruciate ligament. \"\n     \"The medial and lateral menisci are intact. Moderate joint effusion.\",\n     \"ACL positive, menisci negative, effusion positive\"),\n    (\"CLINICAL: knee pain, rule out ACL tear.\\nFINDINGS: The cruciate ligaments are intact. \"\n     \"No meniscal tear. No joint effusion.\",\n     \"history mention must NOT make ACL positive\"),\n    (\"Status post ACL reconstruction. The graft is intact and well positioned. \"\n     \"Horizontal tear of the posterior horn of the medial meniscus.\",\n     \"post-op ACL suppressed, medial meniscus positive\"),\n    (\"Grade 4 chondromalacia of the medial femorotibial compartment with osteophytes. \"\n     \"Grade 2 intrasubstance signal in the lateral meniscus without surface extension.\",\n     \"Medial OA high, lateral meniscus low\"),\n    (\"Popliteal Baker cyst measuring 3 cm. Subchondral marrow oedema at the medial \"\n     \"tibial plateau, degenerative in nature.\",\n     \"Baker positive, contusion damped\"),\n    (\"Ön çapraz bağ rüptürü izlenmektedir. Medial menisküste yırtık yoktur. \"\n     \"Eklem içi sıvı artışı mevcut.\",\n     \"Turkish: ACL positive, medial meniscus negative, effusion positive\"),\n    (\"Discoid lateral meniscus without tear. Tricompartmental gonarthrosis.\",\n     \"discoid decoy, global OA inherited\"),\n]\n\nrows = []\nfor text, note in CASES:\n    r = extract(text)\n    rows.append({'case': note, **{t: round(r[t], 2) for t in TARGETS}})\nselftest = pd.DataFrame(rows).set_index('case')\npd.set_option('display.width', 200)\nprint(selftest.to_string())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:41:05.15978Z","iopub.execute_input":"2026-09-16T03:41:05.160185Z","iopub.status.idle":"2026-09-16T03:41:05.203225Z","shell.execute_reply.started":"2026-09-16T03:41:05.160157Z","shell.execute_reply":"2026-09-16T03:41:05.202539Z"}},"outputs":[{"name":"stdout","text":"                                                                     ACL   MCL  Medial Meniscus  Lateral Meniscus  Medial OA  Lateral OA  PF OA  Effusion  Synovitis  Baker's  Contusion  Fracture\ncase                                                                                                                                                                                              \nACL positive, menisci negative, effusion positive                   0.78  0.28             0.16              0.16       0.28        0.28   0.28      0.97       0.61     0.28       0.28      0.28\nhistory mention must NOT make ACL positive                          0.16  0.28             0.16              0.16       0.28        0.28   0.28      0.16       0.30     0.28       0.28      0.28\npost-op ACL suppressed, medial meniscus positive                    0.28  0.28             0.66              0.28       0.28        0.28   0.28      0.28       0.30     0.28       0.28      0.28\nMedial OA high, lateral meniscus low                                0.28  0.28             0.28              0.58       0.97        0.28   0.28      0.28       0.30     0.28       0.28      0.28\nBaker positive, contusion damped                                    0.28  0.28             0.28              0.28       0.28        0.28   0.28      0.28       0.30     0.83       0.60      0.28\nTurkish: ACL positive, medial meniscus negative, effusion positive  0.72  0.28             0.16              0.28       0.28        0.28   0.28      0.83       0.52     0.28       0.28      0.28\ndiscoid decoy, global OA inherited                                  0.28  0.28             0.28              0.16       0.79        0.79   0.79      0.28       0.30     0.28       0.28      0.28\n","output_type":"stream"}],"execution_count":8},{"cell_type":"code","source":"# ============== 7. Run over the whole training set ==========================\ndef find_root(explicit=None) -> Path:\n    \"\"\"Kaggle mounts the competition in a couple of shapes; try them all.\"\"\"\n    cands = []\n    if explicit:\n        cands.append(Path(explicit))\n    cands += [\n        Path('/kaggle/input/rsna-knee-abnormality-detection'),\n        Path('/kaggle/input/competitions/rsna-knee-abnormality-detection'),\n        Path('data'), Path('.'),\n    ]\n    for c in cands:\n        if (c / 'train.csv').is_file():\n            return c\n    base = Path('/kaggle/input')\n    if base.is_dir():\n        for d in sorted(base.rglob('train.csv')):\n            return d.parent\n    raise FileNotFoundError('train.csv not found; set ROOT manually')\n\n\nOUT_DIR = Path('/kaggle/working') if Path('/kaggle/working').is_dir() else Path('.')\nOUT_CSV = OUT_DIR / 'report_labels.csv'\n\nlabels = None\ntrain_df = None\ntry:\n    ROOT = find_root(os.environ.get('RSNA_ROOT'))\n    print('competition root:', ROOT)\n    train_df = pd.read_csv(ROOT / 'train.csv', dtype={'StudyInstanceUID': str})\n    assert 'Report' in train_df.columns, 'train.csv has no Report column'\n    print(f'{len(train_df):,} studies')\n\n    records = []\n    for i, (uid, rep) in enumerate(zip(train_df['StudyInstanceUID'],\n                                       train_df['Report'].fillna(''))):\n        row = extract(rep)\n        row['StudyInstanceUID'] = uid\n        records.append(row)\n        if (i + 1) % 500 == 0 or i + 1 == len(train_df):\n            print(f'  {i + 1}/{len(train_df)}', flush=True)\n\n    cols = (TARGETS + [t + '__conf' for t in TARGETS]\n            + [t + '__npos' for t in TARGETS] + [t + '__nneg' for t in TARGETS])\n    labels = pd.DataFrame(records)[['StudyInstanceUID'] + cols]\n    labels.to_csv(OUT_CSV, index=False)\n    print('wrote', OUT_CSV, labels.shape)\n    print(labels[TARGETS].describe().T[['mean', 'std', 'min', 'max']].round(3).to_string())\nexcept FileNotFoundError as e:\n    print('[skip] competition data not attached:', e)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:41:13.224645Z","iopub.execute_input":"2026-09-16T03:41:13.225269Z","iopub.status.idle":"2026-09-16T03:41:33.423757Z","shell.execute_reply.started":"2026-09-16T03:41:13.22524Z","shell.execute_reply":"2026-09-16T03:41:33.422943Z"}},"outputs":[{"name":"stdout","text":"competition root: /kaggle/input/competitions/rsna-knee-abnormality-detection\n4,407 studies\n  500/4407\n  1000/4407\n  1500/4407\n  2000/4407\n  2500/4407\n  3000/4407\n  3500/4407\n  4000/4407\n  4407/4407\nwrote /kaggle/working/report_labels.csv (4407, 49)\n                   mean    std    min   max\nACL               0.334  0.249  0.094  0.97\nMCL               0.281  0.212  0.080  0.97\nMedial Meniscus   0.466  0.288  0.040  0.97\nLateral Meniscus  0.333  0.241  0.060  0.97\nMedial OA         0.452  0.272  0.040  0.97\nLateral OA        0.422  0.251  0.080  0.97\nPF OA             0.501  0.291  0.080  0.97\nEffusion          0.516  0.295  0.100  0.97\nSynovitis         0.444  0.172  0.074  0.97\nBaker's           0.387  0.238  0.114  0.97\nContusion         0.388  0.245  0.040  0.97\nFracture          0.300  0.144  0.040  0.97\n","output_type":"stream"}],"execution_count":9},{"cell_type":"code","source":"# ====== 8. Audit against the 58 gold studies — the only honest check ========\nAUDIT = None\nif labels is not None and set(TARGETS).issubset(train_df.columns):\n    gold = train_df.dropna(subset=TARGETS).set_index('StudyInstanceUID')[TARGETS]\n    pred = labels.set_index('StudyInstanceUID')\n    common = [u for u in gold.index if u in pred.index]\n    print(f'{len(common)} fully gold-labelled studies')\n\n    try:\n        from sklearn.metrics import roc_auc_score\n    except ImportError:\n        roc_auc_score = None\n\n    rows = []\n    for t in TARGETS:\n        y = gold.loc[common, t].astype(int).to_numpy()\n        p = pred.loc[common, t].to_numpy(float)\n        g = (p > 0.5).astype(int)\n        tp = int(((y == 1) & (g == 1)).sum())\n        fp = int(((y == 0) & (g == 1)).sum())\n        fn = int(((y == 1) & (g == 0)).sum())\n        tn = int(((y == 0) & (g == 0)).sum())\n        prec = tp / max(tp + fp, 1)\n        rec = tp / max(tp + fn, 1)\n        # best threshold on this tiny set: indicative only, do NOT tune hard on 58 studies\n        best_t, best_f1 = 0.5, 0.0\n        for th in np.unique(np.round(p, 3)):\n            gg = (p > th).astype(int)\n            tp2 = ((y == 1) & (gg == 1)).sum()\n            f1 = 2 * tp2 / max(2 * tp2 + ((y == 0) & (gg == 1)).sum()\n                               + ((y == 1) & (gg == 0)).sum(), 1)\n            if f1 > best_f1:\n                best_t, best_f1 = float(th), float(f1)\n        auc = (roc_auc_score(y, p) if roc_auc_score and len(set(y)) > 1 else np.nan)\n        rows.append({'positives': int(y.sum()), 'precision': prec, 'recall': rec,\n                     'f1': 2 * prec * rec / max(prec + rec, 1e-9),\n                     'accuracy': (tp + tn) / max(len(y), 1), 'auc': auc,\n                     'best_thr': best_t, 'f1@best': best_f1,\n                     'mean_conf': float(pred.loc[common, t + '__conf'].mean())})\n    AUDIT = pd.DataFrame(rows, index=TARGETS)\n    print(AUDIT.round(3).to_string())\n    print('\\nWorst-read findings (lowest recall):',\n          ', '.join(AUDIT.nsmallest(3, 'recall').index))\n    print('AUC is the number that matters: the competition scores macro ROC-AUC, so a '\n          'label set that orders studies correctly is worth more than one that is '\n          'merely accurate at threshold 0.5.')\n\n    try:\n        import matplotlib.pyplot as plt\n        fig, ax = plt.subplots(figsize=(9, 4.2))\n        y = np.arange(len(AUDIT))\n        ax.barh(y - 0.2, AUDIT['recall'], height=0.4, label='recall', color='#2f6f9f')\n        ax.barh(y + 0.2, AUDIT['precision'], height=0.4, label='precision', color='#c8622d')\n        ax.set_yticks(y); ax.set_yticklabels(AUDIT.index); ax.set_xlim(0, 1)\n        ax.legend(loc='lower right'); ax.set_title('Report extractor vs 58 gold studies')\n        plt.tight_layout(); plt.show()\n    except Exception as e:\n        print('[plot skipped]', e)\nelse:\n    print('[skip] no gold columns available for the audit')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:41:43.145095Z","iopub.execute_input":"2026-09-16T03:41:43.145489Z","iopub.status.idle":"2026-09-16T03:41:44.429947Z","shell.execute_reply.started":"2026-09-16T03:41:43.145462Z","shell.execute_reply":"2026-09-16T03:41:44.429348Z"}},"outputs":[{"name":"stdout","text":"58 fully gold-labelled studies\n                  positives  precision  recall     f1  accuracy    auc  best_thr  f1@best  mean_conf\nACL                      24      0.742   0.958  0.836     0.845  0.930     0.635    0.868      0.589\nMCL                       9      0.438   0.778  0.560     0.810  0.890     0.648    0.700      0.494\nMedial Meniscus          26      0.758   0.962  0.847     0.845  0.909     0.550    0.857      0.618\nLateral Meniscus         23      0.692   0.783  0.735     0.776  0.829     0.588    0.800      0.553\nMedial OA                15      0.591   0.867  0.703     0.810  0.881     0.777    0.733      0.357\nLateral OA               11      0.364   0.727  0.485     0.707  0.811     0.845    0.667      0.361\nPF OA                    21      0.565   0.619  0.591     0.690  0.755     0.567    0.605      0.397\nEffusion                 35      0.643   0.771  0.701     0.603  0.712     0.160    0.773      0.634\nSynovitis                27      0.586   0.630  0.607     0.621  0.605     0.528    0.630      0.343\nBaker's                  12      0.625   0.833  0.714     0.862  0.895     0.663    0.720      0.302\nContusion                19      0.517   0.789  0.625     0.690  0.833     0.828    0.757      0.468\nFracture                 18      0.619   0.722  0.667     0.776  0.801     0.680    0.706      0.364\n\nWorst-read findings (lowest recall): PF OA, Synovitis, Fracture\nAUC is the number that matters: the competition scores macro ROC-AUC, so a label set that orders studies correctly is worth more than one that is merely accurate at threshold 0.5.\n","output_type":"stream"},{"output_type":"display_data","data":{"text/plain":"<Figure size 900x420 with 1 Axes>","image/png":"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oYCZmRlqDY3i6ppERERU5nF1TSqN8r9zp6WlwdTUtETa5Nw+IiIiIiIiLcIkj4iIiIiISIswySMiIiIiItIiXHiF3ilhZmCJzQ8mIiIiIqKPiyN5REREREREWoRJHhERERERkRZhkkdERERERKRFmOQRERERERFpES68Qu90OqgxjCRiTYdBRESfCc/oC5oOgYioTONIHhERERERkRZhkkdERERERKRFmOQRERERERFpESZ5REREREREWoRJHhERERERkRZhkkdERERERKRFmOQRERERERFpESZ5H5FMJoNIJIJIJIJEIkHVqlUxdepU5OTkAADi4+OV+/+7ff/9929t9+jRo2jfvj3KlSsHfX191K5dG3PnzkVubm6B9atXrw6pVIp79+6V+DkSEREREVHpwiTvI/Pz80NqaiquXbuGMWPGICwsDD///LNKnStXriA1NVW5TZgwodD2tmzZAi8vL1SpUgUHDx7E5cuX8d133+HHH39Ez549IQiCSv0jR47g5cuX6Nq1K1asWPFRzpGIiIiIiEoPJnkfmVQqhZWVFezt7TFs2DD4+Phg+/btKnUqVqwIKysr5WZsbFxgWy9evMDgwYPRsWNH/Pbbb6hbty4cHBwwaNAgrFixAhs3bsT69etVjomOjsbXX3+NPn36YPny5W+NNTMzEwqFQmUjIiIiIqKyRVfTAXxuDAwM8Pjx4/c6du/evXj8+DFCQkLU9gUEBMDV1RW///47evToAQB4/vw5NmzYgBMnTqB69epIS0vDn3/+iebNmxfYfkREBMLDw9XKvzMOhlhq8F4xExERFdtIuaYjKFRipEzTIRARvRNH8j4RQRCwf/9+7NmzB61atVLZV6VKFRgbGyu3wpLAq1evAgDc3NwK3F+9enVlHQCIjY2Fi4sLatasCbFYjJ49eyI6OrrQGENDQ5GWlqbc7ty5U9zTJCIiIiIiDeNI3ke2Y8cOGBsbIzs7G3l5efj6668RFhamUufPP/+EiYmJ8nO5cuXe2uabz90VZvny5ejdu7fyc+/eveHl5YVffvlFpb98UqkUUqm0SG0TEREREVHpxCTvI2vZsiUWL14MiUQCGxsb6OqqX3JHR0eYm5u/sy1XV1cAQFJSEpo0aaK2PykpCTVq1AAA/P333zh+/DhOnjyJ8ePHK+vk5uYiNjYWgwcPfs8zIiIiIiKi0ozT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Q9GjRw9YWlpi1qxZAICYmBj07dsXY8aMQbVq1dCpUyecOnUKdnZ2b40lOjoaT58+Rf369dGnTx+MGDECFStWLNb5uLi44PTp03ByckL37t3h7OyMIUOGoGXLljh27BgsLCxw//597Ny5E126dFE7XkdHB507d0Z0dHSx+iUiIiIiorJBJBR1FQ/67CgUCpiZmaHW0CiurklEREQfhKtrEhUs/zt3WloaTE1NS6RNrR/JIyIiIiIi+pwwySMiIiIiItIiTPKIiIiIiIi0yGf7CgUquoSZgSU2P5iIiIiIiD4ujuQRERERERFpESZ5REREREREWoRJHhERERERkRZhkkdERERERKRFuPAKvdPpoMYwkog1HQYREVGxeEZf0HQIREQawZE8IiIiIiIiLcIkj4iIiIiISIswySMiIiIiItIiTPKIiIiIiIi0CJM8IiIiIiIiLcIkj4iIiIiISIswySsh8fHxEIlEePbsGQBALpfD3Ny8WG04ODggMjKyxGMjIiIiIqLPx2eR5MlkMohEIgwdOlRtX1BQEEQiEWQyWYn22aNHD1y9erVE2wSAJ0+eYOTIkbC3t4dEIoGNjQ0GDBiAlJSUAutHRERALBbj559/LvFYiIiIiIio9PkskjwAsLW1RWxsLF6+fKkse/XqFdauXQs7O7sS78/AwAAVK1Ys0TafPHmCL774Avv378eSJUtw/fp1xMbG4vr162jYsCFu3rypdszy5csxbtw4LF++vERjISIiIiKi0umzSfLq168PW1tbbN68WVm2efNm2NnZoV69eip18/LyEBERAUdHRxgYGMDd3R0bN25UqbNr1y64urrCwMAALVu2RHJyssr+N6dr3rhxA19++SUqVaoEY2NjNGzYEPv37y/WOUyaNAl3797F/v370a5dO9jZ2aFFixbYs2cP9PT0EBQUpFL/0KFDePnyJaZOnQqFQoGjR4++tf3MzEwoFAqVjYiIiIiIyhZdTQfwKQ0YMAAxMTEIDAwE8HqUq3///oiPj1epFxERgdWrV2PJkiVwcXHB4cOH0bt3b1haWsLLywt37tzBV199haCgIAwZMgSnT5/GmDFj3tp3eno62rdvj+nTp0MqlWLlypUICAjAlStXijSSmJeXh9jYWAQGBsLKykpln4GBAYYPH47vv/8eT548gYWFBQAgOjoavXr1gp6eHnr16oXo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MVGlrHz58qhatSqys7Pxyy+/ICAgAAkJCViyZIlKPQcHB6SnpyMuLg7u7u4wNDSEq6srAgMD0bdvX8yZMwf16tXDw4cPERcXhzp16sDf37/QWFxcXLBq1So0aNAACoUCY8eOfefI19uIxWIkJSUpf36TiYkJQkJCMGrUKOTl5aFZs2ZIS0tDQkICTE1N0a9fv2L3eevWLfz222/o2LEjbGxscOXKFVy7dg19+/YFAEyZMgWtW7eGs7MzevbsiZycHOzatQvjx48H8Pq5xIULF6Jx48bIzc3F+PHjVUYR586dC2tra9SrVw86OjrYsGEDrKysSt37ComIiIiIqGQUe3XN+Ph45ahT/hYeHg53d3fMnTsXM2fORK1atbBmzRq1pfqbNGmCoUOHokePHrC0tMSsWbMAvJ6u2LdvX4wZMwbVqlVDp06dcOrUqXe+oy46OhpPnz5F/fr10adPH4wYMQIVK1Ys7impMDU1hampaaH7p02bhsmTJyMiIgJubm7w8/PDzp074ejo+F79GRoa4vLly+jSpQtcXV0xZMgQBAUF4ZtvvgEAeHt7Y8OGDdi+fTvq1q2LVq1a4eTJk8rj58yZA1tbWzRv3hxff/01QkJCVKbGmpiYYNasWWjQoAEaNmyI5ORk7Nq1SznaSkRERERE2kUkvPlAF9H/USgUMDMzQ62hUVxdk4iIiLQeV9ckTcj/zp2WlvbWwabi4HAOERERERGRFmGSR0REREREpEWY5BEREREREWmREn0ZOmmnhJmBJTY/mIiIiIiIPi6O5BEREREREWkRJnlERERERERahEkeERERERGRFmGSR0REREREpEW48Aq90+mgxjCSiDUdBhEREZEaz+gLmg6BqNThSB4REREREZEWYZJHRERERESkRZjkERERERERaREmeURERERERFqESR4REREREZEWYZJHRERERESkRUp1khcfHw+RSIRnz54BAORyOczNzYvVhoODAyIjI0s8tg/xPudBRERERERUFO+d5MlkMohEIgwdOlRtX1BQEEQiEWQy2YfEpqZHjx64evVqibYZFhYGkUgEPz8/tX0///wzRCIRvL29S7TPj3EeREREREREwAeO5Nna2iI2NhYvX75Ulr169Qpr166FnZ3dBwf3JgMDA1SsWLHE27W2tsbBgwfxzz//qJQvX768TJ0HERERERHRByV5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\n"},"metadata":{}}],"execution_count":10},{"cell_type":"code","source":"# ====== 8b. Error inspection — read the actual misses ========================\n# Precision/recall/AUC tell you THAT a target is under-performing; they don't tell\n# you WHETHER the extractor is wrong or the gold label is a judgment call the\n# report genuinely leaves ambiguous. Printing the disagreements next to the raw\n# report text is the fastest way to tell those apart, and it catches systematic\n# bugs (an inverted target, a section header that isn't matching, a decoy that\n# slipped through) that a single aggregate number hides.\nN_EXAMPLES = 3\n\nif AUDIT is not None:\n    report_by_uid = train_df.set_index('StudyInstanceUID')['Report']\n\n    def show_misses(target, n=N_EXAMPLES):\n        y = gold.loc[common, target].astype(int)\n        p = pred.loc[common, target].astype(float)\n        g = (p > 0.5).astype(int)\n\n        fp = [u for u in common if y[u] == 0 and g[u] == 1]\n        fn = [u for u in common if y[u] == 1 and g[u] == 0]\n\n        def dump(uids, label):\n            print(f'  -- {label} ({len(uids)}) --')\n            for u in uids[:n]:\n                score = pred.loc[u, target]\n                conf = pred.loc[u, target + '__conf']\n                npos = pred.loc[u, target + '__npos']\n                nneg = pred.loc[u, target + '__nneg']\n                snippet = (report_by_uid.get(u) or '')[:300].replace('\\n', ' ')\n                print(f'    [{u}] score={score:.2f} conf={conf:.2f} npos={npos} nneg={nneg}')\n                print(f'      \"{snippet}...\"')\n\n        print(f'\\n=== {target} ===')\n        dump(fp, 'false positives')\n        dump(fn, 'false negatives')\n\n    # Focus on the worst-performing targets first; widen to AUDIT.index to see all twelve.\n    for t in AUDIT.nsmallest(4, 'f1').index:\n        show_misses(t)\nelse:\n    print('[skip] run the audit cell first (needs gold columns)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:41:52.48607Z","iopub.execute_input":"2026-09-16T03:41:52.486608Z","iopub.status.idle":"2026-09-16T03:41:52.50742Z","shell.execute_reply.started":"2026-09-16T03:41:52.486568Z","shell.execute_reply":"2026-09-16T03:41:52.506469Z"}},"outputs":[{"name":"stdout","text":"\n=== Lateral OA ===\n  -- false positives (14) --\n    [1.2.826.0.1.3680043.8.498.10170898615867673028696505248839028269] score=0.77 conf=0.53 npos=0.0 nneg=0.0\n      \" The study reveals normal knee joint alignment.   No fracture is seen.      ACL is intact.   PCL is preserved.     The MCL is intact.    Medial meniscus is not torn.     The FCL, popliteus and biceps femoris tendons are preserved.           Horizontal tear at anterior horn of the lateral meniscus is...\"\n    [1.2.826.0.1.3680043.8.498.10306159113324811538703788080836752052] score=0.66 conf=0.70 npos=1.0 nneg=0.0\n      \"Exam Type: MRI KNEE RIGHT WO CONTRAST Exam Date and Time: [DATE] [TIME] Indication: 2 years of pain with walking up and down the stairs twisting and squatting. Comparison: No relevant prior studies available for comparison.  TECHNIQUE: MRI of the right knee was performed on a 1.5 Tesla system body c...\"\n    [1.2.826.0.1.3680043.8.498.11851412923016044948101698015974810604] score=0.97 conf=0.85 npos=2.0 nneg=0.0\n      \"Exam Type: MRI KNEE RIGHT WO CONTRAST Exam Date and Time: [DATE] [TIME] Indication: Pain Comparison: No relevant prior studies available for comparison.  TECHNIQUE: MRI of the right knee was performed on a 3.0 Tesla system with a dedicated knee coil in three planes (axial, sagittal, and coronal), us...\"\n  -- false negatives (3) --\n    [1.2.826.0.1.3680043.8.498.16060119389060497136231217718921482192] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"Technique: MRI of the knee. ACL normal. MCL normal. Medial meniscus tear. Lateral meniscus tear. Incipient OA of all three compartmens.  Baker's cyst present. Large effusion. Insufficient fracture of medial femoral condyl with adjacent bone marrow oedema. Conclusion: Medial meniscus tear. Lateral me...\"\n    [1.2.826.0.1.3680043.8.498.62465595376489211274225312453216559395] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"MRI right knee  INDICATION: Assess ligamentous injury  There is a vertical tear in the periphery of the posterior horn of the medial meniscus. No lateral meniscal tear. Complete rupture of the ACL. Complete rupture of the PCL. Extensor mechanism intact. Complete rupture of the MCL at the level of th...\"\n    [1.2.826.0.1.3680043.8.498.77362718298550276679350855963451855003] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"Technique: MRI of the knee. ACL normal. MCL normal. Medial meniscus tear. Lateral meniscus normal. Cartilages normal. Medial retinaculum rupture and medial patello-femoral ligament tear at the site of femoral attachment with haematoma. No Baker's cyst. Mild effusion. Patella type 3 Wiberg. Hypoplast...\"\n\n=== MCL ===\n  -- false positives (9) --\n    [1.2.826.0.1.3680043.8.498.11287937729196958426538087439102017580] score=0.65 conf=0.45 npos=0.0 nneg=0.0\n      \"MRI of left Knee with  -3-Plane Loc R'T, Sag PD FS, AX T2 FS, Sag T2 FS, Sag T1, CORT2 FS  > There is no abnormal intensity or articular surface disrupt in the medial meniscus, It appears to be normal. > There is no increased intensity in the ACL, MCL and LCL, it appears to be normal > Interstitial ...\"\n    [1.2.826.0.1.3680043.8.498.11915937982684988073644209606907169581] score=0.63 conf=0.72 npos=1.15 nneg=0.0\n      \"FINDINGS:  Fluid: Small joint effusion with synovial thickening compatible with synovitis. There is popliteal cyst measuring 21 x 17 x 35 mm. Small amount of edema along the inferior margin of the popliteal cyst suggesting a minimal partial rupture.  Medial compartment (meniscus, collateral ligament...\"\n    [1.2.826.0.1.3680043.8.498.18392509497170616983977319528036573378] score=0.89 conf=0.78 npos=1.5 nneg=1.0\n      \"SOL DİZ MRG. Tetkik protokolü: Çok düzlemli, çok sekanslı. Bulgular: Medial ve lateral  menisküs posterior hornu ve gövde kesiminde grade II dejenerasyon izlenmiştir.Arka çapraz bağ, lateral kollateral ligaman normaldir.Anterior çapraz bağda komplet rüptüre sekonder bütünlük kaybı izlenmiştir. Media...\"\n  -- false negatives (2) --\n    [1.2.826.0.1.3680043.8.498.12505035424093604269515328931488770819] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"Técnica: RMN de la rodilla. Resultados: Rotura del LCA. Rotura parcial del LCM. Rotura de menisco interno y lateral. Derrame. Contusiones óseas femorotibiales. Impresión: Rotura del LCA. Rotura parcial del LCM. Rotura de menisco interno y lateral. Derrame. Contusiones óseas femorotibiales....\"\n    [1.2.826.0.1.3680043.8.498.32321830776739689645700555055955725945] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"ΜΑΓΝΗΤΙΚΗ ΤΟΜΟΓΡΑΦΙΑ ∆ΕΞΙΟΥ ΓΟΝΑΤΟΣ Τεχνική: Η εξέταση έγινε µε ακολουθίες παλµών Τ1, PD fs και Τ2*. Ευρήµατα: - Εκτεταµένο οίδηµα του οστικού µυελού στην εγγύς µετάφυση/επίφυση της κνήµης παριστά οστικό µώλωπα στο πλαίσιο της αναφερόµενης κάκωσης. - Οιδηµατώδης απεικόνιση του πρόσθιου χιαστού συνδέ...\"\n\n=== PF OA ===\n  -- false positives (10) --\n    [1.2.826.0.1.3680043.8.498.12801308844398614687904447633432197492] score=0.57 conf=0.47 npos=0.0 nneg=0.0\n      \"ΤΕΧΝΙΚΗ Η εξέταση έγινε µε ακολουθίες παλµών Τ1, πυκνότητας πρωτονίων µε καταστολή του σήµατος του λίπους και Τ2 βαθµιδωτής ηχούς. ΕΥΡΗΜΑΤΑ - Στο έσω διαµέρισµα παρατηρείται εκφυλιστική ρήξη στο οπίσθιο κέρας και σώµα του µηνίσκου. Επιπλέον παρατηρείται ήπιο οστεοµυελικό οίδηµα και πλήρης εξάλειψη τ...\"\n    [1.2.826.0.1.3680043.8.498.22109739962224309418874538994436903404] score=0.84 conf=0.87 npos=2.15 nneg=0.0\n      \"Exam Type: MRI KNEE LEFT WO CONTRAST Exam Date and Time: [DATE] [TIME] Indication: Pain. Comparison: [REDACTED].  TECHNIQUE: MRI of the lumbar knee was performed on a 3.0 Tesla system with a dedicated knee coil in three planes (axial, sagittal, and coronal), using a standard non-contrast protocol.  ...\"\n    [1.2.826.0.1.3680043.8.498.30246079718471552972130572444383079911] score=0.83 conf=0.70 npos=1.0 nneg=0.0\n      \"SAĞ DİZ MRG. Tetkik protokolü: Çok düzlemli, çok sekanslı. Bulgular: Medial menisküste grade II dejenerasyon, radial yırtık ile uyumlu görünüm mevcuttur. Lateral menisküste grade II dejenerasyon, kova sapı yırtığı ile uyumlu görünüm mevcuttur. Arka çaprazda fokal sinyal artışları mevcuttur. Ön çapra...\"\n  -- false negatives (8) --\n    [1.2.826.0.1.3680043.8.498.12448079646359892252441208258836556945] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"Técnica: RMN de la rodilla. Resultados: Fricción de la banda iliotibial. Síndrome de pinzamiento de la almohadilla grasa de Hoffa. Condropatía rotuliana. Impresión: Fricción de la banda iliotibial. Síndrome de pinzamiento de la almohadilla grasa de Hoffa. Condropatía rotuliana....\"\n    [1.2.826.0.1.3680043.8.498.16060119389060497136231217718921482192] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"Technique: MRI of the knee. ACL normal. MCL normal. Medial meniscus tear. Lateral meniscus tear. Incipient OA of all three compartmens.  Baker's cyst present. Large effusion. Insufficient fracture of medial femoral condyl with adjacent bone marrow oedema. Conclusion: Medial meniscus tear. Lateral me...\"\n    [1.2.826.0.1.3680043.8.498.28925345859498351203477642741908452608] score=0.28 conf=0.05 npos=0.0 nneg=0.0\n      \"SAĞ DİZ MRG. Tetkik protokolü: Çok düzlemli, çok sekanslı. Bulgular: Lateral menisküs normal. Medial menisküs ekstrüde görünümde olup posterior hornunda yırtık ile uyumlu sinyal değişiklikleri mevcuttur. Arka çapraz bağ, çm çapraz bağ , lateral ve medial kollateral ligaman normaldir.Medial kollatera...\"\n\n=== Synovitis ===\n  -- false positives (12) --\n    [1.2.826.0.1.3680043.8.498.11287937729196958426538087439102017580] score=0.97 conf=0.80 npos=1.65 nneg=1.0\n      \"MRI of left Knee with  -3-Plane Loc R'T, Sag PD FS, AX T2 FS, Sag T2 FS, Sag T1, CORT2 FS  > There is no abnormal intensity or articular surface disrupt in the medial meniscus, It appears to be normal. > There is no increased intensity in the ACL, MCL and LCL, it appears to be normal > Interstitial ...\"\n    [1.2.826.0.1.3680043.8.498.11557620559191469069130827959098335840] score=0.55 conf=0.18 npos=0.0 nneg=0.0\n      \"Antecedentes Clínicos: Rodilla traumática aguda. Hallazgos: Rotura espesor y ancho total de la unión del tercio medio del tercio proximal del LCA asociado a discreto edema óseo del aspecto posterior de platillo tibial lateral. LCP y ligamentos colaterales sin alteraciones significativas. Aumento de ...\"\n    [1.2.826.0.1.3680043.8.498.11771393824519892797114773408583976756] score=0.68 conf=0.87 npos=2.15 nneg=0.0\n      \"MRI of Knee with  -Locator, SG PD FatSat, SG T2W FS, SG T1W, CO T2W FS, AX T2W FS    * High signal in the soft tissue medial to the medial collateral ligament with mild increased signal intensity of proximal ligament, suspect grade 2 injury. * Interstitial hyperintense signal of ACL, in favor of gra...\"\n  -- false negatives (10) --\n    [1.2.826.0.1.3680043.8.498.10306159113324811538703788080836752052] score=0.41 conf=0.18 npos=0.0 nneg=0.0\n      \"Exam Type: MRI KNEE RIGHT WO CONTRAST Exam Date and Time: [DATE] [TIME] Indication: 2 years of pain with walking up and down the stairs twisting and squatting. Comparison: No relevant prior studies available for comparison.  TECHNIQUE: MRI of the right knee was performed on a 1.5 Tesla system body c...\"\n    [1.2.826.0.1.3680043.8.498.12448079646359892252441208258836556945] score=0.30 conf=0.18 npos=0.0 nneg=0.0\n      \"Técnica: RMN de la rodilla. Resultados: Fricción de la banda iliotibial. Síndrome de pinzamiento de la almohadilla grasa de Hoffa. Condropatía rotuliana. Impresión: Fricción de la banda iliotibial. Síndrome de pinzamiento de la almohadilla grasa de Hoffa. Condropatía rotuliana....\"\n    [1.2.826.0.1.3680043.8.498.16060119389060497136231217718921482192] score=0.30 conf=0.18 npos=0.0 nneg=0.0\n      \"Technique: MRI of the knee. ACL normal. MCL normal. Medial meniscus tear. Lateral meniscus tear. Incipient OA of all three compartmens.  Baker's cyst present. Large effusion. Insufficient fracture of medial femoral condyl with adjacent bone marrow oedema. Conclusion: Medial meniscus tear. Lateral me...\"\n","output_type":"stream"}],"execution_count":11},{"cell_type":"code","source":"# ====== 8d. Full-dataset coverage audit ======================================\n# Independent of the 58 gold studies: across ALL training reports, how many trip NO\n# lexicon term for ANY of the 12 targets? A high rate usually means either genuinely\n# unremarkable reports (fine) or an unsupported language / phrasing style (not fine --\n# these reports contribute nothing but the flat 0.28/0.05 default, which is lost\n# training signal, not a labelling problem you can fix by tuning thresholds).\nif labels is not None and train_df is not None:\n    report_by_uid = train_df.set_index('StudyInstanceUID')['Report']\n    npos_cols = [t + '__npos' for t in TARGETS]\n    nneg_cols = [t + '__nneg' for t in TARGETS]\n    silent = (labels[npos_cols].sum(axis=1) == 0) & (labels[nneg_cols].sum(axis=1) == 0)\n    print(f'{silent.sum()} / {len(labels)} reports ({silent.mean():.1%}) matched NO '\n          f'lexicon term for ANY of the 12 targets.')\n    if silent.sum():\n        print('\\nExamples (check whether these are genuinely clean reports or an '\n              'uncovered language/phrasing style):')\n        for u in labels.loc[silent, 'StudyInstanceUID'].head(8):\n            txt = report_by_uid.get(u) or ''\n            print(f'  [{u}] \"{txt[:200]}...\"')\nelse:\n    print('[skip] run section 7 first')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:42:19.89329Z","iopub.execute_input":"2026-09-16T03:42:19.894005Z","iopub.status.idle":"2026-09-16T03:42:19.907376Z","shell.execute_reply.started":"2026-09-16T03:42:19.893966Z","shell.execute_reply":"2026-09-16T03:42:19.906505Z"}},"outputs":[{"name":"stdout","text":"329 / 4407 reports (7.5%) matched NO lexicon term for ANY of the 12 targets.\n\nExamples (check whether these are genuinely clean reports or an uncovered language/phrasing style):\n  [1.2.826.0.1.3680043.8.498.10004945927472656027199792075652399585] \"[DATE]: * MR Knie Rechts 15ch AA Klinische Inlichtingen: [DATE]. Diagnostische vraagstellling: Meniscusscheur/mediaal? Scanprotocol (DRB) : sag intermediair gewogen seq zonder en met fs, ax/ cor pd ge...\"\n  [1.2.826.0.1.3680043.8.498.10059200627175320481920009076697103801] \"Técnica: RMN de la rodilla. Resultados: Meniscopatía interna. Lesión condral en el cóndilo femoral medial. Condromalacia rotuliana. Sinovitis. Bursitis prepatelar. Impresión: meniscopatía interna. Les...\"\n  [1.2.826.0.1.3680043.8.498.10077803906105306230229342168184970097] \"ΤΕΧΝΙΚΗ: Η εξέταση πραγµατοποιήθηκε µε ακολουθίες Τ1, Τ1, Pd fs. ΕΥΡΗΜΑΤΑ - Παρατηρείται ικανής έκτασης ενδαρθρική συλλογή υγρού. - Εικόνα δισκοειδους µορφολογίας έξω µηνίσκου. - Σηµειώνεται οριζοντιο...\"\n  [1.2.826.0.1.3680043.8.498.10090136633727522725960302994815319097] \"Technique: MRI of the knee. ACL normal. MCL normal. Medial meniscus tear. Lateral meniscus normal. OA changes of all three compartments. No Baker's cyst. No effusion. Coclusion: Medial meniscus tear. ...\"\n  [1.2.826.0.1.3680043.8.498.10133843191898064967257276100779798471] \"Técnica: RMN de la rodilla. Resultados: Lesión osteocondral de la rótula. Pinzamiento de la almohadilla grasa de Hoffa. Impresión: Lesión osteocondral de la rótula. Pinzamiento de la almohadilla grasa...\"\n  [1.2.826.0.1.3680043.8.498.10199522802568420589167284753473646773] \"Técnica: RMN de la rodilla. Resultados: Pinzamiento de la almohadilla grasa de Hoffa. Impresión: Pinzamiento de la almohadilla grasa de Hoffa...\"\n  [1.2.826.0.1.3680043.8.498.10211815027850731240126403602239595062] \"Técnica: RMN de la rodilla. Resultados: Pinzamiento de la almohadilla grasa de Hoffa. . Impresión: Pinzamiento de la almohadilla grasa de Hoffa....\"\n  [1.2.826.0.1.3680043.8.498.10299396087685174806129850512856451005] \"ΜΤ ΑΡΙΣΤΕΡΟΥ ΓΟΝΑΤΟΣ Τεχνική: Η εξέταση έγινε µε ακολουθίες παλµών Τ1, πυκνότητας πρωτονίων µε καταστολή του σήµατος του λίπους και Τ2 βαθµιδωτής ηχούς. Ευρήµατα: - Αναγνωρίζεται πλήρης ρήξη του πρόσθ...\"\n","output_type":"stream"}],"execution_count":12},{"cell_type":"code","source":"# ====== 9. Pooled (score, confidence) -> probability recalibration =========\n# AUC is invariant to any monotonic transform of a SINGLE score, so rescaling `score`\n# alone can never change it. But mapping (score, confidence) -> probability through\n# logistic regression uses TWO signals -- a study can be reranked past another with a\n# similar score if it has higher confidence -- so this genuinely can move ranking, not\n# just calibration. Only 58 gold studies exist, so this is validated leave-one-study-out\n# (never a train/test split) and auto-gated: the calibrated file is written ONLY if LOO\n# demonstrably beats the raw score. If it doesn't, that's a real, useful negative result --\n# it means go fix 8c/8d instead of tuning this.\nif AUDIT is not None:\n    from sklearn.linear_model import LogisticRegression\n    from sklearn.metrics import roc_auc_score\n\n    X_by_t, Y_by_t = {}, {}\n    for t in TARGETS:\n        X_by_t[t] = np.c_[pred.loc[common, t].to_numpy(float),\n                           pred.loc[common, t + '__conf'].to_numpy(float)]\n        Y_by_t[t] = gold.loc[common, t].astype(int).to_numpy()\n\n    X = np.vstack([X_by_t[t] for t in TARGETS])\n    Y = np.concatenate([Y_by_t[t] for t in TARGETS])\n    n = len(common)\n\n    loo = np.zeros_like(Y, dtype=float)\n    for i in range(n):\n        keep = np.ones(n, bool); keep[i] = False\n        mask = np.tile(keep, len(TARGETS))\n        m = LogisticRegression(max_iter=1000).fit(X[mask], Y[mask])\n        loo[~mask] = m.predict_proba(X[~mask])[:, 1]\n\n    raw_auc = roc_auc_score(Y, X[:, 0]) if len(set(Y)) > 1 else float('nan')\n    loo_auc = roc_auc_score(Y, loo) if len(set(Y)) > 1 else float('nan')\n    print(f'pooled raw score AUC      : {raw_auc:.3f}')\n    print(f'pooled LOO-calibrated AUC : {loo_auc:.3f}')\n\n    rows = []\n    for ti, t in enumerate(TARGETS):\n        y = Y_by_t[t]\n        if len(set(y)) < 2:\n            continue\n        sl = slice(ti * n, ti * n + n)\n        rows.append({'target': t, 'raw_auc': roc_auc_score(y, X[sl, 0]),\n                     'loo_auc': roc_auc_score(y, loo[sl])})\n    per_target = pd.DataFrame(rows).set_index('target')\n    print('\\n' + per_target.round(3).to_string())\n\n    USE_CALIBRATED = (raw_auc == raw_auc and loo_auc == loo_auc and loo_auc > raw_auc)\n    if USE_CALIBRATED:\n        model = LogisticRegression(max_iter=1000).fit(X, Y)\n        cal = labels.copy()\n        for t in TARGETS:\n            f = np.c_[labels[t].to_numpy(float), labels[t + '__conf'].to_numpy(float)]\n            cal[t] = model.predict_proba(f)[:, 1]\n        cal.to_csv(OUT_DIR / 'report_labels_calibrated.csv', index=False)\n        print(f'\\nLOO AUC improved ({raw_auc:.3f} -> {loo_auc:.3f}): wrote '\n              f'{OUT_DIR / \"report_labels_calibrated.csv\"}. Use this file for training.')\n    else:\n        print(f'\\nLOO AUC did not improve ({raw_auc:.3f} -> {loo_auc:.3f}): keeping the raw '\n              f'{OUT_CSV.name}. The (score, confidence) pair adds no ranking information '\n              f'beyond score alone here -- go fix vocabulary coverage (8c/8d) instead of '\n              f'recalibrating.')\nelse:\n    print('[skip] run the audit cell first (needs gold columns)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-16T03:42:28.285418Z","iopub.execute_input":"2026-09-16T03:42:28.286102Z","iopub.status.idle":"2026-09-16T03:42:28.794532Z","shell.execute_reply.started":"2026-09-16T03:42:28.286072Z","shell.execute_reply":"2026-09-16T03:42:28.793926Z"}},"outputs":[{"name":"stdout","text":"pooled raw score AUC      : 0.828\npooled LOO-calibrated AUC : 0.796\n\n                  raw_auc  loo_auc\ntarget                            \nACL                 0.930    0.918\nMCL                 0.890    0.868\nMedial Meniscus     0.909    0.899\nLateral Meniscus    0.829    0.795\nMedial OA           0.881    0.842\nLateral OA          0.811    0.741\nPF OA               0.755    0.667\nEffusion            0.712    0.680\nSynovitis           0.605    0.571\nBaker's             0.895    0.850\nContusion           0.833    0.792\nFracture            0.801    0.717\n\nLOO AUC did not improve (0.828 -> 0.796): keeping the raw report_labels.csv. The (score, confidence) pair adds no ranking information beyond score alone here -- go fix vocabulary coverage (8c/8d) instead of recalibrating.\n","output_type":"stream"}],"execution_count":13},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}