| Field | Value | |
|---|---|---|
| 0 | Rows (suspected cases) | 32,826 |
| 1 | Deaths | 836 |
| 2 | Date range (onset) | 2018-01-02 → 2023-11-29 |
| 3 | States | 3 |
| 4 | LGAs with ≥1 case | 55 |
| 5 | Wards with ≥1 case | 731 |
| 6 | Onset date present | 100% |
1 Data overview
The analysis starts from a single consolidated file — CHOLERA_BAY 2018_2023.xlsx, a case line-list in which each row is one suspected cholera case. Before any trigger logic, we need to know what the file contains and how evenly it is populated.
1.1 What the line-list holds
Each case carries an onset date (complete for every retained row — the backbone of any weekly signal), the reporting state, LGA and ward, an outcome (alive/dead), and patient age and sex. Oral cholera vaccine (OCV) fields exist but are sparsely filled. Crucially the line-list is suspected cases — it is not restricted to laboratory-confirmed cholera, which matters for how a trigger threshold should be read.
1.2 Cases by year and state
Show code
tab = (
llp.pivot_table(index="year", columns="state", values="case", aggfunc="sum", fill_value=0)
.reindex(columns=U.STATES)
)
fig, ax = plt.subplots(figsize=(10, 5))
bottom = np.zeros(len(tab))
for st in U.STATES:
ax.bar(tab.index.astype(str), tab[st], bottom=bottom, label=st, color=U.STATE_COLORS[st])
bottom += tab[st].values
for i, total in enumerate(tab.sum(axis=1)):
ax.text(i, total + 200, f"{int(total):,}", ha="center", va="bottom", fontsize=10, fontweight="bold")
ax.set_ylabel("Suspected cases")
ax.set_title("Suspected cholera cases per year, by state (BAY, 2018–2023)")
ax.legend(title="State", ncol=3, loc="upper left")
ax.margins(y=0.12)
plt.tight_layout()
plt.show()
The burden is dominated by three years — 2018, 2021 and 2022 — with Borno carrying most cases in the largest years. 2019 and 2023 are thin, and 2020 is effectively absent. The next chapter shows the 2020 gap is a reporting failure, not a true absence of cholera.
1.3 Case-fatality by year
Show code
summ = (
ll.groupby("year")
.agg(cases=("case", "sum"), deaths=("death", "sum"))
.assign(cfr_pct=lambda d: (100 * d["deaths"] / d["cases"]).round(2))
.reset_index()
)
total = pd.DataFrame(
{
"year": ["All"],
"cases": [summ["cases"].sum()],
"deaths": [summ["deaths"].sum()],
"cfr_pct": [round(100 * summ["deaths"].sum() / summ["cases"].sum(), 2)],
}
)
out = pd.concat([summ, total], ignore_index=True)
out.columns = ["Year", "Cases", "Deaths", "CFR (%)"]
out.style.hide(axis="index").format({"Cases": "{:,}", "Deaths": "{:,}"})| Year | Cases | Deaths | CFR (%) |
|---|---|---|---|
| 2018 | 6,471 | 234 | 3.620000 |
| 2019 | 1,116 | 3 | 0.270000 |
| 2020 | 3 | 0 | 0.000000 |
| 2021 | 10,077 | 237 | 2.350000 |
| 2022 | 14,991 | 358 | 2.390000 |
| 2023 | 168 | 4 | 2.380000 |
| All | 32,826 | 836 | 2.550000 |
Overall CFR sits around 2.5%, above the <1% target for well-managed cholera response and consistent with outbreaks in hard-to-reach conflict-affected areas. The near-zero 2019 CFR (0.3%) is an artefact of that year’s sparse reporting rather than unusually mild disease.
1.4 Geographic spread
Show code
g = (
llp.groupby("state")
.agg(
cases=("case", "sum"),
deaths=("death", "sum"),
lgas=("ADM2_PCODE", "nunique"),
)
.reindex(U.STATES)
.reset_index()
)
g["cfr_pct"] = (100 * g["deaths"] / g["cases"]).round(2)
g.columns = ["State", "Cases", "Deaths", "Affected LGAs", "CFR (%)"]
g.style.hide(axis="index").format({"Cases": "{:,}", "Deaths": "{:,}"})| State | Cases | Deaths | Affected LGAs | CFR (%) |
|---|---|---|---|---|
| Borno | 21,132 | 564 | 26 | 2.670000 |
| Adamawa | 3,459 | 52 | 13 | 1.500000 |
| Yobe | 8,235 | 220 | 17 | 2.670000 |
Cases reach dozens of LGAs, but as Epidemiology shows, a small number of LGAs account for most of the burden — which is what makes an LGA-level trigger plausible.