Leçon 6 sur 8
Unité · Des valeurs qui ne peuvent pas être vraies
Six districts, trois districts
Des noms de lieux en texte libre face à une liste administrative — normaliser, apparier à l'identique, relire le reliquat, et stocker le résultat dans un fichier de correspondance plutôt que dans une suite de remplacements. Plus l'assertion qui arrête la cinquième graphie du prochain tour.
Le tableau à six lignes pour trois districts
Regroupez l’enquête ménage EAH par district et voici ce qui revient.
| district | Ménages | Sous 15 L/personne/jour |
|---|---|---|
| NORD-OUEST | 33 | 21,2 % |
| nord-ouest | 20 | 20,0 % |
| Sud-Est | 803 | 16,4 % |
| Nord-Ouest | 727 | 14,7 % |
| Nord Ouest | 22 | 9,1 % |
| Centre | 798 | 8,9 % |
Six lignes. Il y a trois districts. Une équipe d’enquêteurs a écrit Nord-Ouest de quatre façons différentes, et comme les variantes se trient séparément, les 802 ménages de ce district sont découpés en morceaux de 727, 33, 22 et 20.
Lisez maintenant le tableau comme le ferait une réunion de coordination. Trié du pire au meilleur, NORD-OUEST est le district prioritaire à 21,2 % — un chiffre calculé sur trente-trois ménages, la plus petite cellule du tableau et la plus facilement déplacée par une poignée d’entretiens. Le vrai Nord-Ouest, ses 802 ménages, est à 15,0 % et arrive deuxième derrière Sud-Est.
Personne ne vous dira que cela s’est produit. Rien n’échoue. Le tableau a l’air correct, il répond seulement à une autre question que celle posée.
La liste administrative fait autorité
La première décision porte sur les noms qui font foi, et la réponse n’est jamais « ceux qui sont dans les données ». Chaque pays dispose d’une hiérarchie administrative officielle — admin 1, admin 2, admin 3 — publiée avec des codes. Dans les opérations humanitaires ce sont les p-codes, diffusés par le Humanitarian Data Exchange et utilisés par tous les clusters, et tout leur intérêt est qu’un code n’a pas de variantes orthographiques.
admin = pd.DataFrame([
{"admin1_pcode": "HT07", "admin1_name": "Nord-Ouest"},
{"admin1_pcode": "HT05", "admin1_name": "Centre"},
{"admin1_pcode": "HT09", "admin1_name": "Sud-Est"},
])
admin <- tibble::tribble(
~admin1_pcode, ~admin1_name,
"HT07", "Nord-Ouest",
"HT05", "Centre",
"HT09", "Sud-Est"
)
Trois conséquences découlent du fait de traiter cette liste comme l’autorité, et elles donnent la forme du reste de la leçon.
- Une valeur des données qui ne se résout pas dans la liste est non appariée, pas fausse. Ce peut être une variante d’écriture, une unité administrative nouvelle, ou un lieu réel que la liste ne couvre pas.
- Le produit de l’appariement est un code, et tout l’aval joint sur le code.
- C’est la liste, et non les données, qui décide du nombre de districts. Un tableau à six lignes échoue à un contrôle au lieu d’être rapporté.
Normalisez, puis appariez à l’identique
La plupart des variantes ne sont pas vraiment des noms différents. Retirez ce qui ne porte pas de sens et elles se rejoignent.
def match_key(series):
return (
series.fillna("")
.str.normalize("NFKD")
.str.encode("ascii", "ignore").str.decode("ascii")
.str.lower()
.str.replace(r"[^a-z0-9]+", " ", regex=True)
.str.strip()
)
wash["district_key"] = match_key(wash["district"])
admin["key"] = match_key(admin["admin1_name"])
matched = wash.merge(
admin[["key", "admin1_pcode", "admin1_name"]],
left_on="district_key", right_on="key", how="left",
)
print(matched["admin1_pcode"].isna().sum(), "rows unmatched")
match_key <- function(x) {
x |>
tidyr::replace_na("") |>
stringi::stri_trans_general("Latin-ASCII") |>
tolower() |>
stringr::str_replace_all("[^a-z0-9]+", " ") |>
stringr::str_squish()
}
wash <- wash |> mutate(district_key = match_key(district))
admin <- admin |> mutate(key = match_key(admin1_name))
matched <- wash |> left_join(admin, by = c("district_key" = "key"))
sum(is.na(matched$admin1_pcode))
La casse, les accents et la distinction trait d’union / espace expliquent
chacune des quatre variantes de Nord-Ouest ici. NORD-OUEST, nord-ouest,
Nord Ouest et Nord-Ouest se normalisent tous en nord ouest, et la jointure
est exacte. Zéro ligne non appariée.
C’est le cas courant et il vaut la peine de l’intérioriser — l’appariement approximatif vient après la normalisation, pas à sa place. Une grande partie de ce qui ressemble à un problème d’appariement difficile est de la ponctuation.
Le reliquat, c’est le vrai travail
Quand l’appariement exact ne solde pas tout — et sur des noms de villages cela n’arrive jamais — le reste est ce qu’il faut traiter.
unmatched = (
matched[matched["admin1_pcode"].isna()]
.groupby("district")
.size()
.sort_values(ascending=False)
)
print(unmatched)
matched |>
filter(is.na(admin1_pcode)) |>
count(district, sort = TRUE)
Traitez la liste par nombre de lignes, pas par ordre alphabétique. Les valeurs non appariées forment presque toujours une tête très courte et une longue traîne — deux ou trois variantes couvrant l’essentiel des lignes, puis des cas isolés. Solder la tête prend dix minutes et récupère la majeure partie des données.
Pour la traîne, produisez des candidats plutôt que des décisions.
from rapidfuzz import process, fuzz
for value in unmatched.index:
best = process.extract(
match_key(pd.Series([value]))[0],
admin["key"].tolist(),
scorer=fuzz.ratio,
limit=3,
)
print(value, "->", best)
for (value in unique(unmatched$district)) {
scores <- stringdist::stringsim(match_key(value), admin$key, method = "jw")
cat(value, "->", admin$admin1_name[order(-scores)][1:3], "\n")
}
Les appariements qu’il faut refuser
L’appariement approximatif sur des noms de lieux est plus dangereux qu’il n’y paraît, car les unités administratives sont fréquemment nommées les unes d’après les autres, et d’après les mêmes saints, rivières et administrateurs coloniaux. Deux communes réelles et distinctes peuvent différer d’un seul caractère.
Trois habitudes qui évitent l’erreur coûteuse.
- Contraignez par l’unité parente. Un village n’a besoin d’être apparié qu’aux villages de sa propre commune, et une commune qu’aux communes de son propre département. Cela élimine gratuitement la plupart des faux candidats et c’est pourquoi on apparie la hiérarchie de haut en bas.
- Refusez l’acceptation automatique en dessous d’un seuil élevé. Une quasi correspondance sur un nom de deux syllabes n’est pas une preuve. Lorsque les deux meilleurs candidats sont à quelques points l’un de l’autre, la réponse est « à relire », pas « le premier ».
- Laissez non apparié ce qui n’est pas apparié. Une ligne sans district est honnête et apparaît dans le tableau de complétude. Une ligne attribuée au mauvais district est invisible et déplace deux chiffres.
Le mode de défaillance n’est pas que le script n’arrive pas à apparier. C’est que le script apparie quelque chose, et que le ménage finit compté dans un district où il n’est pas.
La correspondance est un fichier, pas une suite de remplacements
Le réflexe est un replace ou un case_when. Résistez-y.
mapping = pd.read_csv("reference/district-mapping.csv") # raw_value, admin1_pcode, decided_by, decided_on
wash = wash.merge(mapping, left_on="district", right_on="raw_value", how="left")
mapping <- readr::read_csv(here::here("reference", "district-mapping.csv"))
wash <- wash |> left_join(mapping, by = c("district" = "raw_value"))
Un fichier de correspondance l’emporte sur une suite de remplacements pour quatre raisons, et c’est la dernière qui compte vraiment.
- Il est relisable par quelqu’un qui ne lit pas le code — en général la personne qui connaît les districts.
- Il est réutilisable par le notebook, le tableau de bord et le script du prochain tour.
- Il est comparable, si bien qu’ajouter une variante apparaît en revue comme une seule ligne.
- Il porte qui a décidé.
decided_byetdecided_ontransforment une correspondance d’artefact technique en trace, et lorsqu’on demandera en octobre pourquoiNord Ouesta été rattaché àHT07, le fichier répond.
Appariez une fois, joignez sur le code pour toujours
Après l’appariement, retirez le nom en texte libre de tout l’aval et emportez le p-code.
wash = wash.drop(columns=["district", "district_key"]).rename(
columns={"admin1_pcode": "admin1"}
)
wash <- wash |> select(-district, -district_key) |> rename(admin1 = admin1_pcode)
C’est l’étape qui fait que le problème reste résolu. Les dénominateurs de population, l’enquête du tour précédent, la matrice 4W du cluster et les données géographiques de la carte se joignent tous sur le code sans jamais recomparer un nom. Deux jeux de données porteurs de p-codes se joignent correctement du premier coup, ce qui est toute la raison d’être de ces codes.
L’assertion qui attrape la cinquième graphie du prochain tour
Tout ce qui précède nettoie le fichier que vous avez. Une ligne empêche le même défaut d’arriver inaperçu au trimestre suivant.
EXPECTED_DISTRICTS = {"HT05", "HT07", "HT09"}
seen = set(wash["admin1"].dropna())
assert seen <= EXPECTED_DISTRICTS, f"unexpected districts: {seen - EXPECTED_DISTRICTS}"
assert wash["admin1"].isna().sum() == 0, "rows with no district after mapping"
EXPECTED_DISTRICTS <- c("HT05", "HT07", "HT09")
stopifnot(
all(wash$admin1 %in% EXPECTED_DISTRICTS),
!any(is.na(wash$admin1))
)
Remarquez ce que cela fait lorsque le programme s’étend réellement à un quatrième district — cela échoue. C’est correct. Un nouveau district est une chose dont quelqu’un devrait vous informer, et un script qui l’absorbe en silence calculera en silence un taux de couverture contre un dénominateur qui ne l’inclut pas.
La suite
Vous disposez maintenant de règles pour les valeurs et d’une correspondance pour les noms, appliquées l’une et l’autre à la main ce trimestre. L’unité suivante les fait tourner toutes seules — une suite de validation qui s’exécute à chaque lecture, rapporte les échecs avec leurs comptes, et arrête la chaîne avant qu’un mauvais export n’atteigne un tableau de bord.