cassionAnalyse de données

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.

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

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.

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.