Leçon 2 sur 8
Unité · Comment la base est structurée
La hiérarchie qui bouge sous vos pieds
Unités d'organisation, niveaux et groupes, et la propriété dont personne ne vous prévient — l'arbre est courant et non historique, si bien qu'une formation réaffectée en mars réécrit en silence les totaux de district de l'an dernier.
Un arbre, et tout y est accroché
Toute valeur du système est rattachée à une unité d’organisation, et les unités forment un arbre unique — pays, région, district, formation sanitaire. Le niveau où siège une unité est une propriété de l’arbre, non une colonne de l’unité.
Level 1 Country
Level 2 Region
Level 3 District
Level 4 Facility <- our 38 units
L’agrégation est le parcours de l’arbre vers le haut. Demandez un chiffre de district et le système somme toutes les formations situées dessous ; demandez un chiffre national et il somme tous les districts. C’est la même discipline « agréger à la granularité » qu’enseignait le cours sur les jointures, avec une granularité fournie par une hiérarchie que quelqu’un d’autre entretient.
import pandas as pd
vax = pd.read_csv("vaccination-coverage-2024.v1.csv", parse_dates=["period"])
print(f"{vax['facility_id'].nunique()} facilities in the extract")
library(dplyr)
n_distinct(vax$facility_id)
Trente-huit, toutes au niveau 4. L’extraction ne porte aucun parent, ce qui est le cas habituel et la raison pour laquelle la plupart des totaux de district de ce secteur se calculent en joignant une liste de formations plutôt que par le système.
Les groupes ne sont pas des niveaux
L’extraction a une colonne facility_type, et il vaut la peine d’être clair sur
ce qu’elle est.
by_type = (
vax.groupby("facility_type")["facility_id"].nunique().sort_values(ascending=False)
)
print(by_type)
vax |> summarise(facilities = n_distinct(facility_id), .by = facility_type)
| Type de formation | Formations |
|---|---|
| Poste de santé | 20 |
| Centre de santé | 16 |
| Hôpital de district | 2 |
C’est un groupe d’unités d’organisation, non un niveau. La distinction compte de trois façons pratiques.
- Une unité a exactement un parent et un nombre quelconque de groupes. Poste de santé est un groupe ; cela ne dit pas où se trouve la formation.
- Les groupes peuvent se chevaucher. Une formation peut être à la fois « poste de santé », « difficile d’accès » et « appuyée par le partenaire X », et agréger sur deux groupes qui se chevauchent compte deux fois.
- Les jeux de groupes sont la version sûre. Un jeu de groupes est un ensemble de groupes mutuellement exclusifs — le type de formation en est généralement un — et agréger sur un jeu de groupes est sûr là où agréger sur un groupe quelconque ne l’est pas.
Demandez si ce sur quoi vous regroupez est un jeu de groupes. Si ce n’en est pas un, vérifiez que les groupes partitionnent avant de sommer.
La propriété dont personne ne vous prévient
Voici celle qui coûte un trimestre.
DHIS2 stocke la hiérarchie courante, non une hiérarchie historique. Une valeur est rattachée à une formation. La formation est rattachée à un parent maintenant. Il n’existe, dans le modèle de données ordinaire, aucune trace du parent qu’elle avait en mars.
Aussi, lorsqu’une formation est réaffectée du district A au district B — révision de limites, réorganisation, correction — toutes ses données historiques la suivent. Le total du district A pour janvier dernier, déjà rapporté et déjà dans le tableur d’un bailleur, change dès que quelqu’un le redemande.
Trois symptômes, tous d’allure de problèmes de qualité et aucun n’en étant un.
- Un total de district différent de la même requête lancée six mois plus tôt, sans aucune saisie entre-temps.
- Un chiffre annuel publié qu’on ne peut pas reproduire.
- Deux rapports de la même période qui divergent, tous deux exacts le jour où ils ont été produits.
Que faire
Vous ne pouvez pas empêcher la hiérarchie de bouger. Vous pouvez l’empêcher de déplacer vos chiffres en silence.
Figez la hiérarchie avec l’extraction. Tirez la liste des unités d’organisation en même temps que les données et stockez-la à côté des valeurs.
org_units = pd.read_csv("outputs/extract/2026-07/org-units.csv")
# ea/facility id, name, parent_id, level, extracted_on
pinned = vax.merge(org_units[["facility_id", "district_id"]],
on="facility_id", how="left", validate="many_to_one")
assert pinned["district_id"].notna().all(), "facility with no district in the pinned tree"
org_units <- readr::read_csv(here::here("outputs", "extract", "2026-07", "org-units.csv"))
pinned <- vax |>
left_join(select(org_units, facility_id, district_id), by = "facility_id",
relationship = "many-to-one")
stopifnot(!any(is.na(pinned$district_id)))
La jointure est many_to_one et l’assertion n’est pas décorative. Une formation
présente dans les données et absente de l’arbre figé signifie que l’arbre a été
tiré à un autre moment que les données, précisément la situation que ceci doit
prévenir.
Agrégez depuis l’arbre figé, pas depuis l’arbre vivant. Un chiffre publié en mars est alors reproductible en septembre, car l’arbre qu’il a employé est sur disque.
Consignez la date d’extraction sur les deux. L’avant-dernière leçon en fait une propriété du script d’extraction plutôt qu’une discipline.
La réaffectation, reconstituée
Là où la date de réaffectation est connue — et quelqu’un la connaît toujours, même si le système ne la connaît pas — le traitement honnête consiste à la porter explicitement.
reassignments = pd.DataFrame([
{"facility_id": "FAC017", "from_district": "D01", "to_district": "D02",
"effective": "2024-04-01", "reason": "boundary revision"},
])
def district_at(facility, period, log=reassignments):
moves = log[(log["facility_id"] == facility)
& (pd.to_datetime(log["effective"]) <= period)]
return moves["to_district"].iloc[-1] if len(moves) else None
reassignments <- tibble::tribble(
~facility_id, ~from_district, ~to_district, ~effective, ~reason,
"FAC017", "D01", "D02", as.Date("2024-04-01"), "boundary revision"
)
C’est une dimension à évolution lente, et la traiter comme telle est la bonne réponse. C’est aussi plus de travail que la plupart des équipes n’en feront, si bien que le repli est un étiquetage honnête.
Les totaux de district sont calculés sur la hiérarchie d’unités d’organisation au 28/07/2026. Une formation a été réaffectée de D01 à D02 en avril 2024 ; ses données 2024 figurent sous D02 sur toute l’année, y compris pour les périodes antérieures à la réaffectation.
Cette phrase est le livrable. Elle ne coûte rien et transforme un chiffre irreproductible en chiffre reproductible.
Agréger vers le haut sans compter deux fois
Deux modes de défaillance quand vous faites l’agrégation vous-même.
Une formation apparaissant deux fois dans l’arbre. Impossible dans DHIS2 — une unité a un parent — mais tout à fait possible dans le CSV de formations que quelqu’un vous a envoyé, où une formation ayant déménagé figure sous les deux districts.
duplicated = org_units["facility_id"].duplicated(keep=False)
assert not duplicated.any(), org_units.loc[duplicated, ["facility_id", "district_id"]]
stopifnot(!any(duplicated(org_units$facility_id)))
Sommer entre niveaux. Un total de district plus ses formations, c’est le district compté deux fois. Cela arrive quand une extraction mélange les niveaux — demandez le seul niveau 4, et vérifiez.
print(org_units["level"].value_counts())
org_units |> count(level)
Toute extraction devrait être à exactement un niveau. Si ce n’est pas le cas, la première chose que fait votre script est de filtrer sur un seul, en disant lequel.
La couverture est un indicateur de niveau district
Un point du cours sur les enquêtes qui arrive ici avec son mécanisme. Les bassins de desserte se chevauchent et les gens traversent les limites pour se faire vacciner, si bien qu’un taux de couverture par formation est un numérateur d’une population sur un dénominateur d’une autre.
by_facility = (
vax[vax["report_submitted"] == True]
.query("antigen == 'penta3'")
.groupby("facility_id")
.agg(doses=("doses_administered", "sum"), target=("target_population", "sum"))
)
by_facility["coverage"] = by_facility["doses"] / by_facility["target"]
print(by_facility["coverage"].describe()[["min", "max"]].round(3))
vax |>
filter(report_submitted, antigen == "penta3") |>
summarise(coverage = sum(doses_administered) / sum(target_population),
.by = facility_id) |>
summarise(min = min(coverage), max = max(coverage))
La dispersion entre formations est bien plus large que toute différence réelle de prestation, car la traversée de limites est un bruit au niveau de la formation et s’annule au niveau du district. Agrégez au niveau où la traversée se produit à l’intérieur de l’unité, et rapportez les chiffres par formation comme une charge de travail plutôt que comme une couverture.
La suite
Vous savez placer une valeur dans l’espace. L’unité suivante la place dans le temps — types de période, à quelle période appartient une saisie tardive, et l’enregistrement de complétude qui décide si la valeur était attendue.