cassionData Analysis

Lesson 5 of 8

Unit · Values that cannot be true

Rules the sector already gave you

Turn "that looks wrong" into a rule table with a source, a severity and a count. Hard bounds against physical limits, soft bounds against the sector's thresholds, and the discipline of flagging rather than deleting.

PythonR90 minWHO Child Growth StandardsSphere StandardsSMART survey

“That looks wrong” is not a rule

Everyone doing this work develops an eye. You scroll a column, something snags, you go and look. It works, and it does not scale, does not transfer to the person who takes over from you, and cannot be defended in a review — because the answer to “why did you drop that row” is “it looked wrong”.

The fix is to write the rule down: a condition, a source, a severity and a count. Once it exists in that form it runs on next quarter’s file, it explains itself, and the number of rows it caught becomes a line in the cleaning log.

The thresholds already exist

You are almost never inventing a limit. This sector is unusually well supplied with published, defensible cut-offs, and using them is what makes a rule a rule rather than an opinion.

Domain Rule Source
MUAC, 6-59 months Below 80 mm or above 220 mm is not a measurement WHO / CMAM field practice
Weight-for-height z Outside -5 to +5 of the reference WHO 2006 growth standards
SMART anthropometry Flag z-scores more than 3 SD from the survey mean SMART plausibility report
Water quantity Below 15 litres per person per day is below minimum Sphere
Water collection Round trip over 30 minutes is limited, not basic, service JMP service ladders
Free residual chlorine 0.2 to 2.0 mg/L at the point of consumption Sphere / WHO
Coverage Doses administered above the target population needs explaining UNICEF indicator guidance

Cite the source in the rule. A threshold with a citation survives being questioned; the same number without one gets argued about every quarter.

Rules as data, not as if-statements

Write the rules as a table the code iterates over. Two reasons, and the second is the one that matters.

The first is obvious: adding a rule is adding a row. The second is that a rule table can be counted, reported and diffed — you can print how many rows each rule caught, compare against last quarter, and paste the whole thing into the cleaning log without rewriting it in prose.

RULES = [
    {
        "id": "muac-range",
        "column": "muac_mm",
        "severity": "error",
        "source": "WHO / CMAM field practice",
        "test": lambda d: d["muac_mm"].between(80, 220) | d["muac_mm"].isna(),
        "message": "MUAC outside 80-220 mm",
    },
    {
        "id": "muac-unit-error",
        "column": "muac_mm",
        "severity": "warning",
        "source": "unit check, cm entered as mm",
        "test": lambda d: ~d["muac_mm"].between(1, 40),
        "message": "MUAC looks like centimetres",
    },
    {
        "id": "age-in-scope",
        "column": "age_months",
        "severity": "warning",
        "source": "CMAM screening protocol, 6-59 months",
        "test": lambda d: d["age_months"].between(6, 59) | d["age_months"].isna(),
        "message": "age outside the screening window",
    },
]
RULES <- tibble::tribble(
  ~id,               ~column,      ~severity,  ~source,                             ~message,
  "muac-range",      "muac_mm",    "error",    "WHO / CMAM field practice",         "MUAC outside 80-220 mm",
  "muac-unit-error", "muac_mm",    "warning",  "unit check, cm entered as mm",      "MUAC looks like centimetres",
  "age-in-scope",    "age_months", "warning",  "CMAM screening protocol, 6-59 mo",  "age outside the screening window"
)

TESTS <- list(
  `muac-range`      = function(d) dplyr::between(d$muac_mm, 80, 220) | is.na(d$muac_mm),
  `muac-unit-error` = function(d) !dplyr::between(d$muac_mm, 1, 40),
  `age-in-scope`    = function(d) dplyr::between(d$age_months, 6, 59) | is.na(d$age_months)
)

Every test passes on a missing value. That is deliberate and it catches people out constantly: NA < 80 is not false, it is unknown, and a rule that treats unknown as a violation will report your missingness twice — once as missingness and once as an implausible value. Missingness is lesson 2’s problem. Keep the two separate.

Applying the table

def apply_rules(df, rules):
    results = []
    flags = pd.DataFrame(index=df.index)
    for rule in rules:
        ok = rule["test"](df)
        flags[rule["id"]] = ~ok
        results.append({
            "rule": rule["id"],
            "severity": rule["severity"],
            "column": rule["column"],
            "failed": int((~ok).sum()),
            "share": round((~ok).mean(), 4),
            "source": rule["source"],
        })
    return pd.DataFrame(results), flags


report, flags = apply_rules(muac, RULES)
print(report.to_string(index=False))
apply_rules <- function(df, rules, tests) {
  flags <- purrr::map_dfc(rules$id, function(id) {
    tibble::tibble(!!id := !tests[[id]](df))
  })
  report <- rules |>
    dplyr::mutate(
      failed = purrr::map_int(id, ~ sum(flags[[.x]], na.rm = TRUE)),
      share  = round(failed / nrow(df), 4)
    )
  list(report = report, flags = flags)
}

out <- apply_rules(muac, RULES, TESTS)
out$report
Rule Severity Failed Share
muac-range error 7 0.17%
muac-unit-error warning 7 0.17%
age-in-scope warning 0 0.00%

Seven rows fail both MUAC rules, which is the answer you want: the same seven records are out of range and look like centimetres, so they are a unit error rather than seven unrelated mistakes. Two rules agreeing is evidence about the cause; one rule firing alone would only tell you something is wrong.

age-in-scope catches nothing, and that is worth keeping too. A rule that never fires costs one line and proves the constraint holds — and the day it starts firing, it is telling you the programme changed.

Hard bounds and soft bounds

Not every rule means the same thing, and collapsing them is how a cleaning script starts deleting real data.

  • Hard bounds are physically or logically impossible. A negative household size, a MUAC of 450 mm, a discharge date before an admission date, doses administered to more children than exist. These are always errors.
  • Soft bounds are implausible but possible. Ten litres per person per day is below the Sphere minimum, and it is also a real thing that happens to real households — that is the finding, not a defect.

The WASH survey shows how easy it is to confuse the two. Twelve households report more than 60 litres per person per day, against a median in the teens. Sixty litres per person is not impossible; it is what a household with a yard connection and a garden actually uses. But in a file where a handful of enumerators recorded the household’s total consumption in a per-person column, it is also exactly what that error looks like.

suspect = wash[wash["litres_per_person_day"] > 60][
    ["household_id", "community", "household_size", "litres_per_person_day", "water_source"]
]
suspect["implied_total"] = suspect["litres_per_person_day"] * suspect["household_size"]
print(suspect)
wash |>
  filter(litres_per_person_day > 60) |>
  mutate(implied_total = litres_per_person_day * household_size) |>
  select(household_id, community, household_size, litres_per_person_day,
         water_source, implied_total)

Look at implied_total. If a household of seven is recorded at 140 litres per person, the implied total is 980 litres a day carried by hand, which is not happening. The rule that resolves an ambiguous value is usually a second column, not a tighter threshold.

The same trap sits in the collection time. Forty-two households report a round trip under six minutes from a source that is not on their plot. Some of those are a tap at the end of the road. Some are an enumerator who wrote hours in a minutes field. Nothing in the column distinguishes them, and the honest rule flags all forty-two for review rather than pretending to know which is which.

Rules that compare two columns

The highest-value rules are rarely about one column’s range. They are about two columns that must agree.

CROSS_RULES = [
    {
        "id": "referral-without-measurement",
        "severity": "warning",
        "test": lambda d: ~(d["outcome"].str.startswith("referred") & d["muac_mm"].isna()),
        "message": "referred but no MUAC recorded",
    },
    {
        "id": "outcome-contradicts-muac",
        "severity": "warning",
        "test": lambda d: ~((d["muac_mm"] >= 125) & (d["oedema"] != True)
                            & (d["outcome"] != "no-action")),
        "message": "no-action expected from the measurement",
    },
]
CROSS_TESTS <- list(
  `referral-without-measurement` = function(d)
    !(startsWith(d$outcome, "referred") & is.na(d$muac_mm)),
  `outcome-contradicts-muac` = function(d)
    !(d$muac_mm >= 125 & !d$oedema & d$outcome != "no-action")
)

Ten records carry a referral decision with no measurement behind it, and nine carry an outcome their own measurement contradicts. Neither is impossible — a child can be referred on clinical judgement, and a referral can be right for a reason the register does not hold. But both are worth a phone call, and neither is findable by looking at either column alone.

Flag, do not delete

The rule adds a column. It does not remove a row.

muac = muac.join(flags.add_prefix("flag_"))
muac["flag_any_error"] = flags[[r["id"] for r in RULES if r["severity"] == "error"]].any(axis=1)
muac <- dplyr::bind_cols(muac, dplyr::rename_with(out$flags, ~ paste0("flag_", .x)))
muac$flag_any_error <- dplyr::if_any(dplyr::starts_with("flag_"), identity)

Three things this buys you that a filter does not.

  • The count is still available. “4,218 screenings, 7 excluded for an implausible measurement” needs both numbers, and a filtered table has thrown one away.
  • The exclusion is reversible. A reviewer who disagrees with a rule can rerun the analysis without it, in one line.
  • The flags are analysable. Which brings us to the last section.

Apply the exclusion once, at the point of computation, and say so:

analysis = muac[~muac["flag_any_error"]]
print(f"{len(muac)} screenings, {len(analysis)} analysed, "
      f"{muac['flag_any_error'].sum()} excluded")
analysis <- muac |> filter(!flag_any_error)
sprintf("%d screenings, %d analysed, %d excluded",
        nrow(muac), nrow(analysis), sum(muac$flag_any_error))

The flag rate is itself an indicator

Once flags are columns, group them the way you group anything else — and what comes back is a statement about data collection rather than about children.

Mean weight-for-height z-score by measurement team in the SMART survey, plotted as depth below zero. Clusters were assigned to teams independently of nutrition status, so a spread of this size between teams is a measurement fault rather than a real difference between populations.

print(muac.groupby("commune")[["flag_muac_unit_error", "flag_any_error"]].mean())
muac |>
  summarise(across(starts_with("flag_"), mean), .by = commune)

If one commune, one team or one enumerator accounts for most of a flag, the finding is not in the data — it is in the supervision. That is a much more useful thing to put in a monthly report than a corrected number, because it is actionable: the team can be retrained, and next quarter’s file will be better rather than merely cleaned.

A rule that fires everywhere is a rule about the world. A rule that fires in one place is a rule about a team.

What comes next

Every rule so far compares a value against a number. The next lesson handles the values that have to be compared against a list — the free-text site and village names that arrive in four spellings, where the authority is an administrative list and the hard part is refusing to invent a match.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.