Lesson 2 of 8
Unit · Measuring a child
From weight and height to a z-score
The LMS method, the reference table lookup, the three z-scores and what each measures. Computed on 930 children it gives a mean of -0.67 and a standard deviation of 1.22 — and that second number is already a finding.
What a z-score says
A z-score answers one question: how far is this child from the median of a healthy reference population, in standard deviations?
A weight-for-height z-score of -2 means this child weighs what a child two standard deviations below the median weighs, for their height. It is not a percentage of anything, and it is not comparable to a percentile without conversion.
The reference is the WHO 2006 Child Growth Standards, built from a multi-country study of children raised in conditions that do not constrain growth — breastfed, non-smoking households, adequate healthcare. That construction is deliberate: the standards describe how children should grow, not how children in any particular place do.
Three z-scores, three questions
| Score | Compares | Deficit it detects | Recovers? |
|---|---|---|---|
| Weight-for-height (WHZ) | Weight against height | Acute — wasting | Yes, in weeks |
| Height-for-age (HAZ) | Height against age | Chronic — stunting | No |
| Weight-for-age (WAZ) | Weight against age | Both together — underweight | Partly |
Weight-for-age is the one to be careful with. It mixes the two deficits, so a low WAZ does not say whether the child is wasted, stunted or both, and the programme response differs. It survives in growth monitoring because it needs no height board, and it should not be used to classify acute malnutrition.
This course computes weight-for-height, because that is what the case definitions and the IPC thresholds are built on.
The LMS method
The standards are published as three parameters per sex, per measurement, per 0.1 cm of length or height:
- L — the Box-Cox power that normalises a skewed distribution
- M — the median
- S — the coefficient of variation
z = ((weight / M)^L - 1) / (L * S)
import pandas as pd
reference = pd.read_csv("who-2006-weight-for-lenhei.csv")
print(reference.head(3))
print(f"{len(reference)} rows: sex x standard x lenhei in 0.1 cm steps")
reference <- readr::read_csv("who-2006-weight-for-lenhei.csv")
nrow(reference)
2,404 rows. Read the table; do not reimplement the interpolation from a
textbook. R has the official anthro package; Python has no maintained
equivalent that installs cleanly, which is exactly why this table is committed
here.
The lookup key has three parts
This is the part that goes wrong, and the joining course’s lab was built on it.
def lookup_key(row):
lying = row["measured_lying"] == "true"
standard = "L" if row["age_months"] < 24 else "H"
lenhei = row["height_cm"]
if standard == "L" and not lying:
lenhei += 0.7
elif standard == "H" and lying:
lenhei -= 0.7
return row["sex"], standard, round(lenhei, 1)
lookup_key <- function(sex, age, height, lying) {
standard <- if (age < 24) "L" else "H"
lenhei <- height +
if (standard == "L" && !lying) 0.7 else if (standard == "H" && lying) -0.7 else 0
list(sex = sex, lorh = standard, lenhei = round(lenhei, 1))
}
Three things in that function are decisions, not mechanics.
The standard follows the age, not the position. A 30-month-old measured lying is still assessed against the height standard, with the adjustment applied.
The adjustment goes on the measurement, not the standard. You are converting the measurement into the one the standard expects.
The rounding is to one decimal, on both sides, in one place. A float computed from an adjustment is not the same float as one read from a CSV, which is the inexact-key join the joining course spent a lab on.
Computing it
reference["key"] = list(zip(reference["sex"], reference["lorh"],
reference["lenhei"].round(1)))
lms = reference.set_index("key")[["l", "m", "s"]]
def whz(row):
key = lookup_key(row)
if key not in lms.index or pd.isna(row["weight_kg"]):
return None
l, m, s = lms.loc[key]
return (((row["weight_kg"] / m) ** l) - 1) / (l * s)
smart["whz"] = smart.apply(whz, axis=1)
print(f"{smart['whz'].notna().sum()} of {len(smart)} computable")
# In R, use the official package rather than the table:
# anthro::anthro_zscores(sex = ..., age = ..., weight = ..., lenhei = ..., measure = ...)
864 of 930 computable. The 66 that are not split into three groups and the distinction matters:
- Missing weight or age — 32 children, and they are missing data.
- Height outside the reference range — the length standard runs 45.0 to 110.0 cm and the height standard 65.0 to 120.0 cm. A child outside it is genuinely unclassifiable.
- A measurement in the wrong unit — four heights recorded in metres, which the joining course’s lab found by anti-join. These are recoverable.
Report the three separately. “66 children excluded” hides that some were data errors you could have fixed.
Flagging: two rules, two answers
Extreme z-scores are excluded before any prevalence, and there are two conventions.
- WHO flags — fixed bounds, exclude z outside -5 to +5. Absolute, comparable across surveys.
- SMART flags — relative, exclude observations more than 3 SD from the survey’s own mean. Adapts to the survey, and shrinks it more when the survey is noisier.
who_flagged = smart["whz"].between(-5, 5)
mean, sd = smart["whz"].mean(), smart["whz"].std()
smart_flagged = smart["whz"].between(mean - 3 * sd, mean + 3 * sd)
print(f"WHO flags keep {who_flagged.sum()}, SMART flags keep {smart_flagged.sum()}")
smart <- smart |>
mutate(who_ok = between(whz, -5, 5),
smart_ok = between(whz, mean(whz, na.rm = TRUE) - 3 * sd(whz, na.rm = TRUE),
mean(whz, na.rm = TRUE) + 3 * sd(whz, na.rm = TRUE)))
They exclude different children and give slightly different rates. State which you used; a SMART plausibility report requires it, and two surveys using different rules are not directly comparable.
The standard deviation is already a finding
analysable = smart.loc[who_flagged, "whz"]
print(f"n = {len(analysable)}, mean = {analysable.mean():.2f}, "
f"sd = {analysable.std():.2f}")
smart |> filter(who_ok) |> summarise(n = n(), mean = mean(whz), sd = sd(whz))
852 children, mean -0.67, standard deviation 1.22.
The mean is unremarkable — a population somewhat below the reference median, which is normal in this sector. The standard deviation is not. A well-measured survey produces a weight-for-height SD between about 0.8 and 1.2, because the reference population’s SD is 1 by construction and real populations are only slightly more variable.
1.22 sits at the edge of acceptable, and an SD above the range means one of three things: measurement error inflating the spread, a genuinely heterogeneous population, or a data entry problem. It is the single most informative number in a plausibility report, and lesson 5 is about reading it properly.
Note also what it does to the prevalence. A wider distribution puts more children past a fixed cut-off, so an inflated SD raises GAM without any child being more malnourished.
Save the z-scores with their inputs
smart[["child_id", "cluster", "team", "age_months", "sex", "weight_kg",
"height_cm", "measured_lying", "oedema", "whz"]].to_csv(
"outputs/smart_with_zscores.csv", index=False)
readr::write_csv(smart, here::here("outputs", "smart_with_zscores.csv"))
Keep the inputs beside the output. When somebody disagrees with a prevalence, the argument is almost never about the arithmetic — it is about the adjustment, the flagging rule or the excluded children, and all three are recoverable only if the inputs travelled with the result.
What comes next
You have a z-score for every analysable child. The next unit turns it into a classification — the case definitions for severe and moderate acute malnutrition, and the clinical sign that overrides both.