Lesson 3 of 8
Unit · Who is a case
The case definitions, and the clause everyone drops
Severe, moderate and global acute malnutrition, the cut-offs the sector agreed on, and the oedema clause that makes every "count below the threshold" implementation wrong.
The definitions
Three terms, and they nest.
| Term | Weight-for-height z | MUAC | Oedema |
|---|---|---|---|
| SAM — severe acute malnutrition | below -3 | below 115 mm | present |
| MAM — moderate acute malnutrition | -3 to below -2 | 115 to below 125 mm | — |
| GAM — global acute malnutrition | below -2 | below 125 mm | present |
GAM is SAM plus MAM. It is not a separate condition; it is the total burden of acute malnutrition, and it is the figure the IPC phases and the 15% emergency threshold are defined against.
Two structural points before any code.
Each row is a complete definition on its own. A child is SAM by weight-for-height, or by MUAC, or by oedema — the criteria are alternatives, not requirements. Lesson 4 is entirely about how far the first two disagree.
The oedema column is an OR, not an AND. A child with bilateral pitting oedema is severe whatever their measurements say. That clause is one term in a boolean expression and it is the term most often left out.
The implementation that is wrong
# WRONG: the definition everybody writes first
sam_wrong = (smart["whz"] < -3).sum()
# WRONG
sum(smart$whz < -3, na.rm = TRUE)
It is wrong in two ways at once. It drops the oedematous children, and na.rm or
a null-propagating comparison silently drops the children with no z-score — so
both the numerator and the denominator are quietly different from what the label
claims.
The implementation that is right
import pandas as pd
analysable = smart[smart["whz"].between(-5, 5)].copy()
oedema = analysable["oedema"] == True
analysable["sam"] = (analysable["whz"] < -3) | oedema
analysable["mam"] = (analysable["whz"].between(-3, -2, inclusive="left")) & ~oedema
analysable["gam"] = analysable["sam"] | analysable["mam"]
n = len(analysable)
for label in ["sam", "mam", "gam"]:
print(f"{label.upper():4} {analysable[label].sum():>4} / {n} "
f"{analysable[label].mean():.1%}")
analysable <- smart |> filter(between(whz, -5, 5))
analysable <- analysable |>
mutate(sam = whz < -3 | oedema,
mam = whz >= -3 & whz < -2 & !oedema,
gam = sam | mam)
analysable |> summarise(across(c(sam, mam, gam), list(n = sum, rate = mean)), n = n())
852 analysable children: GAM 14.9%, SAM 3.8%.
Three details in that code are load-bearing.
MAM excludes oedema explicitly. An oedematous child whose z-score falls in the
moderate band is severe, not moderate, and without the & ~oedema they would be
counted in both — so SAM plus MAM would exceed GAM.
The bounds are half-open. -3 <= z < -2. A child at exactly -3.0 is severe.
Getting this wrong moves a handful of children and is the kind of thing two
analysts discover they disagree on at the worst moment.
The denominator is stated. 852, not 930 — the difference is children with no computable z-score and children outside the flagging bounds, and lesson 2 required you to report those three groups separately.
Check that the parts sum to the whole
assert (analysable["sam"] & analysable["mam"]).sum() == 0, "a child is both"
assert analysable["gam"].sum() == analysable["sam"].sum() + analysable["mam"].sum()
stopifnot(sum(analysable$sam & analysable$mam) == 0,
sum(analysable$gam) == sum(analysable$sam) + sum(analysable$mam))
Two assertions, and they catch the oedema mistake, the bound mistake and any future edit that breaks the nesting. Run them every time.
The MUAC definitions, on the register
The screening register has MUAC and oedema and no weight or height, so it supports the MUAC-based definitions only.
muac = pd.read_csv("muac-screening-artibonite-2024.v1.csv")
measured = muac[muac["muac_mm"].notna()].copy()
oed = measured["oedema"] == True
measured["sam"] = (measured["muac_mm"] < 115) | oed
measured["gam"] = (measured["muac_mm"] < 125) | oed
print(f"n = {len(measured):,} GAM {measured['gam'].mean():.1%} "
f"SAM {measured['sam'].mean():.1%}")
muac |>
filter(!is.na(muac_mm)) |>
summarise(n = n(),
gam = mean(muac_mm < 125 | oedema),
sam = mean(muac_mm < 115 | oedema))
Name the measure in the indicator. gam_muac_percent and gam_whz_percent
are different indicators that both get called “GAM”, and the indicator design
course’s rule about putting the measure in the name is not pedantry here — the
next lesson shows the two disagreeing on three-quarters of severe cases.
What a definition does not say
Three things the case definition deliberately leaves out, each of which someone will assume.
Age. The definitions apply to children 6 to 59 months. Below six months the standards and the case definitions are different; above 59 months they do not apply at all. Check the age range before classifying.
out_of_scope = ~analysable["age_months"].between(6, 59)
print(f"{out_of_scope.sum()} children outside 6-59 months")
sum(!between(analysable$age_months, 6, 59), na.rm = TRUE)
Admission. A case definition says who is malnourished. Whether they are
admitted depends on the programme’s protocol, which may use one criterion, both,
or MUAC only for community screening and weight-for-height at the site. The
register’s admission_criterion records what the site entered, not everything the
child met.
Severity within severe. SAM with complications needs inpatient care; SAM without needs outpatient. The definition does not distinguish them, and the appetite test and clinical signs that do are not in an anthropometric dataset.
A case definition is a classification rule, not a treatment decision. Confusing the two produces a caseload figure that does not match any programme’s admissions and an argument nobody can resolve from the data.
What comes next
You can classify a child by either measure. The next lesson puts the two measures on the same children and finds that they disagree on three-quarters of severe cases — and that the disagreement has a cause you can name in one number.