cassionData Analysis

Back to the lessonLesson 6 of 8Values that cannot be true

Six districts, three districts

The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.

Slides · PDFSlides · PowerPoint

  1. Slide 1 / 24

    What this lesson covers

    • The table with six rows and three districts
    • The administrative list is the authority
    • Normalise, then match exactly
    • What is left over is the actual work
    • The matches you must refuse
    • The mapping is a file, not a chain of replacements
    • Match once, join on the code forever
    • The assertion that catches next round's fifth spelling
    • What comes next
    Speaker notes
    Free-text place names against an administrative list — normalise, match exactly, review what is left, and store the result as a mapping file rather than a chain of replacements. Plus the assertion that stops next round's fifth spelling.
  2. Slide 2 / 24

    The table with six rows and three districts

    districtHouseholdsBelow 15 L/person/day
    NORD-OUEST3321.2%
    nord-ouest2020.0%
    Sud-Est80316.4%
    Nord-Ouest72714.7%
    Nord Ouest229.1%
    Centre7988.9%
    Speaker notes
    Group the WASH household survey by district and this comes back: Six rows. There are three districts. One enumerator team wrote Nord-Ouest four different ways, and because the variants sort apart, that district's 802 households are split into pieces of 727, 33, 22 and 20. Now read the table the way a coordination meeting would. Sorted worst first, NORD-OUEST is the priority district at 21.2% — a figure computed on thirty-three households, which is the smallest cell in the table and the one most easily moved by a handful of interviews. The real Nord-Ouest, all 802 households of it, sits at 15.0% and is second behind Sud-Est. Nobody will tell you this happened. Nothing errors. The table looks fine, it just answers a different question from the one asked.
  3. Slide 3 / 24

    The administrative list is the authority — In Python

    admin = pd.DataFrame([
        {"admin1_pcode": "HT07", "admin1_name": "Nord-Ouest"},
        {"admin1_pcode": "HT05", "admin1_name": "Centre"},
        {"admin1_pcode": "HT09", "admin1_name": "Sud-Est"},
    ])
    Speaker notes
    The first decision is whose names are correct, and the answer is never "the ones in the data". Every country has an official administrative hierarchy — admin 1, admin 2, admin 3 — published with codes. In humanitarian operations these are p-codes, distributed through the Humanitarian Data Exchange and used by every cluster, and the whole point of them is that a code does not have spelling variants.
  4. Slide 4 / 24

    The administrative list is the authority — In R

    admin <- tibble::tribble(
      ~admin1_pcode, ~admin1_name,
      "HT07",        "Nord-Ouest",
      "HT05",        "Centre",
      "HT09",        "Sud-Est"
    )
  5. Slide 5 / 24

    The administrative list is the authority

    • A value in the data that does not resolve to the list is unmatched, not wrong. It may be a spelling variant, a new…
    • The output of matching is a code, and everything downstream joins on the code.
    • The list, not the data, decides how many districts there are. A table with six rows fails a check rather than being…
    Speaker notes
    Three consequences follow from treating this list as the authority, and they are the shape of the rest of the lesson.
  6. Slide 6 / 24

    Normalise, then match exactly — In Python (cont.)

    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"]],
    Speaker notes
    Most of the variants are not really different names. Strip what does not carry meaning and they collapse.
  7. Slide 7 / 24

    Normalise, then match exactly — In Python (cont.)

        left_on="district_key", right_on="key", how="left",
    )
    print(matched["admin1_pcode"].isna().sum(), "rows unmatched")
  8. Slide 8 / 24

    Normalise, then match exactly — In R

    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))
    Speaker notes
    Case, accents and the hyphen-versus-space distinction account for every one of the four Nord-Ouest variants here. NORD-OUEST, nord-ouest, Nord Ouest and Nord-Ouest all normalise to nord ouest, and the merge is exact. Zero rows unmatched. That is the common case and it is worth internalising: reach for fuzzy matching after normalisation, not instead of it. A great deal of what looks like a difficult linkage problem is punctuation.
  9. Slide 9 / 24

    What is left over is the actual work — In Python

    unmatched = (
        matched[matched["admin1_pcode"].isna()]
        .groupby("district")
        .size()
        .sort_values(ascending=False)
    )
    print(unmatched)
    Speaker notes
    When the exact match does not clear everything — and on village names it never does — the residue is what you work through.
  10. Slide 10 / 24

    What is left over is the actual work — In R

    matched |>
      filter(is.na(admin1_pcode)) |>
      count(district, sort = TRUE)
  11. Slide 11 / 24

    What is left over is the actual work

    • Work the list by row count, not alphabetically — The unmatched values are almost always a very short head and a long…
    Speaker notes
    Work the list by row count, not alphabetically. The unmatched values are almost always a very short head and a long tail: two or three variants covering most of the rows, then singletons. Resolving the head takes ten minutes and recovers most of the data. For the tail, generate candidates rather than deciding:
  12. Slide 12 / 24

    What is left over is the actual work — In Python

    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)
  13. Slide 13 / 24

    What is left over is the actual work — In R

    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")
    }
  14. Slide 14 / 24

    The matches you must refuse

    • Constrain by the parent unit. A village only needs to be matched against villages in its own commune, and a commune…
    • Refuse to auto-accept below a high threshold. A near-match on a two-syllable name is not evidence. Where the top…
    • Leave unmatched values unmatched. A row with no district is honest and shows up in the completeness table. A row…
    Speaker notes
    Fuzzy matching on place names is more dangerous than it looks, because administrative units are frequently named after each other and after the same saints, rivers and colonial officials. Two real, distinct communes can be one character apart. Three habits that prevent the expensive mistake.
  15. Slide 15 / 24

    The matches you must refuse

    The failure mode is not that the script cannot match. It is that the script matches something, and the household ends up counted in a district it is not in.
  16. Slide 16 / 24

    The mapping is a file, not a chain of replacements — In Python

    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")
    Speaker notes
    The instinct is a replace or a case_when. Resist it.
  17. Slide 17 / 24

    The mapping is a file, not a chain of replacements — In R

    mapping <- readr::read_csv(here::here("reference", "district-mapping.csv"))
    wash <- wash |> left_join(mapping, by = c("district" = "raw_value"))
  18. Slide 18 / 24

    The mapping is a file, not a chain of replacements

    • It is reviewable by someone who does not read code — usually the person who knows the districts.
    • It is reusable across the notebook, the dashboard and next round's script.
    • It is diffable, so adding a variant shows up in review as one line.
    • It carries who decided. decided_by and decided_on turn a mapping from a technical artefact into a record, and…
    Speaker notes
    A mapping file beats a chain of replacements on four counts, and the last one is what actually matters.
  19. Slide 19 / 24

    Match once, join on the code forever — In Python

    wash = wash.drop(columns=["district", "district_key"]).rename(
        columns={"admin1_pcode": "admin1"}
    )
    Speaker notes
    After matching, drop the free-text name from everything downstream and carry the p-code.
  20. Slide 20 / 24

    Match once, join on the code forever — In R

    wash <- wash |> select(-district, -district_key) |> rename(admin1 = admin1_pcode)
    Speaker notes
    This is the step that makes the problem stay solved. Population denominators, last round's survey, the cluster's 4W matrix and the geodata for the map all join on the code without ever comparing a name again. Two datasets that both carry p-codes join correctly the first time, which is the entire reason the codes exist.
  21. Slide 21 / 24

    The assertion that catches next round's fifth spelling — In Python

    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"
    Speaker notes
    Everything above cleans the file you have. One line stops the same defect arriving unnoticed next quarter.
  22. Slide 22 / 24

    The assertion that catches next round's fifth spelling — In R

    EXPECTED_DISTRICTS <- c("HT05", "HT07", "HT09")
    
    stopifnot(
      all(wash$admin1 %in% EXPECTED_DISTRICTS),
      !any(is.na(wash$admin1))
    )
    Speaker notes
    Note what this does when the programme genuinely expands into a fourth district: it fails. That is correct. A new district is a thing someone should tell you about, and a script that silently absorbs it will silently compute a coverage figure against a denominator that does not include it.
  23. Slide 23 / 24

    What comes next

    • You now have rules for values and a mapping for names, and both were applied by hand this quarter.
    Speaker notes
    You now have rules for values and a mapping for names, and both were applied by hand this quarter. The next unit makes them run by themselves — a validation suite that executes on every read, reports what failed with counts, and stops the pipeline before a bad export reaches a dashboard.
  24. Slide 24 / 24

    Where this goes next

    Read the full lesson, with runnable code Back to the lesson