---
title: "Coverage and dropout rate by facility"
subtitle: "Routine vaccination coverage, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## What this produces

Monthly and annual coverage by antigen, penta1-to-penta3 dropout by facility, and
the over-reporting flag — applied at an aggregation level where it actually
means something.

Every dataset on this platform is synthetic. Coverage here describes no real
district.

## Setup

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

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "vaccination-coverage-2024.v1.csv"
)

epi = pd.read_csv(URL, dtype={"facility_id": "string"})
epi["period"] = pd.to_datetime(epi["period"])
epi["month"] = epi["period"].dt.to_period("M")

print(f"{epi['facility_id'].nunique()} facilities, "
      f"{epi['month'].nunique()} months, "
      f"{epi['antigen'].nunique()} antigens")
epi.head()
```

## A non-report is not zero children vaccinated

A facility that did not report appears as a row with `report_submitted` false and
zero doses — **not** as a missing row. Sum doses without filtering on that flag
and you have asserted that no child in that catchment was vaccinated that month.

```{python}
not_submitted = ~epi["report_submitted"]
print(f"facility-months not reported : {int(not_submitted.sum() / epi['antigen'].nunique())}")
print(f"non-reporting rows with 0 doses: "
      f"{int((not_submitted & (epi['doses_administered'] == 0)).sum())} "
      f"of {int(not_submitted.sum())}")
```

Every one of them. That is the trap: the zero looks like data.

## The denominator is annual, the reporting is monthly

`target_population` is the **annual** target, repeated on every row. Monthly
coverage is doses divided by the target over twelve. Dividing by the full annual
figure understates coverage twelvefold, and it is the most common mistake made
against data shaped like this.

```{python}
MONTHS_IN_YEAR = 12

penta3 = epi[epi["antigen"] == "penta3"].copy()

monthly = penta3.groupby("month").apply(
    lambda g: pd.Series({
        "doses": g["doses_administered"].sum(),
        "annual_target": g["target_population"].sum(),
        "completeness": g["report_submitted"].mean(),
    }),
    include_groups=False,
)

monthly["wrong_annual_denominator"] = monthly["doses"] / monthly["annual_target"]
monthly["correct_monthly_denominator"] = monthly["doses"] / (
    monthly["annual_target"] / MONTHS_IN_YEAR
)
monthly.round(3)
```

The first column is a coverage figure of about 5%, which would read as a
catastrophic programme failure. It is an arithmetic error.

## Dropout between penta1 and penta3

Dropout is the share of children who start the series and do not finish it:
`(penta1 − penta3) / penta1`. It is a better programme signal than coverage
because it does not depend on the population estimate at all — both terms come
from the same register.

```{python}
reported = epi[epi["report_submitted"]]

series = (
    reported[reported["antigen"].isin(["penta1", "penta3"])]
    .pivot_table(
        index="facility_id", columns="antigen",
        values="doses_administered", aggfunc="sum",
    )
    .dropna()
)

series["dropout"] = (series["penta1"] - series["penta3"]) / series["penta1"]
series = series.sort_values("dropout", ascending=False)

print(f"median dropout: {series['dropout'].median():.1%}")
series.head(10).round(3)
```

Median dropout is about 14%. A facility reporting near-zero dropout deserves as
much suspicion as one reporting a very high figure — near-zero usually means the
penta3 count was reconstructed from the penta1 count rather than counted.

## The over-reporting flag, applied at the right level

Penta3 exceeding penta1 is impossible in a real schedule: a child cannot receive
the third dose without the first. It is the classic over-reporting signal.

Applied month by month, it is useless here:

```{python}
by_month = (
    reported[reported["antigen"].isin(["penta1", "penta3"])]
    .pivot_table(
        index=["facility_id", "month"], columns="antigen",
        values="doses_administered",
    )
    .dropna()
)
by_month["impossible"] = by_month["penta3"] > by_month["penta1"]

flagged_once = by_month.groupby("facility_id")["impossible"].any().sum()
print(f"facilities flagged at least once: {flagged_once} "
      f"of {epi['facility_id'].nunique()}")
```

Thirty-seven of thirty-eight. A check that flags almost the whole district
identifies nothing — ordinary month-to-month noise crosses that line constantly,
because the two doses are given to different children in different months and
the counts do not have to move together.

The flag has to be applied where the noise averages out:

```{python}
persistent = series[series["dropout"] < 0]
print(f"facilities with penta3 above penta1 on the annual total: {len(persistent)}")
persistent.round(3)
```

Six facilities. That is a list a supervisor can act on.

```{python}
months_flagged = by_month.groupby("facility_id")["impossible"].agg(["sum", "size"])
months_flagged.columns = ["months flagged", "months reported"]
months_flagged.loc[persistent.index].sort_values("months flagged", ascending=False)
```

## Coverage by antigen, on a denominator that holds

```{python}
def annual_coverage(df):
    reported_only = df[df["report_submitted"]]
    return (
        reported_only.groupby("antigen")
        .apply(
            lambda g: g["doses_administered"].sum()
            / (g["target_population"].sum() / MONTHS_IN_YEAR),
            include_groups=False,
        )
        .rename("coverage")
        .to_frame()
        .round(3)
    )

annual_coverage(epi)
```

The denominator here counts only facilities that reported, which keeps the
numerator and the denominator describing the same set of facilities. That is the
subject of the completeness-adjustment example, and it is the difference between
a coverage figure that moves with reporting and one that moves with vaccination.

## What to report

Coverage with its denominator stated, dropout as the programme signal that does
not depend on a population estimate, and the over-reporting list with the
aggregation level it was computed at. A flag that fires on thirty-seven of
thirty-eight facilities is not a finding — it is a badly specified check.
