Bases de données

Des ressources sur les principaux modèles de données et sur des bases de données accessibles en ligne

1 - Qu'est-ce qu'une base de données ?

Par Boris Delange | 15.10.2024

Dans cet article, nous allons :

  • Définir ce qu'est une base de données en partant d'une analogie avec Excel
  • Evoquer les principes de conception qui permettent de créer une base de données
  • Aborder les notions de tables, de jointures et de schémas
  • Aborder le SQL, ce langage de programmation qui permet de requêter les bases de données

Principes de conception

Une base de données (BDD) est comme un grand tableur Excel, où chaque feuille représente une table.

Tout l’enjeu d’une base de données est :

  • d’éviter les redondances des données, pour prendre moins d’espace
  • de contenir le moins de cases vides possibles, toujours pour occuper moins d’espace
  • d’être le plus flexible possible, pour pouvoir ajouter de nouvelles données que l’on n’avait pas prévues initialement

Structurer les données en tables

Comment feriez-vous pour stocker dans un tableur Excel les données de biologie et les données démographiques de 5 patients ?


Mettons que l’on ait besoin de stocker le taux d’hémoglobine, les plaquettes et les leucocytes.

Le premier réflexe qui vient à l’esprit est de créer une colonne par paramètre de biologie.

Nous ajoutons une colonne date_biologie pour connaître la date de réalisation du prélèvement biologique.


patient_id age sexe date_admission date_sortie date_biologie hémoglobine plaquettes leucocytes
1 45 M 2024-10-01 2024-10-10 2024-10-03 13.5 / /
1 45 M 2024-10-01 2024-10-10 2024-10-04 / 150,000 7,200
2 60 F 2024-09-25 2024-10-05 2024-09-26 12.8 180,000 8,000
3 38 F 2024-10-05 2024-10-12 2024-10-07 14.0 220,000 /
3 38 F 2024-10-05 2024-10-12 2024-10-08 / / 6,500
4 52 M 2024-09-20 2024-09-30 2024-09-21 11.5 140,000 9,500
5 29 F 2024-10-08 2024-10-15 2024-10-09 13.2 170,000 7,800
5 29 F 2024-10-16 2024-10-20 2024-10-16 14.2 / /

Comment lire ce tableau ?


Le patient 1 a un seul séjour (une seule date_admission) et deux dosages biologiques à deux dates différentes durant ce même séjour (deux valeurs pour date_biologie).

Le patient 5 a deux séjours (deux valeurs pour date_admission), avec une biologie prélevée par séjour (deux valeurs différentes de date_biologie).

Nous pouvons remarquer deux choses :

  • Il existe une redondance des données démographiques (âge, sexe, dates d'admission et de sortie)
  • Nous avons dû créer une ligne par date de prélèvement biologique, ce qui fait que nous avons des cases vides aux dates où certaines biologies n'ont pas été réalisées


Si l’on revient à nos trois principes de conception (éviter les redondances, moins de cases vides et flexibilité), il semblerait que l’on puisse faire mieux.

Pourquoi ne pas créer une table (ou une feuille Excel pour continuer avec l’analogie) pour les patients ?


patient_id age sexe
1 45 M
2 60 F
3 38 F
4 52 M
5 29 F

On gagne ainsi de la place avec 3 lignes en moins.

Pourquoi ne pas avoir intégré les séjours dans cette table ?


Si on avait intégré les séjours dans cette table (avec les colonnes date_admission et date_sortie), nous aurions certes eu une seule ligne pour les patients 1 à 4, mais deux lignes pour le patient 5, qui a deux séjours différents.


Toujours dans une logique de diminuer le nombre de lignes, on préférera créer une table pour les séjours.

patient_id admission_id date_admission date_sortie
1 1 2024-10-01 2024-10-10
2 2 2024-09-25 2024-10-05
3 3 2024-10-05 2024-10-12
4 4 2024-09-20 2024-09-30
5 5 2024-10-08 2024-10-15
5 6 2024-10-16 2024-10-20

Pour finir, nous allons créer une table pour stocker les données de biologie.

patient_id admission_id date_biologie hémoglobine plaquettes leucocytes
1 1 2024-10-03 13.5 / /
1 1 2024-10-04 / 150,000 7,200
2 2 2024-09-26 12.8 180,000 8,000
3 3 2024-10-07 14.0 220,000 /
3 3 2024-10-07 / / 6,500
4 4 2024-09-21 11.5 140,000 9,500
5 5 2024-10-09 13.2 170,000 7,800
5 6 2024-10-16 14.2 / /

OK, mais ici nous avons encore des cases vides, nous pourrions optimiser.


La solution est de créer une colonne pour le nom du paramètre biologique, et une colonne pour sa valeur. Ainsi, plus de case vide !

patient_id admission_id date_biologie paramètre valeur
1 1 2024-10-03 hémoglobine 13.5
1 1 2024-10-04 plaquettes 150,000
1 1 2024-10-04 leucocytes 7,200
2 2 2024-09-26 hémoglobine 12.8
2 2 2024-09-26 plaquettes 180,000
2 2 2024-09-26 leucocytes 8,000
3 3 2024-10-07 hémoglobine 14.0
3 3 2024-10-07 plaquettes 220,000
3 3 2024-10-07 leucocytes 6,500
4 4 2024-09-21 hémoglobine 11.5
4 4 2024-09-21 plaquettes 140,000
4 4 2024-09-21 leucocytes 9,500
5 5 2024-10-09 hémoglobine 13.2
5 5 2024-10-09 plaquettes 170,000
5 5 2024-10-09 leucocytes 7,800
5 6 2024-10-16 hémoglobine 14.2

Nous venons de créer une base de données !


Alors certes, cela peut paraître moins lisible au premier abord, mais quand on a des millions de données, il est nécessaire d’optimiser leur stockage.

Et vous le verrez si vous faites un peu de programmation, cette manière d’organiser les données est finalement bien plus lisible qu’un fichier Excel à 50, 100 colonnes…

Jointures

Les données sont maintenant éparpillées sur plusieurs tables.

Comment les fusionner de nouveau ?


Si l’on veut fusionner plusieurs tables, on fera ce que l’on appelle une jointure.

Par exemple, si on fait une jointure entre les tables patients et séjours, en faisant une correspondance sur la colonne patient_id, nous obtiendrons cette table :

patient_id age sexe admission_id date_admission date_sortie
1 45 M 1 2024-10-01 2024-10-10
2 60 F 2 2024-09-25 2024-10-05
3 38 F 3 2024-10-05 2024-10-12
4 52 M 4 2024-09-20 2024-09-30
5 29 F 5 2024-10-08 2024-10-15
5 29 F 6 2024-10-16 2024-10-20

On pourra joindre les données de la table biologie de la même façon, et obtenir le tableau du début de l’article.

Requêter une base de données

Il existe un langage de programmation qui permet spécifiquement de requêter les bases de données.

Ce langage se nomme SQL, pour Structured Query Language.

C’est un langage assez simple et facile d’accès.

Il se compose de quelques mots clefs qui permettent d’obtenir les données que l’on veut, dont (non exhaustif) :

  • SELECT : vous choisissez les colonnes que vous voulez garder
  • FROM : de quelle table seront extraites les données ?
  • WHERE : quels filtres appliquer sur les données ?

Par exemple :

SELECT patient_id, age, sexe
FROM patients
WHERE age > 45

On sélectionne les colonnes patient_id, age, sexe de la table patients où la valeur de la colonne age est supérieur à la valeur 45.

On a le résultat suivant :

patient_id age sexe
2 60 F
4 52 M

Schémas de BDD

Ce que l’on appelle un schéma de base de données est la structure des tables qui composent une base de données.

Il spécifie :

  • le nom des tables
  • le nom des colonnes de chaque table
  • le type de données de chaque colonne (si la colonne doit comprendre des données de type texte ou numérique par exemple)

Par exemple, OMOP est un schéma de BDD spécialisée dans les données de santé.

Le schéma de données du modèle OMOP

Cette base est assez complexe, ce qui est nécessaire pour englober toutes les données de santé.

De même que nous l’avons fait plus haut, vous pouvez retrouver la table person qui correspond aux patients et la table visit_detail qui correspond aux séjours.

Conclusion

Les points à retenir :

  • Une base de données est un ensemble de tables avec un schéma particulier (noms des colonnes et type des données)
  • Les schémas des bases sont contruits selon des principes : éviter les redondances, optimiser l'espace et permettre la flexibilité
  • Les tables peuvent être liées entre-elles à l'aide de jointures
  • Le SQL est un langage de programmation permettant de requêter les bases de données

Pour aller plus loin :

2 - MIMIC

Par Boris Delange | 29.07.2024

Description de la base

La base de données MIMIC, pour Medical Information Mart for Intensive Care, est une base de données nord-américaine contenant des données de plus de 50 000 patients admis en réanimation. Il s’agit de l’une des bases de données de réanimation les plus utilisées, du fait de son accès gratuit.

Malgré des données d’une qualité imparfaite, elle constitue un bon socle pour apprendre à manipuler les données issues d’entrepôts de données de santé (EDS).

Elle existe en plusieurs versions, dont la plus récente est la MIMIC-IV.

Données test (publiques)

La base de donneés MIMIC comporte pour les versions III et IV des bases tests, qui contiennent les données anonymisées de 100 patients et qui sont accessibles publiquement.

Vous pouvez télécharger les données ici :

Données complètes

Pour accéder aux bases de données complètes, il est nécessaire de valider quelques étapes.

Rendez-vous sur la page de la base MIMIC-III.

Vous verrez cet encadré tout en bas de la page :

Vous devez donc commencer par vous inscrire sur le site physionet.org.

Vous devrez faire une demande d’accès à Physionet, en renseignant quelques informations et en donnant les coordonnées d’un superviseur ou d’un collègue, à qui un mail sera envoyé.

Vous devrez ensuite compléter le CITI Course, il s’agit d’une formation nécessaire afin d’accéder aux données hébergées sur le site Physionet. Les différentes étapes sont détaillées ici.

Vous pourrez ensuite télécharger le certificat une fois le CITI Course terminé, vous pourrez le le déposer ici pour validation par l’équipe de Physionet.

Il ne vous restera plus qu’à signer le data use agreement.

3 - Requêtes usuelles v5.4

Par Boris Delange, Antoine Lamer | 02.09.2024 | MàJ 12.11.2024

Introduction

Nous présentons ici quelques requêtes que l’on utilise fréquemment pour requêter les tables de bases de données au format OMOP.

Ces requêtes se basent sur la version 5.4 du schéma OMOP.

Le schéma de données du modèle commun OMOP v5.4 de Martijn Schuemie et Renske Los

Pour chaque table, les requêtes sont disponibles :

  • en utilisant la librairie dplyr de R (directement depuis LinkR)
  • en utilisant du SQL (PostgreSQL)
  • en utilisant la librairie pandas de Python

Pour le code en dplyr, nous utiliserons la fonction join_concepts qui est disponible dans LinkR (pas besoin de les déclarer de nouveau) :

# Permet de faire la jointure des ID et des noms de concepts

join_concepts <- function(df, concept_df, cols) {
  
  for (col in cols) {
    key <- paste0(col, "_concept_id")
    name <- paste0(col, "_concept_name")
    
    df <- df %>%
      dplyr::left_join(
        concept_df %>%
          dplyr::select(!!key := concept_id, !!name := concept_name),
        by = key,
        copy = TRUE
      ) %>%
      dplyr::relocate(!!name, .after = !!key)
  }
  
  return(df)
}

Dans les exemples, les données des tables OMOP sont chargées dans une liste d$.

# Charger les tables dans une liste 'd'
d <- list()

for (table in c("person", "visit_detail")){
    sql <- glue::glue_sql("SELECT * FROM {`table`}, .con = con)
    d[[table]] <- DBI::dbGetQuery(con, sql)
}

CONDITION_OCCURRENCE

OMOP CDM v5.4 PERSON Table
  1. Jointure des noms de concepts
d$condition_occurrence %>%
    join_concepts(d$concept, c("condition", "condition_type"))
  1. Calcul du nombre d’occurrences par condition_concept_name
d$condition_occurrence %>%
    join_concepts(d$concept, "condition") %>%
    dplyr::collect() %>%
    dplyr::count(condition_concept_name, sort = TRUE) %>%
    print(n = 100)

DRUG_STRENGTH

OMOP CDM v5.4 DRUG_STRENGTH Table
  1. Jointure des noms de concepts
d$drug_exposure %>%
    join_concepts(d$concept, c("drug", "drug_type", "route")) %>%
    dplyr::left_join(
        d$drug_strength %>%
            join_concepts(d$concept, c("ingredient", "amount_unit", "numerator_unit", "denominator_unit")) %>%
            dplyr::select(
                drug_concept_id, ingredient_concept_id, ingredient_concept_name,
                amount_value, amount_unit_concept_id, amount_unit_concept_name,
                numerator_value, numerator_unit_concept_id, numerator_unit_concept_name,
                denominator_value, denominator_unit_concept_id, denominator_unit_concept_name
            ),
        by = "drug_concept_id",
        copy = TRUE
    )
  1. Calcul du nombre d’occurrences par drug_concept_name
d$drug_exposure %>%
    join_concepts(d$concept, "drug") %>%
    dplyr::count(drug_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)
  1. Affichage des administrations

Comme indiqué ici, il existe différents cas pour calculer les doses des administrations médicamenteuses.

Nous pouvons retenir deux cas :

  • le champ amount_value est rempli, dans ce cas c’est celui-ci qu’il faut utiliser (cas des médicaments per os)
  • le champ amount_value n’est pas rempli, dans ce cas il faut utiliser le le champ numerator_value (cas des administrations intra-veineuses, sous-cutanées, en aérosols et en patchs)

Nous allons créer un champ amount, qui sera la synthèse des champs amount_value et numerator_value, ce sera la quantité délivrée du médicament.

Ensuite, nous pourrons déterminer le débit, en rapportant le champ amount au temps entre le début et la fin de l’administration. Nous allons pour cela créer un champ duration_hours, qui sera le nombre d’heures pendant lequel est délivré le médicament.

Nous créons également un champ daily_dose pour calculer la dose quotidienne moyenne sur la période, exprimée en daily_dose_unit.

Le modèle OMOP a initialement été développé pour des analyses épidémiologiques, il n’était pas important de savoir précisément la date et l’heure de l’administration d’un médicament, mais uniquement la quantité reçue globalement sur une période temps.

Ceci pose problème dans le cas des entrepôts de données de santé, où l’heure précise d’administration d’un médicament nous importe.

Il est donc important lors du processus d’ETL de créer une nouvelle ligne à chaque changement de dose, et une ligne par administration intermittente.

Par exemple, si un patient reçoit 1 g de paracétamol toutes les 6 heures pendant 5 jours, plutôt que de créer une ligne avec quantity = 5*4 = 20 et la date de fin à + 5 jours de la date de début, on préférera créer une ligne par administration, soit 20 lignes avec les horaires précis d’administration, avec date de début = date de fin.

d$drug_exposure %>%
    join_concepts(d$concept, c("drug", "drug_type", "route")) %>%
    dplyr::left_join(
        d$drug_strength %>%
            join_concepts(d$concept, c("ingredient", "amount_unit", "numerator_unit", "denominator_unit")) %>%
            dplyr::select(
                drug_concept_id, ingredient_concept_id, ingredient_concept_name,
                amount_value, amount_unit_concept_id, amount_unit_concept_name,
                numerator_value, numerator_unit_concept_id, numerator_unit_concept_name,
                denominator_value, denominator_unit_concept_id, denominator_unit_concept_name
            ),
        by = "drug_concept_id",
        copy = TRUE
    ) %>%
    dplyr::collect() %>%
    dplyr::arrange(person_id, drug_exposure_start_datetime) %>%
    dplyr::mutate(
        amount = dplyr::case_when(
            !is.na(amount_value) ~ quantity * amount_value,
            !is.na(numerator_value) ~ quantity * numerator_value
        ),
        amount_unit = dplyr::case_when(
            !is.na(amount_value) ~ amount_unit_concept_name,
            !is.na(numerator_value) ~ numerator_unit_concept_name
        ),
        duration_hours = as.numeric(difftime(drug_exposure_end_datetime, drug_exposure_start_datetime, units = "hours")),
        rate = dplyr::case_when(
            !is.na(numerator_value) & !is.na(duration_hours) & duration_hours > 0 ~ amount / duration_hours
        ),
        rate_unit = dplyr::case_when(
            !is.na(rate) & !is.na(amount_unit) ~ paste0(amount_unit, " per hour")
        ),
        daily_dose = dplyr::case_when(
            is.na(rate) & !is.na(amount) ~ amount / duration_hours * 24
        ),
        daily_dose_unit = dplyr::case_when(
            is.na(rate) & !is.na(amount_unit) ~ paste0(amount_unit, " per day")
        )
    ) %>%
    dplyr::select(
        person_id, drug_concept_name,
        drug_exposure_start_datetime, drug_exposure_end_datetime, duration_hours,
        amount, amount_unit, rate, rate_unit, daily_dose, daily_dose_unit
    )

MEASUREMENT

OMOP CDM v5.4 MEASUREMENT Table
  1. Jointure des noms de concepts
d$measurement %>%
    join_concepts(d$concept, c("measurement", "measurement_type", "operator", "unit"))
  1. Calcul du nombre d’occurrences par measurement_concept_name et unit_concept_name
d$measurement %>%
    join_concepts(d$concept, c("measurement", "unit")) %>%
    dplyr::count(measurement_concept_name, unit_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)

OBSERVATION

OMOP CDM v5.4 OBSERVATION Table
  1. Jointure des noms de concepts
d$observation %>%
    join_concepts(d$concept, c("observation", "observation_type", "value_as", "qualifier", "unit"))
  1. Calcul du nombre d’occurrences par observation_concept_name
d$observation %>%
    join_concepts(d$concept, "observation") %>%
    dplyr::count(observation_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)

PERSON

OMOP CDM v5.4 PERSON Table
  1. Calcul de l’âge

L’âge est calculé depuis les variables d$visit_occurrence (hospitalisations) ou d$visit_detail (séjours dans les unités au cours d’une hospitalisation).

Il s’agit de l’âge du patient à l’admission pour chaque hospitalisation ou admission dans une unité.

# Le code de calcul des dates étant mal converti en SQL, nous collectons les données avec dplyr::collect(),
# ce qui signifie que les données sont copiées dans un dataframe localement.
# Le code dplyr n'est ainsi pas converti en SQL.

d$visit_occurrence %>%
    dplyr::left_join(
        d$person %>% dplyr::select(person_id, birth_datetime),
        by = "person_id"
    ) %>%
    dplyr::collect() %>%
    dplyr::mutate(
        age = round(as.numeric(difftime(visit_start_datetime, birth_datetime, units = "days")) / 365.25, 1)
    )
-- DuckDB / PostgreSQL

SELECT 
    v.*, 
    ROUND(
        EXTRACT(EPOCH FROM (v.visit_start_datetime - p.birth_datetime)) / (365.25 * 86400), 
        1
    ) AS age
FROM 
    visit_occurrence v
LEFT JOIN 
    (SELECT person_id, birth_datetime FROM person) p
ON 
    v.person_id = p.person_id;
import pandas as pd

merged_df = pd.merge(
    visit_occurrence,
    person[['person_id', 'birth_datetime']],
    on='person_id',
    how='left'
)

merged_df['age'] = round(
    (merged_df['visit_start_datetime'] - merged_df['birth_datetime']).dt.total_seconds() / (365.25 * 86400), 
    1
)
  1. Jointure des noms de concepts
d$person %>%
    join_concepts(d$concept, c("gender", "race", "ethnicity"))
-- DuckDB / PostgreSQL

SELECT 
    p.*, 
    c.concept_name AS gender_concept_name
FROM 
    person p
LEFT JOIN 
    (SELECT concept_id AS gender_concept_id, concept_name FROM concept) c
ON 
    p.gender_concept_id = c.gender_concept_id;
import pandas as pd

concept_df = concept[['concept_id', 'concept_name']].rename(
    columns={'concept_id': 'gender_concept_id', 'concept_name': 'gender_concept_name'}
)

merged_df = pd.merge(
    person, 
    concept_df, 
    how='left', 
    left_on='gender_concept_id', 
    right_on='gender_concept_id'
)
  1. Calcul du nombre d’occurrences par gender_concept_name
d$person %>%
    join_concepts(d$concept, "gender") %>%
    dplyr::count(gender_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)

VISIT_DETAIL

OMOP CDM v5.4 VISIT_DETAIL Table
  1. Jointure des noms de concepts
d$visit_detail %>%
    join_concepts(d$concept, c("visit_detail", "visit_detail_type", "admitted_from", "discharge_to"))
  1. Calcul du nombre d’occurrences par visit_detail_concept_name
d$visit_detail %>%
    join_concepts(d$concept, "visit_detail")) %>%
    dplyr::count(visit_detail_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)

VISIT_OCCURRENCE

OMOP CDM v5.4 VISIT_OCCURRENCE Table
  1. Jointure des noms de concepts
d$visit_occurrence %>%
    join_concepts(d$concept, c("visit", "visit_type", "admitted_from", "discharge_to"))
  1. Calcul du nombre d’occurrences par visit_concept_name
d$visit_occurrence %>%
    join_concepts(d$concept, "visit") %>%
    dplyr::count(visit_concept_name, sort = TRUE) %>%
    dplyr::collect() %>%
    print(n = 100)