Leçon 4 sur 8
Unité · Identité et doublons
La même personne, deux fois, sous deux identifiants
L'appariement d'enregistrements quand rien ne joint — blocage, normalisation, distance d'édition et score. Douze paires candidates dans le registre de dépistage, six réelles, et quatre fabriquées par les âges manquants de la leçon 2.
Le doublon sans clé
Un enfant dépisté le matin, renvoyé chez lui, puis ramené l’après-midi par un autre accompagnant est enregistré deux fois sous deux identifiants. De même pour un ménage visité par deux enquêteurs dans la même rue. De même pour un bénéficiaire figurant sur trois listes de distribution sous trois graphies du même nom.
Aucun de ces cas ne partage de clé. duplicated() ne les trouvera jamais. Et ils
comptent — dans une charge de cas ils gonflent le numérateur, dans un taux de
couverture ils gonflent le numérateur sans le dénominateur, et dans un décompte
de bénéficiaires ils sont précisément ce qu’un audit cherche.
Les retrouver relève de l’appariement d’enregistrements, discipline différente du dédoublonnage. Le dédoublonnage supprime des lignes identiques. L’appariement décide si deux lignes qui ne sont pas identiques décrivent la même chose — et il peut se tromper dans les deux sens, raison pour laquelle le produit de cette leçon est une liste à faire relire par un humain, et non un tableau nettoyé.
Commencez par les attributs qui ne devraient pas coïncider
Le registre de dépistage n’a ni nom ni ménage. Il a une combinaison qui devrait être quasi unique par accident — commune, date, âge, sexe et mesure.
CANDIDATE_KEY = ["commune", "screening_date", "age_months", "sex", "muac_mm"]
candidates = (
muac.groupby(CANDIDATE_KEY, dropna=False)
.filter(lambda g: g["child_id"].nunique() > 1)
.sort_values(CANDIDATE_KEY)
)
print(candidates[["child_id"] + CANDIDATE_KEY].to_string(index=False))
CANDIDATE_KEY <- c("commune", "screening_date", "age_months", "sex", "muac_mm")
candidates <- muac |>
group_by(across(all_of(CANDIDATE_KEY))) |>
filter(n_distinct(child_id) > 1) |>
ungroup() |>
arrange(across(all_of(CANDIDATE_KEY)))
Douze groupes reviennent. Six sont le même enfant réinscrit sous un nouvel identifiant. Six sont deux enfants différents qui se trouvent coïncider.
Regardez les faux avant les vrais
| Commune | Date | Âge | Sexe | PB | Identifiants |
|---|---|---|---|---|---|
| Desdunes | 2024-01-20 | 36 | f | 134 | CH03315, CH09241 |
| Gros-Morne | 2024-06-10 | manquant | f | 131 | CH00576, CH02413 |
| Gros-Morne | 2024-06-10 | manquant | m | 123 | CH01302, CH04050 |
| Gros-Morne | 2024-06-13 | manquant | m | -99 | CH00519, CH00755 |
| Gros-Morne | 2024-06-14 | manquant | m | 137 | CH01558, CH02913 |
| Verrettes | 2024-11-09 | 26 | f | 127 | CH02193, CH03129 |
Quatre des douze viennent de Gros-Morne, la semaine du 10 juin, et toutes ont un âge manquant. C’est la semaine dont le formulaire était mal configuré — la non-réponse de la leçon 2 qui revient dans une autre leçon sous un autre déguisement.
Voici pourquoi. Regrouper sur une colonne manquante fait s’apparier chaque ligne manquante avec toutes les autres sur cette colonne. Le bloc s’effondre : au lieu de comparer des enfants du même âge, vous comparez tous les enfants d’âge inconnu. Dans une commune qui dépiste quatre-vingt-cinq enfants en une semaine, deux garçons ayant le même PB au millimètre n’a rien de surprenant.
Une colonne de blocage comportant des valeurs manquantes fabrique des appariements. Excluez les lignes manquantes du bloc ou bloquez sur autre chose — ne laissez jamais un trou compter comme un accord.
blocked = muac[muac["age_months"].notna() & muac["muac_mm"].notna()]
blocked <- muac |> filter(!is.na(age_months), !is.na(muac_mm))
Faites-le et huit candidats subsistent — les six réinscriptions réelles et deux coïncidences authentiques à Verrettes et Saint-Marc. Huit, c’est une liste qu’un superviseur peut confronter au registre papier en une après-midi. Douze, dont quatre absurdes, c’est une liste qui apprend aux gens à ignorer les listes.
Le blocage, et pourquoi on ne peut pas s’en passer
Comparer chaque ligne à toutes les autres est quadratique. Sur 4 218 enregistrements cela fait 8,9 millions de paires — lent mais supportable. Sur un registre de 300 000 bénéficiaires, cela fait 45 milliards, ce qui ne l’est pas.
Le blocage consiste à ne comparer que les enregistrements déjà d’accord sur quelque chose de simple et fiable — la commune, le site de distribution, le mois, la première lettre du nom de famille. Les comparaisons coûteuses n’ont ensuite lieu qu’à l’intérieur de chaque bloc.
blocks = blocked.groupby(["commune", "sex"])
print(sum(len(g) * (len(g) - 1) // 2 for _, g in blocks), "pairs to compare")
blocked |>
count(commune, sex) |>
summarise(pairs = sum(n * (n - 1) / 2))
L’arbitrage est explicite : un bloc trop grossier est lent, et un bloc trop fin rate les paires en désaccord sur la colonne de blocage. Deux graphies d’un nom de village placées dans des blocs différents ne seront jamais comparées. C’est pourquoi la colonne de blocage doit être celle en laquelle vous avez le plus confiance, et pourquoi bloquer sur un champ en texte libre est généralement une erreur.
Normalisez avant de comparer
Pour tout ce qui relève du texte, l’essentiel du travail se fait avant le calcul de la moindre distance.
import re
import unicodedata
def normalise(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.replace(r"\s+", " ", regex=True)
.str.strip()
)
households["head_key"] = normalise(households["head_of_household"])
normalise <- function(x) {
x |>
tidyr::replace_na("") |>
stringi::stri_trans_general("Latin-ASCII") |>
tolower() |>
stringr::str_replace_all("[^a-z0-9 ]", " ") |>
stringr::str_squish()
}
households$head_key <- normalise(households$head_of_household)
Casse, accents, espaces doubles et ponctuation expliquent la grande majorité des noms « différents » dans les données de ce secteur. Retirez-les et une part étonnante de votre problème d’appariement approximatif devient de l’appariement exact.
Ne retirez les accents que pour la clé de comparaison, jamais dans les données
conservées. Étienne est le nom de la personne. etienne est un index.
Distance d’édition et choix d’un seuil
Pour ce qui survit à la normalisation, comparez avec une distance entre chaînes. N’importe laquelle des distances usuelles convient ; ce qui compte est de choisir un seuil délibérément et de dire lequel.
from rapidfuzz import fuzz, process
matches = process.extract(
"jean baptiste pierre",
households["head_key"].tolist(),
scorer=fuzz.token_sort_ratio,
score_cutoff=88,
limit=10,
)
scores <- stringdist::stringsim(
"jean baptiste pierre",
households$head_key,
method = "jw"
)
which(scores > 0.88)
token_sort_ratio et Jaro-Winkler sont deux valeurs par défaut raisonnables ici,
pour une raison qu’il vaut la peine de connaître — dans ce secteur les noms
arrivent avec leurs éléments permutés (Pierre Jean Baptiste contre Jean Baptiste Pierre) et avec une faute de frappe bien plus rarement dans les
premiers caractères que dans les derniers.
Le seuil est un curseur entre précision et rappel, pas un réglage. À 95 vous obtenez peu de paires et manquez de vrais doublons. À 80 vous en obtenez beaucoup, la plupart fausses, et le relecteur cesse de lire. Réglez-le en prenant un échantillon de cinquante paires à votre seuil proposé et en vérifiant combien sont réelles. Inscrivez le seuil retenu et le taux de justesse mesuré dans le journal de nettoyage.
Notez plusieurs champs, n’enchaînez pas les filtres
Un seul champ ne suffit jamais. Comparez-en une poignée et combinez-les, pour qu’un accord fort sur un champ compense un accord faible sur un autre.
def pair_score(a, b):
name = fuzz.token_sort_ratio(a["head_key"], b["head_key"]) / 100
same_site = 1.0 if a["community"] == b["community"] else 0.0
size_gap = abs(a["household_size"] - b["household_size"])
size = max(0.0, 1 - size_gap / 5)
return 0.6 * name + 0.25 * same_site + 0.15 * size
pair_score <- function(a, b) {
name <- stringdist::stringsim(a$head_key, b$head_key, method = "jw")
same_site <- as.numeric(a$community == b$community)
size <- pmax(0, 1 - abs(a$household_size - b$household_size) / 5)
0.6 * name + 0.25 * same_site + 0.15 * size
}
Les pondérations relèvent du jugement, et elles doivent être visibles plutôt qu’enfouies dans une chaîne de conditions. Un filtre enchaîné traite chaque critère comme absolu : une faute dans le nom de la communauté et la paire disparaît. Un score laisse les indices s’additionner, ce qui est de toute façon la manière dont un relecteur humain raisonne.
Le produit est une file de relecture, pas un tableau propre
C’est ce qui sépare un appariement utile d’un appariement qui provoque un incident.
review = (
pairs.sort_values("score", ascending=False)
.loc[:, ["id_a", "id_b", "score", "head_a", "head_b", "community", "size_gap"]]
.assign(decision="", reviewer="", reviewed_on="")
)
review.to_csv("outputs/review/duplicate-candidates.csv", index=False)
review <- pairs |>
arrange(desc(score)) |>
select(id_a, id_b, score, head_a, head_b, community, size_gap) |>
mutate(decision = "", reviewer = "", reviewed_on = "")
readr::write_csv(review, here::here("outputs", "review", "duplicate-candidates.csv"))
Trois colonnes vides, et c’est là tout l’intérêt. Le fichier part chez la personne qui tient le registre, revient avec une décision sur chaque ligne, et c’est ce fichier revenu que le script de nettoyage lit — pas le score, et pas un seuil appliqué automatiquement.
Un doublon fusionné est un enregistrement détruit. Si la fusion était fausse, la preuve qu’elle l’était a disparu avec lui.
Ce qu’il ne faut pas automatiser
Dans les données de bénéficiaires, de protection et de gestion de cas, la fusion automatique n’est pas un raccourci technique à faible taux d’erreur. C’est une décision qui porte sur une personne, et les deux modes de défaillance ne sont pas symétriques.
- Une fausse fusion supprime quelqu’un. Deux ménages n’en font plus qu’un ; l’un cesse de recevoir l’assistance sans aucun moyen de savoir pourquoi. Dans une charge de cas de protection, deux survivantes deviennent un seul dossier, et l’histoire de l’une est rattachée au nom de l’autre.
- Une fausse scission compte double. C’est coûteux et embarrassant, et cela ne prive personne de rien.
Cette asymétrie impose de sous-fusionner par défaut. Signalez, faites relire, et
laissez décider la personne qui tient le registre. Lorsqu’une fusion a lieu,
conservez les deux identifiants d’origine dans l’enregistrement fusionné pour
pouvoir revenir en arrière — une table d’appariement avec kept_id, merged_id,
score, reviewer et date à côté des données.
Rien de tout cela n’est de la prudence gratuite. Les principes de gestion de l’information VBG qui régissent une partie des données de ce secteur le disent directement : le préjudice d’un enregistrement mal traité retombe sur la personne qu’il décrit, pas sur l’analyste.
La suite
Vous savez désormais quelles lignes désignent la même chose et quelles colonnes les identifient. L’unité suivante s’attaque aux valeurs elles-mêmes — les mesures qui ne peuvent pas être vraies, les codes de sortie qui contredisent leur propre mesure, et les seuils sectoriels qui transforment « cela semble faux » en une règle qu’un script applique.