clinical-nlp-api / src /utils /text_utils.py
Ayodeji Akande
Pin ruff config and fix the resulting lint findings
4dc0836
Raw
History Blame Contribute Delete
11.3 kB
"""
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