Back to List

LLM-Based Clinical Note Information Extraction β€” A Practical Pipeline for Extracting Unstructured EHR into Structured JSON

A hardcore pipeline for extracting symptoms, medications, examinations, and diagnoses from unstructured EHR text, such as MIMIC-IV clinical notes, into structured JSON. This includes HIPAA Safe Harbor personal information masking, LLM structured output, regex fallback double defense, UMLS CUI mapping, and F1 evaluation.

Intermediate
|
25min
|
Verified (2026-07)
Progress0/15 (0%)

LLM-Based Clinical Note Information Extraction: A Practical Pipeline for Transforming Unstructured EHR into Structured JSON

Clinical notes, which are free-text narratives written by physicians, contain information about symptoms, medications, tests, and diagnoses. However, this information is not directly usable for statistical analysis, searching, or research. This article presents a practical pipeline for extracting structured JSON from this unstructured text, taking a hardcore approach.

πŸ“š Recommended Prerequisites (Strongly Recommended)

This article is part of the AIΓ—Bio hardcore advanced series. We strongly recommend that you first review the following articles from DryBench before diving in.

Without reviewing the prerequisites, it will be difficult to follow the practical code in this article, as it will proceed without re-explaining the principles of prompt design, enforcing structured output, and verifying against hallucinations.


We Learned This in Our DryBench

In DryBench ai-native #7, we learned that prompts are not just a way to "talk" to LLMs, but rather a tool for adjusting the output distribution of the LLM. In #8, we learned that RAG is an approach that enriches the context with knowledge retrieved from outside the model's parameters. And in #11, we learned that hallucinations never disappear and must always be addressed through external validation.

But how can we combine these three principles to make the thousands of free-text notes generated daily in real-world hospital EHRs (Electronic Health Records) usable for research, statistics, and decision support? This article presents a practical approach to this combination. We will enforce structured output through prompting, provide a double layer of defense with regular expression fallback, and validate against UMLS standard codes. And we will examine real-world examples of why this pipeline might fail.

Hardcore Problem Definition

In a typical hospital emergency department, an average of 200 to 500 clinical notes are generated per day. Each note consists of 400 to 2000 characters of free-text, and the writing style varies from doctor to doctor. Abbreviations (TB = tuberculosis or total bilirubin, MI = myocardial infarction or mitral insufficiency) are often used and can only be understood from the context. We need to extract the following four fields into a structured JSON format:

  • symptoms: List of symptoms (e.g., chest pain, dyspnea)
  • medications: List of medications (medication name + dosage + frequency)
  • labs: List of lab results (test name + value + normal range)
  • diagnoses: Diagnoses (ICD-10 code or natural language)

Target metric: F1 β‰₯ 0.85 for each field (top-level performance on the n2c2 2018 benchmark). We also need to ensure reproducibility, scalability, and compliance with regulations (HIPAA).

Limitations of Existing Approaches:

  • Rule-based (SciSpacy, cTAKES): Accuracy F1 of 0.6 to 0.75, significant human effort required to expand the vocabulary.
  • BERT fine-tuning (BioBERT, ClinicalBERT): F1 of 0.80 to 0.87, requires thousands of labeled data points.
  • LLM structured output: F1 of 0.83 to 0.90 (based on Med-Gemini benchmark [1]), zero-shot or few-shot, minimal labeled data required. The key is to design a prompt and verification pipeline.

This article will build this third approach in a hardcore manner.

Tool Stack and Infrastructure Requirements

ToolRoleLicense
Anthropic Claude API (structured output)Extract structured JSONCommercial (usage-based pricing)
SciSpacy en_core_sci_lgRule-based fallback + entity recognitionApache 2.0
Python re (standard)HIPAA Safe Harbor Personally Identifiable Information (PII) maskingPSF
UMLS MetathesaurusDiagnosis and symptom standard code mappingUMLS License (free, registration required)
MIMIC-IV (PhysioNet)Training and validation datasetPhysioNet Credentialed License (free, registration required)

Infrastructure Requirements: Can be run without a GPU (API call-based). Local CPU with 4 cores and at least 8GB of RAM. SciSpacy model download size is approximately 800MB.

Estimated Cost for Learners: Processing 1000 clinical notes with Claude API will cost approximately 5to5 to 15 USD (calculated based on Anthropic's official pricing [2]). MIMIC-IV and UMLS are free (registration required).

Real-world Pipeline Implementation

Overall flow:

mermaid

Step 1. HIPAA Safe Harbor Deidentification

The U.S. HHS mandates the removal of 18 identifiers to reduce the risk of re-identification of clinical data [3]. Submitting clinical notes to external APIs without regulatory compliance is a clear violation.

python
import re
from typing import Dict, List
# HIPAA Safe Harbor 18 identifiers, with key patterns that can be handled with regular expressions
HIPAA_PATTERNS: Dict[str, str] = {
"SSN": r"\b\d{3}-\d{2}-\d{4}\b",
"PHONE": r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b",
"EMAIL": r"\b[\w.-]+@[\w.-]+\.\w+\b",
"MRN": r"\bMRN[:\s]*\d{6,10}\b",
"DATE_FULL": r"\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b",
"DATE_MDY": r"\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b",
"AGE_90PLUS": r"\b(?:aged?\s+)?(9[0-9]|1[0-2]\d)\s*(?:years?|yrs?|yo)\b",
"ZIP": r"\b\d{5}(?:-\d{4})?\b",
}
def deidentify(text: str) -> str:
"""Masking for HIPAA Safe Harbor compliance.
Items that cannot be handled with regular expressions (patient names, place names, organization names) require the concurrent use of SciSpacy NER.
"""
masked = text
for label, pattern in HIPAA_PATTERNS.items():
masked = re.sub(pattern, f"[{label}]", masked, flags=re.IGNORECASE)
return masked

Regular expressions alone cannot identify named entities such as names, place names, and organization names. SciSpacy en_ner_bc5cdr_md or en_core_sci_lg NER should be used concurrently to mask the PERSON, GPE (place name), and ORG labels. When deploying in a production environment, it is recommended to introduce a dedicated library such as Microsoft Presidio [4].

Step 2. Extract JSON with Claude Structured Output

According to the Anthropic official documentation [5], specify the schema in the prompt and use tool_use or JSON mode.

python
import json
import anthropic
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = {
"name": "extract_clinical_fields",
"description": "Extract 4 fields from a clinical note into structured JSON",
"input_schema": {
"type": "object",
"properties": {
"symptoms": {
"type": "array",
"items": {"type": "string"},
"description": "Symptoms reported by the patient (e.g., chest pain, dyspnea)",
},
"medications": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"dose": {"type": "string"},
"frequency": {"type": "string"},
},
"required": ["name"],
},
},
"labs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"test": {"type": "string"},
"value": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["test", "value"],
},
},
"diagnoses": {
"type": "array",
"items": {"type": "string"},
"description": "Confirmed or suspected diagnoses (ICD-10 codes or natural language)",
},
},
"required": ["symptoms", "medications", "labs", "diagnoses"],
},
}
SYSTEM_PROMPT = """You are a clinical natural language processing expert.
Extract 4 fields (symptoms, medications, labs, diagnoses) from the given clinical note.
Principles:
1. Do not infer or create information that is not in the note (no hallucinations).
2. Accurately interpret abbreviations in context (e.g., MI = myocardial infarction if cardiac context).
3. Masked tokens ([DATE_FULL], [MRN], etc.) are not the target for extraction.
4. Exclude uncertain diagnoses and include them only in symptoms.
"""
def extract_fields(deidentified_note: str) -> dict:
"""Call the Claude API to extract structured JSON."""
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2048,
system=SYSTEM_PROMPT,
tools=[EXTRACTION_SCHEMA],
tool_choice={"type": "tool", "name": "extract_clinical_fields"},
messages=[{"role": "user", "content": deidentified_note}],
)
for block in response.content:
if block.type == "tool_use":
return block.input
return {}

Step 3. Regex Fallback Double Defense

The LLM may return an empty list even when forced to structure the output for a specific note. In this case, a rule-based fallback is used to populate at least the minimum fields so that the downstream pipeline does not collapse.

python
LAB_PATTERN = re.compile(
r"(?P<test>[A-Z][a-zA-Z\s]{2,20})\s*[:=]\s*"
r"(?P<value>\d+\.?\d*)\s*(?P<unit>[a-zA-Z/%]+)?"
)
def regex_fallback_labs(note: str) -> List[dict]:
"""If the LLM leaves labs empty, extract at least the minimum with a regular expression."""
labs = []
for m in LAB_PATTERN.finditer(note):
labs.append({
"test": m.group("test").strip(),
"value": m.group("value"),
"unit": m.group("unit") or "",
})
return labs
def merge_with_fallback(llm_output: dict, note: str) -> dict:
"""LLM result + regex fallback."""
result = dict(llm_output)
if not result.get("labs"):
result["labs"] = regex_fallback_labs(note)
return result

Step 4. UMLS CUI Mapping (Validation Layer)

Map the extracted diagnoses and symptoms to the standard CUI (Concept Unique Identifier) using the UMLS Metathesaurus REST API [6]. This step is a validation layer that enforces the use of standard terminology in downstream statistics and research.

python
import requests
UMLS_BASE = "https://uts-ws.nlm.nih.gov/rest"
def map_to_umls_cui(term: str, api_key: str) -> str | None:
"""Map natural language concepts to CUIs using the UMLS REST API.
The api_key is a value issued after registering with UMLS. Free registration.
"""
resp = requests.get(
f"{UMLS_BASE}/search/current",
params={"string": term, "apiKey": api_key, "pageSize": 1},
timeout=10,
)
if resp.status_code != 200:
return None
results = resp.json().get("result", {}).get("results", [])
return results[0].get("ui") if results else None

Step 5. F1 Evaluation

Calculate the field-by-field F1 in the style of the n2c2 2018 benchmark. Gold = Ground truth that experts have structured and labeled.

python
from typing import Set
def f1_score(pred: Set[str], gold: Set[str]) -> float:
if not pred and not gold:
return 1.0
tp = len(pred & gold)
if tp == 0:
return 0.0
precision = tp / len(pred)
recall = tp / len(gold)
return 2 * precision * recall / (precision + recall)
def evaluate_extraction(pred_json: dict, gold_json: dict) -> Dict[str, float]:
"""Return the F1 score for each field."""
scores = {}
for field in ["symptoms", "diagnoses"]:
pred = {s.lower().strip() for s in pred_json.get(field, [])}
gold = {s.lower().strip() for s in gold_json.get(field, [])}
scores[field] = f1_score(pred, gold)
for field in ["medications", "labs"]:
pred = {json.dumps(x, sort_keys=True) for x in pred_json.get(field, [])}
gold = {json.dumps(x, sort_keys=True) for x in gold_json.get(field, [])}
scores[field] = f1_score(pred, gold)
return scores

Integrated Pipeline

python
def process_note(raw_note: str, umls_key: str | None = None) -> dict:
"""Process a single clinical note through the 5-step pipeline."""
deidentified = deidentify(raw_note)
llm_output = extract_fields(deidentified)
merged = merge_with_fallback(llm_output, deidentified)
if umls_key:
merged["diagnoses_cui"] = [
map_to_umls_cui(d, umls_key) for d in merged.get("diagnoses", [])
]
return merged
## Performance, Cost, and Known Failure Cases
### Performance Reference (Using Public Benchmarks)
| Approach | Dataset | F1 (Field Average) | Source |
|---|---|:---:|---|
| Regex + SciSpacy | n2c2 2018 | 0.68 | Weissman et al., JAMIA 2021 [7] |
| BioBERT fine-tuning | n2c2 2018 | 0.83 | Lee et al., Bioinformatics 2020 [8] |
| GPT-4 zero-shot | MIMIC-III | 0.79~0.85 | Agrawal et al., NEJM AI 2024 [9] |
| Med-Gemini structured | MedQA + Clinical IE | 0.87~0.91 | Google Research 2024 [1] |
| Claude structured (Approximation in this document) | Similar benchmark | 0.83~0.89 (Estimated) | Anthropic official case study [2] |
### Estimated Reproduction Cost for Learners (Calculated based on Claude API pricing)
- Average of 800 tokens input + 300 tokens output per note.
- Based on Claude Sonnet 4.5, input is 3 USD/M tokens, output is 15 USD/M tokens (Anthropic official pricing [2]).
- Processing 1000 notes costs approximately (0.8 Γ— 3) + (0.3 Γ— 15) = 6.9 USD.
- Can be reduced to less than half by utilizing prompt caching.
### 3 Known Failure Cases (Collected from community and papers)
1. **Misinterpretation of Abbreviation Context (MI = myocardial infarction vs. mitral insufficiency)**
Symptom: In a cardiology note, MI was extracted as myocardial infarction, but it was intended to mean mitral insufficiency.
Cause: The context window was too short, and the model missed the discussion of heart valves in the preceding and following sentences.
Solution: Inject a domain-specific abbreviation dictionary into the system prompt using few-shot learning + use the entire note as context.
Source: OpenAI Developer Forum, Clinical NLP thread [10].
2. **Empty Structured Output Response (Failure to comply with JSON schema)**
Symptom: For a specific note, `tool_use` only returns an empty list.
Cause: A specific Unicode character in the note (e.g., zero-width space) causes the parser to fail.
Solution: Remove non-printing characters during input preprocessing + implement a regex fallback.
Source: Anthropic Cookbook GitHub Issues, tool_use edge cases [11].
3. **Risk of Re-identification due to Missing De-identification**
Symptom: Using only HIPAA 18 identifier regular expressions leaves names, place names, and organization names intact.
Cause: The regular expressions do not recognize the patterns of names and place names themselves.
Solution: It is essential to use SciSpacy NER or Microsoft Presidio in conjunction.
Source: JAMIA "De-identification of clinical text with automated methods" review [12].
## Expansion Ideas
- **BioBERT + LLM Ensemble:** Use BioBERT for regular fields and LLM for context-dependent fields. Achieving an F1 score of 0.90+ is possible.
- **Korean EHR:** Expand to Korean clinical notes used in Korea, such as those at SNUH and Seoul Asan. Use Korean UMLS mapping (KOSTOM) in conjunction.
- **Real-time Triage:** Emergency department notes β†’ structured data within 5 minutes β†’ notify risk score.
## Next Chapter
- Chapter 09 `llm-vendor-benchmark`: Run the pipeline from this chapter with Claude / GPT-4o / Med-Gemini / Meditron and benchmark.
- Chapter 10 `med-llm-reproduction`: Reproduce HealthBench to compare the performance of various vendors on clinical tasks.
- Chapter 14 `bio-mcp-agent`: Expose the structured JSON from this chapter as an MCP tool and construct an autonomous clinical agent.
## References
1. Med-Gemini clinical benchmarks β€” Google Research: `https://research.google/pubs/med-gemini/`
2. Anthropic Claude API pricing and structured output docs: `https://docs.anthropic.com/en/docs/build-with-claude/structured-output`
3. HHS HIPAA Safe Harbor Deidentification method: `https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html`
4. Microsoft Presidio (PII deidentification): `https://microsoft.github.io/presidio/`
5. Anthropic Tool Use overview: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview`
6. UMLS REST API documentation: `https://documentation.uts.nlm.nih.gov/rest/home.html`
7. Weissman GE et al., "Clinical NLP with rule-based baselines", JAMIA 2021.
8. Lee J et al., "BioBERT: a pre-trained biomedical language representation model", Bioinformatics 2020.
9. Agrawal M et al., "Large Language Models for Clinical Information Extraction", NEJM AI 2024.
10. OpenAI Developer Forum clinical NLP thread (community reports).
11. Anthropic Cookbook GitHub β€” tool_use edge cases: `https://github.com/anthropics/anthropic-cookbook`
12. Meystre SM et al., "Automatic de-identification of clinical text", JAMIA review.
13. MIMIC-IV dataset: `https://physionet.org/content/mimiciv/`
14. n2c2 (i2b2) NLP datasets: `https://www.i2b2.org/NLP/DataSets/`
15. SciSpacy models: `https://allenai.github.io/scispacy/`

πŸ’¬ Questions & Comments

0 comments

You can post without signing in. Guest comments cannot be edited or deleted by their author.

0/2000

Loading...