Alert fatigue by arithmetic
Simulate a modelNeeds Python with NumPy, SciPy
Problem
A check with a 0.1% false-alarm rate sounds strict. Run 12,000 of them every day and they fire about 12 times a day on perfectly clean data, so people learn to ignore them. The textbook correction for many tests (Bonferroni: divide the threshold by the number of checks) silences the false alarms, and with them most of the real ones: a defect must now be enormous to clear the stricter bar. Alert fatigue and silent corruption are the same trade, seen from two sides.
Create 12,000 checks, each of which fires with probability 0.001 on perfectly clean random data. Simulate 90 days. Count the alerts per day (about 12). Then apply a Bonferroni correction (fire only if the check's p-value is below 0.001 ÷ 12,000) and add one injected real defect of moderate size per week. Report how many of the real defects each setting catches.
The question. How many alerts a day does the suite raise on clean data, and how many of the weekly real defects does each setting catch?
Predict
About 12 alerts per day. You will see that the corrected suite is quiet and misses most small real defects.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
alert-fatigue-by-arithmetic.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "scipy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Alert fatigue by arithmetic. 12,000 checks, each firing with probability 0.001 on clean data, for 90 days. Then a Bonferroni correction (fire only below 0.001 / 12,000), and one injected real defect of moderate size per week: how many does each setting catch? """ import argparse, json import numpy as np from scipy.stats import norm p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--checks", type=int, default=12_000, help="Checks in the suite.") p.add_argument("--alpha", type=float, default=0.001, help="Per-check false-positive rate.") p.add_argument("--days", type=int, default=90, help="Days simulated.") p.add_argument("--effect", type=float, default=3.5, help="Size of an injected defect, in standard errors of its check's statistic.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) z_plain = norm.isf(args.alpha / 2); z_bonf = norm.isf(args.alpha / args.checks / 2) defect_days = list(range(3, args.days, 7)) rows, caught = [], {"plain": 0, "bonferroni": 0} for d in range(args.days): z = rng.standard_normal(args.checks) # each check's statistic on clean data real = d in defect_days if real: z[rng.integers(args.checks)] += args.effect * rng.choice([-1, 1]) # one check sees a real defect fires_plain = int(np.sum(np.abs(z) > z_plain)); fires_bonf = int(np.sum(np.abs(z) > z_bonf)) rows.append({"day": d + 1, "alerts_plain": fires_plain, "alerts_bonferroni": fires_bonf}) if real: hit = np.abs(z) > z_plain caught["plain"] += bool(np.max(np.abs(z)) > z_plain) caught["bonferroni"] += bool(np.max(np.abs(z)) > z_bonf) summary = {"alerts_per_day_plain": float(np.mean([r["alerts_plain"] for r in rows])), "alerts_per_day_bonferroni": float(np.mean([r["alerts_bonferroni"] for r in rows])), "defects": len(defect_days), "caught_plain": caught["plain"], "caught_bonferroni": caught["bonferroni"]} print(f"per-check threshold {args.alpha}: {summary['alerts_per_day_plain']:.1f} alerts/day on clean data; " f"caught {caught['plain']} of {len(defect_days)} real defects (each among ~12 false alarms that day)") print(f"Bonferroni ({args.alpha} / {args.checks:,}): {summary['alerts_per_day_bonferroni']:.2f} alerts/day; " f"caught {caught['bonferroni']} of {len(defect_days)} real defects") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"days": rows, "summary": [summary]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run alert-fatigue-by-arithmetic.py uv run alert-fatigue-by-arithmetic.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --checks12,000 | Checks in the suite. |
| --alpha0.001 | Per-check false-positive rate. |
| --days90 | Days simulated. |
| --effect3.5 | Size of an injected defect, in standard errors of its check's statistic. |
| --seed0 | Random seed. |
Our run
90 days, one real defect a week
| Alerts/day, plain | Defects caught, plain | Alerts/day, Bonferroni | Defects caught, Bonferroni | Real defects |
|---|---|---|---|---|
| 12.2 | 13 | 0 | 0 | 13 |
per-check threshold 0.001: 12.2 alerts/day on clean data; caught 13 of 13 real defects (each among ~12 false alarms that day)
Bonferroni (0.001 / 12,000): 0.00 alerts/day; caught 0 of 13 real defectsWhat we found
On clean data the suite raised 12.2 alerts a day, as expected. It caught 13 of the 13 real defects, each one buried among that day's false alarms. With the Bonferroni correction it raised 0 alerts a day and caught 0 of the real defects: quiet, and blind to defects of this size.
Read it
Ten thousand monitors, each with a 1% false-alarm rate, produce about a hundred false alarms every day by construction. Neither setting is the answer. Fewer, better-targeted checks on what matters; thresholds in business units rather than p-values; and routing alerts by severity so that a person sees the dozen that matter, not the hundreds that fired.