Données corrompues, résultats corrompus
Supposons que quelqu'un veuille exécuter une mesure de qualité clinique sur vos données FHIR — une mesure de style HEDIS écrite en CQL, par exemple, ou un tableau de bord sur le contrôle du diabète, ou une cohorte pour une étude. Il extrait quelques millions de ressources via une exportation Bulk FHIR, applique sa logique et obtient un résultat.
Peut-il faire confiance à ce résultat?
Tout a été validé par rapport à US Core. Toutes les références ont été résolues. Le validateur était au vert de bout en bout. Et pourtant : peut-être que 40 % des observations de laboratoire ne contiennent 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 tous les patients sous médicaments antidiabétiques n'ont aucun diagnostic de diabète, parce que le système source conservait ces données dans une table que personne n'a mappée.
Rien de tout cela n'enfreint une seule règle de profil. Tout cela s'écoule directement dans la mesure et fausse silencieusement le résultat. Des données corrompues produisent des résultats corrompus — sauf qu'ici la corruption est invisible, parce que chaque octet est du FHIR bien formé. Et la réponse honnête à la question « quelle proportion de ces données est erronée? » 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 figurer dans une Condition, ou comme Observation avec un code diagnostique, ou être seulement implicite par une MedicationRequest pour la metformine ou une Procedure. Une mesure qui interroge uniquement Condition n'est pas fausse — elle passe simplement à côté de tous les patients 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 à corriger plus tard. C'est l'obstacle entre les données FHIR et chaque cas d'utilisation qui a motivé leur collecte — l'analytique, la mesure de la qualité, la recherche, un modèle.
FHIR profile l'instance, pas le jeu de données
Le réflexe est de se tourner vers les profils, et les profils font réellement partie de la réponse — simplement pas celle que les gens supposent. 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, fixée par écrit — sans profil, chaque jeu de données a une forme différente et aucun contrôle commun ne peut même être formulé. Les profils sont le fondement sur lequel repose toute cette approche.
Mais un profil est un accord, pas un audit — et les profils FHIR sont, par conception, permissifs. La plupart des éléments restent optionnels; le must-support demande à un système d'être capable de renseigner un champ sans jamais exiger une valeur; les liaisons sont souvent extensibles; des soupapes de sécurité comme data-absent-reason sont intégrées. Cette souplesse est délibérée, pour permettre le flux des données du monde réel. C'est aussi pourquoi un profil décrit la forme des bonnes données sans préciser quelle proportion de vos données la respecte — un contrat, pas une mesure.
Et même les règles qu'un profil fait appliquer, 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 importent le plus ici sont donc exactement celles auxquelles il ne peut pas répondre :
- Quelle fraction de
Observation.valueest nulle? (un taux, pas une règle) - Un identifiant se répète-t-il pour deux patients? (l'unicité s'étend sur plusieurs enregistrements)
- Notre prévalence du diabète est-elle de 0,1 % alors qu'elle devrait être d'environ 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 « moins de 5 % de valeurs nulles est acceptable », parce qu'un profil n'a aucune notion de combien.
Cette asymétrie résume toute l'argumentation en une seule image. FHIR vous offre un riche profil d'instance — une StructureDefinition qui définit à quoi ressemble une ressource bien formée — et rien pour le profil de jeu de données : aucune façon standard d'énoncer, ou de vérifier, à quoi ressemble une bonne collection de ces ressources. Tout ce qui suit vise à construire cette moitié manquante.
On peut observer cette lacune 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 travaillent sur ce qu'un système devrait faire d'une Period dont la fin précède le début. Des données réelles, issues d'une vraie conversion de DSÉ. Le débat porte sur la question d'envoyer ces données, de les supprimer, de les déplacer dans une extension ou de les marquer comme peu fiables. C'est une discussion soignée et réfléchie.
Et chaque mot de cette discussion porte sur une mauvaise période. Personne ne demande jamais quelle proportion des périodes dans le jeu de données sont inversées, parce qu'il n'existe aucune façon de poser cette question.
Pire encore, la conclusion à laquelle la communauté revient sans cesse élargit le fossé. Si la pratique acceptée pour les données erronées est de les déplacer hors de l'élément calculable vers une extension, ou de marquer la ressource avec une balise de sécurité 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 frappante de ce problème est l'extension data-absent-reason. Marquer un élément 1..1 pourrait laisser croire qu'on garantit 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 compte, la ressource passe, et aucune valeur n'a jamais été fournie.
Ce n'est pas une faille que quelqu'un a oublié de combler; c'est hérité de US Core, délibérément, comme soupape de sécurité pour les données héritées, externes et caviardées. Des développeurs l'ont rencontré en pratique — un champ obligatoire satisfait par une raison d'absence et rien d'autre — et la lecture de la communauté elle-même est que cela contrecarre silencieusement l'objectif 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 est remplacée par une raison d'absence plutôt qu'une valeur. C'est un taux sur l'ensemble du jeu de données. Un profil ne peut pas le calculer. Une requête, oui.
Le fossé apparaît même là où on attendrait le plus un correctif. Da Vinci DEQM — l'IG pour l'échange de données de mesure de qualité — comporte une section intitulée Data Quality. Son contenu, en intégralité, est que les mesures devraient utiliser des profils définis comme US Core ou QI-Core afin que les données échangées soient standardisées et adaptées à l'évaluation. Aucun seuil. Aucun taux. Aucun agrégat. Pas un seul mécanisme pour évaluer la qualité — juste des profils à nouveau, le même outil qui ne peut pas répondre à la question.
Ce n'est donc pas 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 proportion de vos données y répond réellement. L'un est propriétaire de l'instance, l'autre est propriétaire 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 dans tout pipeline d'analytique sérieux, et le mécanisme est toujours le même, et toujours aussi simple : un contrôle est une requête qui retourne les lignes enfreignant une règle. Zéro ligne, les données passent. Des lignes présentes, ces lignes sont le problème.
La même idée se retrouve sous différents noms dans tous les principaux outils :
| Outil | Ce qu'est un contrôle |
|---|---|
| dbt | un SELECT qui retourne les lignes en échec — avec des modèles génériques comme not_null, unique, accepted_values, relationships |
| SQLMesh | un audit : une requête qui doit retourner 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 · Soda | la validation sous forme de code : des attentes 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 se dégradent de la même poignée de façons quel que soit le domaine. C'est une partie mature et porteuse de l'ingénierie des données, pas une pratique marginale.
OMOP l'a apporté 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 schéma de requête par contrôle aux données cliniques : pointez-le sur une base de données OMOP CDM, il exécute des milliers de contrôles et retourne un rapport noté. Personne dans ce monde ne publierait un jeu de données sans en avoir fait un.
Ce qu'OMOP ajoute, c'est une taxonomie des façons dont les données de santé se dégradent spécifiquement — le cadre de Kahn, qui classe chaque contrôle en trois questions :
| Catégorie | La question | Exemple |
|---|---|---|
| Conformance | Les données ont-elles la bonne forme? | status contient une valeur hors de l'ensemble autorisé |
| Completeness | Les données sont-elles présentes? | 40 % des observations n'ont aucune valeur |
| Plausibility | Les données sont-elles crédibles? | Un poids corporel de 1 000 kg |
Deux mécanismes le font fonctionner. Premièrement, un type de contrôle est un modèle, pas une requête — un 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 contrôles concrets. Deuxièmement, chaque contrôle porte un seuil : moins de 5 % de mauvaises lignes, c'est un succès; au-delà de 5 %, c'est un échec. C'est ce qui rend ces contrôles flous d'une façon qu'un invariant ne peut jamais être. Un invariant est binaire. Un contrôle 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 évalue les données Bulk FHIR avec exactement ces trois catégories. L'Initiative d'informatique médicale 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 simplement jamais adopté.
FHIR dispose maintenant des pièces : SQLQuery + extensions
Lisez le diagramme de gauche à droite et vous avez toute l'idée. Une ViewDefinition aplatit FHIR en table. Une requête SQL sur cette table retourne les lignes enfreignant une règle — le même contrôle de style dbt que toutes les autres piles exécutent. Quelques extensions sur cette requête — catégorie Kahn, seuil, gravité — transforment une requête ordinaire en un contrôle noté que vous pouvez afficher sur un tableau de bord.
C'est l'ensemble de la proposition : un contrôle de qualité des données est un SQLQuery plus trois extensions. Aucune nouvelle ressource, aucune nouvelle opération, aucun nouveau moteur — un contrôle est structurellement identique à toute autre requête, et les extensions sont la seule chose qui en fait un contrôle.
Aucune des pièces n'est nouvelle — tout 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 à contrôler | ViewDefinition — aplatit FHIR en colonnes |
| Un contrôle | Library SQLQuery — une requête sur cette vue |
| Un moyen de l'exécuter | $sqlquery-run — l'opération existante |
| Composition, agrégations | relatedArtifact: depends-on — dépendances entre requêtes |
| Règles de schéma | profils — déjà la source de vérité |
Voilà 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, des années de travail. SQL on FHIR standardise cette couche, de sorte qu'en FHIR ce n'est plus un problème d'infrastructure. C'est simplement du contenu : écrire les requêtes.
Et parce qu'un contrôle n'est que du SQL sur une vue plate standard, c'est une spécification, pas 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 le standardiser. 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 vis-à-vis 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. Lors du récent connectathon HL7 Vulcan, nous l'avons mis en œuvre : une transformation FHIR vers OMOP construite uniquement à partir de ces primitives, plus 258 contrôles DQD — chacun une Library(type=sqlquery) portant les trois extensions mentionnées ci-dessus, pas la maquette de la section précédente. La transformation a réussi les 172 cas de référence et le corrigé de 23 cas avec zéro erreur de conformité. Les contrôles ont signalé 5 échecs sur nos propres données de sortie 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 intégré, correspondant à la ligne (notre contrôle plausibleGender a détecté exactement leurs 6 conditions d'hyperplasie bénigne de la prostate et 4 cancers de la prostate chez des patientes de sexe féminin).
Deux choses se sont démarquées. Porter une décennie de contrôles de qualité des données accumulés n'a coûté pratiquement rien — un contrôle DQD est une requête SQL retournant les mauvaises lignes, et SQL on FHIR exécute exactement cela. Et les contrôles ont immédiatement prouvé leur valeur : plausibleStartBeforeEnd a détecté une visite se terminant trois jours avant son début, 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; le contrôle les trouve dans l'ensemble du jeu de données, automatiquement.
À quoi ça 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)" }]}]}
Un contrôle est un SQLQuery sur cette vue. Les extensions portent la sémantique — celui-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à sa valeur :
-- 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 vraiment son utilité — aucun profil ne peut en exprimer quoi que ce soit. Le DQD d'OMOP possède toute une famille de contrôles de plausibilité, et ils se transposent directement aux Observations codées LOINC. Voici trois des plus utiles.
Valeur en dehors de la plage physiologique pour son code — plausibleValueLow / plausibleValueHigh du DQD. Les bornes sont dans une petite table de référence, une ligne par code LOINC, ce qui correspond exactement au schéma de modèle mentionné plus tôt : un contrôle, 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
Unité incorrecte 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 de sexe féminin (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 inter-ressources s'inscrivent également ici. « Un patient sous médicament antidiabétique devrait avoir un diagnostic de diabète » est une jointure — courante en SQL, difficile voire impossible en FHIRPath.
Les métriques de profilage ne sont pas du tout des résultats binaires, juste 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égations se composent via le même mécanisme de dépendance, pointant vers d'autres contrôles 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
La valeur ajoutée se manifeste là où FHIR fait déjà son travail : le Guide d'implémentation. Un auteur d'IG publie 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 contrôles de qualité — des contrôles SQLQuery sur ces tables, chacun étiqueté 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 savoir si vos données y répondent. » Un consommateur pointe le paquet vers une exportation Bulk et obtient un tableau de bord de qualité des données en retour — ce jeu de données réussit 94 contrôles sur 100 de cet IG — sans écrire une seule ligne de code de validation personnalisé. Les profils, les vues et les contrôles voyagent ensemble, rédigés par les personnes qui comprennent le domaine.
Les prochaines étapes
Assemblez les pièces et l'image est simple. Aujourd'hui, un Guide d'implémentation publie un profil d'instance, et de plus en plus des ViewDefinitions. Avec ceci, il publie la moitié manquante — un profil de jeu de données : un ensemble de contrôles 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 contrôles immédiatement. L'auteur les rédige une fois; chaque serveur évalue les données de la même façon — aucun outillage personnalisé, aucune configuration par fournisseur.
Une limite honnête : ces contrôles ne peuvent pas être dérivés automatiquement des invariants d'un profil, parce que le sous-ensemble FHIRPath de ViewDefinition est plus restreint que ce que ces invariants utilisent. Le catalogue de base est rédigé à la main — un travail ponctuel que la communauté partage.
Et c'est là l'invitation. Ce n'est pas hypothétique — c'est un travail actif dans le groupe de travail SQL on FHIR, avec les définitions d'extensions et un ensemble de départ de contrôles dans l'issue #375, développé lors des appels du groupe. La taxonomie est établie et la machinerie existe; ce qui reste, c'est construire le catalogue, ouvertement. Si vous avez développé des outils de qualité des données pour FHIR — ou si vous avez déjà souhaité que FHIR en dispose — venez aider à le concevoir : apportez vos contrôles dans le fil et rejoignez un appel.





