Spaces:
Running
Running
File size: 11,281 Bytes
79b0bef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | """
src/utils/text_utils.py
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Text cleaning and preprocessing utilities for clinical notes.
Clinical text is messy in ways that general-purpose NLP tools
do not handle well:
- Transcription artefacts ("XXXX" for redacted names)
- Section headers in all-caps ("HISTORY OF PRESENT ILLNESS:")
- Non-standard whitespace from copy-paste out of EMR systems
- Mixed abbreviations ("h/o", "s/p", "c/o", "w/")
- Fragmented sentences and bullet points
These helpers normalise text before it enters the NER pipeline
or the classifier, without destroying clinical meaning.
All functions are pure (no side effects) and return new strings
rather than modifying in place. This makes them safe to use
in parallel pipelines.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"""
from __future__ import annotations
import re
import unicodedata
# ββ Common clinical abbreviation expansions βββββββββββββββββββββββ
# Expand before NER so the model sees full words, not shorthand.
# Keys are lowercase; matching is case-insensitive.
_ABBREVIATIONS: dict[str, str] = {
r"\bh/o\b": "history of",
r"\bs/p\b": "status post",
r"\bc/o\b": "complains of",
r"\bw/\b": "with",
r"\bw/o\b": "without",
r"\bp/w\b": "presenting with",
r"\bpt\b": "patient",
r"\bpts\b": "patients",
r"\bhtn\b": "hypertension",
r"\bdm\b": "diabetes mellitus",
r"\bcad\b": "coronary artery disease",
r"\bcopd\b": "chronic obstructive pulmonary disease",
r"\buti\b": "urinary tract infection",
r"\bcva\b": "cerebrovascular accident",
r"\bmi\b": "myocardial infarction",
r"\bgerd\b": "gastroesophageal reflux disease",
r"\bsob\b": "shortness of breath",
r"\bcp\b": "chest pain",
r"\bn/v\b": "nausea and vomiting",
r"\bf/u\b": "follow up",
# optional periods between letters so "q.d.", "b.i.d." etc. also match,
# not just the period-free forms "qd", "bid"
r"\bq\.?d\.?\b": "once daily",
r"\bb\.?i\.?d\.?\b": "twice daily",
r"\bt\.?i\.?d\.?\b": "three times daily",
r"\bq\.?i\.?d\.?\b": "four times daily",
r"\bprn\b": "as needed",
r"\bpo\b": "by mouth",
r"\biv\b": "intravenous",
r"\bim\b": "intramuscular",
r"\bsc\b": "subcutaneous",
r"\bnpo\b": "nothing by mouth",
r"\bicu\b": "intensive care unit",
r"\bed\b": "emergency department",
# NOTE: deliberately no "or" -> "operating room" entry. "or" is
# overwhelmingly the common English conjunction ("headache or sore
# throat") in clinical text; blindly expanding it corrupted notes
# system-wide (e.g. "headache or sore throat" -> "headache operating
# room sore throat"). The rare genuine OR-abbreviation usage is
# almost always written in caps and is not worth this tradeoff.
}
# Placeholder patterns that indicate redacted or missing information.
# Replace with a neutral token rather than deleting (deletion shifts
# character offsets and breaks entity span extraction).
_REDACTION_PATTERNS: list[str] = [
r"X{2,}", # "XXXX" redacted names
r"\[.*?\]", # [REDACTED], [NAME], [DATE]
r"\*{2,}", # **** redacted fields
r"_{3,}", # ___ blank fields
]
def clean_clinical_text(text: str, expand_abbreviations: bool = True) -> str:
"""Normalise a raw clinical note for downstream NLP processing.
Steps applied in order:
1. Decode unicode characters to ASCII equivalents where possible
2. Normalise whitespace (tabs, multiple spaces, Windows line endings)
3. Replace redaction placeholders with a neutral token
4. Optionally expand common clinical abbreviations
5. Fix common transcription quirks
Args:
text: Raw clinical note text as a string.
expand_abbreviations: Whether to expand clinical shorthand
(e.g. "h/o" β "history of"). Disable when you need the
original wording preserved (e.g. for exact string matching).
Returns:
Cleaned text string. Length may differ from input due to
whitespace normalisation and abbreviation expansion.
Example::
raw = "Pt c/o SOB and CP. H/o HTN. XXXX denies N/V."
clean_clinical_text(raw)
# β "Patient complains of shortness of breath and chest pain.
# History of hypertension. [ANON] denies nausea and vomiting."
"""
if not text or not text.strip():
return ""
# Step 1: Unicode normalisation β convert curly quotes, em-dashes etc.
text = unicodedata.normalize("NFKD", text)
text = text.encode("ascii", errors="ignore").decode("ascii")
# Step 2: Normalise line endings and tabs to single spaces,
# but preserve paragraph breaks (double newlines) as sentence
# boundaries for the segmenter.
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"\n{3,}", "\n\n", text) # collapse 3+ newlines to 2
text = re.sub(r"[ \t]+", " ", text) # collapse horizontal whitespace
text = text.strip()
# Step 3: Replace redaction placeholders
for pattern in _REDACTION_PATTERNS:
text = re.sub(pattern, "[ANON]", text, flags=re.IGNORECASE)
# Step 4: Expand abbreviations
if expand_abbreviations:
text = expand_clinical_abbreviations(text)
# Step 5: Fix double-punctuation from expansion side effects
text = re.sub(r"\.{2,}", ".", text)
text = re.sub(r"\s+\.", ".", text)
text = re.sub(r"\s{2,}", " ", text)
return text.strip()
def expand_clinical_abbreviations(text: str) -> str:
"""Replace clinical shorthand with full words.
Matches are case-insensitive and word-boundary aware so "pt"
in "patient" is not incorrectly expanded.
Args:
text: Text containing clinical abbreviations.
Returns:
Text with abbreviations replaced by their expansions.
"""
for pattern, expansion in _ABBREVIATIONS.items():
text = re.sub(pattern, expansion, text, flags=re.IGNORECASE)
return text
def segment_into_sections(text: str) -> dict[str, str]:
"""Split a clinical note into its named sections.
Common section headers (CHIEF COMPLAINT, HISTORY OF PRESENT
ILLNESS, MEDICATIONS, etc.) are detected by their all-caps
formatting followed by a colon.
Args:
text: A full clinical note, potentially multi-section.
Returns:
Dictionary mapping section name (lowercase, underscored)
to section text. An ``"unsectioned"`` key holds any text
that appears before the first header.
Example::
note = "CHIEF COMPLAINT: chest pain\\nHISTORY: ..."
sections = segment_into_sections(note)
sections["chief_complaint"] # β "chest pain"
sections["history"] # β "..."
"""
# Match headers like "CHIEF COMPLAINT:" or "PAST MEDICAL HISTORY:"
header_pattern = re.compile(
r"^([A-Z][A-Z\s/\-]{2,50}):\s*", re.MULTILINE
)
sections: dict[str, str] = {}
last_key = "unsectioned"
last_end = 0
for match in header_pattern.finditer(text):
# Save everything from the previous header to this one
sections[last_key] = text[last_end: match.start()].strip()
last_key = _normalise_section_name(match.group(1))
last_end = match.end()
# Save the final section
sections[last_key] = text[last_end:].strip()
# Remove empty sections
return {k: v for k, v in sections.items() if v}
def _normalise_section_name(raw: str) -> str:
"""Convert a section header to a clean dictionary key.
Args:
raw: Header text as matched (e.g. ``'CHIEF COMPLAINT'``).
Returns:
Lowercase, underscore-separated key
(e.g. ``'chief_complaint'``).
"""
return re.sub(r"[\s/\-]+", "_", raw.strip().lower())
def truncate_to_tokens(
text: str,
max_tokens: int = 512,
words_per_token: float = 0.75,
) -> str:
"""Truncate text to approximately `max_tokens` BERT tokens.
BERT-family models have a 512-token limit. Rather than
letting the tokeniser silently truncate (which loses the end
of the note), this function truncates at a sentence boundary
close to the limit.
The ``words_per_token`` ratio (default 0.75) is an empirical
approximation for clinical English β clinical text tends to
have more medical terminology, which tokenises into more
subword pieces than ordinary English.
Args:
text: Input text to truncate.
max_tokens: Maximum number of BERT tokens (default 512).
words_per_token: Approximate words-per-token ratio.
Returns:
Truncated text ending at the nearest sentence boundary.
"""
max_words = int(max_tokens * words_per_token)
words = text.split()
if len(words) <= max_words:
return text
# Truncate to max_words and find the last sentence boundary
truncated = " ".join(words[:max_words])
last_stop = max(
truncated.rfind("."),
truncated.rfind("!"),
truncated.rfind("?"),
)
if last_stop > len(truncated) * 0.5:
# A sentence boundary exists in the second half β use it
return truncated[: last_stop + 1].strip()
# No clean boundary found β fall back to hard word truncation
return truncated.strip()
def extract_numeric_values(text: str) -> list[dict[str, str]]:
"""Extract numeric measurements from clinical text.
Finds values like "120/80 mmHg", "98.6Β°F", "Hb 11.2 g/dL",
"SpO2 94%".
Args:
text: Clinical note text.
Returns:
List of dicts with keys ``'value'``, ``'unit'``, ``'context'``.
Context is the surrounding 20 characters for disambiguation.
Example::
extract_numeric_values("BP was 130/85 mmHg, temp 37.2 C")
# β [
# {"value": "130/85", "unit": "mmHg", "context": "BP was 130/85 mmHg"},
# {"value": "37.2", "unit": "C", "context": "temp 37.2 C"},
# ]
"""
pattern = re.compile(
r"(\d+(?:[./]\d+)?)\s*" # numeric value, possibly fractional
r"(mmHg|bpm|mg/dL|g/dL|mmol/L|" # common clinical units
r"mEq/L|IU/L|U/L|ng/mL|mcg/mL|"
r"%|Β°?[CF]|Β°?C|kg|lbs?|cm|mm|"
r"mL|L|mg|mcg|g)\b",
re.IGNORECASE,
)
results = []
for match in pattern.finditer(text):
start = max(0, match.start() - 10)
end = min(len(text), match.end() + 10)
results.append({
"value": match.group(1),
"unit": match.group(2),
"context": text[start:end].strip(),
})
return results
def word_count(text: str) -> int:
"""Return the number of whitespace-delimited words in text.
Args:
text: Any string.
Returns:
Integer word count.
"""
return len(text.split()) if text and text.strip() else 0
|