---
title: "Couverture et taux d'abandon par formation sanitaire"
subtitle: "Couverture vaccinale de routine, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## Ce que produit ce document

La couverture mensuelle et annuelle par antigène, le taux d'abandon entre penta1
et penta3 par formation sanitaire, et le signalement de sur-rapportage — appliqué
à un niveau d'agrégation où il signifie réellement quelque chose.

Tous les jeux de données de cette plateforme sont synthétiques. La couverture
présentée ne décrit aucun district réel.

## Mise en place

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

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "vaccination-coverage-2024.v1.csv"
)

epi = pd.read_csv(URL, dtype={"facility_id": "string"})
epi["period"] = pd.to_datetime(epi["period"])
epi["mois"] = epi["period"].dt.to_period("M")

print(f"{epi['facility_id'].nunique()} formations, "
      f"{epi['mois'].nunique()} mois, "
      f"{epi['antigen'].nunique()} antigenes")
epi.head()
```

## Un rapport non transmis n'est pas zéro enfant vacciné

Une formation qui n'a pas rapporté apparaît sous la forme d'une ligne avec
`report_submitted` à faux et zéro dose — et **non** comme une ligne absente.
Sommez les doses sans filtrer sur ce drapeau et vous affirmez qu'aucun enfant de
cette aire de responsabilité n'a été vacciné ce mois-là.

```{python}
non_transmis = ~epi["report_submitted"]
print(f"formations-mois non rapportees : {int(non_transmis.sum() / epi['antigen'].nunique())}")
print(f"lignes non rapportees a 0 dose : "
      f"{int((non_transmis & (epi['doses_administered'] == 0)).sum())} "
      f"sur {int(non_transmis.sum())}")
```

Toutes, sans exception. C'est là le piège : le zéro a l'apparence d'une donnée.

## Le dénominateur est annuel, le rapportage est mensuel

`target_population` est la cible **annuelle**, répétée sur chaque ligne. La
couverture mensuelle est le nombre de doses rapporté à la cible divisée par
douze. Diviser par le chiffre annuel complet sous-estime la couverture d'un
facteur douze, et c'est l'erreur la plus courante face à des données de cette
forme.

```{python}
MOIS_PAR_AN = 12

penta3 = epi[epi["antigen"] == "penta3"].copy()

mensuel = penta3.groupby("mois").apply(
    lambda g: pd.Series({
        "doses": g["doses_administered"].sum(),
        "cible_annuelle": g["target_population"].sum(),
        "completude": g["report_submitted"].mean(),
    }),
    include_groups=False,
)

mensuel["denominateur_annuel_faux"] = mensuel["doses"] / mensuel["cible_annuelle"]
mensuel["denominateur_mensuel_correct"] = mensuel["doses"] / (
    mensuel["cible_annuelle"] / MOIS_PAR_AN
)
mensuel.round(3)
```

La première colonne donne une couverture d'environ 5 %, qui se lirait comme un
effondrement catastrophique du programme. C'est une erreur d'arithmétique.

## L'abandon entre penta1 et penta3

L'abandon est la part des enfants qui commencent la série sans la terminer :
`(penta1 − penta3) / penta1`. C'est un meilleur signal de programme que la
couverture, car il ne dépend d'aucune estimation de population — les deux termes
proviennent du même registre.

```{python}
rapportees = epi[epi["report_submitted"]]

serie = (
    rapportees[rapportees["antigen"].isin(["penta1", "penta3"])]
    .pivot_table(
        index="facility_id", columns="antigen",
        values="doses_administered", aggfunc="sum",
    )
    .dropna()
)

serie["abandon"] = (serie["penta1"] - serie["penta3"]) / serie["penta1"]
serie = serie.sort_values("abandon", ascending=False)

print(f"abandon median : {serie['abandon'].median():.1%}")
serie.head(10).round(3)
```

L'abandon médian avoisine 14 %. Une formation rapportant un abandon proche de
zéro mérite autant de suspicion qu'une formation en rapportant un très élevé :
un abandon quasi nul signifie généralement que le décompte de penta3 a été
reconstitué à partir de celui de penta1 plutôt que compté.

## Le signalement de sur-rapportage, appliqué au bon niveau

Un penta3 supérieur au penta1 est impossible dans un calendrier réel : un enfant
ne peut pas recevoir la troisième dose sans la première. C'est le signal
classique de sur-rapportage.

Appliqué mois par mois, il ne sert à rien ici :

```{python}
par_mois = (
    rapportees[rapportees["antigen"].isin(["penta1", "penta3"])]
    .pivot_table(
        index=["facility_id", "mois"], columns="antigen",
        values="doses_administered",
    )
    .dropna()
)
par_mois["impossible"] = par_mois["penta3"] > par_mois["penta1"]

signalees_une_fois = par_mois.groupby("facility_id")["impossible"].any().sum()
print(f"formations signalees au moins une fois : {signalees_une_fois} "
      f"sur {epi['facility_id'].nunique()}")
```

Trente-sept sur trente-huit. Un contrôle qui signale la quasi-totalité du
district n'identifie rien : le bruit ordinaire d'un mois à l'autre franchit cette
ligne en permanence, parce que les deux doses sont administrées à des enfants
différents à des mois différents et que les décomptes n'ont aucune raison
d'évoluer de concert.

Le contrôle doit s'appliquer là où le bruit se compense :

```{python}
persistantes = serie[serie["abandon"] < 0]
print(f"formations avec penta3 > penta1 sur le total annuel : {len(persistantes)}")
persistantes.round(3)
```

Six formations. Voilà une liste sur laquelle un superviseur peut agir.

```{python}
mois_signales = par_mois.groupby("facility_id")["impossible"].agg(["sum", "size"])
mois_signales.columns = ["mois signales", "mois rapportes"]
mois_signales.loc[persistantes.index].sort_values("mois signales", ascending=False)
```

## La couverture par antigène, sur un dénominateur qui tient

```{python}
def couverture_annuelle(df):
    rapportees_seules = df[df["report_submitted"]]
    return (
        rapportees_seules.groupby("antigen")
        .apply(
            lambda g: g["doses_administered"].sum()
            / (g["target_population"].sum() / MOIS_PAR_AN),
            include_groups=False,
        )
        .rename("couverture")
        .to_frame()
        .round(3)
    )

couverture_annuelle(epi)
```

Le dénominateur ne compte ici que les formations ayant rapporté, ce qui maintient
numérateur et dénominateur sur le même ensemble de formations. C'est l'objet de
l'exemple sur l'ajustement pour la complétude, et c'est ce qui distingue une
couverture qui varie avec le rapportage d'une couverture qui varie avec la
vaccination.

## Ce qu'il faut rapporter

La couverture avec son dénominateur énoncé, l'abandon comme signal de programme
indépendant de toute estimation de population, et la liste de sur-rapportage
accompagnée du niveau d'agrégation auquel elle a été calculée. Un signalement qui
se déclenche sur trente-sept formations sur trente-huit n'est pas un constat :
c'est un contrôle mal spécifié.
