---
title: "Screening coverage over the campaign year"
subtitle: "MUAC screening — Artibonite, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## What this produces

Screenings per month and commune across the 2024 campaign, and the data quality
pattern hiding inside the volume. It is the counterpart to the prevalence
example: that one asks how malnourished the children were, this one asks whether
you screened enough of them, in the right places, consistently enough to believe
the answer.

Every dataset on this platform is synthetic. Nothing here describes a real
child.

## A warning about the word "coverage"

What follows is **not** programme coverage. Coverage is cases reached over cases
existing, and this register has no denominator of children in the population —
only the children who turned up. Calling screening volume "coverage" is one of
the most common ways a nutrition report overstates what it knows.

What volume *can* tell you is where the campaign was interrupted, which is a
real and useful question with an honest answer.

## Setup

```{python}
import pandas as pd
import numpy as np

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "muac-screening-artibonite-2024.v1.csv"
)

muac = pd.read_csv(
    URL,
    dtype={"child_id": "string", "commune": "string", "sex": "string"},
    na_values={"muac_mm": ["-99"]},
)

muac["screening_date"] = pd.to_datetime(muac["screening_date"], format="%Y-%m-%d")
muac["month"] = muac["screening_date"].dt.to_period("M")

print(f"{len(muac):,} screenings, {muac['commune'].nunique()} communes")
print(f"{muac['month'].min()} to {muac['month'].max()}")
```

## Volume over the year

```{python}
monthly = muac.groupby("month").size()
monthly
```

January and December are visibly thinner than the months between them. Before
reading that as a campaign that started slowly and wound down, check the obvious
alternative: a register that covers part of a month at each end. Here the first
screening is mid-January and the last is mid-December, so the two low months are
an artefact of the reporting window rather than a drop in activity.

This is the check to run every time a first or last period looks weak.

```{python}
print("first screening:", muac["screening_date"].min().date())
print("last screening :", muac["screening_date"].max().date())
```

## Volume by commune

```{python}
by_commune = (
    muac.groupby("commune")
    .size()
    .sort_values(ascending=False)
    .rename("screenings")
    .to_frame()
)
by_commune["share"] = (by_commune["screenings"] / len(muac)).round(3)
by_commune
```

Gonaïves and Saint-Marc account for a large share of the register. That is not
in itself a finding — they are the larger communes — but it means an unweighted
department-wide rate is dominated by them, and a commune with 189 screenings
carries an interval wide enough that its rank is close to meaningless.

## Where the campaign was interrupted

The useful pattern is not the total, it is the month a commune's activity
departs from its own norm.

```{python}
grid = (
    muac.pivot_table(
        index="commune", columns="month", values="child_id", aggfunc="count"
    )
    .fillna(0)
    .astype(int)
)

# Each commune against its own median month, so a big commune and a small one
# are comparable.
median = grid.median(axis=1)
relative = grid.div(median, axis=0).round(2)
relative
```

```{python}
# Stack first, then filter. Filtering the frame and stacking afterwards leaves
# the NaN cells the mask produced, and current pandas keeps them — you get a
# result with every commune-month in it and no error to tell you why.
flat = relative.stack()
quiet = flat[flat < 0.5].sort_values()

print(f"{len(quiet)} commune-months below half that commune's median")
quiet
```

Almost all of them are December and January, which is the reporting-window
artefact again rather than an interruption. The one that is not — Gros-Morne in
January — is a campaign that started late in that commune, and it is the only
entry here worth asking about.

## The pattern that is not about volume

Screening volume held up in June. What did not hold up was completeness — and a
count of rows will never show you that, because the rows are there.

```{python}
june = muac[muac["month"] == pd.Period("2024-06")]

completeness = (
    june.assign(missing_age=june["age_months"].isna())
    .groupby("commune")["missing_age"]
    .agg(rows="size", missing_age_rate="mean")
    .sort_values("missing_age_rate", ascending=False)
    .round(3)
)
completeness
```

One commune's June is far worse than the rest. Narrow it to the week and the
cause becomes obvious:

```{python}
worst = completeness.index[0]

weekly = (
    muac[muac["commune"] == worst]
    .assign(week=lambda d: d["screening_date"].dt.to_period("W"))
    .groupby("week")["age_months"]
    .agg(rows="size", missing_age_rate=lambda s: s.isna().mean())
    .sort_values("missing_age_rate", ascending=False)
    .round(3)
)
weekly.head()
```

One team, one week, one tablet form with the age field misconfigured. The
screenings happened and the children were measured; only the age went missing.

## Why this matters for the prevalence table

If you drop incomplete rows before computing prevalence, you remove that commune
far more than any other — and then rank communes partly on whose form was
broken.

```{python}
GAM_MM = 125

unit_error = muac["muac_mm"].notna() & (muac["muac_mm"] < 40)
muac.loc[unit_error, "muac_mm"] = muac.loc[unit_error, "muac_mm"] * 10

measured = muac[muac["muac_mm"].notna()]
complete = measured[measured["age_months"].notna()]

comparison = pd.DataFrame({
    "keeping_missing_age": measured.groupby("commune")["muac_mm"].apply(
        lambda s: (s < GAM_MM).mean()
    ),
    "dropping_missing_age": complete.groupby("commune")["muac_mm"].apply(
        lambda s: (s < GAM_MM).mean()
    ),
})
comparison["difference"] = (
    comparison["dropping_missing_age"] - comparison["keeping_missing_age"]
)
comparison.sort_values("difference", key=abs, ascending=False).round(4)
```

The MUAC thresholds for 6 to 59 months are a single band and do not need age at
all, so the right decision here is to keep those rows for the MUAC indicator and
exclude them only from analyses that genuinely require age. That decision is
available only because you looked.

## Plot it

```{python}
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 5))

for commune in grid.index:
    ax.plot(
        range(len(grid.columns)),
        grid.loc[commune],
        marker="o",
        markersize=3,
        linewidth=1,
        color="#9AA8A3",
        alpha=0.7,
    )

ax.plot(
    range(len(grid.columns)),
    grid.loc[worst],
    marker="o",
    markersize=4,
    linewidth=2,
    color="#2F5D50",
    label=worst,
)

ax.set_xticks(range(len(grid.columns)))
ax.set_xticklabels([str(m) for m in grid.columns], rotation=45, ha="right")
ax.set_ylabel("Screenings")
ax.set_title("Screenings per commune per month, 2024")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)

plt.tight_layout()
plt.show()
```

## What to report

State the volume, state the completeness separately, and never let one stand in
for the other. A campaign that screened its target number of children with a
broken age field has a volume problem of zero and a data problem that changes
the ranking — and only one of those is visible in a count of rows.
