cassionData Analysis

Lesson 2 of 8

Unit · Before you change anything

Missingness that is not an accident

A global completeness figure hides the only thing that matters. Break missingness down by site and week, name the mechanism, and put a number on what dropping the incomplete rows does to your ranking.

PythonR90 minWHO Child Growth StandardsSphere Standards

5.4% is not a finding

The register is missing age_months on 226 of 4,218 rows. Reported as “the dataset is 94.6% complete”, that number is worse than useless: it invites the reader to conclude the problem is small, and it is not small, because it is not spread out.

A completeness figure only means something once you know where the holes are. Scattered missingness costs precision. Clustered missingness costs the answer. This lesson is how to tell which one you have, and what to say about it.

Break it down by group before you do anything else

by_commune = (
    muac.assign(missing_age=muac["age_months"].isna())
    .groupby("commune")
    .agg(missing=("missing_age", "mean"), n=("missing_age", "size"))
    .sort_values("missing", ascending=False)
)
print((by_commune["missing"] * 100).round(1))
muac |>
  group_by(commune) |>
  summarise(missing = mean(is.na(age_months)), n = n()) |>
  arrange(desc(missing)) |>
  mutate(missing = round(100 * missing, 1))
Commune Missing age Rows
Gros-Morne 17.7% 361
Anse-Rouge 5.7% 229
Saint-Michel 5.1% 335
Dessalines 4.9% 450
Ennery 3.0% 263

Eleven communes sit between 3% and 6%. One sits at 17.7%. That is not a distribution with a long tail; it is eleven communes with a normal amount of missingness and one commune with a problem.

Then by time, inside the group that stands out

gros_morne = muac[muac["commune"] == "Gros-Morne"].copy()
gros_morne["week"] = gros_morne["screening_date"].dt.to_period("W").dt.start_time

print(
    gros_morne.groupby("week")["age_months"]
    .apply(lambda s: s.isna().mean())
    .sort_values(ascending=False)
    .head()
)
muac |>
  filter(commune == "Gros-Morne") |>
  mutate(week = lubridate::floor_date(screening_date, "week", week_start = 1)) |>
  group_by(week) |>
  summarise(missing = mean(is.na(age_months)), n = n()) |>
  arrange(desc(missing)) |>
  head()

The week beginning 10 June: 54 of 85 screenings, 63.5%. Every other week in that commune is under 10%.

That is no longer a data quality statistic. It is one team, one week, one tablet form with the age field misconfigured, and it has a name, a date and probably a person who remembers it. Missingness that resolves to an event is missingness you can ask about, and often fix at source.

Name the mechanism, in words the report can carry

The statistical literature has three categories. You do not need the notation, but you do need the distinction, because it decides what you are allowed to do.

  • Missing completely at random. The holes are unrelated to anything, including the value that is missing. Dropping those rows costs precision and nothing else.
  • Missing at random. The holes depend on something you observed — a commune, a week, an enumerator. Dropping them biases the result, but you can adjust for the thing they depend on, because you have it.
  • Missing not at random. The holes depend on the missing value itself. The sickest children are the ones the queue never reached; the households that refused the water quality test are the ones with the dirtiest water. Nothing in the data can fix this, and the only honest response is to say so.

The age missingness here is missing at random: it depends on commune and week, both of which are recorded. The -99 MUAC codes may be worse than that — if measurement was skipped when a child was distressed, and distress correlates with illness, that is missing not at random, and no amount of code will tell you.

The category is not a statistical technicality. It is the difference between “we dropped 226 rows” and “we dropped 226 rows, 54 of them from one commune in one week, which moved that commune’s rate by 1.1 points”.

Put a number on what dropping them costs

This is the step almost nobody does, and it takes six lines. Compute the indicator twice: once on everything, once on complete cases only.

def gam_rate(df):
    assessed = df["muac_mm"].notna() | df["oedema"].notna()
    cases = assessed & ((df["muac_mm"] < 125) | (df["oedema"] == True))
    return pd.Series({
        "assessed": int(assessed.sum()),
        "cases": int(cases.sum()),
        "rate": round(cases.sum() / assessed.sum(), 3),
    })


all_rows = muac.groupby("commune").apply(gam_rate)
complete = muac[muac["age_months"].notna()].groupby("commune").apply(gam_rate)

comparison = all_rows.join(complete, rsuffix="_complete")
comparison["shift"] = comparison["rate_complete"] - comparison["rate"]
print(comparison.sort_values("shift"))
gam_rate <- function(df) {
  df |>
    mutate(
      assessed = !is.na(muac_mm) | !is.na(oedema),
      case     = assessed & (muac_mm < 125 | oedema)
    ) |>
    summarise(
      assessed = sum(assessed, na.rm = TRUE),
      cases    = sum(case, na.rm = TRUE),
      rate     = round(cases / assessed, 3),
      .by      = commune
    )
}

comparison <- gam_rate(muac) |>
  left_join(gam_rate(filter(muac, !is.na(age_months))),
            by = "commune", suffix = c("", "_complete")) |>
  mutate(shift = rate_complete - rate) |>
  arrange(shift)
Commune All rows Complete cases Shift
Gros-Morne 14.5% 13.4% -1.1 pt
Anse-Rouge 15.6% 16.5% +0.9 pt
Dessalines 5.8% 5.4% -0.4 pt
Saint-Michel 7.6% 7.6% 0.0 pt

Read the first two rows together. On all rows, Gros-Morne is second worst at 14.5% and 1.7 points clear of the commune below it. On complete cases it is 13.4%, and the commune below it is at 13.3%. The gap between second and third place goes from 1.7 points to 0.1 — which is to say, the ranking you would put in front of a nutrition cluster meeting depends on a form misconfiguration in one commune in June.

Notice also that nothing here crossed a threshold in a way that changed a decision. Say that too. A sensitivity check that finds no effect is a result, and it is the one that lets you stop worrying.

The three responses, and when each is honest

Drop them, and report the drop. Legitimate when the missingness is scattered and you have shown it is. The reporting is not optional: “n = 3,992 of 4,218; 226 rows excluded for missing age” belongs in the table footnote, not in your head.

Keep them, in a category of their own. Often the right answer for a categorical variable, because “not recorded” is a real finding about the programme. A completeness column beside the indicator column says more than either alone.

Impute — carefully, and rarely. For a variable used to disaggregate rather than to compute, filling missing age from the median age of the same commune and month is defensible if you flag every imputed row and report the indicator both ways. For a variable in the numerator, imputation is inventing the finding.

muac["age_imputed"] = muac["age_months"].isna()
muac["age_months_filled"] = muac.groupby("commune")["age_months"].transform(
    lambda s: s.fillna(s.median())
)
muac <- muac |>
  mutate(age_imputed = is.na(age_months)) |>
  group_by(commune) |>
  mutate(age_months_filled = ifelse(is.na(age_months),
                                    median(age_months, na.rm = TRUE),
                                    age_months)) |>
  ungroup()

The flag column is the point. An imputed value that is indistinguishable from a measured one has stopped being an estimate and started being a claim.

Missing rows, not just missing values

The hardest missingness is the kind with no blank cell, because the row is not there at all.

Share of health facilities submitting a monthly report through 2024. The collapse across August and September moves together across the district, which is what makes it a reporting event rather than a facility-level failure.

In the routine vaccination extract, a facility that did not report is not absent — it is present with doses_administered of zero and report_submitted of false. Six hundred and forty-two rows are in that state. Compute coverage without separating them and every non-reporting facility becomes a facility that vaccinated nobody.

vax["reported"] = vax["report_submitted"] == True

reporting_rate = vax.groupby("period")["reported"].mean()
coverage = (
    vax[vax["reported"]]
    .groupby("period")
    .apply(lambda g: g["doses_administered"].sum() / g["target_population"].sum())
)
print(pd.DataFrame({"reporting_rate": reporting_rate, "coverage": coverage}))
vax |>
  summarise(
    reporting_rate = mean(report_submitted),
    coverage = sum(doses_administered[report_submitted]) /
               sum(target_population[report_submitted]),
    .by = period
  )

Two numbers, always published together. Coverage computed on facilities that reported, and the reporting rate that says how much of the district that covers. A single coverage figure that quietly includes silent facilities in its denominator is the most common defect in routine health data, and it always points the same way — down.

What comes next

Missingness is a hole where something should be. The next unit is the opposite problem: something that is there twice. Lesson 3 asks whether the column you have been treating as a key actually is one — and shows what a join does when it is not.

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.