cassionData Analysis

Lesson 6 of 8

Unit · Working the table

Grouping to a numerator and a denominator

groupby, named aggregation, and the two defaults that silently change a denominator — dropped missing groups and unobserved categories. Producing both halves of an indicator in one operation so they cannot drift.

Python90 min

An indicator is two numbers

Almost every figure this sector reports is a numerator over a denominator, and almost every argument about a figure is an argument about the denominator. The practical consequence for your code: compute both in the same operation.

Two separate calculations drift. One gets a filter the other does not, someone edits one line, and the ratio becomes a number nobody can reconstruct.

import pandas as pd

muac = read_register(RAW / "muac-screening-artibonite-2024.v1.csv")

measured = muac["muac_mm"].notna()
gam = measured & (muac["muac_mm"] < 125)

by_commune = (
    muac.assign(measured=measured, gam=gam)
    .groupby("commune")
    .agg(
        screened=("child_id", "size"),
        measured=("measured", "sum"),
        gam_cases=("gam", "sum"),
    )
)
by_commune["gam_rate"] = (by_commune["gam_cases"] / by_commune["measured"]).round(3)
             screened  measured  gam_cases  gam_rate
commune
Gros-Morne        361       351         48     0.137
Anse-Rouge        229       223         30     0.135
L-Estere          189       187         18     0.096
Terre-Neuve       241       235         21     0.089

Three columns and every one of them is load-bearing. screened is how many children came; measured is how many produced a usable MUAC and is the denominator; gam_cases is the numerator. Publishing the rate without the denominator beside it is how the argument starts.

Note that screened and measured differ — 4,218 children were screened and 4,146 have a MUAC. Dividing by the wrong one shifts the district rate by enough to matter and by too little to notice.

Named aggregation

The form used above is the one to standardise on:

.agg(
    output_name=("input_column", "function"),
    ...
)

It names the output column at the point the aggregation is defined, so there is no second step renaming muac_mm_mean to something a reader understands, and no multi-level column index to flatten.

The older forms still work and are worth recognising in someone else’s code:

muac.groupby("commune")["muac_mm"].mean()               # one column, one function
muac.groupby("commune").agg({"muac_mm": ["mean", "std"]})  # MultiIndex columns

The second produces columns like ("muac_mm", "mean"), which then need flattening. Named aggregation avoids the problem rather than solving it.

Functions can be your own:

def share_below(series, threshold=125):
    valid = series.dropna()
    return (valid < threshold).mean() if len(valid) else float("nan")

muac.groupby("commune").agg(
    gam_rate=("muac_mm", share_below),
    n=("muac_mm", "count"),
)

count excludes missing; size includes them. That single distinction is the difference between the two denominators above, and it is worth saying out loud every time you use one.

Two defaults that change a denominator

Improved water source against basic service, by district. Counting sources overstates coverage everywhere; the gap is households whose source is improved and more than thirty minutes away.

Both bars in each pair come from the same groupby. The only difference is which rows the numerator counts — and it moves the answer by twenty to thirty points in every district. That is what “the denominator is the point” means in practice.

Missing group keys are dropped

d = pd.DataFrame({"g": ["a", None, "a"], "v": [1, 2, 3]})

d.groupby("g")["v"].sum()                  # {'a': 4}
d.groupby("g", dropna=False)["v"].sum()    # {'a': 4, nan: 2}

By default groupby discards rows whose group key is missing. They vanish from the table and from the total, so the parts no longer sum to the whole and nothing says why.

In this sector the missing key is usually the interesting one: a facility with no district recorded, a household with no site code, a child with no commune. Use dropna=False and decide deliberately, or assert the key is complete:

assert muac["commune"].notna().all(), "rows with no commune would be dropped"

Unobserved categories reappear as empty rows

If the group key is categorical, the default is now to return only observed categories:

c = pd.DataFrame({"k": pd.Categorical(["x", "y"], categories=["x", "y", "z"]),
                  "v": [1, 2]})

len(c.groupby("k", observed=True)["v"].sum())    # 2
len(c.groupby("k", observed=False)["v"].sum())   # 3 — includes an empty "z"

Both are right for different questions. Reporting on facilities that submitted data wants observed=True; reporting coverage against a list of facilities that should have submitted wants observed=False, because a facility with zero rows is the finding.

Pass it explicitly. The default changed between pandas versions, and a script that relies on it produces a different table on a colleague’s machine.

Grouping by more than one key

by_commune_month = (
    muac.assign(month=muac["screening_date"].dt.to_period("M"), gam=gam)
    .groupby(["commune", "month"], observed=True)
    .agg(measured=("muac_mm", "count"), gam_cases=("gam", "sum"))
)

The result has a MultiIndex. Two ways to work with it:

by_commune_month.loc["Gonaives"]                      # one commune, all months
by_commune_month.reset_index()                        # back to flat columns

reset_index() before writing to CSV, always — otherwise the index columns either disappear or arrive unnamed.

To turn the long result into the wide table a report wants:

wide = by_commune_month["gam_cases"].unstack("month", fill_value=0)

fill_value=0 is safe here because a commune-month with no cases genuinely had zero. It would be wrong for a rate, where the absence means “not computed”, not “zero percent” — a distinction worth checking every time you reach for it.

transform: a group statistic on every row

agg collapses; transform returns a value per original row. This is how you compare a row against its own group without a join:

muac["commune_mean"] = muac.groupby("commune")["muac_mm"].transform("mean")
muac["vs_commune"] = muac["muac_mm"] - muac["commune_mean"]

Useful for flagging a site whose measurements sit systematically away from the rest — which is exactly the check the SMART survey analysis project uses to find a team measuring 0.5 kg light.

apply, and why to avoid it

apply runs a Python function per group and is the slowest thing in pandas by a wide margin. It is also the most flexible, so it is what people reach for first.

# Works, but slow, and returns something whose shape depends on the function.
muac.groupby("commune").apply(lambda g: g["muac_mm"].mean(), include_groups=False)

Prefer a named aggregation. Reach for apply when the operation genuinely needs the whole group frame — a per-group regression, a per-group ranking with ties broken on a second column — and pass include_groups=False, which is now required to avoid the grouping columns being handed to your function.

Pivot tables read better for a report

pivot_table is groupby plus unstack with a friendlier signature:

pd.crosstab(muac["commune"], muac["outcome"])

pd.crosstab(
    muac["commune"], muac["outcome"],
    values=muac["muac_mm"], aggfunc="mean",
).round(1)

crosstab with normalize="index" gives row proportions, which is usually what a referral table should show. Keep the counts beside them: a 50% referral rate on four children is a different fact from 50% on four hundred, and only the count distinguishes them.

Writing the result out

OUTPUTS = PROJECT / "outputs" / "tables"
OUTPUTS.mkdir(parents=True, exist_ok=True)

by_commune.reset_index().to_csv(OUTPUTS / "gam_by_commune.csv", index=False)

index=False after reset_index() — otherwise you get an unnamed integer column that someone will later open in Excel and sort by.

For a table someone will read rather than compute on, write the definition with it:

definitions = pd.DataFrame([
    ("GAM", "MUAC < 125 mm or bilateral pitting oedema",
     "children with a MUAC measurement or a recorded oedema assessment"),
], columns=["indicator", "numerator", "denominator"])

definitions.to_csv(OUTPUTS / "definitions.csv", index=False)

A number and its definition travel together, or the monthly argument about the denominator starts again.

What comes next

The table aggregates correctly and both halves of every rate come from one operation. The next unit gets the last piece right — dates, ages and reporting periods, where an age in months decides which growth standard applies — and then wraps the whole thing in a script that runs on someone else’s machine.

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.