cassionAnalyse de données

Leçon 6 sur 8

Unité · Faire coïncider les pièces

Les lignes qui n'ont jamais été écrites

Une ligne absente n'a aucune valeur à avoir manquante. Construire la grille que les données auraient dû remplir, retrouver où sont passées 1 755 lignes de présence, et comprendre pourquoi une grille complète ne prouve pas que tout le monde a rapporté.

PythonR90 minDéfinitions d'indicateurs de l'UNICEFObjectifs de développement durable (ODD)

Une ligne absente n’a aucune valeur à avoir manquante

Tous les contrôles du cours de nettoyage regardaient des valeurs — cases vides, sentinelles, nombres implausibles. Tous partagent une hypothèse : que la ligne est là.

La défaillance traitée ici est d’une autre nature. Une école fermée n’a aucune ligne de présence. Une formation sanitaire qui n’a pas rapporté n’a aucune ligne dans l’extraction. Un mois sans distribution n’a rien du tout. Il n’y a aucune case vide à compter, aucun NA à détecter, et tous les résumés que vous calculez portent sur les périodes qui se trouvent avoir été enregistrées.

Le remède a toujours la même forme — construire la grille des périodes qui devraient exister, y joindre les données, et laisser les trous apparaître sous forme de lignes.

Construire la grille

students = roster["student_id"].drop_duplicates()
school_days = attendance["attendance_date"].drop_duplicates()

grid = pd.MultiIndex.from_product(
    [students, school_days], names=["student_id", "attendance_date"]
).to_frame(index=False)

complete = grid.merge(attendance, on=["student_id", "attendance_date"], how="left")

print(f"grid {len(grid):,}, actual {len(attendance):,}, "
      f"missing {len(grid) - len(attendance):,}")
grid <- tidyr::expand_grid(
  student_id = unique(roster$student_id),
  attendance_date = unique(attendance$attendance_date)
)

complete <- grid |> left_join(attendance, by = c("student_id", "attendance_date"))

cat(sprintf("grid %d, actual %d, missing %d\n",
            nrow(grid), nrow(attendance), nrow(grid) - nrow(attendance)))

1 200 élèves par 60 jours de classe font une grille de 72 000. Le fichier en contient 70 245. 1 755 lignes n’ont jamais été écrites.

dplyr offre un chemin plus court lorsque la trame est déjà les données.

complete <- attendance |>
  tidyr::complete(student_id, attendance_date)

complete() remplit le produit cartésien des valeurs qu’il trouve, ce qui est juste quand toutes les combinaisons devraient exister et faux quand un élève a rejoint l’école en cours de trimestre — il n’inventera pas de jours pour un élève que le fichier ne mentionne pas avant mars. Construisez la grille explicitement quand l’univers est défini hors des données, ce qui est le cas habituel.

Où sont passées les 1 755

missing = complete[complete["present"].isna()]
by_school = (
    missing.merge(roster[["student_id", "school_id"]], on="student_id")
    .groupby("school_id")
    .agg(missing_rows=("present", "size"),
         days=("attendance_date", "nunique"))
)
print(by_school)
complete |>
  filter(is.na(present)) |>
  left_join(select(roster, student_id, school_id), by = "student_id") |>
  summarise(missing_rows = n(), days = n_distinct(attendance_date), .by = school_id)
school_id lignes manquantes jours
SCH07 915 15
SCH18 840 15

Deux écoles, quinze jours chacune, et 61 × 15 + 56 × 15 = 1 755. La grille rend compte de chacune des lignes manquantes, et sous une forme qui nomme la cause : deux écoles ont été fermées les mêmes quinze jours de mars.

C’est le bénéfice. Avant la grille, les lignes manquantes étaient invisibles. Après, ce sont deux écoles et une plage de dates — une grève, une inondation, une période d’examens, quelque chose qu’un collègue peut confirmer en un coup de téléphone.

Absent n’est pas absent

Vient maintenant la décision, et c’est toute la raison d’être de cette leçon.

naive = complete["present"].fillna(False)
print("attendance rate treating gaps as absences:", naive.mean())
complete |> summarise(rate = mean(coalesce(present, FALSE)))

Remplir les trous par « absent » fait ressembler SCH07 et SCH18 à une urgence d’abandon scolaire, puisqu’un quart de leur trimestre est désormais enregistré comme tous les enfants manquants tous les jours. Le traitement correct est l’inverse : ces jours n’étaient pas des jours de classe pour ces écoles, et ils n’ont leur place ni au numérateur ni au dénominateur.

open_days = attendance.merge(roster[["student_id", "school_id"]], on="student_id")
school_calendar = open_days[["school_id", "attendance_date"]].drop_duplicates()

grid = (
    roster[["student_id", "school_id"]]
    .merge(school_calendar, on="school_id")
)
print(len(grid), "student-days the schools were actually open")
school_calendar <- attendance |>
  left_join(select(roster, student_id, school_id), by = "student_id") |>
  distinct(school_id, attendance_date)

grid <- roster |>
  select(student_id, school_id) |>
  left_join(school_calendar, by = "school_id", relationship = "many-to-many")

Construisez la grille par école, pas par district. L’univers des périodes est une propriété de l’unité de rapportage, et supposer un calendrier unique et partagé est la manière dont une fermeture devient une absence.

Décider si un trou est un zéro, une valeur manquante ou une période qui ne devrait pas exister n’est pas une question technique. C’est l’analyse, et cela a sa place dans le journal avec son motif.

Une grille complète ne prouve pas que tout le monde a rapporté

L’extraction vaccinale est le contre-exemple, et il compte parce qu’il ressemble au bon cas.

expected = (
    vax["facility_id"].nunique()
    * vax["period"].nunique()
    * vax["antigen"].nunique()
)
print(expected, "expected rows;", len(vax), "actual")
c(expected = n_distinct(vax$facility_id) * n_distinct(vax$period) * n_distinct(vax$antigen),
  actual = nrow(vax))

38 × 12 × 6 = 2 736, et le fichier compte 2 736 lignes. La grille est parfaite. Et 642 de ces lignes portent report_submitted à false et zéro dose.

Le contrôle de grille passe donc, et les données restent pleines de trous — ce sont simplement des trous entourés d’une ligne. Deux contrôles distincts, et il vous faut les deux : chaque ligne attendue est-elle présente, et chaque ligne présente contient-elle un rapport.

coverage = (
    vax[vax["report_submitted"]]
    .groupby(["period", "antigen"])
    .agg(doses=("doses_administered", "sum"), target=("target_population", "sum"))
)
reporting = vax.groupby(["period", "antigen"])["report_submitted"].mean()
vax |>
  summarise(
    reporting_rate = mean(report_submitted),
    doses  = sum(doses_administered[report_submitted]),
    target = sum(target_population[report_submitted]),
    .by = c(period, antigen)
  )

Des clés de période qui se trient

Un petit point mécanique qui cause des ennuis disproportionnés.

vax["period"] = pd.to_datetime(vax["period"])
vax["month"] = vax["period"].dt.strftime("%Y-%m")     # 2024-08, sorts correctly
vax <- vax |> mutate(month = format(period, "%Y-%m"))

Employez des clés de période ISO — 2024-08, 2024-Q3, 2024-W32 — partout où une période sert de clé. Aug, August et 08/2024 se trient mal, et 08/2024 est ambigu entre deux conventions toutes deux d’usage quotidien dans ce secteur.

Le mois doit porter son année. Une grille de douze mois indexée sur month seul fusionne silencieusement août 2023 et août 2024 dès que deux années de données se retrouvent dans le même dossier.

Aligner un registre journalier sur un agrégat mensuel

La dernière forme de cette leçon, et celle sur laquelle la suivante s’appuie. Pour comparer un registre ligne à ligne à une remontée mensuelle, agrégez le registre à la granularité de l’agrégat — jamais l’inverse.

monthly = (
    attendance.assign(month=attendance["attendance_date"].dt.strftime("%Y-%m"))
    .merge(roster[["student_id", "school_id"]], on="student_id")
    .groupby(["school_id", "month"])
    .agg(marks=("present", "size"),
         present=("present", "sum"))
    .reset_index()
)
monthly <- attendance |>
  mutate(month = format(attendance_date, "%Y-%m")) |>
  left_join(select(roster, student_id, school_id), by = "student_id") |>
  summarise(marks = n(), present = sum(present %in% TRUE), .by = c(school_id, month))

Deux choses à remarquer, car ce sont deux choix. Le mois auquel appartient un enregistrement vient de la date de l’événement, non de la date de soumission du formulaire — une remontée tardive appartient toujours au mois qu’elle décrit. Et le dernier mois du fichier est très souvent partiel, si bien qu’un graphique de tendance qui l’inclut paraît toujours en baisse.

last = monthly["month"].max()
print(f"excluding partial period {last}")
monthly = monthly[monthly["month"] < last]
monthly <- monthly |> filter(month < max(month))

Écartez-le ou signalez-le, mais ne le tracez jamais en silence à côté de mois complets.

La suite

Vous disposez maintenant d’un registre agrégé à la même granularité que la remontée mensuelle. La leçon suivante met les deux côte à côte, cherche pourquoi ils divergent, et transforme l’écart en un tableau publiable plutôt qu’en une discussion à gagner.

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.