Leçon 3 sur 8
Unité · Une ligne par quoi ?
Une ligne par quoi ?
La granularité d'une table décide du sens de tout nombre qu'on en tire. Ménages contre personnes, épisodes contre patients, et le plusieurs-à-plusieurs qui transforme 2 403 lignes en 13 004 sans que personne l'ait décidé.
Énoncez la granularité à voix haute
La granularité d’une table, c’est ce qu’est une ligne. Un entretien de ménage. Un enfant dépisté un jour donné. Un couple formation-mois-antigène. Un épisode d’admission.
Cela ressemble à une formalité. C’est la phrase la plus utile que vous puissiez écrire au-dessus d’une table, car presque toute défaillance de jointure et presque toute dispute sur un dénominateur sont en réalité deux personnes qui tiennent en tête deux granularités différentes.
# grain: one household interview
wash = pd.read_csv("wash-household-survey-2024.v1.csv")
# grain: one student per school day
attendance = pd.read_csv("school-attendance-2024.v1.csv")
# grain: one facility x month x antigen
vax = pd.read_csv("vaccination-coverage-2024.v1.csv")
# grain: one household interview
wash <- read_csv("wash-household-survey-2024.v1.csv")
# grain: one student per school day
attendance <- read_csv("school-attendance-2024.v1.csv")
# grain: one facility x month x antigen
vax <- read_csv("vaccination-coverage-2024.v1.csv")
Trois commentaires, et ils préviendront plus de dégâts que toutes les assertions de ce cours. Une jointure n’est sûre que si vous savez nommer la granularité des deux côtés et celle du résultat.
La granularité décide du sens du nombre
L’enquête EAH couvre 2 403 ménages et 13 004 personnes, soit une taille moyenne de 5,52. Deux phrases parfaitement exactes sortent de ce fichier.
sized = wash[wash["household_size"].notna() & wash["litres_per_person_day"].notna()]
by_household = (sized["litres_per_person_day"] < 15).mean()
by_person = (
sized.loc[sized["litres_per_person_day"] < 15, "household_size"].sum()
/ sized["household_size"].sum()
)
print(f"households below 15 L/p/d: {by_household:.1%}")
print(f"people in those households: {by_person:.1%}")
sized <- wash |> filter(!is.na(household_size), !is.na(litres_per_person_day))
sized |>
summarise(
by_household = mean(litres_per_person_day < 15),
by_person = sum(household_size[litres_per_person_day < 15]) / sum(household_size)
)
13,3 % des ménages, 13,5 % des personnes. Et sur la défécation à l’air libre, 13,4 % des ménages contre 13,9 % des personnes.
Les écarts sont ici faibles, et cela mérite d’être dit franchement — sur ce fichier le choix déplace à peine le nombre. Il déplace ce que le nombre est. Sphere énonce son standard de quantité d’eau par personne, donc c’est le chiffre par personne qui répond au standard ; un chiffre par ménage répond à « combien de ménages sont concernés », ce dont a besoin un plan de distribution.
Là où l’écart n’est pas faible est précisément là où cela compte le plus. Les grands ménages manquent systématiquement plus d’eau par personne, si bien que dans une enquête où la taille des ménages varie davantage, les deux chiffres se séparent — et rapporter alors le chiffre par ménage face à un standard par personne sous-estime le problème.
Indiquez la granularité dans le libellé. share_of_households_below_15l et
share_of_people_below_15l ne peuvent pas être confondus ;
water_below_minimum le peut.
Développer les ménages en personnes
Il arrive qu’il faille réellement une ligne par personne — pour joindre à une structure par âge et sexe, pour alimenter une pondération individuelle, ou pour calculer un dénominateur de population à partir de l’enquête elle-même.
people = wash.loc[wash["household_size"].notna()].copy()
people["household_size"] = people["household_size"].astype(int)
people = people.loc[people.index.repeat(people["household_size"])]
people["person_index"] = people.groupby("household_id").cumcount() + 1
print(len(wash), "households ->", len(people), "people")
people <- wash |>
filter(!is.na(household_size)) |>
tidyr::uncount(household_size, .remove = FALSE, .id = "person_index")
cat(nrow(wash), "households ->", nrow(people), "people\n")
2 403 lignes deviennent 13 004. Trois choses à retenir de cette opération.
- C’est une affirmation, pas une transformation. Chaque personne du ménage est désormais supposée avoir l’accès à l’eau, l’assainissement et l’hygiène du ménage. Pour la quantité d’eau c’est raisonnable ; pour « qui va chercher l’eau » c’est faux.
- Rien des individus n’est réel.
person_indexest une position, pas une personne. Ne désagrégez pas dessus, et ne le laissez pas ressembler à un identifiant. - Les quarante-six ménages sans taille enregistrée disparaissent. Dites-le, ou la population développée est discrètement amputée de 46 ménages par rapport à l’enquête dont elle vient.
Pondérer vaut généralement mieux que développer
Le développement est gourmand en mémoire et facile à mal employer. Lorsque vous n’avez besoin que de statistiques pondérées par la population, pondérez.
sized["weight"] = sized["household_size"]
weighted_share = (
(sized["litres_per_person_day"] < 15) * sized["weight"]
).sum() / sized["weight"].sum()
sized |>
summarise(share = weighted.mean(litres_per_person_day < 15, w = household_size))
Même réponse, une seule table, et la pondération est visible en tant que colonne — ce qui permet à un relecteur de voir sur quoi vous avez pondéré. Une table développée cache la pondération dans le nombre de lignes.
Agrégez à la granularité avant de joindre
C’est la règle opérationnelle pour laquelle toute la leçon existe.
Vous avez un fichier de présence journalière et vous voulez un résumé par élève joint à la liste. Le mauvais ordre consiste à joindre d’abord et agréger ensuite — cela fonctionne, mais fait traverser la jointure à 70 245 lignes et rend la relation plusieurs-à-un alors qu’elle n’avait pas à l’être.
per_student = (
attendance.assign(present=attendance["present"].map({"true": True, "false": False}))
.groupby("student_id")
.agg(days_marked=("present", "size"),
days_present=("present", "sum"))
.reset_index()
)
per_student["attendance_rate"] = per_student["days_present"] / per_student["days_marked"]
# grain: one student. Now the join is one-to-one.
summary = roster.merge(per_student, on="student_id", how="left", validate="one_to_one")
per_student <- attendance |>
summarise(
days_marked = sum(present %in% c(TRUE, FALSE)),
days_present = sum(present %in% TRUE),
.by = student_id
) |>
mutate(attendance_rate = days_present / days_marked)
summary <- roster |>
left_join(per_student, by = "student_id", relationship = "one-to-one")
Deux gains, et c’est le second qui compte vraiment. La jointure est désormais
one_to_one, donc le contrôle le plus fort possible s’applique. Et l’agrégation
est écrite là où l’on voit son dénominateur — days_marked compte les jours où
une marque existe, ce qui n’est pas la même chose que les jours d’ouverture de
l’école, et l’avant-dernière leçon est entièrement consacrée à cette distinction.
Le plusieurs-à-plusieurs que personne n’a choisi
Un plusieurs-à-plusieurs n’est presque jamais voulu. Il survient quand la clé n’est pas celle que vous croyiez.
# household file: one row per household. member file: one row per person.
# Both carry `community`. Joining on it instead of household_id:
bad = households.merge(members, on="community", how="inner")
bad <- households |> inner_join(members, by = "community")
Chaque ménage d’une communauté s’apparie désormais à chaque personne de cette communauté. Quatre cents ménages de six personnes, dans une seule communauté, produisent 400 × 2 400 lignes — neuf cent soixante mille — à partir d’un fichier dont la granularité honnête est de 2 400 personnes.
L’indice est toujours le même — un nombre de lignes qui est un produit et non une
somme, et un total dont l’ordre de grandeur est suspicieusement rond.
validate="one_to_many" l’attrape avant que vous ayez à le remarquer.
Des épisodes, pas des personnes
Une dernière granularité à nommer, car c’est là que dérapent les chiffres des programmes de traitement. Dans un registre PCIMA, une ligne est en général un épisode d’admission, pas un enfant. Un enfant réadmis après rechute a deux lignes.
Ce qui implique ceci.
- La charge de cas est un décompte d’épisodes. Deux admissions du même enfant sont deux admissions, et les rapporter comme une seule sous-estime le travail accompli.
- Les enfants atteints est un décompte d’enfants distincts, plus petit.
- Le taux de guérison a des épisodes au numérateur et au dénominateur, et mélanger un numérateur d’épisodes avec un dénominateur d’enfants produit un taux supérieur à 100 % que quelqu’un passera une après-midi à expliquer.
print("episodes:", len(admissions))
print("children:", admissions["child_id"].nunique())
print("readmitted:", (admissions.groupby("child_id").size() > 1).sum())
c(episodes = nrow(admissions),
children = n_distinct(admissions$child_id),
readmitted = sum(count(admissions, child_id)$n > 1))
Publier les deux nombres, avec leurs libellés, met fin à la discussion avant qu’elle commence.
Inscrivez la granularité dans le nom du fichier
Une dernière petite habitude qui rapporte. Quand vous enregistrez une table intermédiaire, mettez la granularité dans son nom.
outputs/tables/attendance_by_student.csv
outputs/tables/attendance_by_school_month.csv
outputs/tables/coverage_by_facility_period_antigen.csv
Six mois plus tard, summary.csv ne vous dit rien et chacun de ceux-ci vous dit
tout. Cela rend aussi une mauvaise jointure évidente au moment où quelqu’un lit
deux noms de fichiers côte à côte.
La suite
La granularité répond à la question de ce qu’est une ligne. La leçon suivante
répond à celle de ce qu’est une colonne — le groupe répété aplati qu’une
plateforme de collecte exporte en symptom_1, symptom_2, symptom_3, et le
pivot qui le retransforme en lignes que l’on peut compter.