Basura entra, basura sale
Alguien quiere ejecutar una medida de calidad clínica sobre sus datos FHIR: una medida estilo HEDIS escrita en CQL, por ejemplo, o un panel de control de diabetes, o una cohorte para un estudio. Extrae varios millones de recursos mediante una exportación Bulk FHIR, aplica su lógica sobre ellos y obtiene un número.
¿Puede confiar en ese número?
Todo validó correctamente contra US Core. Cada referencia se resolvió. El validador estaba en verde de arriba abajo. Y aun así: quizás el 40% de las observaciones de laboratorio no tienen valor numérico. Quizás la mitad de los pacientes no tienen ningún encuentro. Quizás un lote de pesos corporales llegó en libras cuando el perfil espera kilogramos: una Quantity perfectamente conforme que contiene un número perfectamente incorrecto. Quizás todos los pacientes con medicación para la diabetes no tienen diagnóstico de diabetes, porque el sistema de origen guardaba esos datos en una tabla que nadie mapeó.
Nada de esto infringe una sola regla de perfil. Todo fluye directamente hacia la medida y desplaza silenciosamente la respuesta. Basura entra, basura sale, excepto que aquí la basura es invisible, porque cada byte de ella es FHIR bien formado. Y la respuesta honesta a «¿cuánta parte de estos datos es incorrecta?» hoy en día es un encogimiento de hombros.
Y eso asume que los datos están siquiera donde se fue a buscarlos. FHIR ofrece más de una manera válida de registrar el mismo hecho clínico. La diabetes de un paciente puede estar en un Condition, o como una Observation con un código diagnóstico, o estar implícita únicamente en una MedicationRequest de metformina o en un Procedure. Una medida que consulta Condition y nada más no está equivocada, simplemente omite silenciosamente a cada paciente cuya diabetes fue modelada de otra forma. Los datos son válidos y conformes, solo que no están donde la lógica buscó, y ningún validador se lo dirá.
Este no es un problema de higiene que se limpiará más adelante. Es el obstáculo que se interpone entre los datos FHIR y cada caso de uso que motivó su recopilación: analítica, medición de calidad, investigación, un modelo.
FHIR valida la instancia, no el conjunto de datos
El instinto es recurrir a los perfiles, y los perfiles son genuinamente parte de la solución, solo que no la parte que la gente suele asumir. Un perfil es donde la comunidad acuerda la representación: que la diabetes va en Condition, codificada con este ValueSet, con estos elementos presentes. Eso es exactamente la ambigüedad del inicio, resuelta: sin un perfil, cada conjunto de datos tiene una forma diferente y ninguna comprobación compartida puede siquiera escribirse. Los perfiles son la base sobre la que se sustenta todo este enfoque.
Pero un perfil es un acuerdo, no una auditoría, y los perfiles FHIR son, por diseño, permisivos. La mayoría de los elementos son opcionales; must-support pide a un sistema que sea capaz de manejar un campo sin requerir nunca un valor; los bindings son frecuentemente extensibles; hay válvulas de escape como data-absent-reason incorporadas. Esa flexibilidad es deliberada para que los datos del mundo real puedan fluir. También es la razón por la que un perfil describe la forma de los datos correctos sin indicar cuánta parte de sus datos la cumple: es un contrato, no una medición.
E incluso las reglas que un perfil sí hace cumplir, su motor las aplica un recurso a la vez. El validador no tiene noción de un segundo recurso, y mucho menos de diez millones. Así que las preguntas que más importan aquí son exactamente las que no puede responder:
- ¿Qué fracción de
Observation.valuees nula? (una tasa, no una regla) - ¿Se repite algún identificador en dos pacientes? (la unicidad abarca varios registros)
- ¿Es nuestra prevalencia de diabetes del 0,1% cuando debería rondar el 10%? (una distribución)
- ¿Tienen los pacientes con metformina un diagnóstico coincidente? (un join)
Un validador, por construcción, no puede contar. Ningún perfil expresará jamás «menos del 5% de valores nulos es aceptable», porque un perfil no tiene concepto de cuántos.
Esa asimetría es todo el argumento en una imagen. FHIR le proporciona un rico perfil de instancia —una StructureDefinition que indica cómo es un recurso bien formado— y nada para el perfil de conjunto de datos: ninguna forma estándar de declarar, ni de comprobar, cómo debe ser una buena colección de esos recursos. Todo lo que sigue trata de construir esa mitad que falta.
Puede ver esto en la práctica dentro de la comunidad. Hay un hilo de 109 mensajes en chat.fhir.org —diecinueve participantes, incluidas algunas de las personas más expertas del ecosistema— que debaten qué debe hacer un sistema con un Period cuyo fin precede a su inicio. Datos reales, de una conversión real de un HCE. El debate abarca si enviarlo, descartarlo, moverlo a una extensión o etiquetarlo como no fiable. Es una discusión cuidadosa y reflexiva.
Y cada palabra de ella trata de un único period incorrecto. Nadie pregunta en ningún momento qué proporción de periods en el conjunto de datos están invertidos, porque no existe forma de preguntarlo.
Peor aún, la conclusión a la que la comunidad sigue llegando amplía aún más la brecha. Si la práctica aceptada para los datos basura es moverlos fuera del elemento computable hacia una extensión, o marcar el recurso con una etiqueta de seguridad de integridad, entonces un conjunto de datos totalmente conforme puede estar lleno de datos inutilizables por diseño. La conformidad no revela el problema. Lo absorbe.
Obligatorio, presente y vacío
La versión más clara de esto es la extensión data-absent-reason. Marque un elemento como 1..1 y podría pensar que ha garantizado un valor. No es así. Una cardinalidad mínima se satisface con que el elemento simplemente esté presente, y un elemento que no lleva nada más que un data-absent-reason vacío está presente. El validador lo cuenta, el recurso pasa y nunca se proporcionó ningún valor.
Esto no es una laguna que alguien olvidó cerrar; se hereda de US Core, deliberadamente, como válvula de escape para datos heredados, externos y redactados. Los implementadores se han encontrado con esto en la práctica —un campo obligatorio satisfecho únicamente por un absent-reason— y la propia lectura de la comunidad es que eso deshace silenciosamente el propósito de marcar el campo como obligatorio. El remedio propuesto es otro invariante a nivel de instancia, escrito y aplicado por IG, que prohíbe la extensión donde se espera un valor real.
Observe lo que eso implica. Para saber si sus campos 1..1 realmente contienen datos, no puede confiar en el símbolo verde: tiene que preguntar qué fracción de ellos usa un absent-reason en lugar de un valor. Eso es una tasa a lo largo del conjunto de datos. Un perfil no puede calcularlo. Una consulta sí puede.
La brecha aparece incluso donde más esperaría encontrar una solución. Da Vinci DEQM —el IG para el intercambio de datos de medidas de calidad— tiene una sección titulada Data Quality. Su contenido, íntegramente, es que las medidas deben usar perfiles definidos como US Core o QI-Core para que los datos intercambiados estén estandarizados y sean adecuados para la evaluación. Sin umbrales. Sin tasas. Sin agregados. Sin un solo mecanismo para evaluar la calidad: solo perfiles de nuevo, la misma herramienta que no puede responder la pregunta.
Así que esto no es un argumento contra los perfiles, sino un argumento a favor de un segundo tipo. El perfil de instancia sigue siendo la fuente de verdad sobre qué es un recurso válido; un perfil de conjunto de datos mide cuánta parte de sus datos realmente lo cumple. Uno es dueño de la instancia, el otro es dueño del conjunto de datos.
Todas las demás plataformas de datos ya comprueban sus datos
Salga del ámbito sanitario y este problema no solo está resuelto: es un requisito básico. Comprobar los datos es una etapa estándar en cualquier pipeline de analítica serio, y el mecanismo es siempre el mismo, y siempre igual de simple: una comprobación es una consulta que devuelve las filas que infringen una regla. Cero filas, los datos pasan. Alguna fila, esas filas son el problema.
La misma idea viene con un nombre diferente en cada herramienta principal:
| Herramienta | En qué consiste una comprobación |
|---|---|
| dbt | un SELECT que devuelve filas fallidas, con plantillas genéricas como not_null, unique, accepted_values, relationships |
| SQLMesh | una auditoría: una consulta que debe devolver cero filas o el pipeline se detiene |
| Amazon Deequ | «pruebas unitarias para datos» sobre Spark: completitud, unicidad, distribución, en miles de millones de filas |
| Great Expectations · Soda | validación como código: expectations legibles por humanos que se ejecutan en CI y en producción |
Observe qué comprueban todas: valores presentes, claves únicas, números en un rango aceptado, referencias que se resuelven, distribuciones con aspecto correcto. La misma lista corta en todas partes, porque los datos fallan de las mismas pocas formas independientemente del dominio. Esta es una parte madura y fundamental de la ingeniería de datos, no una práctica marginal.
OMOP lo llevó a los datos de salud hace una década
La analítica sanitaria ya dio este salto. El Data Quality Dashboard de OHDSI toma ese mismo patrón de una consulta por comprobación y lo aplica a datos clínicos: apúntelo a una base de datos OMOP CDM, ejecuta miles de comprobaciones y devuelve un informe calificado. Nadie en ese mundo publicaría un conjunto de datos sin uno.
Lo que OMOP añade es una taxonomía de las formas en que los datos de salud específicamente fallan: el marco Kahn, que clasifica cada comprobación en tres preguntas:
| Categoría | La pregunta | Ejemplo |
|---|---|---|
| Conformance | ¿Tienen los datos la forma correcta? | status contiene un valor fuera del conjunto permitido |
| Completeness | ¿Están los datos presentes? | El 40% de las observaciones no tienen valor |
| Plausibility | ¿Se pueden creer los datos? | Un peso corporal de 1000 kg |
Dos mecanismos lo hacen funcionar. Primero, un tipo de comprobación es una plantilla, no una consulta: una plantilla not_null se expande por cada campo obligatorio de cada tabla, lo que es como unas dos docenas de plantillas se convierten en miles de comprobaciones concretas. Segundo, cada comprobación lleva un umbral: filas incorrectas por debajo del 5% pasa, por encima del 5% falla. Eso es lo que hace que estas comprobaciones sean difusas de una manera que un invariante nunca puede ser. Un invariante es binario. Una comprobación de calidad de datos es estadística, y la realidad es estadística.
Esta taxonomía tampoco es una peculiaridad de OMOP. La NCQA Bulk FHIR Quality Coalition califica los datos Bulk FHIR exactamente con estas tres categorías. La Medical Informatics Initiative de Alemania evalúa la calidad de los datos FHIR con Kahn. PhUSE ha evaluado datos de API FHIR para presentaciones ante la FDA con el mismo marco. El vocabulario está consolidado: FHIR simplemente nunca lo adoptó.
FHIR ya tiene las piezas: SQLQuery + extensiones
Lea el diagrama de izquierda a derecha y tendrá la idea completa. Una ViewDefinition aplana FHIR en una tabla. Una consulta SQL sobre esa tabla devuelve las filas que infringen una regla, la misma comprobación estilo dbt que ejecuta cualquier otra plataforma. Unas pocas extensiones sobre esa consulta —categoría Kahn, umbral, severidad— convierten una consulta simple en una comprobación calificada que puede mostrar en un panel de control.
Esa es la propuesta completa: una comprobación de calidad de datos es un SQLQuery más tres extensiones. Sin nuevo recurso, sin nueva operación, sin nuevo motor: una comprobación es estructuralmente idéntica a cualquier otra consulta, y las extensiones son lo único que la convierte en una comprobación.
Ninguna de las partes es nueva: cada pieza que necesita un panel de calidad de datos ya existe en la especificación:
| Un DQD necesita… | FHIR ya tiene |
|---|---|
| Una tabla plana para comprobar | ViewDefinition — aplana FHIR en columnas |
| Una comprobación | Library SQLQuery — una consulta sobre esa vista |
| Una forma de ejecutarla | $sqlquery-run — la operación existente |
| Composición, roll-ups | relatedArtifact: depends-on — dependencias entre consultas |
| Reglas de esquema | perfiles — ya la fuente de verdad |
Eso es lo que cambió. Construir un DQD solía ser un proyecto de infraestructura: OHDSI necesitaba su propio motor SQL, su propio modelo de datos plano, años de trabajo. SQL on FHIR estandariza esa capa, por lo que en FHIR ya no es un problema de infraestructura. Es solo contenido: escribir las consultas.
Y como una comprobación no es más que SQL sobre una vista plana estándar, es una especificación, no una implementación. El mismo SQLQuery se ejecuta en Postgres, DuckDB o Spark, o se compila para los motores que ya ejecuta cada equipo de analítica: una prueba dbt, una restricción Deequ, una suite de Great Expectations. Esa es la razón principal para estandarizarlo. No para construir otro motor de calidad de datos —FHIR no necesita uno—, sino para dar al ecosistema una forma portátil y neutral en cuanto a proveedor de declarar cómo es un buen conjunto de datos FHIR, escrita una vez y ejecutada en cualquier lugar.
Esto no es un experimento mental. En el reciente connectathon HL7 Vulcan lo ejecutamos: una transformación FHIR-a-OMOP construida únicamente con estos primitivos, más 258 comprobaciones DQD —cada una como Library(type=sqlquery) con las tres extensiones mencionadas, no el prototipo de la sección anterior. La transformación superó los 172 casos de prueba dorada y la clave de respuestas de 23 casos con cero errores de conformidad. Las comprobaciones detectaron 5 fallos en nuestra propia salida y 20 en las tablas doradas del grupo de trabajo, cada uno una señal de completitud o plausibilidad que el grupo de trabajo había sembrado deliberadamente, vinculada a la fila (nuestra comprobación plausibleGender detectó exactamente sus 6 condiciones de hiperplasia benigna de próstata y 4 condiciones de cáncer de próstata en pacientes femeninas).
Dos cosas destacaron. Portar una década de comprobaciones de calidad de datos acumuladas no costó prácticamente nada: una comprobación DQD es una consulta SQL que devuelve filas incorrectas, y SQL on FHIR ejecuta exactamente eso. Y las comprobaciones demostraron su valor de inmediato: plausibleStartBeforeEnd detectó una visita que terminaba tres días antes de comenzar, en las propias tablas doradas del grupo de trabajo, no en los 130 encuentros de origen, no en las predicciones de nadie, un artefacto que ningún humano había detectado. La comunidad debate a mano un único Period invertido; la comprobación los encuentra en todo el conjunto de datos, automáticamente.
Cómo se ve en la práctica
Todo lo que sigue comparte una única tabla de origen: una ViewDefinition que aplana Observation:
{ "resourceType": "ViewDefinition", "name": "obs_flat", "resource": "Observation",
"select": [{ "column": [
{ "name": "id", "path": "getResourceKey()" },
{ "name": "status", "path": "status" },
{ "name": "loinc", "path": "code.coding.where(system='http://loinc.org').code.first()" },
{ "name": "patient_id", "path": "subject.getReferenceKey(Patient)" },
{ "name": "value", "path": "value.ofType(Quantity).value" },
{ "name": "unit", "path": "value.ofType(Quantity).code" },
{ "name": "effective", "path": "effective.ofType(dateTime)" }]}]}
Una comprobación es un SQLQuery sobre esa vista. Las extensiones llevan la semántica: esta dice completitud, avisar por encima del 5% de valores faltantes:
{ "resourceType": "Library", "id": "dqc-obs-value-complete",
"type": { "coding": [{ "code": "sql-query" }] },
"extension": [
{ "url": ".../dq-category", "valueCode": "completeness" },
{ "url": ".../dq-threshold", "valueDecimal": 0.05 },
{ "url": ".../dq-severity", "valueCode": "warning" }],
"relatedArtifact": [
{ "type": "depends-on", "resource": "ViewDefinition/obs_flat", "label": "obs" }],
"content": [{ "contentType": "application/sql", "data": "<base64>" }]}
El SQL en el interior es deliberadamente sencillo, y esa es la ventaja:
-- completeness: rows where the measurement is missing
SELECT id FROM obs WHERE value IS NULL
La integridad referencial solo añade una segunda vista y una segunda dependencia, patient_flat etiquetada como pat:
SELECT o.id, o.patient_id
FROM obs o LEFT JOIN pat ON o.patient_id = pat.id
WHERE o.patient_id IS NOT NULL AND pat.id IS NULL
La plausibilidad es donde esto demuestra su valor: ningún perfil puede expresar ninguna de estas comprobaciones. El DQD de OMOP tiene toda una familia de comprobaciones de plausibilidad, y se portan directamente a Observations codificadas con LOINC. Las tres más útiles.
Valor fuera del rango fisiológico para su código: plausibleValueLow / plausibleValueHigh de DQD. Los límites viven en una pequeña tabla de referencia, una fila por código LOINC, que es exactamente el patrón de plantilla del que se habló antes: una comprobación, miles de límites concretos.
-- 29463-7 body weight (kg) 0–650 | 8480-6 systolic BP (mm[Hg]) 0–300
-- 8867-4 heart rate (/min) 0–300 | 4548-4 HbA1c (%) 0–20
SELECT o.id, o.loinc, o.value, o.unit
FROM obs o JOIN obs_range r ON o.loinc = r.loinc
WHERE o.value < r.low OR o.value > r.high
Unidad incorrecta para la medición: plausibleUnitConceptIds de DQD. Un peso corporal registrado en cualquier unidad que no sea una unidad de masa es sospechoso independientemente de lo razonable que parezca el número:
SELECT id, value, unit FROM obs
WHERE loinc = '29463-7' AND unit NOT IN ('kg', 'g', '[lb_av]')
Una prueba que contradice el sexo del paciente: plausibleGender de DQD. Un resultado de antígeno prostático específico en una paciente femenina (patient_flat lleva gender):
SELECT o.id, o.patient_id
FROM obs o JOIN pat ON o.patient_id = pat.id
WHERE o.loinc = '2857-1' AND pat.gender = 'female'
Las reglas entre recursos también encajan aquí. «Un paciente con medicación para la diabetes debería tener un diagnóstico de diabetes» es un join: rutinario en SQL, difícil o imposible en FHIRPath.
Las métricas de perfilado no son de aprobación o fallo en absoluto, solo los números que necesita un panel de control:
SELECT count(*) AS "rowCount",
count(*) FILTER (WHERE value IS NULL) AS "nullCount_value",
count(DISTINCT patient_id) AS "distinctCount_patient",
min(value) AS "min_value", max(value) AS "max_value"
FROM obs
Y los roll-ups se componen mediante el mismo mecanismo de dependencias, apuntando a otras comprobaciones en lugar de a vistas:
SELECT category, count(*) AS checks, sum(failed) AS failed
FROM ( SELECT 'conformance' category, (SELECT count(*) FROM c1) > 0 failed
UNION ALL SELECT 'conformance', (SELECT count(*) FROM c2) > 0
UNION ALL SELECT 'completeness', (SELECT count(*) FROM c3) > 0 ) t
GROUP BY category
La recompensa llega donde FHIR ya hace su trabajo: la Implementation Guide. Un autor de IG hoy en día distribuye un perfil de instancia: el acuerdo sobre qué va dónde y cómo se codifica. Con esto, el mismo IG lleva su otra mitad, un perfil de conjunto de datos, en el mismo bundle:
- las ViewDefinitions que aplanan los datos conformes con esos perfiles en tablas, y
- las comprobaciones de calidad: comprobaciones SQLQuery sobre esas tablas, cada una etiquetada con su categoría Kahn y su umbral.
Ahora un IG dice más que «aquí está la forma que deben tener sus datos». Dice «aquí está la forma, aquí se muestra cómo consultarla y aquí se explica cómo saber si sus datos la cumplen». Un consumidor apunta el paquete a una exportación Bulk y obtiene de vuelta un panel de calidad de datos —este conjunto de datos supera 94 de 100 comprobaciones de este IG— sin escribir una sola línea de código de validación a medida. Los perfiles, las vistas y las comprobaciones viajan juntos, redactados por quienes entienden el dominio.
Hacia dónde va esto
Ponga las piezas juntas y el panorama es simple. Hoy una Implementation Guide distribuye un perfil de instancia y, cada vez más, ViewDefinitions. Con esto, distribuye la mitad que faltaba: un perfil de conjunto de datos, un conjunto curado de comprobaciones de calidad de datos que especifica cómo es un buen conjunto de datos para este IG. Publique ambos juntos y cualquier motor SQL on FHIR conforme ejecuta las comprobaciones de forma predeterminada. El autor las escribe una vez; cada servidor califica los datos contra ellas de la misma manera, sin herramientas a medida ni configuración por proveedor.
Un límite honesto: estas comprobaciones no se pueden derivar automáticamente de los invariantes de un perfil, porque el subconjunto de FHIRPath de ViewDefinition es más pequeño que el que utilizan esos invariantes. El catálogo base se escribe a mano: un trabajo puntual que la comunidad comparte.
Y esa es la invitación. Esto no es hipotético: es trabajo activo en el grupo de trabajo SQL on FHIR, con las definiciones de extensiones y un conjunto inicial de comprobaciones en el issue #375, que avanza en las llamadas del grupo. La taxonomía está consolidada y la maquinaria existe; lo que queda es construir el catálogo, de forma abierta. Si ha construido herramientas de calidad de datos sobre FHIR —o alguna vez ha deseado que FHIR las tuviera—, venga a ayudar a diseñarlo: lleve sus comprobaciones al hilo y únase a una llamada.





