Extracción de información de notas clínicas basada en LLM: un flujo de trabajo práctico para estructurar datos no estructurados de los EHR en formato JSON
Las notas clínicas escritas por los médicos contienen información sobre síntomas, tratamientos, pruebas y diagnósticos, pero no están en un formato adecuado para su uso directo en análisis estadísticos, búsquedas o investigaciones. Este artículo presenta un flujo de trabajo práctico, desde una perspectiva rigurosa, para extraer esta información no estructurada y transformarla en JSON estructurado.
📚 Recomendación de conocimientos previos (muy recomendable)
Este artículo es una sección avanzada de la serie AI×Bio Hardcore. Se recomienda encarecidamente que consulte y revise los siguientes artículos de DryBench antes de comenzar.
- DryBench ai-native #7: Ingeniería de prompts
- DryBench ai-native #8: RAG y contexto
- DryBench ai-native #11: Alucinaciones y alineación
Si intenta abordar este artículo sin los conocimientos previos necesarios, comenzará directamente con el código práctico sin una nueva explicación de los principios del diseño de prompts, la imposición de una salida estructurada y la verificación de alucinaciones, lo que dificultará su comprensión.
Lo que ya hemos aprendido en DryBench
En DryBench ai-native #7, aprendimos que un prompt no es simplemente "hablar" con el LLM, sino un mecanismo que reconfigura la distribución de la salida del LLM; en #8, aprendimos que RAG es un enfoque que incorpora conocimientos externos al contexto a través de los resultados de la búsqueda; y en #11, aprendimos que las alucinaciones nunca desaparecen por completo y deben detectarse necesariamente mediante una verificación externa.
Sin embargo, para utilizar los miles de registros de notas escritas a mano que se generan diariamente en los sistemas de historia clínica electrónica (HCE) de los hospitales reales con fines de investigación, análisis estadísticos y apoyo a la toma de decisiones, ¿cómo debemos combinar estos tres principios? Este artículo presenta una aplicación práctica de esta combinación. Se impone una salida estructurada mediante prompts, se implementa una doble capa de defensa con una alternativa basada en expresiones regulares y se verifica con códigos estándar UMLS. Además, analizaremos casos reales de por qué este flujo de trabajo puede fallar.
Definición del problema
En la sala de emergencias de un hospital estadounidense, se generan diariamente un promedio de 200 a 500 notas clínicas. Cada nota consta de entre 400 y 2000 caracteres de texto libre, en el que el estilo de expresión varía según el médico y las abreviaturas (TB = tuberculosis u bilirrubina total, MI = infarto de miocardio o insuficiencia mitral) se distinguen únicamente por el contexto. De aquí, debemos extraer los siguientes 4 campos en formato JSON estructurado.
symptoms: Lista de síntomas (por ejemplo, dolor torácico, disnea)medications: Lista de medicamentos (nombre del medicamento + dosis + frecuencia)labs: Resultados de las pruebas (nombre de la prueba + valor + rango normal)diagnoses: Diagnóstico (código CIE-10 o texto)
Objetivo: cada campo con F1 ≥ 0.85 (nivel superior del conjunto de referencia n2c2 2018). Se garantiza la reproducibilidad, escalabilidad y cumplimiento normativo (HIPAA).
Limitaciones de los enfoques existentes:
- Basado en reglas (SciSpacy, cTAKES): Precisión F1 de 0.6~0.75; requiere mucho tiempo humano para la expansión del vocabulario.
- Ajuste fino de BERT (BioBERT, ClinicalBERT): F1 de 0.80~0.87; requiere miles de datos etiquetados.
- Salida estructurada de LLM: F1 de 0.83~0.90 (según la referencia Med-Gemini [1]), zero-shot o few-shot, con necesidad casi nula de datos etiquetados. El diseño del flujo de trabajo de prompt y validación es clave.
Este capítulo implementa el tercer enfoque.
Conjunto de herramientas e infraestructura requerida
| Herramienta | Función | Licencia |
|---|---|---|
| API de Anthropic Claude (salida estructurada) | Extracción de JSON estructurado | Comercial (pago por uso) |
SciSpacy en_core_sci_lg | Fallback basado en reglas + reconocimiento de entidades | Apache 2.0 |
Python re (estándar) | Enmascaramiento de información personal según HIPAA Safe Harbor | PSF |
| UMLS Metathesaurus | Mapeo de códigos estándar para diagnósticos y síntomas | Licencia UMLS (gratuito, requiere registro) |
| MIMIC-IV (PhysioNet) | Conjunto de datos para entrenamiento y validación | Licencia Credenciada de PhysioNet (gratuito, requiere registro) |
Requisitos de infraestructura: Ejecutable sin GPU (basado en llamadas a API). CPU local de 4 núcleos, RAM superior a 8 GB. Descarga del modelo SciSpacy de aproximadamente 800 MB.
Costo estimado para la reproducción por parte del estudiante: Al procesar 1000 notas clínicas, el costo de la API de Claude es de aproximadamente 5~15 USD (calculado según la tabla de precios oficial de Anthropic [2]). MIMIC-IV y UMLS son gratuitos (requieren registro).
Implementación práctica del flujo de trabajo
Flujo completo:
Paso 1. Desidentificación según el principio de protección de HIPAA
El Departamento de Salud y Servicios Humanos (HHS) de EE. UU. exige la eliminación obligatoria de 18 identificadores para reducir el riesgo de reidentificación de datos clínicos [3]. Enviar notas clínicas a una API externa sin cumplir con la normativa constituye una violación evidente.
import refrom typing import Dict, List
# Principales patrones procesables por expresión regular entre los 18 identificadores de HIPAA Safe HarborHIPAA_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: """Enmascaramiento conforme a la regulación HIPAA Safe Harbor.
Los elementos no procesables por expresiones regulares (nombres de pacientes, topónimos, nombres de instituciones) requieren el uso combinado de NER con SciSpacy. """ masked = text for label, pattern in HIPAA_PATTERNS.items(): masked = re.sub(pattern, f"[{label}]", masked, flags=re.IGNORECASE) return maskedLas expresiones regulares por sí solas no pueden identificar entidades nominales como nombres de personas, lugares o instituciones. Para ello, combine el NER de SciSpacy en_ner_bc5cdr_md o en_core_sci_lg con el enmascaramiento de las etiquetas PERSON, GPE (lugares) y ORG. Para su implementación en un entorno de producción, se recomienda utilizar bibliotecas especializadas como Microsoft Presidio [4].
Paso 2. Extracción de JSON mediante la salida estructurada de Claude
Según la documentación oficial de Anthropic [5], especifique el esquema en el mensaje y utilice tool_use o el modo JSON.
import jsonimport anthropic
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = { "name": "extract_clinical_fields", "description": "Extraer 4 campos en JSON estructurado a partir de notas clínicas", "input_schema": { "type": "object", "properties": { "symptoms": { "type": "array", "items": {"type": "string"}, "description": "Síntomas presentados por el paciente (por ejemplo, dolor torácico, disnea)", }, "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": "Diagnóstico confirmado/sospechado (código ICD-10 o lenguaje natural)", }, }, "required": ["symptoms", "medications", "labs", "diagnoses"], },}
SYSTEM_PROMPT = """Eres un experto en procesamiento de lenguaje natural clínico.Extrae 4 campos (síntomas, medicamentos, análisis de laboratorio, diagnósticos) de las notas clínicas proporcionadas.
Principios:1. No infieras ni inventes información que no esté en la nota (prohibido alucinar).2. Interpreta con precisión las siglas según el contexto (ej.: MI = infarto de miocardio si el contexto es cardíaco).3. Los tokens enmascarados ([DATE_FULL], [MRN], etc.) no son objeto de extracción.4. Excluye los diagnósticos inciertos de 'diagnósticos' e inclúyelos únicamente en 'síntomas'."""
def extract_fields(deidentified_note: str) -> dict: """Llama a la API de Claude para extraer JSON estructurado.""" 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 {}Paso 3. Fallback de expresiones regulares como doble defensa
Incluso si se fuerza la generación de una salida estructurada para una entrada específica, el modelo de lenguaje (LLM) podría devolver una lista vacía. En este caso, se utiliza un mecanismo de respaldo basado en reglas para completar al menos los campos mínimos y evitar que falle el procesamiento posterior.
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]: """Si el LLM deja labs vacío, aplica extracción mínima por regex.""" 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: """Resultado del LLM + fallback por regex.""" result = dict(llm_output) if not result.get("labs"): result["labs"] = regex_fallback_labs(note) return resultPaso 4. Mapeo de CUI de UMLS (capa de validación)
Los diagnósticos y síntomas extraídos se mapean a los CUI (identificadores únicos de concepto) estándar mediante la API REST del Metathesaurus de UMLS [6]. Esta etapa es una capa de validación que garantiza el uso de vocabularios estandarizados en los análisis y la investigación posteriores.
import requests
UMLS_BASE = "https://uts-ws.nlm.nih.gov/rest"
def map_to_umls_cui(term: str, api_key: str) -> str | None: """Mapea conceptos en lenguaje natural a CUI mediante la API REST de UMLS.
api_key es el valor obtenido después del registro en UMLS. Registro gratuito. """ 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 NonePaso 5. Evaluación de F1
Se calcula el valor F1 para cada campo, siguiendo el formato del conjunto de datos de referencia n2c2 2018. Gold = la respuesta correcta, con etiquetas estructuradas, proporcionada por un experto.
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]: """Retorna F1 por campo.""" 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 scoresFlujo de trabajo integrado
def process_note(raw_note: str, umls_key: str | None = None) -> dict: """Procesa una nota clínica mediante un pipeline de 5 etapas.""" 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 mergedRendimiento, costo y casos de fallo conocidos
Referencias de rendimiento (citación de evaluaciones comparativas públicas)
| Enfoque | Conjunto de datos | F1 (promedio de campos) | Fuente |
|---|---|---|---|
| Regex + SciSpacy | n2c2 2018 | 0.68 | Weissman et al., JAMIA 2021 [7] |
| Ajuste fino de BioBERT | 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 estructurado | MedQA + IE clínico | 0.87~0.91 | Google Research 2024 [1] |
| Claude estructurado (aproximación de este artículo) | Evaluaciones comparativas similares | 0.83~0.89 (estimado) | Anthropic official case study [2] |
Costo estimado de reproducción para estudiantes (cálculo basado en la tabla de tarifas de la API de Claude)
- Nota promedio: 800 tokens de entrada + 300 tokens de salida.
- Basado en Claude Sonnet 4.5: entrada a 3 USD/M tokens, salida a 15 USD/M tokens (tabla de tarifas oficial de Anthropic [2]).
- Para procesar 1000 notas: aproximadamente (0.8 × 3) + (0.3 × 15) = 6.9 USD.
- Al utilizar la caché de prompts, el costo puede reducirse a menos de la mitad.
3 casos de fallo conocidos (recopilación comunitaria y académica)
-
Interpretación errónea del contexto de las siglas (MI = infarto de miocardio vs. insuficiencia mitral) Síntoma: En notas de cardiología, se extrajo MI como infarto de miocardio, pero se pretendía insuficiencia mitral. Causa: Ventana de contexto corta que omitió la discusión sobre válvulas cardíacas en oraciones anteriores y posteriores. Prevención: Inyectar un diccionario de siglas del dominio en el prompt del sistema mediante few-shot + usar toda la nota como contexto. Fuente: OpenAI Developer Forum, hilo de NLP clínico [10].
-
Respuesta vacía en la salida estructurada (incumplimiento del esquema JSON) Síntoma: En ciertas notas,
tool_usedevolvió únicamente una lista vacía. Causa: Ciertos caracteres Unicode dentro de la nota (como espacios de ancho cero) provocaron el colapso del analizador sintáctico. Prevención: Eliminación de caracteres no imprimibles en el preprocesamiento de entrada + fallback con regex obligatorio. Fuente: Anthropic Cookbook GitHub Issues, casos límite detool_use[11]. -
Omisión en la desidentificación que genera riesgo de reidentificación Síntomas: Al usar únicamente la expresión regular de los 18 identificadores de HIPAA, persisten nombres propios, topónimos y nombres de instituciones. Causa: La expresión regular no puede reconocer las formas de nombres propios o topónimos. Solución: Es necesario complementar con NER de SciSpacy o Microsoft Presidio. Fuente: Revisión "De-identification of clinical text with automated methods" en JAMIA [12].
Ideas de expansión
- Ensamble BioBERT + LLM: Campos regulares con BioBERT, campos dependientes del contexto con LLM. Posibilidad de alcanzar un F1 superior a 0,90.
- EHR coreano: Ampliación utilizando notas clínicas en coreano utilizadas en SNUH y Asan Medical Center en Corea del Sur. Combinar con el mapeo de UMLS coreano (KOSTOM).
- Triaje en tiempo real: Notas de urgencias → estructuración en menos de 5 minutos → alerta de puntuación de riesgo.
Próximo capítulo
- Capítulo 09
llm-vendor-benchmark: Ejecutar la canalización de este capítulo con Claude / GPT-4o / Med-Gemini / Meditron por separado y realizar pruebas comparativas. - Capítulo 10
med-llm-reproduction: Comparar el rendimiento en tareas clínicas de varios proveedores mediante la reproducción de HealthBench. - Capítulo 14
bio-mcp-agent: Exponer el JSON estructurado de este capítulo como herramienta MCP para configurar agentes clínicos autónomos.
Referencias
- Benchmarks clínicos Med-Gemini — Google Research:
https://research.google/pubs/med-gemini/ - Documentación de precios y salida estructurada de la API de Anthropic Claude:
https://docs.anthropic.com/en/docs/build-with-claude/structured-output - Método Safe Harbor de desidentificación HIPAA de HHS:
https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html - Microsoft Presidio (desidentificación de PII):
https://microsoft.github.io/presidio/ - Descripción general del uso de herramientas de Anthropic:
https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview - Documentación de la API REST de UMLS:
https://documentation.uts.nlm.nih.gov/rest/home.html - Weissman GE et al., "Clinical NLP with rule-based baselines", JAMIA 2021.
- Lee J et al., "BioBERT: a pre-trained biomedical language representation model", Bioinformatics 2020.
- Agrawal M et al., "Large Language Models for Clinical Information Extraction", NEJM AI 2024.
- OpenAI Developer Forum clinical NLP thread (community reports).
- Anthropic Cookbook GitHub — tool_use edge cases:
https://github.com/anthropics/anthropic-cookbook - Meystre SM et al., "Automatic de-identification of clinical text", JAMIA review.
- MIMIC-IV dataset:
https://physionet.org/content/mimiciv/ - n2c2 (i2b2) NLP datasets:
https://www.i2b2.org/NLP/DataSets/ - SciSpacy models:
https://allenai.github.io/scispacy/