---
title: "Qualité de l'eau et chloration"
subtitle: "Enquête ménage EAH, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## La question

Un chlore résiduel libre supérieur ou égal à 0,2 mg/L prédit-il une classe de
risque E. coli plus faible ? Et — c'est ce qui décide si la réponse signifie
quelque chose — quelle part de l'échantillon peut réellement y répondre ?

Tous les jeux de données de cette plateforme sont synthétiques. Aucun ménage réel
n'est décrit.

## Mise en place

```{python}
import pandas as pd
import numpy as np

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "wash-household-survey-2024.v1.csv"
)

wash = pd.read_csv(URL, dtype={"household_id": "string"})
wash["district"] = (
    wash["district"].str.strip().str.lower().str.replace(" ", "-", regex=False)
)
len(wash)
```

## Commencer par le dénominateur, non par le résultat

La qualité de l'eau n'est testée que sur un sous-ensemble. Rapporter un résultat
de qualité au regard de l'échantillon complet surestime l'effort de test réalisé,
et c'est la première chose qu'un relecteur vérifiera.

```{python}
testes = pd.DataFrame({
    "menages": [
        len(wash),
        int(wash["free_residual_chlorine_mgl"].notna().sum()),
        int(wash["ecoli_cfu_100ml"].notna().sum()),
        int(
            (
                wash["free_residual_chlorine_mgl"].notna()
                & wash["ecoli_cfu_100ml"].notna()
            ).sum()
        ),
    ]
}, index=["enquetes", "chlore teste", "E. coli teste", "les deux testes"])
testes["part de l'echantillon"] = (testes["menages"] / len(wash)).round(3)
testes
```

**Seule la dernière ligne peut répondre à la question.** Tout ce qui suit est
calculé sur ce sous-ensemble, et le rapport doit le dire : un constat sur la
chloration présenté au regard de 2 403 ménages alors que 320 ont été testés pour
les deux paramètres est une déformation des données probantes, quel que soit le
constat.

## La non-réponse n'est pas aléatoire

Environ 530 ménages déclarent traiter leur eau sans qu'aucune mesure de chlore ne
soit consignée. C'est une incohérence entre champs, non une valeur manquante au
hasard, et cela compte : supprimer ces lignes retire des ménages qui traitent
leur eau plus souvent que la moyenne, ce qui biaise la comparaison dans une
direction prévisible.

```{python}
traite_non_teste = wash["water_treated_at_home"] & wash[
    "free_residual_chlorine_mgl"
].isna()
print(f"traitent leur eau sans mesure de chlore : {int(traite_non_teste.sum())}")

pd.crosstab(
    wash["water_treated_at_home"],
    wash["free_residual_chlorine_mgl"].notna().map(
        {True: "chlore teste", False: "non teste"}
    ),
    normalize="index",
).round(3)
```

Les ménages qui traitent leur eau sont ici *davantage* susceptibles d'être
testés : le sous-ensemble testé surreprésente donc le traitement. Tout effet
estimé sur lui est un effet parmi les testés, non dans la population.

## La comparaison

La valeur guide de l'OMS pour le chlore résiduel libre au point de distribution
est de 0,2 mg/L. E. coli est rapporté en classes de risque normalisées plutôt
qu'en dénombrement brut, parce que le dénombrement est surdispersé et que ce sont
les classes qui déclenchent une action de programme.

```{python}
deux = wash.dropna(subset=["free_residual_chlorine_mgl", "ecoli_cfu_100ml"]).copy()

deux["chlore_suffisant"] = deux["free_residual_chlorine_mgl"] >= 0.2
deux["classe_risque"] = pd.cut(
    deux["ecoli_cfu_100ml"],
    bins=[-1, 0, 10, 100, np.inf],
    labels=["0 (conforme)", "1-10 (faible)", "11-100 (intermediaire)", ">100 (eleve)"],
)

tableau = pd.crosstab(
    deux["chlore_suffisant"],
    deux["classe_risque"],
    normalize="index",
).round(3)
tableau.index = ["sous 0,2 mg/L", "0,2 mg/L ou plus"]
tableau
```

```{python}
effectifs = pd.crosstab(deux["chlore_suffisant"], deux["classe_risque"])
effectifs.index = ["sous 0,2 mg/L", "0,2 mg/L ou plus"]
effectifs
```

Le motif est net : les ménages au niveau de la valeur guide ou au-dessus sont
bien plus souvent exempts d'E. coli détectable, et aucun n'atteint la classe de
risque élevé.

## Est-ce davantage que du bruit d'échantillonnage ?

```{python}
from scipy.stats import chi2_contingency

conformes = pd.crosstab(
    deux["chlore_suffisant"], deux["ecoli_cfu_100ml"] == 0
)
chi2, p, dof, attendu = chi2_contingency(conformes)

taux_bas = conformes.loc[False, True] / conformes.loc[False].sum()
taux_haut = conformes.loc[True, True] / conformes.loc[True].sum()

print(f"conformes, chlore sous 0,2   : {taux_bas:.1%}")
print(f"conformes, chlore >= 0,2     : {taux_haut:.1%}")
print(f"ecart                        : {taux_haut - taux_bas:+.1%}")
print(f"p du khi-deux                : {p:.2e}")
```

Deux réserves sur cette valeur p. Elle indique que l'association est peu
susceptible d'être due au hasard ; elle n'indique pas que la chloration l'a
*causée*, car les ménages qui chlorent diffèrent de ceux qui ne le font pas par
des caractéristiques que cette enquête ne relève pas. Et sur quelques centaines
d'observations, une valeur p aussi faible traduit surtout l'ampleur de l'écart :
c'est la taille d'effet qu'il faut rapporter, non la valeur p.

## Où se trouvent les ménages non testés

```{python}
par_district = wash.groupby("district").agg(
    menages=("household_id", "size"),
    chlore_teste=("free_residual_chlorine_mgl", lambda s: s.notna().mean()),
    ecoli_teste=("ecoli_cfu_100ml", lambda s: s.notna().mean()),
).round(3)
par_district
```

Si la couverture des tests diffère selon le district, une comparaison de la
qualité de l'eau entre districts est en partie une comparaison de qui a été
testé. Vérifiez-le avant de classer quoi que ce soit.

## Ce qu'il faut rapporter

Le constat, le dénominateur sur lequel il repose, et le fait que le
sous-ensemble testé surreprésente les ménages qui traitent leur eau. Puis la
recommandation sur laquelle le programme peut effectivement agir — ici, tester
davantage de ménages, et non conclure fermement sur la chloration à partir de
320 d'entre eux.
