2  Data quality

A trigger is only as good as the data feeding it. This chapter asks whether the line-list reports consistently enough to calibrate percentile thresholds, and what the gaps do to those thresholds.

2.1 Reporting volume over time

Show code
national = sw.groupby("week", as_index=False)["cases"].sum()
fig, ax = plt.subplots(figsize=(11, 4.5))
ax.fill_between(national["week"], national["cases"], color=U.HDX["blue"], alpha=0.25)
ax.plot(national["week"], national["cases"], color=U.HDX["blue"], lw=1.2)
ax.axvspan(pd.Timestamp("2020-01-01"), pd.Timestamp("2020-12-31"), color=U.HDX["gray"], alpha=0.18)
ax.text(pd.Timestamp("2020-07-01"), national["cases"].max() * 0.85, "2020\nreporting gap",
        ha="center", va="top", color=U.HDX["gray_dark"], fontsize=9)
ax.set_ylabel("Suspected cases / week")
ax.set_title("Weekly suspected cholera cases, BAY states (2018–2023)")
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
plt.tight_layout()
plt.show()
Figure 2.1: Suspected cases per week across all three BAY states. Grey band marks the 2020 reporting collapse.

The series is spiky and seasonal, with sharp peaks in the outbreak years and long near-zero stretches between them. The 2020 stretch is not a genuine lull.

2.1.1 LGAs reporting per week

Show code
rep = (
    lw[lw["cases"] > 0]
    .groupby("week")["ADM2_PCODE"].nunique()
    .reindex(pd.date_range(U.WEEK_START, U.WEEK_END, freq="W-MON"), fill_value=0)
)
fig, ax = plt.subplots(figsize=(11, 4))
ax.bar(rep.index, rep.values, width=6, color=U.HDX["teal"])
ax.set_ylabel("LGAs reporting ≥1 case")
ax.set_title("Reporting breadth: LGAs with cases per week")
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
plt.tight_layout()
plt.show()
Figure 2.2: Number of LGAs reporting at least one case each week. Reporting breadth collapses in 2020 and is thin in 2019 and 2023.

2.1.2 Per-year summary

Show code
q = (
    llp.assign(active=1)
    .groupby("year")
    .agg(
        cases=("case", "sum"),
        active_weeks=("week", "nunique"),
        lgas_reporting=("ADM2_PCODE", "nunique"),
    )
    .reset_index()
)
q["weeks_of_52"] = q["active_weeks"].astype(str) + " / 52"
q = q[["year", "cases", "weeks_of_52", "lgas_reporting"]]
q.columns = ["Year", "Cases", "Weeks with ≥1 case", "LGAs reporting"]
q.style.hide(axis="index").format({"Cases": "{:,}"})
Table 2.1: Reporting depth by year: cases, active weeks, and the number of distinct LGAs reporting.
Year Cases Weeks with ≥1 case LGAs reporting
2018 6,471 46 / 52 37
2019 1,116 29 / 52 13
2020 3 3 / 52 1
2021 10,077 36 / 52 41
2022 14,991 31 / 52 34
2023 168 24 / 52 18

2.2 Gap identification

Three years fall short of usable reporting:

  • 2020 — effectively empty (3 cases). WHO and partner reporting confirm cholera transmission across BAY states in 2020; the line-list simply does not capture it. This is a data gap.
  • 2019 — thin (≈1,100 cases, near-zero deaths). Plausibly a genuinely quieter year, but the 0.3% CFR hints at under-capture too.
  • 2023 — partial (168 cases, ends late November). The file was compiled mid-outbreak-cycle; 2023 is truncated, not complete.

2.2.1 What this means for zero-filling

We build the LGA × week panel by zero-filling every week an LGA reports nothing. That is the right choice for a seasonal signal (most weeks genuinely have no cholera), but it is the wrong choice for a reporting-gap year: zero-filling 2020 asserts “no cholera” when the truth is “no data”. Percentile thresholds computed over a window that includes 2020 are therefore biased downward.

2.3 Impact on percentile baselines

How much does including the gap years move the threshold? The candidate trigger keys off the 99th percentile of weekly cases. Below we recompute that percentile for each state over three baseline choices.

Show code
def p99_by_state(df):
    return {st: np.percentile(df[df["state"] == st]["cases"], 99) for st in U.STATES}

full = p99_by_state(sw)
no2020 = p99_by_state(sw[sw["week"].dt.year != 2020])
outbreak = p99_by_state(sw[sw["week"].dt.year.isin([2018, 2021, 2022])])
imp = pd.DataFrame(
    {
        "State": U.STATES,
        "All years (2018–23)": [round(full[s]) for s in U.STATES],
        "Excl. 2020": [round(no2020[s]) for s in U.STATES],
        "Outbreak yrs only": [round(outbreak[s]) for s in U.STATES],
    }
)
imp.style.hide(axis="index")
Table 2.2: 99th-percentile weekly cases per state under three baseline windows. Including gap years pulls thresholds down.
State All years (2018–23) Excl. 2020 Outbreak yrs only
Borno 1640 1769 1882
Adamawa 131 152 293
Yobe 347 350 355

The effect is modest at the state level here (the outbreak weeks that set the 99th percentile survive in every window), but it grows for shorter rolling windows and for per-LGA thresholds, where a single gap year is a larger share of the baseline. The practical rule matches the DRC analysis’s conclusion.

Recommendations for calibration

  1. Exclude 2020 from any baseline pool — it is missing data, not a quiet year.
  2. Treat 2019 and 2023 as partial; prefer a baseline anchored on the well-reported outbreak years (2018, 2021, 2022) when setting thresholds.
  3. Prefer an expanding / multi-year baseline over a short rolling window, so no single bad year dominates the threshold.
  4. Before operational use, confirm a minimum-reporting guard (e.g. require a minimum number of reporting LGAs in a week) so a reporting rebound isn’t mistaken for an outbreak.

2.4 Timeliness — the binding constraint

This retrospective file cannot answer the question that matters most for AA: how quickly do these records arrive? A trigger needs case data within days-to-weeks of onset. The earlier BAY exploration flagged timeliness as the primary blocker, and nothing in this file resolves it. Every result that follows is retrospective and assumes data that, in production, would need to arrive fast enough to act on.