4  Trigger exploration

This chapter defines a candidate trigger and shows how its pieces behave on the historical data. It reuses the design from the earlier BAY exploration and the DRC cholera framework, not an endorsed Nigeria framework — thresholds here are illustrative.

4.1 The candidate trigger

For each LGA and each epidemiological week, the trigger evaluates two conditions:

  1. Per-capita percentile. Weekly cases exceed 0.01765% of the LGA’s population — the 99th-percentile weekly caseload (FRAC_THRESH_99 = 0.0001765).
  2. Growth. Weekly cases are at least the previous week (off a base of ≥5 cases, so a jump from 1→4 doesn’t fire).

A week is at alert if either holds. The trigger fires (activates) when alert level is sustained for 3 consecutive weeks — filtering one-week blips and confirming a real trajectory.

Why per-capita, and why zero-fill? A fixed case count means very different things in a 250k-person LGA and a 1.5m-person one, so the threshold scales with population. And percentiles are computed over the zero-filled weekly series (see Data quality) — percentiles taken only over weeks with cases sit far too high to ever fire.

4.1.1 What the threshold means in cases

Show code
th = (
    lw[["ADM2_PCODE", "ADM2_EN", "state", "Pop2023"]]
    .drop_duplicates()
    .assign(thresh_cases=lambda d: (d["Pop2023"] * U.FRAC_THRESH_99).round().astype(int))
)
th["priority"] = th["ADM2_PCODE"].isin(U.PRIORITY_LGAS)
show = pd.concat([
    th[th["priority"]],
    th[~th["priority"]].sort_values("Pop2023", ascending=False).head(6),
]).sort_values(["priority", "Pop2023"], ascending=[False, False])
show = show[["ADM2_EN", "state", "Pop2023", "thresh_cases", "priority"]]
show.columns = ["LGA", "State", "Population (2023)", "Weekly-case threshold", "Priority LGA"]
show.style.hide(axis="index").format({"Population (2023)": "{:,.0f}"})
Table 4.1: Per-capita 99th-percentile threshold translated into weekly cases, for the four priority LGAs and the largest-burden LGAs.
LGA State Population (2023) Weekly-case threshold Priority LGA
Bama Borno 476,640 84 True
Ngala Borno 418,531 74 True
Dikwa Borno 186,974 33 True
Numan Adamawa 147,496 26 True
Maiduguri Borno 920,655 162 False
Fune Yobe 539,767 95 False
Gwoza Borno 487,808 86 False
Jakusko Yobe 411,130 73 False
Damboa Borno 408,825 72 False
Jere Borno 372,865 66 False

A weekly threshold of roughly 30–100 cases (depending on LGA size) is what the percentile rule implies. The growth rule catches fast take-offs that haven’t yet cleared the absolute bar.

4.2 Alert heatmap

Show code
active_lgas = trig[trig["alert"]]["ADM2_PCODE"].unique()
sub = trig[trig["ADM2_PCODE"].isin(active_lgas)].copy()
# 0 none, 1 alert, 2 sustained
sub["level"] = np.where(sub["sustained"], 2, np.where(sub["alert"], 1, 0))
order = (
    sub.groupby(["ADM2_PCODE", "ADM2_EN", "state"])["level"].sum()
    .sort_values(ascending=False).reset_index()
)
mat = sub.pivot_table(index="ADM2_PCODE", columns="week", values="level", fill_value=0)
mat = mat.reindex(order["ADM2_PCODE"])
labels = order.set_index("ADM2_PCODE").loc[mat.index, "ADM2_EN"]

cmap = ListedColormap([U.HDX["gray_light"], U.HDX["amber"], U.HDX["red"]])
fig, ax = plt.subplots(figsize=(11, max(4, 0.28 * len(mat))))
weeks = mat.columns
ax.imshow(mat.values, aspect="auto", cmap=cmap, vmin=0, vmax=2,
          extent=[mdates.date2num(weeks[0]), mdates.date2num(weeks[-1]), len(mat), 0])
ax.set_yticks(np.arange(len(mat)) + 0.5)
ax.set_yticklabels(labels, fontsize=7)
ax.xaxis_date()
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.grid(False)
handles = [plt.Rectangle((0, 0), 1, 1, color=c) for c in [U.HDX["amber"], U.HDX["red"]]]
ax.legend(handles, ["Alert week", "Sustained trigger"], loc="upper right", framealpha=0.9)
ax.set_title("Weekly trigger state by LGA")
plt.tight_layout()
plt.show()
Figure 4.1: Weekly trigger state for every LGA that ever reaches alert. Amber = alert week; red = sustained (3-week) trigger. Activity clusters tightly in the 2018, 2021 and 2022 seasons.

Alerts are tightly clustered in the three outbreak seasons; between them the panel is quiet. That is the pattern a well-behaved trigger should show — dormant in calm periods, lit up during outbreaks.

4.3 Sensitivity to the design choices

How many activations does the trigger produce under alternative thresholds? Below we sweep the percentile, the growth multiple and the consecutive-week requirement, one axis at a time.

Show code
rows = []

def n_act(frac=U.FRAC_THRESH_99, growth=U.GROWTH_MULTIPLE, consec=U.CONSEC_WEEKS):
    t = U.apply_trigger(lw, frac_thresh=frac, growth_multiple=growth, consec=consec)
    return len(U.activations(t))

rows.append(("Baseline (p99 frac, 4×, 3 wk)", n_act()))
rows.append(("Percentile → p95 frac", n_act(frac=U.FRAC_THRESH_95)))
rows.append(("Growth → 3×", n_act(growth=3)))
rows.append(("Growth → 5×", n_act(growth=5)))
rows.append(("Window → 2 consecutive weeks", n_act(consec=2)))
rows.append(("Window → 4 consecutive weeks", n_act(consec=4)))
sens = pd.DataFrame(rows, columns=["Configuration", "LGA-year activations"])
sens.style.hide(axis="index")
Table 4.2: LGA-year activations under alternative trigger parameters (one parameter varied from the baseline at a time). Baseline = p99 frac OR 4× growth, 3 weeks.
Configuration LGA-year activations
Baseline (p99 frac, 4×, 3 wk) 27
Percentile → p95 frac 81
Growth → 3× 28
Growth → 5× 26
Window → 2 consecutive weeks 39
Window → 4 consecutive weeks 20

The trigger is most sensitive to the percentile (loosening to p95 sharply raises activations) and to the consecutive-week window (2 weeks is much noisier; 4 weeks much stricter). The growth multiple moves the count less. The baseline (p99 / 4× / 3 weeks) sits in a sensible middle — active in outbreak years, quiet otherwise — which is why the following chapters use it.