"""Parse radiology study_description strings into structured tags. Strategy: regex-based keyword matching on a normalized form of the description. Each description maps to a set of *region* tags plus optional modality/contrast/laterality. Relevance is then determined primarily by region-set overlap. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import FrozenSet, Optional def _norm(s: str) -> str: s = s.upper() s = re.sub(r"[^A-Z0-9 /_]", " ", s) s = re.sub(r"\s+", " ", s).strip() return s # Region keyword → canonical region tag. # Order matters where prefixes overlap; longer/more-specific entries first. REGION_PATTERNS: list[tuple[str, str]] = [ # "HEAD AND NECK" / "HEAD/NECK" — almost always soft-tissue neck, not brain. # Must come before the bare HEAD pattern. Note: \W+ alone wouldn't catch # the literal " AND " separator (A/N/D are word chars), so we match it # explicitly. (r"\bHEAD\s+AND\s+NECK\b|\bHEAD\W+NECK\b|\bH/N\b|\bHEAD/NECK\b", "neck"), # Bone scan / whole-body NM imaging (r"\bBONE SCAN\b|\bSKELETAL SURVEY\b", "wholebody"), # Ultrasound breast screening variants that don't say "breast" or "mam" (r"\bULTRASOUND BILAT SCREEN\b", "breast"), # Vascular (must come before generic anatomy because "CAROTID" has its own meaning) (r"\bCAROTID\b", "vasc_carotid"), (r"\bTRANSCRANIAL\b", "vasc_carotid"), (r"\bVENOUS\b.*\b(LEG|LEGS|LE)\b", "vasc_le"), (r"\bVAS\b.*\b(LE|LEG)\b", "vasc_le"), (r"\bDOPPL?ER?\b.*\b(LEG|LE)\b", "vasc_le"), (r"\b(UE|UPPER EXTREM|UP VENOUS|ARM)\b", "vasc_ue"), (r"\bAORTA\b", "vasc_aorta"), (r"\bRENAL ART\b", "vasc_renal"), # Cardiac (r"\bECHO\b", "heart"), (r"\bTTE\b", "heart"), (r"\bN?M\s*MYO\s*PERF\b|\bMYO ?PERF\b", "heart"), (r"\bMYOCARD\b", "heart"), (r"\bSPECT\b", "heart"), # in this dataset SPECT is myocardial perfusion (r"\bCORONARY\b", "heart"), (r"\bCARDIAC\b", "heart"), # Breast / mammography (r"\bMAM\b|\bMAMMO\w*\b", "breast"), (r"\bBREAST\b", "breast"), # Brain / head / skull (r"\bBRAIN\b", "brain"), (r"\bHEAD\b", "brain"), (r"\bSKULL\b(?! TO )", "brain"), # "skull to thigh" is whole-body PET, not skull (r"\bCEREBRAL\b", "brain"), # Sinuses / maxillofacial (r"\bSINUS\w*\b", "sinuses"), (r"\bMAXFACIAL\b|\bMAXILLOFACIAL\b|\bFACIAL\b", "sinuses"), (r"\bORBIT\w*\b", "sinuses"), # Neck / thyroid / soft tissue neck (r"\bTHYROID\b", "neck"), (r"\bSOFT TISSUE NECK\b", "neck"), (r"\bNECK\b", "neck"), # Spine (r"\bC[ -]?SPINE\b|\bCERVICAL SPINE\b|\bCERVICL SPINE\b|\bCERV SPINE\b", "c_spine"), (r"\bT[ -]?SPINE\b|\bTHORACIC SPINE\b|\bTHOR SPINE\b", "t_spine"), (r"\bL[ -]?SPINE\b|\bLUMBAR SPINE\b|\bLUMBAR\b|\bLUM SPINE\b|\bSPINE\W*LUMBAR\b", "l_spine"), (r"\bSACRUM\b|\bSACRAL\b|\bCOCCYX\b", "sacrum"), (r"\bSPINE\b", "spine_other"), # Chest / lungs (r"\bCHEST\b", "chest"), (r"\bLUNG\w*\b", "chest"), (r"\bTHORAX\b", "chest"), (r"\bRIB\w*\b", "chest"), # Abdomen and pelvis (compound first). Note: \W in Python regex does NOT # match underscore (since _ is a word char), so we use an explicit class # to handle "ABD_PEL", "ABD/PEL", "ABD PEL". (r"\bABD(?:OMEN)?[ /_\-]+PEL\w*\b|\bABD AND PEL\b", "abd_pel"), (r"\bABDOMEN\b|\bABD\b|\bABDOMINAL\b", "abdomen"), (r"\bKUB\b", "abdomen"), (r"\bRENAL COLIC\b", "abd_pel"), (r"\bPELVIS\b|\bPELVIC\b", "pelvis"), (r"\bENDOVAGINAL\b|\bTRANSVAGINAL\b|\bUTERUS\b|\bOVAR\w*\b", "pelvis"), (r"\bKIDNEY\w*\b|\bRENAL\b", "abdomen"), (r"\bLIVER\b|\bHEPAT\w*\b|\bGALLBLAD\w*\b|\bBILIARY\b", "abdomen"), # GI fluoro (r"\bESOPHAG\w*\b|\bBARIUM\b|\bGI SERIES\b|\bUPPER GI\b", "gi_fluoro"), # Whole-body PET (r"\bSKULL TO THIGH\b|\bWHOLE BODY\b", "wholebody"), # Bone density (r"\bDXA\b|\bBONE DENS\w*\b", "bone_density"), # Joints / extremities (r"\bSHOULDER\b", "shoulder"), (r"\bHIP\b", "hip"), (r"\bKNEE\b", "knee"), (r"\bANKLE\b", "ankle"), (r"\bFOOT\b|\bFEET\b|\bTOE\w*\b", "foot"), (r"\bELBOW\b", "elbow"), (r"\bWRIST\b", "wrist"), (r"\bHAND\b|\bFINGER\w*\b", "hand"), (r"\bFEMUR\b", "femur"), (r"\bTIBIA\b|\bFIBULA\b", "tib_fib"), (r"\bHUMERUS\b", "humerus"), (r"\bCLAVICLE\b", "clavicle"), # EEG and neuro physiology (r"\bEEG\b", "eeg"), ] # Modality detection. Some modality words also imply region (ECHO->heart, DXA->bone_density) # but we still record the modality separately. MODALITY_PATTERNS: list[tuple[str, str]] = [ (r"\bCTA\b|\bCT ANGIO\w*\b", "cta"), (r"\bMRA\b|\bMR ANGIO\w*\b", "mra"), (r"\bMRI\b|\bMR\b(?! ANGIO)", "mri"), (r"\bCT\b", "ct"), (r"\bMAMMO\w*\b|\bMAM\b", "mammo"), (r"\bUS\b|\bULTRASOUND\b|\bSONOGR\w*\b|\bECHO\b|\bDOPPL?ER?\b", "us"), (r"\bPET\b", "pet"), (r"\bSPECT\b|\bMYO PERF\b|\bNM\b|\bNUCLEAR\b", "nm"), (r"\bDXA\b|\bBONE DENS\w*\b", "dxa"), (r"\bEEG\b", "eeg"), (r"\bFLUORO\w*\b|\bBARIUM\b|\bESOPHAG\w*\b|\bGI SERIES\b", "fluoro"), # XR / plain film (r"\bXR\b|\bX-?RAY\b|\bRADIOGRAPH\w*\b", "xr"), (r"\b\d+\s*VIEW", "xr"), (r"\bAP\b|\bPA\b|\bLAT\b|\bFRONTAL\b", "xr"), ] # Plain-word descriptors (e.g. "Chest", "Abdomen", "Breast", "Thyroid") - these are # implicitly XR/plain films of that region in this dataset (frequent in the data). PLAIN_REGION_FALLBACK = { "CHEST": ("chest", "xr"), "ABDOMEN": ("abdomen", "xr"), "PELVIC": ("pelvis", "xr"), "BREAST": ("breast", "mammo"), "THYROID": ("neck", "us"), "BONE DENSITY": ("bone_density", "dxa"), } @dataclass(frozen=True) class StudyTags: regions: FrozenSet[str] modality: Optional[str] contrast: Optional[str] # 'with' | 'without' | 'with_without' | None laterality: Optional[str] # 'left' | 'right' | 'bilateral' | None is_outside: bool = False raw_norm: str = "" def _detect_contrast(s: str) -> Optional[str]: has_with = bool(re.search(r"\bW\b|\bWITH\b|\bW/\b|\bW CON\b|\bWITH CON\w*\b|\bWITH CNTR\w*\b|\bW CNTR\w*\b", s)) has_without = bool(re.search(r"\bWO\b|\bWITHOUT\b|\bW/O\b|\bWO CON\b|\bWITHOUT CON\w*\b|\bWITHOUT CNTR\w*\b|\bWO CNTR\w*\b", s)) # combined like "wo/w" or "WITHOUT/WITH" if re.search(r"\bWO/W\b|\bW/WO\b|\bWITHOUT/WITH\b|\bWITH/WITHOUT\b|\bWO\s*W\b", s): return "with_without" if has_with and has_without: return "with_without" if has_with: return "with" if has_without: return "without" return None def _detect_laterality(s: str) -> Optional[str]: if re.search(r"\bBI\b|\bBIL\b|\bBILAT\w*\b|\bBOTH\b", s): return "bilateral" has_left = bool(re.search(r"\bLEFT\b|\bLT\b|\bL\b(?! SPINE)", s)) has_right = bool(re.search(r"\bRIGHT\b|\bRT\b|\bR\b(?! SPINE)", s)) if has_left and has_right: return "bilateral" if has_left: return "left" if has_right: return "right" return None # Tags that, when present, override all other region tags. e.g. DXA hip/spine # imaging is its own category — it's only relevant to other DXA studies in this # dataset, not to MRI hip or spine X-ray. EXCLUSIVE_REGION_TAGS = {"bone_density", "eeg"} # When a more-specific spine tag matches, drop the generic spine_other. SPECIFIC_SPINE_TAGS = {"c_spine", "t_spine", "l_spine", "sacrum"} def parse_description(description: str) -> StudyTags: s = _norm(description) if not s: return StudyTags(frozenset(), None, None, None, False, "") # Special: "outside films" - radiologists almost always look at outside priors if "OUTSIDE FILMS" in s or s == "OUTSIDE": return StudyTags(frozenset({"unknown"}), None, None, None, True, s) regions: set[str] = set() matched_spans: list[tuple[int, int]] = [] for pat, tag in REGION_PATTERNS: for m in re.finditer(pat, s): span = (m.start(), m.end()) # Honour the priority-by-order contract: skip this match if it # overlaps a span already claimed by a higher-priority pattern. # Without this, "HEAD AND NECK" tags as both 'neck' (correct) AND # 'brain' (the bare HEAD pattern firing inside the same span). if any(span[0] < pe and ps < span[1] for ps, pe in matched_spans): continue regions.add(tag) matched_spans.append(span) # Apply exclusive-tag override exclusive_present = regions & EXCLUSIVE_REGION_TAGS if exclusive_present: regions = exclusive_present # Drop generic spine when a specific spine level is present if regions & SPECIFIC_SPINE_TAGS: regions.discard("spine_other") if not regions: # Plain-word fallbacks (e.g. "Chest", "Abdomen") — only when description is short # and contains exactly that word. for word, (region, _modality) in PLAIN_REGION_FALLBACK.items(): if word in s and len(s) <= len(word) + 4: regions.add(region) modality: Optional[str] = None for pat, mod in MODALITY_PATTERNS: if re.search(pat, s): modality = mod break if modality is None: for word, (_region, mod) in PLAIN_REGION_FALLBACK.items(): if word in s and len(s) <= len(word) + 4: modality = mod break contrast = _detect_contrast(s) laterality = _detect_laterality(s) return StudyTags( regions=frozenset(regions) if regions else frozenset({"unknown"}), modality=modality, contrast=contrast, laterality=laterality, is_outside=False, raw_norm=s, )