cassionAnalyse de données

Leçon 7 sur 8

Des données ordonnées au tableau d'indicateurs

Transformer un jeu de données propre en tableau d'indicateurs désagrégé conforme au cadre logique, avec numérateur, dénominateur et intervalle de confiance explicitement énoncés.

PythonR90 minCadre logiqueNormes OMS de croissance de l'enfant

Un indicateur est un numérateur rapporté à un dénominateur, désagrégé

Cette phrase résume toute la leçon. La plupart des désaccords de rapportage sont en réalité des désaccords sur le dénominateur, et la plupart des autres portent sur les lignes qui appartiennent au numérateur.

Donc, avant tout code : écrivez la définition.

La fiche de référence d’indicateur

Pour chaque indicateur que vous rapportez, remplissez ceci. Cela prend cinq minutes et met fin au débat avant qu’il ne commence.

Champ Pour notre exemple
Indicateur Prévalence de la malnutrition aiguë globale (MAG) par PB
Numérateur Enfants de 6 à 59 mois dépistés avec un PB inférieur à 125 mm, ou porteurs d’œdèmes bilatéraux prenant le godet
Dénominateur Enfants de 6 à 59 mois disposant d’une mesure de PB valide ou d’une évaluation des œdèmes consignée
Désagrégation Commune, sexe, tranche d’âge (6-23, 24-59 mois)
Fréquence Trimestrielle, et annuelle pour le rapport bailleur
Source Registre de dépistage de masse communautaire, CommCare
Décision éclairée Quelles communes reçoivent un site PCIMA supplémentaire au trimestre suivant
Valeur de référence Une MAG égale ou supérieure à 15 % constitue le seuil d’urgence

Notez ce que le dénominateur n’est pas. Ce n’est pas l’ensemble des enfants de la commune — ce serait une prévalence ajustée sur la couverture, qui exige un chiffre de population dont ce registre ne dispose pas. Ce n’est pas non plus l’ensemble des lignes du fichier — cela inclurait des lignes sans aucune mesure. Être explicite là-dessus est ce qui empêche le chiffre d’être discrètement redéfini d’un rapport à l’autre.

Si vous ne savez pas énoncer le dénominateur en une phrase, vous n’avez pas encore d’indicateur. Vous avez une colonne dont vous vous apprêtez à prendre la moyenne.

Construisez le numérateur comme une colonne

Ne filtrez pas. Créez un indicateur binaire, conservez toutes les lignes, et laissez l’agrégation faire le travail — le dénominateur reste ainsi visible dans le même tableau que le numérateur.

import numpy as np

MAS_MM = 115
MAG_MM = 125

muac["evalue"] = muac["muac_mm"].notna() | muac["oedema"].notna()

muac["mas"] = np.where(
    ~muac["evalue"],
    np.nan,
    ((muac["muac_mm"] < MAS_MM) | (muac["oedema"] == True)).astype(float),
)

muac["mag"] = np.where(
    ~muac["evalue"],
    np.nan,
    ((muac["muac_mm"] < MAG_MM) | (muac["oedema"] == True)).astype(float),
)
MAS_MM <- 115
MAG_MM <- 125

muac <- muac |>
  mutate(
    evalue = !is.na(muac_mm) | !is.na(oedema),
    mas = if_else(evalue, (muac_mm < MAS_MM) | oedema %in% TRUE, NA),
    mag = if_else(evalue, (muac_mm < MAG_MM) | oedema %in% TRUE, NA)
  )

Deux détails faciles à manquer et coûteux à négliger :

  • Les œdèmes signent une MAS indépendamment de la mesure. Un enfant à 130 mm porteur d’œdèmes bilatéraux prenant le godet est en malnutrition aiguë sévère. Filtrer sur le seul muac_mm sous-estime la charge de cas, et la sous-estime précisément parmi les cas les plus graves.
  • oedema == True plutôt qu’un test de véracité. Un œdème manquant n’est pas un œdème absent. En R, oedema %in% TRUE traite NA comme absence d’œdème pour cet indicateur tout en le laissant distinguable ailleurs ; en Python, la comparaison explicite fait la même chose.

Le tableau d’indicateurs

def tableau_indicateurs(df, par):
    groupes = df.groupby(par, dropna=False)
    tableau = groupes.agg(
        depistes=("child_id", "size"),
        denominateur=("evalue", "sum"),
        cas_mas=("mas", "sum"),
        cas_mag=("mag", "sum"),
    )
    tableau["taux_mag"] = tableau["cas_mag"] / tableau["denominateur"]
    tableau["taux_mas"] = tableau["cas_mas"] / tableau["denominateur"]
    return tableau.reset_index()


par_commune = tableau_indicateurs(muac, "commune").sort_values(
    "taux_mag", ascending=False
)
print(par_commune.round(4))
tableau_indicateurs <- function(df, par) {
  df |>
    group_by(across(all_of(par))) |>
    summarise(
      depistes     = n(),
      denominateur = sum(evalue),
      cas_mas      = sum(mas, na.rm = TRUE),
      cas_mag      = sum(mag, na.rm = TRUE),
      .groups = "drop"
    ) |>
    mutate(
      taux_mag = cas_mag / denominateur,
      taux_mas = cas_mas / denominateur
    )
}

par_commune <- tableau_indicateurs(muac, "commune") |> arrange(desc(taux_mag))
par_commune

Le tableau porte volontairement depistes et denominateur en colonnes distinctes. Ils diffèrent des lignes sans aucune évaluation, et un lecteur qui voit les deux peut juger sur quelle part du registre repose le taux. Un tableau ne rapportant que le taux le dissimule entièrement.

Exécutez-le : le taux de MAG global s’établit autour de 8,6 % et la MAS autour de 2,2 %, avec une amplitude d’environ 5 % à 15 % selon les communes. Une commune franchit le seuil d’urgence de 15 % ; la meilleure en est loin. Ces valeurs sont énoncées dans les notes de qualité du jeu de données précisément pour que vous puissiez contrôler votre travail — si votre MAG ressort à 30 %, vous avez un bogue, pas une famine.

La désagrégation

Le cadre logique demandait commune, sexe et tranche d’âge. La tranche d’âge doit être construite, et ses bornes sont un choix à énoncer.

muac["tranche_age"] = pd.cut(
    muac["age_months"],
    bins=[6, 24, 60],
    labels=["6-23 mois", "24-59 mois"],
    right=False,
)

par_sexe = tableau_indicateurs(muac, ["commune", "sex"])
par_age = tableau_indicateurs(muac, "tranche_age")

print(par_age.round(4))
muac <- muac |>
  mutate(
    tranche_age = cut(
      age_months,
      breaks = c(6, 24, 60),
      labels = c("6-23 mois", "24-59 mois"),
      right = FALSE
    )
  )

par_sexe <- tableau_indicateurs(muac, c("commune", "sex"))
par_age <- tableau_indicateurs(muac, "tranche_age")

par_age

right = FALSE rend les bandes fermées à gauche : de 6 à 23 mois révolus, puis de 24 à 59. L’inverse place les enfants de 24 mois dans la tranche inférieure, ce qui déplace légèrement tous les taux et reste invisible dans le résultat. Énoncez la convention dans la fiche de référence.

Rappelez-vous le problème d’âge manquant de la leçon 5. La désagrégation par âge exclut nécessairement les lignes sans âge, et Gros-Morne y est surreprésentée. Publiez le tableau par âge avec cette réserve attachée, ou publiez-le en excluant Gros-Morne et dites-le.

Énoncez votre degré d’incertitude

Un taux calculé sur 340 enfants n’est pas la même affirmation qu’un taux calculé sur 12. Attachez-y un intervalle.

from scipy.stats import beta

def intervalle(succes, n, confiance=0.95):
    if n == 0:
        return (np.nan, np.nan)
    bas = beta.ppf((1 - confiance) / 2, succes, n - succes + 1) if succes > 0 else 0.0
    haut = beta.ppf(1 - (1 - confiance) / 2, succes + 1, n - succes) if succes < n else 1.0
    return (bas, haut)


bornes = par_commune.apply(
    lambda r: intervalle(r["cas_mag"], r["denominateur"]), axis=1
)
par_commune["mag_bas"] = [b[0] for b in bornes]
par_commune["mag_haut"] = [b[1] for b in bornes]

print(par_commune[["commune", "denominateur", "taux_mag", "mag_bas", "mag_haut"]].round(4))
intervalle <- function(cas, n) {
  if (n == 0) return(c(NA_real_, NA_real_))
  test <- binom.test(cas, n)
  test$conf.int
}

par_commune <- par_commune |>
  rowwise() |>
  mutate(
    mag_bas  = intervalle(cas_mag, denominateur)[1],
    mag_haut = intervalle(cas_mag, denominateur)[2]
  ) |>
  ungroup()

par_commune |> select(commune, denominateur, taux_mag, mag_bas, mag_haut)

Regardez à présent le classement. Plusieurs communes ont des intervalles qui se recouvrent, ce qui signifie que l’ordre entre elles n’est pas soutenu par les données. Si la décision éclairée par ce tableau est « quelle commune reçoit le site PCIMA supplémentaire », cela compte énormément — et un tableau sans intervalles vous aurait laissé les classer avec une assurance infondée.

Il s’agit par ailleurs d’un recensement des enfants dépistés et non d’un échantillon probabiliste : l’intervalle ne décrit donc que la variation d’échantillonnage. Il ne dit rien sur la question de savoir si les enfants venus au dépistage ressemblent à ceux qui n’y sont pas venus, ce qui constitue généralement la plus grande source d’erreur et relève de la section des limites.

Mettez-le en forme pour le rapport

rapport = par_commune.assign(
    mag=lambda d: (d["taux_mag"] * 100).round(1).astype(str) + "%",
    ic=lambda d: "("
    + (d["mag_bas"] * 100).round(1).astype(str)
    + " - "
    + (d["mag_haut"] * 100).round(1).astype(str)
    + ")",
)[["commune", "depistes", "denominateur", "cas_mag", "mag", "ic"]]

rapport.columns = [
    "Commune", "Dépistés", "Évalués", "Cas MAG", "Taux MAG", "IC 95%",
]
rapport.to_csv("output/tables/mag-par-commune.csv", index=False)
print(rapport.to_string(index=False))
rapport <- par_commune |>
  transmute(
    Commune     = commune,
    `Dépistés`  = depistes,
    `Évalués`   = denominateur,
    `Cas MAG`   = cas_mag,
    `Taux MAG`  = sprintf("%.1f%%", taux_mag * 100),
    `IC 95%`    = sprintf("(%.1f - %.1f)", mag_bas * 100, mag_haut * 100)
  )

readr::write_csv(rapport, "output/tables/mag-par-commune.csv")
rapport

Chaque colonne de ce tableau est défendable : vous pouvez dire d’où vient chaque chiffre, ce qu’il a compté, ce qu’il n’a pas compté, et avec quelle incertitude.

La suite

La dernière leçon fait en sorte que tout cela s’exécute à nouveau — sur l’export du trimestre suivant, sur le portable de quelqu’un d’autre, sans rien modifier.

Animer cette leçon

La leçon en diaporama, la prose étant reléguée dans les notes du présentateur plutôt que projetée. Produit à partir de cette page, dont il ne peut donc pas s'écarter.

Le PDF ne requiert aucun logiciel et se projette depuis n'importe quel poste. Le fichier PowerPoint est fait pour être modifié : appliquez la charte de votre organisation, retirez une section pour une séance plus courte, ou fusionnez deux leçons en atelier.