Introduction to Natural Language Processing for Medical Report Analysis: Pattern Matching with Regular Expressions
Article authored by Michele D. Pierri, MD
Cardiac Surgeon & Medical Technology Researcher
Last updated: January 2025
Reading time: 25 minutes
Abstract
Medical discharge summaries contain critical clinical information that remains largely inaccessible to automated analysis due to their unstructured nature. This article introduces Natural Language Processing (NLP) techniques for extracting structured data from medical discharge letters, beginning with the fundamental approach of pattern matching using regular expressions (regex). Through detailed examples from a realistic post-CABG discharge summary, we demonstrate both the power and limitations of regex-based extraction, establishing the foundation for more advanced NLP techniques covered in subsequent articles.
Article Series:
- Introduction to NLP and Regex for Medical Reports (this article)
- Advanced Linguistic Analysis with spaCy and scispaCy
- Machine Learning and Large Language Models for Clinical Text Analysis
1. The Challenge of Unstructured Clinical Data
1.1 Why Medical Text Analysis Matters
In medical specialties clinical documentation generates vast amounts of unstructured data. A single discharge summary following coronary artery bypass grafting (CABG) contains:
- Quantitative surgical parameters: cardiopulmonary bypass time, aortic cross-clamp time, number of grafts
- Anatomical details: graft conduits (LIMA, SVG, radial artery), target vessels (LAD, RCA, OM)
- Clinical outcomes: postoperative complications, length of stay, functional status
- Medication regimens: drugs, dosages, frequencies, durations
- Temporal information: procedure dates, complication timing (POD – postoperative day), follow-up schedules
Despite the wealth of information, this data exists in narrative form rather than structured database fields. This creates significant challenges:
For Clinical Research:
- Manual chart review is time-consuming and expensive
- Large-scale retrospective studies become impractical
- Identification of patient cohorts requires extensive human effort
For Quality Improvement:
- Surgical registries require manual data entry
- Complication tracking lacks automation
- Benchmarking against standards is delayed
For Clinical Decision Support:
- Real-time alerts based on text documentation are difficult
- Pattern recognition across patient populations is limited
- Predictive modeling requires structured inputs
1.2 Natural Language Processing as a Solution
Natural Language Processing offers automated extraction of structured information from free text. The progression from simple pattern matching to sophisticated AI models represents increasing levels of semantic understanding:
- Pattern Matching (Regex) – character-level string matching
- Linguistic Analysis (spaCy) – syntactic parsing and part-of-speech tagging
- Biomedical NER (scispaCy) – domain-specific entity recognition
- Machine Learning – data-driven classification and extraction
- Large Language Models – semantic reasoning and complex inference
This series will explore each level progressively. We begin with regex because:
- It requires no machine learning expertise
- It provides deterministic, explainable results
- It works well for semi-structured medical documents
- It forms the foundation for understanding more complex approaches
2. Sample Document: Post-CABG Discharge Summary
To demonstrate NLP techniques concretely, we’ll use a realistic cardiac surgery discharge letter throughout this series. This document contains typical elements found in actual clinical practice:
CARDIAC SURGERY DISCHARGE SUMMARY
Patient: John Smith
MRN: 12345678
DOB: 15/03/1958
Admission Date: 10/01/2024
Discharge Date: 18/01/2024
DIAGNOSIS:
1. Triple vessel coronary artery disease
2. Reduced left ventricular function (LVEF 35%)
3. Hypertension
4. Type 2 Diabetes Mellitus
PROCEDURE PERFORMED:
Coronary Artery Bypass Grafting x3 (CABG)
- LIMA to LAD
- SVG to OM1
- SVG to RCA
Date of surgery: 11/01/2024
OPERATIVE DETAILS:
The procedure was performed via median sternotomy under general anesthesia.
Cardiopulmonary bypass time: 98 minutes
Aortic cross-clamp time: 67 minutes
All anastomoses were completed with satisfactory flow measurements.
POSTOPERATIVE COURSE:
The patient was extubated on POD 1 without complications. Chest tubes were removed on POD 2.
Postoperative atrial fibrillation was noted on POD 3, successfully cardioverted with amiodarone
loading followed by maintenance dose. Patient remained in sinus rhythm thereafter.
No wound infections or sternal instability noted. Echocardiography on POD 5 showed LVEF 40%
with good biventricular function and no pericardial effusion.
DISCHARGE MEDICATIONS:
1. Aspirin 100mg daily
2. Clopidogrel 75mg daily (for 12 months)
3. Atorvastatin 80mg daily
4. Metoprolol 50mg twice daily
5. Ramipril 5mg daily
6. Amiodarone 200mg daily (for 6 weeks)
7. Metformin 1000mg twice daily
8. Furosemide 40mg daily (for 2 weeks)
FOLLOW-UP:
- Outpatient cardiology clinic in 2 weeks
- Cardiac rehabilitation program enrollment
- INR monitoring not required (patient on dual antiplatelet therapy)
The patient was discharged home in stable condition with appropriate wound care instructions.
Dr. Sarah Johnson, MD
Cardiac Surgery Department
This document exemplifies the information density and variability characteristic of clinical documentation. Our task is to extract structured data suitable for databases, registries, and analytical systems.
3. Regular Expressions: Fundamentals for Medical Text
3.1 What Are Regular Expressions?
Regular expressions (regex) are sequences of characters that define search patterns. They enable sophisticated pattern matching far beyond simple text search. In medical text processing, regex serves as the foundation for extracting predictably formatted information.
Core Concept: Regex operates at the character level, matching literal characters and special metacharacters that represent classes or quantities of characters.
3.2 Essential Regex Components for Medical Text
Before tackling medical examples, let’s establish the fundamental building blocks.
Quick Reference Table: Essential Regex Metacharacters
| Metacharacter | Meaning | Equivalent | Example | Matches |
|---|---|---|---|---|
\d | Any digit | [0-9] | \d+ | “123”, “5” |
\D | Any non-digit | [^0-9] | \D+ | “abc”, “!@#” |
\w | Word character | [A-Za-z0-9_] | \w+ | “LVEF”, “test_1” |
\W | Non-word character | [^A-Za-z0-9_] | \W | “@”, ” “, “.” |
\s | Whitespace | [ \t\n\r\f] | \s+ | ” “, “tn” |
\S | Non-whitespace | [^\t\n\r\f] | \S+ | “text”, “123” |
. | Any character (except newline) | – | L.EF | “LVEF”, “L3EF” |
* | Zero or more | – | \d* | “”, “5”, “123” |
+ | One or more | – | \d+ | “5”, “123” |
? | Zero or one (optional) | – | colou?r | “color”, “colour” |
{n} | Exactly n times | – | \d{2} | “35” (2 digits) |
{n,m} | Between n and m times | – | \d{2,4} | “35”, “123”, “1234” |
^ | Start of string/line | – | ^Patient | “Patient” at line start |
$ | End of string/line | – | MD$ | “MD” at line end |
\b | Word boundary | – | \bLVEF\b | “LVEF” (not “LVEF35”) |
| `\ | ` | Alternation (OR) | – | `(CABG\ |
[] | Character class | – | [0-9] | Any single digit |
() | Capture group | – | (\d+)% | Captures “35” from “35%” |
(?:) | Non-capturing group | – | `(?:Mr\ | Dr)` |
Common Medical Text Patterns:
| Use Case | Pattern | Example Match |
|---|---|---|
| Date (EU format) | \d{2}/\d{2}/\d{4} | “15/03/2024” |
| Medical Record Number | \d{6,10} | “12345678” |
| Percentage | \d+(?:\.\d+)?% | “35%”, “35.5%” |
| Medication dose | \d+(?:\.\d+)?\s*mg | “100mg”, “2.5 mg” |
| Blood pressure | \d{2,3}/\d{2,3} | “120/80” |
| Time duration | `d+s*(?:min\ | hour)s?` |
3.2.1 Literal Characters
The simplest regex matches exact characters:
import re
text = "LVEF 35%"
pattern = "LVEF"
match = re.search(pattern, text)
# Matches: "LVEF"
3.2.2 Character Classes
Square brackets [] match any single character within:
# Match any digit
pattern = r"[0-9]"
# Matches: "3", "5"
# Match any letter
pattern = r"[A-Za-z]"
# Matches: "L", "V", "E", "F"
# Shorthand for digits
pattern = r"\d" # Equivalent to [0-9]
# Shorthand for word characters
pattern = r"\w" # Equivalent to [A-Za-z0-9_]
3.2.3 Quantifiers
Control how many times a pattern should match:
# * = zero or more
pattern = r"\d*" # Matches: "", "3", "35", "123"
# + = one or more
pattern = r"\d+" # Matches: "3", "35", "123" (but not empty string)
# ? = zero or one (optional)
pattern = r"mg?" # Matches: "m" or "mg"
# {n} = exactly n times
pattern = r"\d{2}" # Matches exactly 2 digits: "35"
# {n,m} = between n and m times
pattern = r"\d{2,4}" # Matches: "35", "123", "1234"
3.2.4 Anchors and Boundaries
Specify position in text:
# ^ = start of string/line
pattern = r"^Patient" # Matches "Patient" only at line start
# $ = end of string/line
pattern = r"MD$" # Matches "MD" only at line end
# \b = word boundary
pattern = r"\bLVEF\b" # Matches "LVEF" as whole word, not "LVEF35"
3.2.5 Groups and Capture
Parentheses () create capture groups to extract specific parts:
pattern = r"LVEF (\d+)%"
match = re.search(pattern, "LVEF 35%")
if match:
lvef_value = match.group(1) # Extracts: "35"
3.2.6 Alternation
The pipe | matches one pattern OR another:
pattern = r"(CABG|OPCAB|PCI)"
# Matches any of: "CABG", "OPCAB", or "PCI"
3.2.7 Non-Capturing Groups
(?:...) groups without capturing (improves performance):
pattern = r"(?:Mr|Mrs|Dr)\.?\s+([A-Z][a-z]+)"
# Groups title but doesn't capture it, captures name only
3.3 Medical-Specific Regex Patterns
Medical text has recurring structures that regex handles well:
3.3.1 Dates
Medical records use various date formats:
# European format: DD/MM/YYYY
date_pattern_eu = r"\b(\d{2})/(\d{2})/(\d{4})\b"
# American format: MM/DD/YYYY
date_pattern_us = r"\b(\d{2})/(\d{2})/(\d{4})\b" # Same pattern, different interpretation
# ISO format: YYYY-MM-DD
date_pattern_iso = r"\b(\d{4})-(\d{2})-(\d{2})\b"
# Flexible pattern accepting multiple separators
date_pattern_flex = r"\b(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})\b"
# Example usage
text = "Admission Date: 10/01/2024"
match = re.search(r"Admission Date:\s*(\d{2}/\d{2}/\d{4})", text)
if match:
admission_date = match.group(1) # "10/01/2024"
3.3.2 Medical Record Numbers (MRN)
Typically 6-10 digit identifiers:
# Basic MRN pattern
mrn_pattern = r"MRN[:\s]+(\d{6,10})"
# More flexible with optional labels
mrn_pattern_flex = r"(?:MRN|Medical Record|Patient ID)[:\s#]*(\d{6,10})"
# Example
text = "Patient ID: 12345678"
match = re.search(mrn_pattern_flex, text)
if match:
mrn = match.group(1) # "12345678"
3.3.3 Vital Signs and Measurements
Numbers with units:
# Blood pressure
bp_pattern = r"(\d{2,3})/(\d{2,3})\s*mmHg"
# Matches: "120/80 mmHg" -> systolic="120", diastolic="80"
# Heart rate
hr_pattern = r"HR[:\s]*(\d{2,3})\s*(?:bpm|beats)"
# Matches: "HR: 72 bpm" -> "72"
# Temperature
temp_pattern = r"(\d{2,3}(?:\.\d{1,2})?)\s*°?[CF]"
# Matches: "37.5°C" or "98.6F" -> "37.5" or "98.6"
# Oxygen saturation
spo2_pattern = r"(?:SpO2|O2 sat)[:\s]*(\d{2,3})%"
# Matches: "SpO2: 98%" -> "98"
3.3.4 Medication Patterns
Drugs with dosages and frequencies:
# Basic medication: Drug Dose Frequency
med_pattern = r"([A-Z][a-z]+)\s+(\d+(?:\.\d+)?)\s*mg\s+([a-z\s]+)"
# Matches: "Aspirin 100mg daily"
# -> drug="Aspirin", dose="100", frequency="daily"
# With optional duration
med_pattern_duration = r"([A-Z][a-z]+)\s+(\d+(?:\.\d+)?)\s*mg\s+([a-z\s]+)(?:\s*\(for\s+([^)]+)\))?"
# Matches: "Clopidogrel 75mg daily (for 12 months)"
# -> drug="Clopidogrel", dose="75", frequency="daily", duration="12 months"
3.3.5 Laboratory Values
Lab results with reference ranges:
# Lab value with unit
lab_pattern = r"([A-Za-z0-9\s]+)[:\s]+(\d+(?:\.\d+)?)\s*([a-zA-Z/]+)"
# Matches: "Creatinine: 1.2 mg/dL"
# -> test="Creatinine", value="1.2", unit="mg/dL"
# With reference range
lab_pattern_ref = r"([A-Za-z0-9\s]+)[:\s]+(\d+(?:\.\d+)?)\s*([a-zA-Z/]+)\s*\((\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\)"
# Matches: "Hemoglobin: 13.5 g/dL (12.0-16.0)"
# -> test, value, unit, ref_low, ref_high
3.4 Building Complex Patterns: A Systematic Approach
For medical text, we often need multi-component patterns. Here’s a systematic method:
Step 1: Identify the Structure
For “Cardiopulmonary bypass time: 98 minutes”, we see:
- Label: “Cardiopulmonary bypass time” or “CPB time”
- Separator: colon and optional whitespace
- Value: numerical
- Unit: “minutes” or “min”
Step 2: Create Pattern Components
# Label variations
label = r"(?:Cardiopulmonary bypass|CPB)\s+time"
# Separator (colon + optional spaces)
separator = r"[:\s]*"
# Numerical value
value = r"(\d+(?:\.\d+)?)"
# Unit variations
unit = r"\s*(?:minutes|min|mins)"
Step 3: Combine with Appropriate Flags
# Complete pattern
cpb_pattern = r"(?:Cardiopulmonary bypass|CPB)\s+time[:\s]*(\d+(?:\.\d+)?)\s*(?:minutes|min|mins)"
# Using re.IGNORECASE flag for case-insensitive matching
match = re.search(cpb_pattern, text, re.IGNORECASE)
Step 4: Test and Refine
test_cases = [
"Cardiopulmonary bypass time: 98 minutes",
"CPB time 98 min",
"cpb time: 98min", # No space before unit
"Bypass time 98 minutes", # Different phrasing - won't match!
]
for test in test_cases:
match = re.search(cpb_pattern, test, re.IGNORECASE)
print(f"'{test}' -> {match.group(1) if match else 'NO MATCH'}")
4. Practical Implementation: Extracting Data from Discharge Summary
Now we’ll build a comprehensive regex-based parser for cardiac surgery discharge summaries. This demonstrates real-world application with production-quality code.
4.1 Architecture Overview
Our parser will have modular methods, each responsible for extracting a specific category of information:
- Patient demographics
- Surgical parameters
- Cardiac function (LVEF)
- Coronary grafts
- Medications
- Complications
4.2 Complete Implementation
import re
from datetime import datetime
from typing import Dict, List, Optional
class CardiacDischargeParser:
"""
Regex-based parser for cardiac surgery discharge summaries.
Demonstrates production-quality pattern matching with:
- Error handling
- Multiple pattern variants
- Data validation
- Type hints for clarity
"""
def __init__(self, text: str):
"""
Initialize parser with discharge summary text.
Args:
text: Full text of discharge summary
"""
self.text = text
self.extracted_data = {}
def extract_patient_demographics(self) -> Dict[str, str]:
"""
Extract demographic information using multiple regex patterns.
Patterns designed to handle variations in formatting while
maintaining specificity to avoid false matches.
Returns:
Dictionary with demographic fields
"""
demographics = {}
# Medical Record Number
# Pattern explanation:
# - MRN[:\s]+ : "MRN" followed by colon or spaces
# - (\d{6,10}) : Capture 6-10 digits
mrn_pattern = r'MRN[:\s]+(\d{6,10})'
mrn_match = re.search(mrn_pattern, self.text)
if mrn_match:
demographics['mrn'] = mrn_match.group(1)
# Date of Birth
# Multiple patterns to handle format variations
dob_patterns = [
r'DOB[:\s]+(\d{2}/\d{2}/\d{4})', # DD/MM/YYYY
r'Date of Birth[:\s]+(\d{2}/\d{2}/\d{4})',
r'DOB[:\s]+(\d{4}-\d{2}-\d{2})', # ISO format
]
for pattern in dob_patterns:
dob_match = re.search(pattern, self.text, re.IGNORECASE)
if dob_match:
demographics['dob'] = dob_match.group(1)
# Calculate age from DOB
try:
# Attempt to parse date (assuming DD/MM/YYYY)
birth_date = datetime.strptime(dob_match.group(1), '%d/%m/%Y')
age = (datetime.now() - birth_date).days // 365
demographics['age'] = age
except ValueError:
# If parsing fails, store DOB without age calculation
pass
break
# Admission and Discharge Dates
# Pattern: "Label: DD/MM/YYYY"
admission_pattern = r'Admission Date[:\s]+(\d{2}/\d{2}/\d{4})'
discharge_pattern = r'Discharge Date[:\s]+(\d{2}/\d{2}/\d{4})'
admission_match = re.search(admission_pattern, self.text, re.IGNORECASE)
discharge_match = re.search(discharge_pattern, self.text, re.IGNORECASE)
if admission_match:
demographics['admission_date'] = admission_match.group(1)
if discharge_match:
demographics['discharge_date'] = discharge_match.group(1)
# Calculate Length of Stay
if admission_match and discharge_match:
try:
adm_date = datetime.strptime(admission_match.group(1), '%d/%m/%Y')
dis_date = datetime.strptime(discharge_match.group(1), '%d/%m/%Y')
los_days = (dis_date - adm_date).days
demographics['length_of_stay_days'] = los_days
except ValueError:
# Dates couldn't be parsed
pass
return demographics
def extract_surgical_parameters(self) -> Dict[str, int]:
"""
Extract quantitative operative parameters.
These are critical quality metrics in cardiac surgery:
- CPB time: Duration of cardiopulmonary bypass
- Cross-clamp time: Duration of aortic occlusion (ischemic time)
Returns:
Dictionary with surgical timing parameters
"""
surgical_data = {}
# Cardiopulmonary Bypass Time
# Pattern breakdown:
# - (?:...) : Non-capturing group for label variations
# - [^:]* : Any characters except colon (bridges "bypass" to "time")
# - (\d+) : Capture the numeric value
# - \s*(?:...) : Optional whitespace + unit variations
cpb_pattern = r'(?:Cardiopulmonary bypass|CPB)[^:]*?time[:\s]*(\d+)\s*(?:minutes|min)'
cpb_match = re.search(cpb_pattern, self.text, re.IGNORECASE)
if cpb_match:
cpb_time = int(cpb_match.group(1))
# Validation: CPB time typically 30-300 minutes
if 30 <= cpb_time <= 300:
surgical_data['cpb_time_minutes'] = cpb_time
else:
# Log warning in production system
surgical_data['cpb_time_minutes'] = cpb_time
surgical_data['cpb_time_warning'] = 'Value outside typical range'
# Aortic Cross-Clamp Time
# Critical parameter: longer times increase myocardial ischemia risk
xclamp_patterns = [
r'(?:Aortic\s+)?cross[- ]?clamp\s+time[:\s]*(\d+)\s*(?:minutes|min)',
r'(?:X|x)[- ]?clamp\s+time[:\s]*(\d+)\s*(?:minutes|min)',
r'clamp\s+time[:\s]*(\d+)\s*(?:minutes|min)',
]
for pattern in xclamp_patterns:
xclamp_match = re.search(pattern, self.text, re.IGNORECASE)
if xclamp_match:
xclamp_time = int(xclamp_match.group(1))
# Validation: Cross-clamp typically 20-180 minutes
if 20 <= xclamp_time <= 180:
surgical_data['crossclamp_time_minutes'] = xclamp_time
else:
surgical_data['crossclamp_time_minutes'] = xclamp_time
surgical_data['crossclamp_warning'] = 'Value outside typical range'
break
return surgical_data
def extract_lvef(self) -> Optional[Dict[str, int]]:
"""
Extract Left Ventricular Ejection Fraction.
LVEF is crucial for:
- Preoperative risk stratification
- Postoperative assessment of cardiac function
- Prognostic evaluation
Challenge: Discharge summaries often mention both pre-op and post-op LVEF.
We attempt to distinguish them by context.
Returns:
Dictionary with LVEF values, or None if not found
"""
# Multiple pattern variations for LVEF
lvef_patterns = [
r'LVEF[:\s]+(\d+)%',
r'ejection fraction[:\s]+(\d+)%',
r'EF[:\s]+(\d+)%',
r'left ventricular function[^(]*?\((?:LVEF\s+)?(\d+)%\)',
]
lvef_values = []
for pattern in lvef_patterns:
matches = re.finditer(pattern, self.text, re.IGNORECASE)
for match in matches:
value = int(match.group(1))
# Validation: Physiologic LVEF range is 15-80%
if 15 <= value <= 80:
# Store value with its position in text
lvef_values.append({
'value': value,
'position': match.start()
})
if not lvef_values:
return None
# If multiple LVEF values found, attempt to identify pre-op vs post-op
if len(lvef_values) >= 2:
# Simple heuristic: earlier in document = preoperative
# More sophisticated: look for contextual keywords
lvef_values.sort(key=lambda x: x['position'])
return {
'preoperative_lvef': lvef_values[0]['value'],
'postoperative_lvef': lvef_values[-1]['value']
}
elif len(lvef_values) == 1:
# Check context for "pre" or "post" keywords
context_start = max(0, lvef_values[0]['position'] - 50)
context_end = min(len(self.text), lvef_values[0]['position'] + 50)
context = self.text[context_start:context_end].lower()
if 'post' in context or 'discharge' in context:
return {'postoperative_lvef': lvef_values[0]['value']}
elif 'pre' in context or 'admission' in context:
return {'preoperative_lvef': lvef_values[0]['value']}
else:
return {'lvef': lvef_values[0]['value']}
return None
def extract_grafts(self) -> List[Dict[str, str]]:
"""
Extract coronary artery bypass graft information.
Pattern: CONDUIT to TARGET
Conduits:
- LIMA: Left Internal Mammary Artery
- RIMA: Right Internal Mammary Artery
- SVG: Saphenous Vein Graft
- RA: Radial Artery
Targets (coronary arteries):
- LAD: Left Anterior Descending
- LCx/OM: Left Circumflex / Obtuse Marginal
- RCA: Right Coronary Artery
- PDA: Posterior Descending Artery
Returns:
List of graft dictionaries
"""
grafts = []
# Pattern explanation:
# - (LIMA|RIMA|SVG|RA) : Capture conduit type
# - \s+to\s+ : " to " connector
# - ([A-Z0-9]+(?:\s+[A-Z0-9]+)?) : Target vessel (may be multi-word)
graft_pattern = r'(LIMA|RIMA|SVG|RA)\s+to\s+([A-Z][A-Z0-9]*\d*)'
matches = re.finditer(graft_pattern, self.text, re.IGNORECASE)
for match in matches:
grafts.append({
'conduit': match.group(1).upper(),
'target': match.group(2).upper()
})
return grafts
def extract_medications(self) -> List[Dict[str, str]]:
"""
Extract discharge medications with dosing information.
Pattern: Number. DrugName Dose Frequency (Duration)
Challenges:
- Medication lists have consistent formatting but vary by institution
- Duration is optional
- Frequency can be complex ("twice daily", "every 6 hours")
Returns:
List of medication dictionaries
"""
medications = []
# First, isolate the medications section
# This improves precision by avoiding false matches elsewhere
med_section_pattern = r'DISCHARGE MEDICATIONS?:(.*?)(?=\n[A-Z]{2,}[A-Z\s]*:|\Z)'
med_section_match = re.search(med_section_pattern, self.text, re.IGNORECASE | re.DOTALL)
if not med_section_match:
return medications
med_text = med_section_match.group(1)
# Pattern for individual medications
# Breakdown:
# - \d+\. : List number (1., 2., etc.)
# - \s+ : Whitespace
# - ([A-Z][a-z]+) : Drug name (capitalized)
# - \s+(\d+(?:\.\d+)?)\s*mg : Dose with unit
# - \s+([^(\n]+) : Frequency (everything until parenthesis or newline)
# - (?:\(([^)]+)\))? : Optional duration in parentheses
med_pattern = r'\d+\.\s+([A-Z][a-z]+)\s+(\d+(?:\.\d+)?)\s*mg\s+([^(\n]+)(?:\(([^)]+)\))?'
matches = re.finditer(med_pattern, med_text)
for match in matches:
medication = {
'name': match.group(1),
'dose_mg': match.group(2),
'frequency': match.group(3).strip(),
}
# Add duration if present
if match.group(4):
medication['duration'] = match.group(4).strip()
medications.append(medication)
return medications
def extract_complications(self) -> List[str]:
"""
Identify postoperative complications mentioned in text.
Strategy:
1. Use dictionary of known complications
2. Search for each term
3. Exclude negated mentions ("no infection", "without bleeding")
Limitation: This is where regex shows weakness - context
understanding requires more sophisticated NLP.
Returns:
List of identified complications
"""
# Common cardiac surgery complications
complications_keywords = [
'atrial fibrillation',
'stroke',
'renal failure',
'kidney injury',
'pneumonia',
'wound infection',
'sternal infection',
'mediastinitis',
'bleeding',
'tamponade',
'myocardial infarction',
'low cardiac output',
]
found_complications = []
text_lower = self.text.lower()
for complication in complications_keywords:
if complication.lower() in text_lower:
# Check for negation in surrounding context
# Pattern: "no/without/absence of" within 5 words before complication
negation_pattern = r'\b(?:no|without|absence of|negative for|ruled out)\s+(?:\w+\s+){0,4}' + re.escape(complication)
negation_match = re.search(negation_pattern, text_lower)
if not negation_match:
# Complication mentioned without negation
found_complications.append(complication)
# Remove duplicates
return list(set(found_complications))
def parse_all(self) -> Dict:
"""
Execute complete parsing pipeline.
Returns:
Comprehensive dictionary of all extracted information
"""
return {
'demographics': self.extract_patient_demographics(),
'surgical_parameters': self.extract_surgical_parameters(),
'lvef': self.extract_lvef(),
'grafts': self.extract_grafts(),
'medications': self.extract_medications(),
'complications': self.extract_complications()
}
# Example usage with our discharge summary
discharge_summary = """[Insert the full discharge summary text from section 2]"""
parser = CardiacDischargeParser(discharge_summary)
results = parser.parse_all()
# Display results
print("=== PATIENT DEMOGRAPHICS ===")
for key, value in results['demographics'].items():
print(f"{key}: {value}")
print("\n=== SURGICAL PARAMETERS ===")
for key, value in results['surgical_parameters'].items():
print(f"{key}: {value}")
print("\n=== EJECTION FRACTION ===")
if results['lvef']:
for key, value in results['lvef'].items():
print(f"{key}: {value}%")
print("\n=== CORONARY GRAFTS ===")
for graft in results['grafts']:
print(f"{graft['conduit']} → {graft['target']}")
print("\n=== DISCHARGE MEDICATIONS ===")
for med in results['medications']:
duration = f" ({med['duration']})" if 'duration' in med else ""
print(f"{med['name']} {med['dose_mg']}mg {med['frequency']}{duration}")
print("\n=== COMPLICATIONS IDENTIFIED ===")
if results['complications']:
for comp in results['complications']:
print(f"• {comp}")
else:
print("No complications detected")
5. Advanced Regex Techniques for Medical Text
5.1 Handling Variability with Optional Groups
Medical documentation exhibits significant variability. Optional groups (?:...)? make patterns flexible:
# Without optional groups - rigid
rigid_pattern = r'Patient Name: ([A-Z][a-z]+ [A-Z][a-z]+)'
# With optional groups - flexible
flexible_pattern = r'(?:Patient\s+)?Name[:\s]+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)'
# Matches all of:
# "Patient Name: John Smith"
# "Name: John Smith"
# "Patient Name: John"
# "Name:John"
5.2 Lookahead and Lookbehind Assertions
Assertions match position without consuming characters:
# Positive lookahead: (?=...)
# Match "LVEF" only if followed by a number
pattern = r'LVEF(?=\s*\d+%)'
# Negative lookahead: (?!...)
# Match "infection" not followed by "ruled out"
pattern = r'infection(?!\s+ruled out)'
# Positive lookbehind: (?<=...)
# Match percentage that follows "LVEF"
pattern = r'(?<=LVEF\s)(\d+)%'
# Negative lookbehind: (?<!...)
# Match "AF" not preceded by "without"
pattern = r'(?<!without\s)AF\b'
Medical application – Extract value only when preceded by label:
# Extract creatinine value only after "Creatinine:"
creat_pattern = r'(?<=Creatinine:\s)(\d+\.\d+)'
text = "Creatinine: 1.2 mg/dL, normal range 0.7-1.3"
match = re.search(creat_pattern, text)
# Extracts: "1.2"
5.3 Greedy vs Non-Greedy Matching
Understanding quantifier behavior prevents over-matching:
# Greedy (default): matches as much as possible
greedy_pattern = r'<(.+)>'
text = "<patient>John Smith</patient>"
match = re.search(greedy_pattern, text)
# Matches: "patient>John Smith</patient" (too much!)
# Non-greedy: add ? after quantifier
non_greedy_pattern = r'<(.+?)>'
match = re.search(non_greedy_pattern, text)
# Matches: "patient" (correct!)
Medical application – Extracting sections:
# Extract content between section headers
# Greedy would capture multiple sections
section_pattern = r'DIAGNOSIS:(.*?)(?=\n[A-Z]{2,}[A-Z\s]*:|$)'
text = """
DIAGNOSIS:
1. CAD
2. Hypertension
PROCEDURE PERFORMED:
CABG x3
"""
match = re.search(section_pattern, text, re.DOTALL)
# Non-greedy .*? stops at "PROCEDURE", not end of text
5.4 Named Capture Groups
Named groups improve code readability:
# Unnamed groups - hard to remember
pattern = r'(\d{2})/(\d{2})/(\d{4})'
match = re.search(pattern, "10/01/2024")
day = match.group(1) # Which number is day again?
month = match.group(2)
year = match.group(3)
# Named groups - self-documenting
pattern = r'(?P<day>\d{2})/(?P<month>\d{2})/(?P<year>\d{4})'
match = re.search(pattern, "10/01/2024")
day = match.group('day') # Clear and explicit
month = match.group('month')
year = match.group('year')
Medical application:
# Blood pressure with named groups
bp_pattern = r'BP[:\s]+(?P<systolic>\d{2,3})/(?P<diastolic>\d{2,3})'
text = "BP: 120/80 mmHg"
match = re.search(bp_pattern, text)
systolic = match.group('systolic') # "120"
diastolic = match.group('diastolic') # "80"
5.5 Regex Flags for Medical Text
Flags modify pattern matching behavior:
import re
# re.IGNORECASE (re.I): Case-insensitive matching
pattern = r'CABG'
re.search(pattern, 'cabg', re.IGNORECASE) # Matches
# re.MULTILINE (re.M): ^ and $ match line starts/ends
pattern = r'^DIAGNOSIS:'
text = "Patient info\nDIAGNOSIS: CAD"
re.search(pattern, text, re.MULTILINE) # Matches
# re.DOTALL (re.S): . matches newlines too
pattern = r'DIAGNOSIS:(.+?)PROCEDURE'
text = "DIAGNOSIS:\n1. CAD\n2. HTN\nPROCEDURE:"
re.search(pattern, text, re.DOTALL) # Matches across lines
# Combining flags
re.search(pattern, text, re.IGNORECASE | re.DOTALL)
6. Common Pitfalls and Solutions
6.1 Pitfall: Over-Matching with Greedy Quantifiers
Problem:
# Trying to extract medication section
pattern = r'MEDICATIONS:(.+)FOLLOW-UP:'
text = "MEDICATIONS: Aspirin 100mg FOLLOW-UP: Clinic"
match = re.search(pattern, text)
# .+ is greedy - captures everything including "FOLLOW-UP:"
Solution:
# Use non-greedy quantifier
pattern = r'MEDICATIONS:(.+?)FOLLOW-UP:'
# Now stops at first "FOLLOW-UP:"
6.2 Pitfall: Not Escaping Special Characters
Problem:
# Trying to match literal parentheses
pattern = r'LVEF (40%)' # Parentheses have special meaning!
text = "Reduced function (LVEF 40%)"
match = re.search(pattern, text) # Won't match as expected
Solution:
# Escape special characters with backslash
pattern = r'LVEF \(40%\)'
# Or use re.escape() for dynamic patterns
value = "40%"
pattern = re.escape(f'LVEF ({value})')
6.3 Pitfall: Negation Detection Failures
Problem:
# Simple keyword search
if 'infection' in text.lower():
complications.append('infection')
# False positive: "No wound infection noted"
Solution:
# Context-aware negation detection
negation_pattern = r'\b(?:no|without|absence of)\s+(?:\w+\s+){0,3}infection'
if 'infection' in text.lower() and not re.search(negation_pattern, text.lower()):
complications.append('infection')
6.4 Pitfall: Ambiguous Abbreviations
Problem:
# "AF" could be:
# - Atrial Fibrillation
# - Aortic Flow
# - And others...
pattern = r'\bAF\b'
# Matches both contexts - needs disambiguation
Solution:
# Use broader context patterns
af_cardiac_pattern = r'(?:atrial fibrillation|a-fib|AF)\s+(?:developed|noted|occurred)'
af_flow_pattern = r'AF\s+(?:measured|velocity|gradient)'
# Check which pattern matches to determine meaning
6.5 Pitfall: Date Format Ambiguity
Problem:
# Is "01/02/2024" January 2nd or February 1st?
pattern = r'(\d{2})/(\d{2})/(\d{4})'
Solution:
# Determine format from context or institution standards
# US format: MM/DD/YYYY
# EU format: DD/MM/YYYY
# Or extract month names when available
pattern = r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})'
7. Performance Optimization for Large-Scale Processing
7.1 Compile Patterns for Reuse
import re
# Inefficient: Pattern compiled on every call
for document in large_dataset:
match = re.search(r'LVEF[:\s]+(\d+)%', document)
# Efficient: Compile once, reuse many times
lvef_pattern = re.compile(r'LVEF[:\s]+(\d+)%')
for document in large_dataset:
match = lvef_pattern.search(document)
7.2 Minimize Backtracking
# Catastrophic backtracking risk
bad_pattern = r'(a+)+(b+)+'
# Better: Atomic groups or possessive quantifiers
# (Note: Python doesn't support possessive quantifiers directly)
better_pattern = r'(?:a+)(?:b+)'
7.3 Strategic Pattern Ordering
# When using multiple patterns, order by likelihood
patterns = [
r'LVEF[:\s]+(\d+)%', # Most common format
r'ejection fraction[:\s]+(\d+)%', # Less common
r'EF[:\s]+(\d+)%', # Rare abbreviation
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
break # Stop on first match
8. Limitations of Regex for Medical NLP
While powerful for structured data, regex has fundamental limitations that motivate more advanced NLP approaches.
8.1 Inability to Handle Semantic Variation
Problem: Synonyms and paraphrasing
Regex requires explicit patterns for every variation:
# Need separate patterns for equivalent concepts
patterns = [
'coronary artery bypass',
'CABG',
'bypass surgery',
'revascularization',
'coronary grafting'
]
A semantic NLP system would understand these as equivalent concepts automatically.
8.2 Context Insensitivity
Problem: Same word, different meanings
text = "Patient has history of AF. AF measured at 60 mL/min."
# "AF" = Atrial Fibrillation vs Aortic Flow
# Regex alone cannot disambiguate
Human readers use surrounding context to determine meaning. Regex cannot.
8.3 Weak Negation Handling
Problem: Complex negation structures
# Simple negation works
"No wound infection" → regex can detect "no...infection"
# Complex negation fails
"The wound showed no signs of infection or dehiscence"
# Regex struggles with multi-clause negation scope
8.4 Inability to Infer Relationships
Problem: Connecting related information
Consider: “Patient developed renal failure requiring dialysis on POD 4.”
A human understands:
- Complication: renal failure
- Intervention: dialysis
- Timing: postoperative day 4
- Causal relationship: renal failure → required → dialysis
Regex can extract each piece but cannot represent the relationships.
8.5 No Statistical Learning
Problem: Cannot improve from data
Regex patterns are static. They don’t learn from:
- Frequency distributions
- Co-occurrence patterns
- Institutional writing styles
- Evolving medical terminology
Machine learning approaches adapt automatically to new data.
8.6 Brittleness to Format Changes
Example: Medication list format change
# Original format
"1. Aspirin 100mg daily"
# New format used by different clinician
"Aspirin - 100mg - once daily"
# Regex pattern breaks, requires manual update
9. When to Use Regex vs Advanced NLP
9.1 Regex is Appropriate When:
Data is semi-structured
- Consistent section headers
- Predictable formatting
- Limited variability
Patterns are well-defined
- Date formats
- Numerical measurements
- Medical record numbers
Precision is critical
- Exact matching required
- False positives unacceptable
- Deterministic behavior needed
Computational resources are limited
- No GPU required
- Minimal memory footprint
- Fast execution
Explainability is required
- Pattern logic is transparent
- Easy to audit and validate
9.2 Advanced NLP is Needed When:
High semantic variability
- Multiple ways to express same concept
- Synonyms and paraphrasing common
- Free-text narratives
Context-dependent interpretation
- Word sense disambiguation needed
- Negation scope is complex
- Temporal relationships matter
Entity relationships required
- Connecting multiple pieces of information
- Building knowledge graphs
- Understanding causality
Learning from data is beneficial
- Patterns evolve over time
- Institutional variations exist
- Continuous improvement needed
10. Conclusion: Regex as Foundation for Medical NLP
Regular expressions provide a powerful, deterministic foundation for extracting structured information from semi-structured medical text. Through this article, we’ve demonstrated:
Key Takeaways
- Regex excels at pattern matching in predictable formats
- Dates, numerical values, medical record numbers
- Consistent section headers and field labels
- Medication lists with standard formatting
- Production implementation requires careful engineering
- Compiled patterns for performance
- Comprehensive error handling
- Validation of extracted values
- Logging and monitoring
- Fundamental limitations exist
- Cannot handle semantic variation
- Struggles with complex negation
- No learning from data
- Brittle to format changes
- Regex is a component, not the complete solution
- Best used in hybrid systems
- Combined with more advanced NLP techniques
- Foundation for understanding what comes next
Looking Ahead: The Next Article
In the next article of this series, “Advanced Linguistic Analysis with spaCy and scispaCy”, we’ll address the limitations of regex by introducing:
- Part-of-speech tagging for understanding grammatical structure
- Dependency parsing to identify relationships between words
- Named Entity Recognition for automatic identification of medical concepts
- Biomedical entity linking to connect extracted terms with standardized medical ontologies (UMLS, SNOMED-CT)
These techniques will enable extraction from less structured text, handle semantic variations automatically, and understand contextual nuances that regex cannot capture.
Example preview: While regex requires explicit patterns for “CABG”, “bypass surgery”, “coronary revascularization”, the next article will show how spaCy automatically recognizes these as the same surgical procedure concept.
References
- Friedman C, Hripcsak G. “Natural language processing and its future in medicine.” Academic Medicine 1999;74(8):890-895.
- Chapman WW, et al. “Overcoming barriers to NLP for clinical text: the role of shared tasks and the need for additional creative solutions.” JAMIA 2011;18(5):540-543.
- Friedlin FJ, McDonald CJ. “A software tool for removing patient identifying information from clinical documents.” JAMIA 2008;15(5):601-610.
- Kreimeyer K, et al. “Natural Language Processing Systems for Capturing and Standardizing Unstructured Clinical Information: A Systematic Review.” Journal of Biomedical Informatics 2017;73:14-29.
- Wang Y, et al. “Clinical information extraction applications: A literature review.” Journal of Biomedical Informatics 2018;77:34-49.
- Uzuner O, et al. “Evaluating the state-of-the-art in automatic de-identification.” JAMIA 2007;14(5):550-563.
- Harkema H, et al. “ConText: An algorithm for determining negation, experiencer, and temporal status from clinical reports.” Journal of Biomedical Informatics 2009;42(5):839-851.
- Demner-Fushman D, et al. “What can natural language processing do for clinical decision support?” Journal of Biomedical Informatics 2009;42(5):760-772.
- Meystre SM, et al. “Extracting information from textual documents in the electronic health record: a review of recent research.” IMIA Yearbook of Medical Informatics 2008:128-144.
- Savova GK, et al. “Mayo clinical Text Analysis and Knowledge Extraction System (cTAKES): architecture, component evaluation and applications.” JAMIA 2010;17(5):507-513.