cassionAnalyse de données

Leçon 4 sur 8

Unité · Ce que Sphère demande et qu'une échelle ne dit pas

Testé sur un tiers, censuré sur un cinquième

E. coli a été testé sur 33,4 % des ménages, le chlore sur 40,0 %, et les deux sur 13,3 %. Dans le registre des points d'eau, 271 lectures de chlore sont la chaîne "<0.1" — et les supprimer relève la conformité apparente de seize points.

PythonR120 minStandards SphèreObjectifs de développement durable (ODD)Norme humanitaire fondamentale (CHS)

Le dénominateur change, et le rapport généralement non

La qualité de l’eau coûte cher à mesurer. Une enquête qui interroge 2 403 ménages en teste une fraction, et tout indicateur de qualité tourne donc sur un dénominateur différent de tout indicateur d’accès.

import pandas as pd

households = pd.read_csv("wash-household-survey-2024.v1.csv")

ecoli = households["ecoli_cfu_100ml"].notna()
chlorine = households["free_residual_chlorine_mgl"].notna()

print(f"E. coli tested:   {ecoli.sum():>5} = {ecoli.mean():.1%}")
print(f"chlorine tested:  {chlorine.sum():>5} = {chlorine.mean():.1%}")
print(f"both:             {(ecoli & chlorine).sum():>5} = {(ecoli & chlorine).mean():.1%}")
library(dplyr)

households |> summarise(
  ecoli = mean(!is.na(ecoli_cfu_100ml)),
  chlorine = mean(!is.na(free_residual_chlorine_mgl)),
  both = mean(!is.na(ecoli_cfu_100ml) & !is.na(free_residual_chlorine_mgl))
)

802 ménages pour E. coli, 961 pour le chlore, et 320 pour les deux. Le dernier nombre est celui qui compte : toute question liant les deux porte sur 13,3 % de l’enquête, et son intervalle sera large.

Imprimez le n analysable pour chaque chiffre de qualité, dans le tableau et non en note de bas de page. Un pourcentage de qualité voisin d’un pourcentage d’accès sur la même ligne se lit comme le même dénominateur, et ici ce n’est jamais le cas.

Les classes de risque du JMP

tested = households[ecoli]
classes = pd.cut(
    tested["ecoli_cfu_100ml"],
    [-1, 0, 10, 100, 10**9],
    labels=["safe <1", "low 1-10", "moderate 11-100", "high >100"],
)
print(classes.value_counts(normalize=True).round(3))
households |>
  filter(!is.na(ecoli_cfu_100ml)) |>
  mutate(risk = cut(ecoli_cfu_100ml, c(-1, 0, 10, 100, Inf),
                    labels = c("safe", "low", "moderate", "high"))) |>
  count(risk) |> mutate(share = n / sum(n))
Classe de risque Ménages Part des testés
Sûre (<1 UFC/100 mL) 430 53,6 %
Faible (1–10) 173 21,6 %
Modérée (11–100) 139 17,3 %
Élevée (>100) 60 7,5 %

Rapportez les classes, non une moyenne. Les dénombrements d’E. coli sont fortement asymétriques et une moyenne de 14 UFC/100 mL ne décrit rien — les classes sont ce dans quoi la directive est écrite et ce sur quoi une décision se prend.

Amélioré n’est pas sûr, et c’est ici que cela se démontre

IMPROVED = {"piped-into-dwelling", "piped-into-yard", "public-tap",
            "borehole", "protected-well", "protected-spring"}

tested = tested.assign(improved=tested["water_source"].isin(IMPROVED))
print(tested.groupby("improved")["ecoli_cfu_100ml"].agg(
    n="size", safe=lambda s: (s < 1).mean(), high=lambda s: (s > 100).mean()
).round(3))
households |>
  filter(!is.na(ecoli_cfu_100ml)) |>
  mutate(improved = water_source %in% improved) |>
  summarise(n = n(), safe = mean(ecoli_cfu_100ml < 1),
            high = mean(ecoli_cfu_100ml > 100), .by = improved)
Source Testés Sûre Risque élevé
Améliorée 625 61,1 % 3,8 %
Non améliorée ou de surface 177 27,1 % 20,3 %

Le type de source prédit fortement la qualité, et 38,9 % des ménages sur une source améliorée ont malgré tout de l’E. coli détectable. La leçon 1 disait que l’échelon supérieur exige l’absence de contamination en plus d’une source améliorée ; voici le nombre qui montre pourquoi c’est une condition distincte et non une formalité.

La valeur censurée, et pourquoi elle n’est pas manquante

Le registre des points d’eau mesure le chlore sur le terrain, et le kit a une limite de détection.

points = pd.read_csv("water-point-monitoring-2024.v1.csv")
readings = points["free_residual_chlorine_mgl"].dropna()

censored = readings.astype(str).str.startswith("<")
print(f"tested {len(readings)}, censored {censored.sum()}")
print(readings[censored].unique())
points |>
  filter(!is.na(free_residual_chlorine_mgl)) |>
  count(censored = startsWith(free_residual_chlorine_mgl, "<"))

811 visites portent une lecture et 271 d’entre elles sont la chaîne <0.1. C’est une mesure : la vraie valeur se situe entre zéro et la limite de détection. Ce n’est pas une valeur manquante, et les deux choses en lesquelles on la transforme habituellement sont fausses toutes les deux.

numeric = pd.to_numeric(readings, errors="coerce")   # <-- the silent one
print(f"after coercion: {numeric.isna().sum()} became NaN")

in_range = readings[~censored].astype(float).between(0.2, 0.5)
print(f"in target range, over all tested:   {in_range.sum() / len(readings):.1%}")
print(f"in target range, dropping censored: {in_range.mean():.1%}")
points |>
  filter(!is.na(free_residual_chlorine_mgl),
         !startsWith(free_residual_chlorine_mgl, "<")) |>
  summarise(in_range = mean(between(as.numeric(free_residual_chlorine_mgl), 0.2, 0.5)))

30,9 % contre 46,5 %. Supprimer les lectures censurées relève la conformité apparente à la cible de chloration de près de seize points, et le fait dans une seule direction — toute valeur censurée est un échec, si bien que les retirer ne retire que des échecs.

pd.to_numeric(..., errors="coerce") le fait en silence et ressemble à une correction de type.

Que faire d’une valeur censurée

Trois options, par ordre de préférence pour cet indicateur.

Classer plutôt que moyenner. La cible est une plage, si bien qu’une lecture censurée répond parfaitement à la question : <0.1 est en dessous de 0,2 et donc non conforme. La conformité se calcule sur les 811 lectures sans aucune substitution.

Substituer la limite, ou sa moitié, s’il vous faut vraiment une moyenne. Dites laquelle vous avez employée — LD/2 est la convention courante — et rapportez à combien de valeurs elle s’est appliquée.

Rapporter la part sous la limite comme un nombre à part entière. 33,4 % de ces lectures sont sous le seuil de détection, ce qui est un constat sur la pratique de chloration et non une gêne dans les données.

compliant = pd.to_numeric(readings.where(~censored), errors="coerce").between(0.2, 0.5)
result = pd.DataFrame({
    "readings": [len(readings)],
    "below detection": [censored.sum()],
    "in 0.2-0.5 range": [compliant.sum()],
    "compliance": [f"{compliant.sum() / len(readings):.1%}"],
})
print(result)
# The denominator is every reading, including the ones below the limit.

Ne laissez jamais une limite de détection rétrécir un dénominateur. C’est la façon la plus courante dont un rapport de qualité de l’eau devient optimiste, et elle survit à la relecture parce que l’arithmétique sur les valeurs restantes est juste.

L’incohérence entre champs qu’il faut nommer

treats = households["water_treated_at_home"]
print(f"treat at home: {treats.sum()}")
print(f"  of which no chlorine reading: {(treats & ~chlorine).sum()}")
households |> filter(water_treated_at_home) |>
  count(no_reading = is.na(free_residual_chlorine_mgl))

533 ménages déclarent traiter leur eau et n’ont aucune mesure de chlore. Le cours sur le nettoyage a établi de quoi il s’agit — une absence corrélée à la chose mesurée — et la conséquence ici est précise : supprimer ces lignes retire des ménages qui traitent leur eau plus souvent que la moyenne, si bien que le sous-échantillon testé n’est pas représentatif de l’enquête dont il vient.

Rapportez-le comme un bloc à part

Water quality

  E. coli, point of collection          802 households tested (33.4% of survey)
    Safe <1 CFU/100 mL                  53.6%
    Low 1-10                            21.6%
    Moderate 11-100                     17.3%
    High >100                            7.5%
    Improved sources: 61.1% safe (n=625)
    Unimproved and surface: 27.1% safe (n=177)

  Free residual chlorine, water points  811 visits tested (30.8% of visits)
    Below detection limit (<0.1 mg/L)   33.4%   counted as non-compliant
    Within 0.2-0.5 mg/L target          30.9%

  Quality was tested on a subsample and does not share the denominator of the
  access indicators above. 533 households reporting home treatment have no
  chlorine reading, so the tested subsample over-represents untreated water.

Son propre bloc, ses propres dénominateurs, sa propre limite. Un chiffre de qualité placé dans un tableau de chiffres d’accès sera lu contre leur dénominateur, et rien dans la mise en page ne l’en empêchera.

La suite

Tout ce qui précède est une coupe transversale : ce qui était vrai le jour où quelqu’un est passé. L’unité suivante porte sur le registre qui visite les mêmes points d’eau douze fois, et sa première conséquence est de transformer un taux de fonctionnalité en trois.

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.

Lancer le diaporamaLire les diapositives

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.