|
16 min de lectura
|

FHIR necesita comprobaciones de calidad de datos

Resumen del artículo

El validador de FHIR responde a la pregunta «¿está bien formado este recurso?». No puede responder «¿es utilizable este conjunto de datos?»: esa pregunta trata de recuentos, tasas y distribuciones. FHIR perfila la instancia, pero no tiene un perfil de conjunto de datos. El mundo OMOP lleva una década funcionando con el Data Quality Dashboard, y toda la industria de la analítica prueba sus datos de la misma forma: una comprobación no es más que una consulta que devuelve las filas incorrectas. Así que un perfil de conjunto de datos para FHIR no necesita ningún recurso nuevo ni ninguna operación nueva: un SQLQuery más tres extensiones que aportan categoría, umbral y gravedad, y que cualquier motor SQL on FHIR puede ejecutar.

Resumir este artículo con:
ChatGPTPerplexityClaudeGrok

Garbage in, garbage out

Alguien quiere ejecutar una medida de calidad clínica sobre sus datos FHIR: una medida de estilo HEDIS escrita en CQL, por ejemplo, o un tablero de control de la diabetes, o una cohorte para un estudio. Extrae unos cuantos millones de recursos mediante una exportación Bulk FHIR, apunta su lógica a ellos y obtiene un número.

¿Debería confiar en ese número?

Todo se validó frente a US Core. Todas las referencias se resolvieron. El validador estaba en verde de arriba abajo. Y sin embargo: quizá el 40 % de las observaciones de laboratorio no llevan ningún valor numérico. Quizá la mitad de los pacientes no tienen ningún Encounter. Quizá un lote de pesos corporales llegó en libras cuando el perfil espera kilogramos: una Quantity perfectamente conforme que contiene un número perfectamente equivocado. Quizá a cada paciente con medicación antidiabética le falta un diagnóstico de diabetes, porque el sistema de origen guardaba esos datos en una tabla que nadie mapeó.

Nada de eso incumple una sola regla de perfil. Todo ello fluye directamente hacia la medida y desplaza el resultado en silencio. Basura entra, basura sale, salvo que aquí la basura es invisible, porque cada byte es FHIR bien formado. Y la respuesta honesta a «¿qué parte de estos datos es errónea?» es hoy encogerse de hombros.

Y eso todavía da por hecho que los datos están donde usted fue a buscarlos. FHIR ofrece más de una forma válida de registrar el mismo hecho clínico. La diabetes de un paciente puede figurar en una Condition, o como una Observation con un código diagnóstico, o quedar solo implícita en una MedicationRequest de metformina o en un Procedure. Una medida que consulta únicamente Condition no está equivocada: simplemente pasa por alto en silencio a todo paciente cuya diabetes se modeló de otra manera. Los datos son válidos y conformes, solo que no están donde la lógica los buscó, y ningún validador se lo advertirá.

Esto no es un problema de higiene que se limpia más tarde. Es lo que se interpone entre los datos FHIR y todos los casos de uso que motivaron su recopilación: la analítica, la medición de la calidad, la investigación, un modelo.

FHIR perfila la instancia, no el conjunto de datos

El instinto es recurrir a los perfiles, y los perfiles son realmente parte de la respuesta, solo que no la parte que se supone. Un perfil es donde la comunidad se pone de acuerdo sobre la representación: que la diabetes va en Condition, codificada a partir de este ValueSet, con estos elementos presentes. Es exactamente la ambigüedad del principio, fijada por escrito: sin un perfil, cada conjunto de datos tiene una forma distinta y ni siquiera puede formularse una comprobación común. Los perfiles son el cimiento sobre el que se apoya todo este enfoque.

Pero un perfil es un acuerdo, no una auditoría, y los perfiles de FHIR son, por diseño, permisivos. La mayoría de los elementos siguen siendo opcionales; must-support exige que un sistema sea capaz de manejar un campo sin exigir jamás un valor; los bindings suelen ser extensibles; hay válvulas de escape como data-absent-reason integradas. Esa laxitud es deliberada, para que los datos del mundo real puedan fluir. Es también la razón por la que un perfil describe la forma de los buenos datos sin decir qué parte de sus datos la rellena: un contrato, no una medición.

E incluso las reglas que un perfil aplica, su motor las aplica de un recurso en un recurso. 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 precisamente las que no puede hacer:

  • ¿Qué fracción de Observation.value es nula? (una tasa, no una regla)
  • ¿Se repite algún identificador entre dos pacientes? (la unicidad abarca varios registros)
  • ¿Nuestra prevalencia de diabetes es del 0,1 % cuando debería rondar el 10 %? (una distribución)
  • ¿Los pacientes con metformina tienen un diagnóstico correspondiente? (una unión)

Un validador, por construcción, no puede contar. Ningún perfil expresará jamás que «menos del 5 % de valores nulos es aceptable», porque un perfil no tiene noción de cuántos.

Left: an instance profile validates one Observation — status, code, value, subject all structurally valid. Right: a dataset profile, the checks that run across millions of records — null rate, unique keys, distribution, cross-resource joins.
FHIR perfila la instancia: un recurso frente a una StructureDefinition. Para lo que no tiene nombre es para el perfil de conjunto de datos: las tasas, las uniones y los umbrales que deciden si los datos en su conjunto son utilizables.

Esa asimetría es todo el argumento en una sola imagen. FHIR le da un rico perfil de instancia —una StructureDefinition que dice cómo es un recurso bien formado— y nada para el perfil de conjunto de datos: ninguna forma estándar de enunciar, o comprobar, cómo es una buena colección de esos recursos. Todo lo que sigue trata de construir esa mitad que falta.

Se puede ver esto en la práctica en la comunidad. Hay un hilo de 109 mensajes en chat.fhir.org —diecinueve participantes, algunos de los más veteranos del ecosistema— que debaten qué debería hacer un sistema con un Period cuyo fin precede a su inicio. Datos reales, procedentes de una conversión real de un EHR. El debate abarca si enviarlos, descartarlos, moverlos a una extensión o marcarlos como poco fiables. Es una discusión cuidadosa y reflexiva.

Y cada palabra de ese debate gira en torno a un único período incorrecto. Nadie pregunta ni una sola vez qué proporción de los períodos del conjunto de datos está invertida, porque no hay forma de preguntarlo.

Peor aún: la conclusión a la que la comunidad llega una y otra vez agranda la brecha. Si la práctica aceptada para los datos basura es sacarlos del elemento computable y meterlos en 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 inservibles por diseño. La conformidad no revela el problema. Lo absorbe.

Obligatorio, presente y vacío

La versión más extrema de esto es la extensión data-absent-reason. Marque un elemento como 1..1 y pensaría que ha garantizado un valor. No es así. Una cardinalidad mínima se satisface con el mero hecho de que el elemento esté presente, y un elemento que no lleva más que un data-absent-reason vacío está presente. El validador lo cuenta, el recurso pasa, y nunca se aportó ningún valor.

Esto no es un resquicio que alguien olvidó cerrar; se hereda de US Core, deliberadamente, como válvula de escape para datos heredados, externos y censurados. Los implementadores se lo han encontrado en la práctica —un campo obligatorio satisfecho por un absent-reason y nada más— y la propia lectura de la comunidad es que esto socava en silencio el sentido de marcar el campo como obligatorio. El remedio propuesto es otra invariante a nivel de instancia, escrita y aplicada por cada IG, que prohíbe la extensión allí donde se espera un valor real.

Fíjese en lo que eso cuesta. Para saber si sus campos 1..1 realmente contienen datos, no puede fiarse de la marca verde: tiene que preguntar qué fracción de ellos está ocupada por un absent-reason en lugar de un valor. Eso es una tasa sobre el conjunto de datos. Un perfil no puede calcularla. Una consulta sí.

La brecha aparece incluso donde más cabría esperar 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, íntegro, es que las medidas deberían usar perfiles definidos como US Core o QI-Core para que los datos intercambiados estén estandarizados y sean aptos para la evaluación. Ningún umbral. Ninguna tasa. Ningún agregado. Ni un solo mecanismo para evaluar la calidad: otra vez solo perfiles, la misma herramienta que no puede responder a la pregunta.

Así que esto no es un argumento en contra de los perfiles: es un argumento a favor de un segundo tipo. El perfil de instancia sigue siendo la fuente de verdad sobre lo que es un recurso válido; un perfil de conjunto de datos mide qué parte de sus datos está realmente a la altura. Uno es dueño de la instancia, el otro del conjunto de datos.

Cualquier otro stack de datos ya prueba sus datos

Salga del ámbito sanitario y este problema no solo está resuelto: es lo mínimo exigible. Probar los datos es una etapa estándar en cualquier pipeline de analítica serio, y el mecanismo es siempre el mismo, y siempre así de sencillo: una comprobación es una consulta que devuelve las filas que incumplen una regla. Cero filas, los datos pasan. Cualquier fila, esas filas son el problema.

La misma idea aparece bajo un nombre distinto en cada herramienta importante:

HerramientaQué es una comprobación
dbtun SELECT que devuelve las filas que fallan, con plantillas genéricas como not_null, unique, accepted_values, relationships
SQLMeshuna auditoría: una consulta que debe devolver cero filas, o el pipeline se detiene
Amazon Deequ«tests unitarios para los datos» sobre Spark: completitud, unicidad, distribución, sobre miles de millones de filas
Great Expectations · Sodala validación como código: expectativas legibles por personas que se ejecutan en CI y en producción

Fíjese en lo que comprueban todas: valores presentes, claves únicas, números dentro de un rango aceptable, referencias que se resuelven, distribuciones que parecen correctas. La misma lista corta en todas partes, porque los datos se estropean de la misma media docena de maneras sea cual sea el dominio. Es una parte madura y esencial 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 en salud ya dio ese paso. El Data Quality Dashboard de OHDSI toma ese mismo patrón de una consulta por comprobación y lo dirige a los datos clínicos: apúntelo a una base de datos OMOP CDM, ejecuta miles de comprobaciones y devuelve un informe con puntuación. En ese mundo nadie 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 se estropean específicamente: el marco de Kahn, que clasifica cada comprobación en tres preguntas:

CategoríaLa preguntaEjemplo
Conformance¿Tienen los datos la forma correcta?status contiene un valor fuera del conjunto permitido
Completeness¿Están presentes los datos?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 despliega sobre cada campo obligatorio de cada tabla, y así es como una veintena de plantillas se convierten en miles de comprobaciones concretas. Segundo, cada comprobación lleva un umbral: menos del 5 % de filas incorrectas pasa; por encima del 5 %, falla. Eso es lo que hace que estas comprobaciones sean difusas de un modo que una invariante nunca puede ser. Una invariante es binaria. 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 evalúa los datos Bulk FHIR con exactamente estas tres categorías. La Iniciativa de Informática Médica de Alemania evalúa la calidad de los datos FHIR con Kahn. PhUSE ha evaluado datos de API FHIR para presentaciones a la FDA sobre el mismo marco. El vocabulario está asentado; FHIR simplemente nunca lo adoptó.

FHIR ya tiene las piezas: SQLQuery + extensiones

FHIR resources flatten into a table via a ViewDefinition, then a SQL query with extensions turns that table into a data quality dashboard.
Toda la canalización se compone de artefactos estándar: una ViewDefinition aplana FHIR, y una consulta SQL con extensiones convierte el resultado en un tablero.

Lea el diagrama de izquierda a derecha y tendrá toda la idea. Una ViewDefinition aplana FHIR en una tabla. Una consulta SQL sobre esa tabla devuelve las filas que incumplen una regla: la misma comprobación al estilo dbt que ejecuta cualquier otro stack. Unas pocas extensiones en esa consulta —categoría de Kahn, umbral, gravedad— convierten una consulta corriente en una comprobación con puntuación que puede mostrar en un tablero.

Esa es toda la propuesta: una comprobación de calidad de datos es un SQLQuery más tres extensiones. Ningún recurso nuevo, ninguna operación nueva, ningún motor nuevo: 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 piezas es nueva: todo lo que necesita un tablero de calidad de datos ya existe en la especificación:

Un DQD necesita…FHIR ya tiene
Una tabla plana que comprobarViewDefinition: aplana FHIR en columnas
Una comprobaciónLibrary SQLQuery: una consulta sobre esa vista
Una forma de ejecutarla$sqlquery-run: la operación existente
Composición, agregacionesrelatedArtifact: depends-on: dependencias entre consultas
Reglas de esquemaperfiles: ya son 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, de modo que en FHIR ya no es un problema de infraestructura. Es simplemente 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 hacia los motores que cada equipo de analítica ya utiliza: un test de dbt, una restricción de Deequ, una suite de Great Expectations. Esa es toda la razón para estandarizarlo. No para construir otro motor de calidad de datos —FHIR no necesita ninguno—, sino para dar al ecosistema una forma portátil y neutral respecto al proveedor de enunciar cómo es un buen conjunto de datos FHIR, redactada una vez y ejecutable en cualquier parte.

Esto no es un experimento mental. En el reciente connectathon de HL7 Vulcan lo pusimos en práctica: una transformación de FHIR a OMOP construida únicamente a partir de estas primitivas, más 258 comprobaciones DQD, cada una una Library(type=sqlquery) que lleva las tres extensiones anteriores, no la maqueta de la sección anterior. La transformación superó los 172 casos de referencia y la clave de 23 casos con cero errores de conformidad. Las comprobaciones señalaron 5 fallos en nuestra propia salida y 20 en las tablas de referencia del grupo de trabajo, cada uno una señal de completitud o plausibilidad que el grupo de trabajo había sembrado deliberadamente, coincidiendo con la fila (nuestra comprobación plausibleGender detectó exactamente sus 6 casos de hiperplasia benigna de próstata y 4 de cáncer de próstata en pacientes de sexo femenino).

Dos cosas destacaron. Portar una década de comprobaciones de calidad de datos acumuladas costó prácticamente nada: una comprobación DQD es una consulta SQL que devuelve las 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 empezar, en las propias tablas de referencia del grupo de trabajo, que no estaba en los 130 encounters de origen ni en las predicciones de nadie, un artefacto que ningún humano había advertido. La comunidad debate manualmente un único Period invertido; la comprobación los encuentra en todo el conjunto de datos, de forma automática.

Qué aspecto tiene

Todo lo que sigue comparte una misma 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 aportan la semántica; esta dice completitud, avisar por encima del 5 % de valores ausentes:

{ "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 de dentro es deliberadamente aburrido, y ahí está la gracia:

-- 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 nada de ello. El DQD de OMOP tiene toda una familia de comprobaciones de plausibilidad, y se trasladan directamente a las Observation codificadas con LOINC. Tres de las más útiles.

Valor fuera del rango fisiológico para su código: plausibleValueLow / plausibleValueHigh del 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 de 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 del DQD. Un peso corporal registrado en algo que no sea una unidad de masa es sospechoso por muy 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 del DQD. Un resultado de antígeno prostático específico en una paciente de sexo femenino (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 antidiabética debería tener un diagnóstico de diabetes» es una unión: rutinaria en SQL, incómoda o imposible en FHIRPath.

Las métricas de perfilado no son en absoluto de tipo pasa/no pasa, solo las cifras que necesita un tablero:

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 las agregaciones se componen mediante el mismo mecanismo de dependencia, 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 aterriza donde FHIR ya hace su trabajo: la Guía de Implementación. Hoy, un autor de IG publica 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 paquete:

  • las ViewDefinitions que aplanan en tablas los datos conformes a esos perfiles, y
  • las comprobaciones de calidad: comprobaciones SQLQuery sobre esas tablas, cada una etiquetada con su categoría de Kahn y su umbral.

Ahora un IG dice algo más que «esta es la forma que deberían tener sus datos». Dice «esta es la forma, así se consulta, y así se sabe si sus datos están a la altura». Un consumidor apunta el paquete a una exportación Bulk y obtiene a cambio un tablero 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. Perfiles, vistas y comprobaciones viajan juntos, redactados por las personas que entienden el dominio.

Hacia dónde va esto

Junte las piezas y el panorama es sencillo. Hoy, una Guía de Implementación publica un perfil de instancia y, cada vez más, ViewDefinitions. Con esto, publica la mitad que falta: un perfil de conjunto de datos, un conjunto seleccionado de comprobaciones de calidad de datos que precisan cómo es realmente un buen conjunto de datos para este IG. Publique ambos juntos, y cualquier motor SQL on FHIR conforme ejecuta las comprobaciones sin más. El autor las escribe una vez; cada servidor evalúa los datos frente a ellas de la misma manera, sin herramientas a medida, sin configuración por proveedor.

Una limitación honesta: estas comprobaciones no pueden derivarse automáticamente de las invariantes de un perfil, porque el subconjunto de FHIRPath de ViewDefinition es más pequeño que el que usan esas 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, impulsado en las reuniones del grupo. La taxonomía está asentada y la maquinaria existe; lo que queda es construir el catálogo, de forma abierta. Si ha creado herramientas de calidad de datos sobre FHIR —o alguna vez deseó que FHIR las tuviera—, venga a ayudar a diseñarlo: traiga sus comprobaciones al hilo y únase a una reunión.

Compartir este artículo
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.