|
16 min de lecture
|

FHIR a besoin de profils de qualité des données

Résumé de l'article

Vous pointez une mesure de qualité sur quelques millions de ressources FHIR et obtenez un résultat. Devriez-vous lui faire confiance ? Peut-être que 40 % des valeurs de laboratoire sont manquantes, que les poids sont arrivés en livres, et que la moitié des cas de diabète se trouvent dans des ressources que votre requête n'atteint jamais. Rien de tout cela n'enfreint une règle de profil ; pourtant, tout cela déplace silencieusement la réponse. Ordures en entrée, ordures en sortie — sauf que les ordures sont du FHIR parfaitement bien formé. Un validateur ne peut pas aider : il examine une ressource à la fois et n'a aucune notion du nombre. Toutes les autres piles de données ont résolu ce problème depuis des années — une vérification est simplement une requête qui renvoie les mauvaises lignes — et OMOP l'a appliqué aux données de santé avec le Data Quality Dashboard. FHIR a besoin de la même chose et dispose déjà des éléments nécessaires : un SQLQuery plus trois extensions portant la catégorie, le seuil et la sévérité, qu'un moteur SQL on FHIR peut exécuter.

Résumer cet article avec :
ChatGPTPerplexityClaudeGrok

Ordures en entrée, ordures en sortie

Quelqu'un souhaite exécuter une mesure de qualité clinique sur vos données FHIR — une mesure de style HEDIS rédigée en CQL, par exemple, ou un tableau de bord de contrôle du diabète, ou une cohorte pour une étude. Il extrait quelques millions de ressources par l'intermédiaire d'une exportation Bulk FHIR, applique sa logique et obtient un résultat.

Devrait-il lui faire confiance ?

Tout a été validé par rapport à US Core. Toutes les références ont été résolues. Le validateur était vert de bout en bout. Et pourtant : peut-être que 40 % des observations de laboratoire ne portent aucune valeur numérique. Peut-être que la moitié des patients n'ont aucune rencontre. Peut-être qu'un lot de poids corporels est arrivé en livres alors que le profil attend des kilogrammes — une Quantity parfaitement conforme contenant un nombre parfaitement erroné. Peut-être que chaque patient sous médicament contre le diabète est dépourvu d'un diagnostic de diabète, parce que le système source conservait ces données dans une table que personne n'a cartographiée.

Rien de tout cela n'enfreint une seule règle de profil. Tout cela s'écoule directement dans la mesure et déplace silencieusement le résultat. Ordures en entrée, ordures en sortie — sauf qu'ici les ordures sont invisibles, parce que chaque octet est du FHIR bien formé. Et la réponse honnête à la question « quelle part de ces données est incorrecte ? » est aujourd'hui un haussement d'épaules.

Et cela suppose encore que les données se trouvent là où vous êtes allé les chercher. FHIR vous offre plus d'une façon valide d'enregistrer le même fait clinique. Le diabète d'un patient peut se trouver dans une Condition, ou comme une Observation avec un code diagnostique, ou être implicitement indiqué uniquement par une MedicationRequest pour la metformine ou une Procedure. Une mesure qui interroge Condition et rien d'autre n'est pas erronée — elle ne fait que rater silencieusement chaque patient dont le diabète a été modélisé autrement. Les données sont valides et conformes, simplement pas là où la logique les cherchait, et aucun validateur ne vous le dira.

Ce n'est pas un problème d'hygiène à régler plus tard. C'est l'obstacle entre les données FHIR et chaque cas d'utilisation qui a motivé leur collecte — analytique, mesure de la qualité, recherche, modélisation.

FHIR profile l'instance, pas le jeu de données

Le réflexe est de se tourner vers les profils, et les profils font véritablement partie de la réponse — mais pas de la façon que la plupart imaginent. Un profil est l'endroit où la communauté s'entend sur la représentation : que le diabète appartient à Condition, codé à partir de ce ValueSet, avec ces éléments présents. C'est exactement l'ambiguïté du début, épinglée — sans profil, chaque jeu de données a une forme différente et aucune vérification partagée n'est même rédigeable. Les profils sont le fondement sur lequel repose toute cette approche.

Mais un profil est un accord, non un audit — et les profils FHIR sont, par conception, permissifs. La plupart des éléments restent facultatifs ; must-support demande à un système d'être capable de traiter un champ sans jamais exiger une valeur ; les liaisons sont fréquemment extensibles ; des soupapes de sûreté comme data-absent-reason sont intégrées. Cette souplesse est délibérée, pour que les données du monde réel puissent circuler. C'est aussi pourquoi un profil décrit la forme des bonnes données sans préciser quelle part de vos données la remplit — un contrat, non une mesure.

Et même les règles qu'un profil impose bel et bien, son moteur les applique une ressource à la fois. Le validateur n'a aucune notion d'une deuxième ressource, encore moins de dix millions. Les questions qui comptent le plus ici sont donc exactement celles qu'il ne peut pas poser :

  • Quelle fraction de Observation.value est nulle ? (un taux, non une règle)
  • Un identifiant se répète-t-il sur deux patients ? (l'unicité couvre plusieurs enregistrements)
  • Notre prévalence du diabète est-elle à 0,1 % alors qu'elle devrait avoisiner 10 % ? (une distribution)
  • Les patients sous metformine ont-ils une condition correspondante ? (une jointure)

Un validateur, par construction, ne peut pas compter. Aucun profil n'exprimera jamais « un taux de valeurs nulles inférieur à 5 % est acceptable », parce qu'un profil n'a aucune notion du nombre.

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 profile l'instance — une ressource par rapport à une StructureDefinition. Ce pour quoi il n'a pas de nom, c'est le profil de jeu de données : les taux, jointures et seuils qui déterminent si les données dans leur ensemble sont utilisables.

Cette asymétrie résume toute l'argumentation en une seule image. FHIR vous offre un riche profil d'instance — une StructureDefinition qui décrit à quoi ressemble une ressource bien formée — et rien pour le profil de jeu de données : aucune façon normalisée d'énoncer, ni de vérifier, à quoi ressemble une bonne collection de ces ressources. Tout ce qui suit porte sur la construction de cette moitié manquante.

On peut observer ce phénomène se dérouler dans la communauté. Il existe un fil de 109 messages sur chat.fhir.org — dix-neuf participants, dont certains des membres les plus chevronnés de l'écosystème — qui débattent de ce qu'un système devrait faire avec une Period dont la fin précède le début. Des données réelles, issues d'une vraie conversion de DSE. Le débat porte sur l'envoi, la suppression, le déplacement dans une extension ou l'étiquetage comme non fiable. C'est une discussion attentive et réfléchie.

Et chaque mot porte sur une mauvaise période. Personne ne demande jamais quelle part des périodes du jeu de données sont inversées, parce qu'il n'existe aucun moyen de le demander.

Pire encore, la conclusion à laquelle la communauté revient sans cesse élargit l'écart. Si la pratique acceptée pour les données erronées consiste à les déplacer hors de l'élément calculable vers une extension, ou à étiqueter la ressource avec un security tag d'intégrité, alors un jeu de données entièrement conforme peut être rempli de données inutilisables par conception. La conformité ne révèle pas le problème. Elle l'absorbe.

Obligatoire, présent et vide

La version la plus aiguë de ce problème est l'extension data-absent-reason. Marquer un élément 1..1 et vous pensez avoir garanti une valeur. Ce n'est pas le cas. Une cardinalité minimale est satisfaite par le simple fait que l'élément soit présent — et un élément ne portant rien d'autre qu'un data-absent-reason vide est présent. Le validateur le comptabilise, la ressource passe, et aucune valeur n'a jamais été fournie.

Ce n'est pas une lacune oubliée ; elle est héritée de US Core, délibérément, comme soupape de sûreté pour les données patrimoniales, externes et expurgées. Des implémenteurs l'ont rencontrée en pratique — un champ obligatoire satisfait par un absent-reason et rien d'autre — et l'analyse de la communauté elle-même est que cela remet silencieusement en question l'utilité de marquer le champ comme obligatoire. Le remède proposé est un autre invariant au niveau de l'instance, rédigé et appliqué par IG, interdisant l'extension là où une vraie valeur est attendue.

Remarquez ce que cela implique. Pour savoir si vos champs 1..1 contiennent réellement des données, vous ne pouvez pas vous fier à la coche verte — vous devez demander quelle fraction d'entre eux représente un absent-reason plutôt qu'une valeur. C'est un taux calculé sur le jeu de données. Un profil ne peut pas le calculer. Une requête, oui.

L'écart apparaît même là où on attendrait le plus une solution. Da Vinci DEQM — l'IG pour l'échange de données de mesure de la qualité — comporte une section intitulée Data Quality. Son contenu, en entier, stipule que les mesures devraient utiliser des profils définis tels que US Core ou QI-Core afin que les données échangées soient normalisées et adaptées à l'évaluation. Aucun seuil. Aucun taux. Aucun agrégat. Pas un seul mécanisme pour évaluer la qualité — simplement des profils encore une fois, le même outil qui ne peut pas répondre à la question.

Il ne s'agit donc pas d'un argument contre les profils — c'est un argument en faveur d'un deuxième type. Le profil d'instance reste la source de vérité pour ce qu'est une ressource valide ; un profil de jeu de données mesure quelle part de vos données y satisfait réellement. L'un s'occupe de l'instance, l'autre s'occupe du jeu de données.

Toutes les autres piles de données testent déjà leurs données

Sortez du domaine de la santé et ce problème n'est pas seulement résolu — c'est une exigence de base. Tester ses données est une étape standard de tout pipeline d'analytique sérieux, et le mécanisme est toujours le même, et toujours aussi simple : une vérification est une requête qui renvoie les lignes qui enfreignent une règle. Zéro ligne, les données passent. Des lignes quelconques, ces lignes constituent le problème.

La même idée est livrée sous un nom différent dans chaque grand outil :

OutilCe qu'est une vérification
dbtun SELECT qui renvoie les lignes en échec — avec des modèles génériques comme not_null, unique, accepted_values, relationships
SQLMeshun audit : une requête qui doit renvoyer zéro ligne, sinon le pipeline s'arrête
Amazon Deequ« tests unitaires pour les données » sur Spark — complétude, unicité, distribution, sur des milliards de lignes
Great Expectations · Sodavalidation-en-tant-que-code : des expectations lisibles par des humains exécutées en IC et en production

Regardez ce qu'ils vérifient tous : valeurs présentes, clés uniques, nombres dans une plage acceptable, références qui se résolvent, distributions qui semblent correctes. La même courte liste partout — parce que les données tombent en panne de la même façon quel que soit le domaine. C'est une partie mature et fondamentale du génie des données, non une pratique marginale.

OMOP l'a appliqué aux données de santé il y a dix ans

L'analytique en santé a déjà franchi ce pas. Le Data Quality Dashboard d'OHDSI applique ce même modèle de requête par vérification aux données cliniques : pointez-le sur une base de données OMOP CDM, il exécute des milliers de vérifications et vous remet un rapport noté. Personne dans ce monde ne publierait un jeu de données sans en avoir un.

Ce qu'OMOP ajoute, c'est une taxonomie des façons dont les données de santé peuvent spécifiquement mal tourner — le cadre de Kahn, qui classe chaque vérification en trois questions :

CatégorieLa questionExemple
ConformanceLes données ont-elles la bonne forme ?status contient une valeur hors de l'ensemble autorisé
CompletenessLes données sont-elles présentes ?40 % des observations n'ont aucune valeur
PlausibilityLes données peuvent-elles être crues ?Un poids corporel de 1 000 kg

Deux mécanismes font fonctionner cela. Premièrement, un type de vérification est un modèle, non une requête — un seul modèle not_null se déploie sur chaque champ obligatoire de chaque table, ce qui explique comment une vingtaine de modèles deviennent des milliers de vérifications concrètes. Deuxièmement, chaque vérification porte un seuil : moins de 5 % de lignes incorrectes, la vérification passe ; au-delà de 5 %, elle échoue. C'est ce qui rend ces vérifications graduées d'une façon qu'un invariant ne peut jamais l'être. Un invariant est binaire. Une vérification de qualité des données est statistique, et la réalité est statistique.

Cette taxonomie n'est pas non plus une particularité d'OMOP. La NCQA Bulk FHIR Quality Coalition note les données Bulk FHIR avec exactement ces trois catégories. La Medical Informatics Initiative allemande évalue la qualité des données FHIR avec Kahn. PhUSE a évalué les données d'API FHIR pour les soumissions à la FDA sur le même cadre. Le vocabulaire est établi — FHIR ne l'a tout simplement jamais adopté.

FHIR dispose maintenant des éléments : SQLQuery + extensions

FHIR resources flatten into a table via a ViewDefinition, then a SQL query with extensions turns that table into a data quality dashboard.
L'ensemble du pipeline est constitué d'artefacts normalisés : une ViewDefinition aplatit FHIR, une requête SQL avec extensions transforme le résultat en tableau de bord.

Lisez le diagramme de gauche à droite et vous avez toute l'idée. Une ViewDefinition aplatit FHIR en une table. Une requête SQL sur cette table renvoie les lignes qui enfreignent une règle — la même vérification de style dbt que toute autre pile exécute. Quelques extensions sur cette requête — catégorie Kahn, seuil, sévérité — transforment une requête ordinaire en une vérification notée que vous pouvez afficher sur un tableau de bord.

C'est l'ensemble de la proposition : une vérification de qualité des données est un SQLQuery plus trois extensions. Aucune nouvelle ressource, aucune nouvelle opération, aucun nouveau moteur — une vérification est structurellement identique à toute autre requête, et les extensions sont la seule chose qui en fait une vérification.

Aucun des éléments n'est nouveau — chaque pièce dont un tableau de bord de qualité des données a besoin existe déjà dans la spécification :

Un DQD a besoin de…FHIR dispose déjà de
Une table plate à vérifierViewDefinition — aplatit FHIR en colonnes
Une vérificationSQLQuery Library — une requête sur cette vue
Un moyen de l'exécuter$sqlquery-run — l'opération existante
Composition, agrégatsrelatedArtifact: depends-on — dépendances entre requêtes
Règles de schémaprofils — déjà la source de vérité

C'est ce qui a changé. Construire un DQD était autrefois un projet d'infrastructure — OHDSI avait besoin de son propre moteur SQL, de son propre modèle de données plat, d'années de travail. SQL on FHIR normalise cette couche, de sorte qu'en FHIR ce n'est plus un problème d'infrastructure. C'est simplement du contenu : rédigez les requêtes.

Et parce qu'une vérification n'est que du SQL sur une vue plate normalisée, c'est une spécification, non une implémentation. Le même SQLQuery s'exécute sur Postgres, DuckDB ou Spark — ou se compile vers les moteurs que chaque équipe d'analytique utilise déjà : un test dbt, une contrainte Deequ, une suite Great Expectations. C'est toute la raison de la normalisation. Non pas pour construire un autre moteur de qualité des données — FHIR n'en a pas besoin — mais pour donner à l'écosystème une façon portable et neutre du point de vue des fournisseurs d'énoncer à quoi ressemble un bon jeu de données FHIR, rédigée une fois et exécutable partout.

Ce n'est pas une expérience de pensée. Au récent connectathon HL7 Vulcan, nous l'avons mis à l'épreuve : une transformation FHIR vers OMOP construite uniquement à partir de ces primitives, plus 258 vérifications DQD — chacune un Library(type=sqlquery) portant les trois extensions ci-dessus, non la maquette de la section précédente. La transformation a réussi les 172 cas de référence et les 23 cas de la clé de réponse avec zéro erreur de conformité. Les vérifications ont signalé 5 échecs sur nos propres résultats et 20 sur les tables de référence du groupe de travail — chacun un signal de complétude ou de plausibilité que le groupe de travail avait délibérément semé, correspondant à la ligne (notre vérification plausibleGender a détecté exactement leurs 6 conditions d'hyperplasie bénigne de la prostate et 4 de cancer de la prostate chez des patientes).

Deux points ont retenu l'attention. Transposer une décennie de vérifications de qualité des données accumulées n'a coûté pratiquement rien — une vérification DQD est une requête SQL renvoyant les mauvaises lignes, et SQL on FHIR exécute exactement cela. Et les vérifications ont prouvé leur valeur immédiatement : plausibleStartBeforeEnd a détecté une visite se terminant trois jours avant d'avoir commencé, se trouvant dans les propres tables de référence du groupe de travail — pas dans les 130 rencontres sources, pas dans les prévisions de quiconque, un artefact qu'aucun humain n'avait repéré. La communauté débat manuellement d'une Period inversée ; la vérification les trouve dans l'ensemble du jeu de données, automatiquement.

À quoi cela ressemble

Tout ce qui suit partage une même table source — une ViewDefinition aplatissant 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)" }]}]}

Une vérification est un SQLQuery sur cette vue. Les extensions portent la sémantique — celle-ci indique complétude, avertir au-delà de 5 % de valeurs manquantes :

{ "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>" }]}

Le SQL à l'intérieur est délibérément banal, et c'est là la fonctionnalité :

-- completeness: rows where the measurement is missing
SELECT id FROM obs WHERE value IS NULL

L'intégrité référentielle ajoute simplement une deuxième vue et une deuxième dépendance, patient_flat étiquetée 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 plausibilité est là où cela prouve sa valeur — aucun profil ne peut en exprimer aucune. Le DQD d'OMOP comprend toute une famille de vérifications de plausibilité, et elles se transposent directement aux Observations codées en LOINC. En voici trois des plus utiles.

Valeur en dehors de la plage physiologique pour son code — plausibleValueLow / plausibleValueHigh du DQD. Les bornes se trouvent dans une petite table de référence, une ligne par code LOINC, ce qui correspond exactement au modèle de gabarit mentionné précédemment : une vérification, des milliers de bornes concrètes.

-- 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

Mauvaise unité pour la mesure — plausibleUnitConceptIds du DQD. Un poids corporel enregistré dans autre chose qu'une unité de masse est suspect, peu importe à quel point le nombre semble raisonnable :

SELECT id, value, unit FROM obs
WHERE loinc = '29463-7' AND unit NOT IN ('kg', 'g', '[lb_av]')

Un test qui contredit le sexe du patient — plausibleGender du DQD. Un résultat d'antigène prostatique spécifique chez une patiente (patient_flat porte 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'

Les règles entre ressources s'inscrivent ici aussi. « Un patient sous médicament contre le diabète devrait avoir un diagnostic de diabète » est une jointure — ordinaire en SQL, difficile voire impossible en FHIRPath.

Les métriques de profilage ne sont pas du tout des résultats binaires, simplement les chiffres dont un tableau de bord a besoin :

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

Et les agrégats se composent par le même mécanisme de dépendance, pointant vers d'autres vérifications plutôt que vers des vues :

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

Le bénéfice se concrétise là où FHIR fait déjà son travail : le Guide d'implémentation. Un auteur d'IG livre aujourd'hui un profil d'instance — l'accord sur ce qui va où et comment c'est codé. Avec ceci, le même IG porte son autre moitié, un profil de jeu de données, dans le même paquet :

  • les ViewDefinitions qui aplatissent les données conformes à ces profils en tables, et
  • les vérifications de qualité — des vérifications SQLQuery sur ces tables, chacune étiquetée avec sa catégorie Kahn et son seuil.

Désormais, un IG dit plus que « voici la forme que vos données devraient prendre. » Il dit « voici la forme, voici comment l'interroger, et voici comment déterminer si vos données y satisfont. » Un consommateur pointe le paquet vers une exportation Bulk et obtient en retour un tableau de bord de qualité des données — ce jeu de données réussit 94 des 100 vérifications de cet IG — sans rédiger une seule ligne de code de validation sur mesure. Les profils, les vues et les vérifications voyagent ensemble, rédigés par les personnes qui comprennent le domaine.

Où cela mène

Assemblez les éléments et l'image est simple. Aujourd'hui, un Guide d'implémentation livre un profil d'instance, et de plus en plus des ViewDefinitions. Avec ceci, il livre la moitié manquante — un profil de jeu de données : un ensemble sélectionné de vérifications de qualité des données qui précisent à quoi ressemble réellement un bon jeu de données pour cet IG. Publiez les deux ensemble, et tout moteur SQL on FHIR conforme exécute les vérifications dès le départ. L'auteur les rédige une fois ; chaque serveur note les données par rapport à elles de la même façon — aucun outillage sur mesure, aucune configuration par fournisseur.

Une limite honnête : ces vérifications ne peuvent pas être dérivées automatiquement des invariants d'un profil, parce que le sous-ensemble FHIRPath de ViewDefinition est plus restreint que ce qu'utilisent ces invariants. Le catalogue de base est rédigé à la main — un travail ponctuel que la communauté partage.

Et c'est l'invitation. Ce n'est pas hypothétique — c'est un travail en cours dans le groupe de travail SQL on FHIR, avec les définitions d'extensions et un ensemble de départ de vérifications dans le problème nº 375, avancé lors des appels du groupe. La taxonomie est établie et la machinerie existe ; ce qui reste, c'est construire le catalogue, en mode ouvert. Si vous avez construit des outils de qualité des données pour FHIR — ou avez déjà souhaité que FHIR en dispose — venez aider à le concevoir : apportez vos vérifications au fil de discussion et participez à un appel.

Partager cet article
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

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