cassionAnalyse de données

Leçon 7 sur 8

Unité · Produire et transmettre

Dates, âges et périodes de rapportage

L'âge en mois révolus calculé correctement plutôt qu'en divisant des jours, des périodes de rapportage qui survivent à un mois partiel, et l'arithmétique de frontière dont dépend un standard de croissance.

Python80 min

Pourquoi cela mérite une leçon entière

L’arithmétique des dates paraît être la partie facile et produit certaines des erreurs les plus coûteuses du secteur. Un âge en mois décide du standard de croissance auquel un enfant est comparé. Une période de rapportage décide si une formation sanitaire compte comme ayant rapporté. Une frontière de mois décide si une distribution tombe dans les chiffres de ce trimestre ou du suivant.

Aucun de ces cas n’échoue bruyamment.

Analyser la date, une fois, explicitement

import pandas as pd

muac = pd.read_csv(path)
muac["screening_date"] = pd.to_datetime(
    muac["screening_date"], format="%Y-%m-%d", errors="raise"
)

Deux habitudes à prendre :

  • Énoncez le format. Un format inféré peut changer d’un fichier à l’autre selon la composition des valeurs — la même colonne lue en jour-d’abord dans un export et en mois-d’abord dans le suivant.
  • Utilisez errors="raise". L’alternative, errors="coerce", transforme toute date illisible en valeur manquante : un fichier au mauvais format de date s’importe donc « avec succès », avec une colonne de dates entièrement vide.

01/02/2024 est soit le 1er février, soit le 2 janvier, et le fichier ne vous dira pas lequel. Si un export vous livre des dates ambiguës, dayfirst=True est une décision que vous documentez, non un réglage que vous acceptez.

L’âge en mois révolus

L’approche évidente divise des jours par un mois moyen :

# Faux assez souvent pour que cela compte.
age_months = ((visit_date - date_of_birth).dt.days / 30.4375).astype(int)

Sur les deux mille premiers jours de vie, ce calcul diverge de la bonne réponse sur 54 d’entre eux — toujours d’un mois, toujours à une frontière. Un enfant de 60 jours est rapporté à 1 mois alors qu’il en a 2.

Calculez directement les mois révolus :

def age_in_months(dob: pd.Series, visit: pd.Series) -> pd.Series:
    months = (visit.dt.year - dob.dt.year) * 12 + (visit.dt.month - dob.dt.month)
    # Le quantieme n'est pas atteint : le dernier mois n'est pas revolu.
    return months - (visit.dt.day < dob.dt.day).astype(int)
dob   = pd.to_datetime(["2022-03-15", "2022-03-16", "2022-02-28"])
visit = pd.to_datetime(["2024-03-15", "2024-03-15", "2024-03-01"])

age_in_months(pd.Series(dob), pd.Series(visit)).tolist()   # [24, 23, 24]

Le deuxième enfant est à un jour de ses deux ans : il a 23 mois, non 24. Ce seul jour est toute la raison d’être de cette fonction.

Pourquoi un mois compte ici

Les standards de croissance de l’OMS changent de mesure exactement à 24 mois. En dessous de 24 mois, l’enfant est mesuré couché et comparé à la courbe poids-pour-longueur ; à partir de 24 mois, debout, contre le poids-pour-taille. Les deux mesures diffèrent d’environ 0,7 cm et les deux courbes sont des tables différentes.

Un enfant poussé de 23 à 24 mois par une règle d’arrondi est comparé au mauvais standard, et son score Z se déplace. Faites cela à quelques centaines d’enfants près de la frontière et l’estimation de prévalence se déplace avec eux — c’est pourquoi le projet Analyse d’enquête SMART rapporte l’attraction des âges vers les années entières comme un constat de plausibilité et non comme une curiosité.

Les tranches d’âge sont l’autre endroit. 0-5, 6-23, 24-59 mois sont les tranches nutritionnelles standard, et un enfant à exactement 24 mois appartient à la troisième :

BANDS = [0, 6, 24, 60]
LABELS = ["0-5m", "6-23m", "24-59m"]

muac["age_band"] = pd.cut(
    muac["age_months"], bins=BANDS, labels=LABELS, right=False, include_lowest=True
)

right=False rend chaque intervalle [bas, haut) — fermé à gauche, ouvert à droite — de sorte que 24 mois tombe dans 24-59m et non dans 6-23m. Le comportement par défaut est l’inverse, et il est faux pour toutes les tranches d’âge employées dans ce secteur.

Vérifiez les frontières plutôt que de leur faire confiance :

check = muac.loc[muac["age_months"].isin([5, 6, 23, 24, 59, 60]),
                 ["age_months", "age_band"]].drop_duplicates().sort_values("age_months")
print(check)

Les périodes de rapportage

Une période est un mois, un trimestre ou une année pour lequel on rapporte, et ce n’est pas la même chose qu’une date.

muac["month"] = muac["screening_date"].dt.to_period("M")

by_month = muac.groupby("month").size()
2024-01    207
2024-02    391
...
2024-11    375
2024-12    156

Les périodes se trient correctement, s’affichent lisiblement et — contrairement à une chaîne comme "2024-01" — acceptent l’arithmétique :

current = pd.Period("2024-06", freq="M")
print(current - 1)                 # 2024-05
print(current.start_time, current.end_time)

Les chaînes se trient aussi correctement si vous employez l’ordre ISO, bonne raison d’utiliser %Y-%m et jamais %m/%Y. Mais une chaîne ne sait pas répondre à « le mois précédent », et cette question surgit dans tout calcul de tendance.

Les périodes partielles sont le piège

Ce registre court du 15 janvier au 13 décembre. Janvier porte 207 dépistages et décembre 156, contre une moyenne mensuelle proche de 375. Aucun de ces deux mois n’est une baisse ; les deux sont partiels.

first, last = muac["screening_date"].min(), muac["screening_date"].max()
print(first.date(), last.date())

muac["partial_period"] = muac["month"].isin(
    [first.to_period("M"), last.to_period("M")]
)

Étiquetez-les plutôt que de les supprimer. Un lecteur qui voit décembre s’effondrer sur un graphique conclut que le programme s’est écroulé ; un lecteur qui ne voit aucun décembre se demande où sont passées les données. Nommer le mois partiel est la seule option qui répond aux deux.

La même logique vaut pour une période inachevée. Un cumul en cours de mois comparé à des mois révolus est la façon la plus courante dont un tableau de bord affiche une baisse fictive tous les mois, et cela arrive parce que les deux s’appellent « le total mensuel ».

Aligner deux sources sur des périodes

L’extraction de vaccination enregistre une date de début de période plutôt qu’une date de prestation :

epi = pd.read_csv(path, parse_dates=["period"])
epi["month"] = epi["period"].dt.to_period("M")
print(epi["month"].nunique())      # 12

Convertir les deux sources en Period avant la jointure est ce qui les fait coïncider. Joindre une date à un début de période ne produit silencieusement aucune correspondance pour chaque mois où une source a utilisé le dernier jour et l’autre le premier.

Rééchantillonner une série temporelle

Quand l’index est un datetime, resample agrège par période sans colonne de regroupement manuelle :

daily = muac.set_index("screening_date")
weekly = daily.resample("W")["child_id"].size()
monthly = daily.resample("MS")["child_id"].size()      # MS = debut de mois

resample comble les trous : une semaine sans dépistage apparaît à zéro plutôt que d’être absente. C’est juste pour une série temporelle et faux pour un dénominateur — une école fermée quinze jours n’a pas de jours de présence, et non une présence nulle ; le projet Assiduité scolaire montre ce que coûte le remplissage.

Les durées

Soustraire deux datetimes donne un Timedelta :

referrals["days_to_service"] = (
    referrals["service_date"] - referrals["referral_date"]
).dt.days

.dt.days tronque vers zéro : un écart de 23 heures vaut 0 jour. Si le même jour compte — et pour un référencement en protection, c’est le cas — dites-le explicitement plutôt que de laisser la troncature décider :

hours = (referrals["service_date"] - referrals["referral_date"]) / pd.Timedelta(hours=1)
referrals["same_day"] = hours < 24

Une durée négative est un constat de qualité des données, non une valeur dont on prend la valeur absolue :

impossible = referrals["days_to_service"] < 0
print(f"service saisi avant le referencement : {impossible.sum()}")

Les fuseaux horaires, brièvement

Les données de programme sont généralement des dates locales naïves et doivent le rester. Attacher un fuseau à une date de dépistage invite une conversion qui fera franchir minuit à un enregistrement et le fera basculer dans le mauvais mois de rapportage.

Là où des horodatages portent réellement un fuseau — heures de soumission des formulaires CommCare ou Kobo côté serveur — convertissez une fois, à la frontière, et stockez la date locale :

submissions["submitted_at"] = pd.to_datetime(
    submissions["submitted_at"], utc=True
).dt.tz_convert("America/Port-au-Prince")

submissions["submission_date"] = submissions["submitted_at"].dt.date

L’horodatage de soumission et la date de la visite qu’il enregistre sont deux choses différentes, et rapporter la première quand on veut dire la seconde décale toute soumission du soir vers le lendemain.

Ce qui vient ensuite

Chaque colonne dit désormais ce qu’elle signifie, y compris celles faites de dates. La dernière leçon transforme l’analyse en un script qu’un autre chargé pourra exécuter au trimestre prochain sur un autre export, sans rien modifier d’autre que ses arguments.

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.