Plan a Response

How Epidemics Spread

Chapter 10 · Plan a Response

Imagine you are handed a report at nine in the morning: ten people in a town of one hundred thousand have a new illness, and three more have turned up since breakfast. Somebody is going to ask you what to do. You cannot know the answer, but you now know how to ask good questions: how fast is it growing, what is R, who is infectious and when, how many cases are we missing, what does each measure buy, and what happens when the measure ends? This last chapter puts the whole course to work. It first looks back at five famous outbreaks and asks what each of them taught, then builds one small outbreak simulator out of the pieces of the earlier chapters, and finally tests a response plan against the numbers we are least sure of.

What was run, and what to trust
Every Python snippet in this chapter and in the three exercise solutions was executed, and the output in the comments is what Python printed (random seeds are fixed, so the same Python random generator should give the same numbers). The history comes from search-result summaries, which are often truncated, so check the originals before quoting. Sources conflicted on several points and I have not resolved them: estimates of the Black Death's toll run from about 39 per cent of Western Europe's population (one economists' synthesis) to over 60 per cent of Europe's (one demographer), and the number of deaths in the 1854 Broad Street outbreak is given as 616 in older accounts and 420 in the current English Wikipedia article. The 1918 influenza figure of at least 50 million deaths comes from a CDC page; other studies put it at up to 100 million. The date of the World Health Organization’s declaration that smallpox was eradicated, 8 May 1980 (resolution WHA33.3), appeared in a search summary. The ring-vaccination strategy for smallpox, the 2014 to 2016 West African Ebola outbreak and the March 2020 pandemic declaration are general knowledge that I did not check this session, so I mark them “(check)”. The simulator is my own simplified model. Every population, R0, delay, detection rate, contact cut and cost weight is assumed or illustrative, and each result is a property of the model, not a forecast for any real disease. No calculus is used: the model steps forward one day at a time, as in How Epidemics Spread 3 and How Epidemics Spread 5.

Five Outbreaks, Five Lessons

Each famous outbreak is remembered for a number, but what makes it useful to a planner is the question it forced people to ask. The table pairs each with the part of this course that explains it. The numbers are rough, and where the sources disagree the table says so.

OutbreakWhat the numbers sayLesson for a plannerWhere we met it
Black Death, 1347 to 1351roughly 39% of Western Europe’s population in one synthesis; 51 to 58%, or over 60%, in other estimates (sources conflict)separation can work without knowing the cause; ports learned to wait before letting people inHow Epidemics Spread 9 (quarantine)
Broad Street cholera, 1854616 deaths in older accounts, 420 in the current English Wikipedia article (conflict)map the cases and look for the shared source; a pump handle is a controlHow Epidemics Spread 1 (counting cases) and How Epidemics Spread 8 (case-control reasoning)
Influenza, 1918 to 1919at least 50 million deaths and about a third of the world infected (CDC); up to 100 million in other studiesa pandemic arrives in waves, and every estimate is wide, so plan for a rangeHow Epidemics Spread 2 (growth) and How Epidemics Spread 8 (estimating)
Smallpox eradication, declared 8 May 1980the WHO declaration; the strategy of vaccinating the contacts of each case, called ring vaccination, was central (check)find every case fast and protect the people around it: tracing plus vaccinationHow Epidemics Spread 4 (herd immunity) and How Epidemics Spread 9 (tracing)
Ebola in West Africa, 2014 to 2016 (check)tens of thousands of cases (I did not verify the totals)when hospital beds and staff run out, isolation fails, so capacity is part of the planHow Epidemics Spread 6 (small outbreaks) and How Epidemics Spread 9 (race against time)

One more is too recent to settle: the pandemic that the World Health Organization declared in March 2020 (check) produced more public modelling, and more arguments about modelling, than all earlier outbreaks put together. Many of the arguments were about exactly the questions in the next sections: which numbers were assumed, how sensitive the answer was to them, and what the models left out.

A Small Outbreak Simulator

The pieces are already built. From How Epidemics Spread 5 the model keeps an exposed (latent) stage between catching the infection and being able to pass it on: a person is Susceptible, then Exposed, then Infectious, then Recovered. From How Epidemics Spread 6 it keeps chance: the number of new infections each day is a random draw (Poisson) and so is the number of people who move between stages (binomial), so every run is different. From How Epidemics Spread 9 it takes two controls: isolation, in which each infectious person is found with some chance each day and then infects nobody, and a closure that multiplies the contact rate by (1 − cut) for a fixed number of days once enough cases have been found. A plan is then just a set of settings. The code runs 100 repeats of each plan and reports the 10th, 50th (median) and 90th percentile of the share ever infected, plus the average number of closed days.

# A small outbreak simulator: stochastic SEIR in daily steps, with isolation and a triggered closure. # Every number is assumed or illustrative. import math import random def poisson(lam): """Poisson draw: Knuth's method for small means, a normal approximation for large ones.""" if lam <= 0: return 0 if lam > 30: return max(0, round(random.gauss(lam, math.sqrt(lam)))) limit, k, p = math.exp(-lam), 0, 1.0 while True: p *= random.random() if p <= limit: return k k += 1 def binom(n, p): """Binomial draw: exact for small n, normal approximation for large n.""" if n <= 0 or p <= 0: return 0 if p >= 1: return n if n < 40: return sum(1 for _ in range(n) if random.random() < p) return min(n, max(0, round(random.gauss(n * p, math.sqrt(n * p * (1 - p)))))) def simulate(R0=2.5, detect=0.0, trigger=None, cut=0.5, closure_days=60, n=100_000, seeds=10, latent=3.0, infectious=5.0, days=300): """Return (share ever infected, peak infectious, closure days used). detect: chance per day that an infectious person is found and isolated (0 = never). trigger: close when this many cases have been found (None = never close).""" s, e, i, q, r = n - seeds, 0, seeds, 0, 0 beta = R0 / infectious found, closed_left, closed_used, peak = 0, 0, 0, seeds for day in range(days): factor = (1 - cut) if closed_left > 0 else 1.0 if closed_left > 0: closed_left -= 1 closed_used += 1 new_e = min(s, poisson(beta * factor * s * i / n)) new_i = binom(e, 1 / latent) new_found = binom(i, detect) new_r = binom(i - new_found, 1 / infectious) s -= new_e e += new_e - new_i i += new_i - new_found - new_r q += new_found r += new_r found += new_found peak = max(peak, i) if trigger is not None and closed_used == 0 and closed_left == 0 and found >= trigger: closed_left = closure_days return (n - s) / n, peak, closed_used def summary(label, **kwargs): random.seed(10) runs = [simulate(**kwargs) for _ in range(100)] attack = sorted(a for a, _, _ in runs) print(f"{label:34s} {attack[10]*100:6.1f}% {attack[50]*100:6.1f}% {attack[90]*100:6.1f}% " f"{sum(c for _, _, c in runs) / 100:8.1f}") print(f"{'plan (100 runs, R0 2.5 assumed)':34s} {'p10':>7s} {'median':>7s} {'p90':>7s} {'closed days':>12s}") summary("no control") summary("isolate (find 25% a day)", detect=0.25) summary("closure at 200 found, 50% cut", detect=0.1, trigger=200) summary("isolate + closure at 200 found", detect=0.25, trigger=200) summary("isolate + early closure (30 found)", detect=0.25, trigger=30) # Output when run: # plan (100 runs, R0 2.5 assumed) p10 median p90 closed days # no control 89.9% 90.1% 90.3% 0.0 # isolate (find 25% a day) 35.4% 37.1% 38.4% 0.0 # closure at 200 found, 50% cut 71.7% 72.1% 72.6% 60.0 # isolate + closure at 200 found 0.5% 0.5% 0.8% 57.6 # isolate + early closure (30 found) 0.1% 0.1% 0.1% 57.6

With no control about 90 per cent of the population is infected in every run. Isolation alone, finding a quarter of the infectious each day (a mean of 4 days from becoming infectious to being found, against a 5-day infectious period; both assumed), cuts that to 37 per cent. A closure by itself, with slow finding (10 per cent a day) and a 50 per cent cut for 60 days, does little: 72 per cent infected, because the closure ends and the epidemic resumes, the lesson of How Epidemics Spread 9. Put the two together and the picture changes: isolation plus a closure triggered at 200 found cases leaves only 0.5 per cent ever infected, and triggering it at 30 found cases leaves 0.1 per cent. The closure holds numbers down while isolation, now facing a small number of cases, catches up. The average number of closed days is a little under 60 in the last two rows; my reading, which I did not test, is that in a few runs the outbreak died out before the trigger was reached.

Plan (100 runs, R0 2.5 assumed)Median ever infectedSpread (10th to 90th)Closed days
No control90.1%89.9% to 90.3%0
Isolation only (25% found a day)37.1%35.4% to 38.4%0
Closure at 200 found, slow finding72.1%71.7% to 72.6%60.0
Isolation + closure at 200 found0.5%0.5% to 0.8%57.6
Isolation + closure at 30 found0.1%0.1% to 0.1%57.6

A caution about the tidy spread. In a population of 100,000 with 10 starting cases the simulation draws large numbers from a normal approximation, so the runs differ less than they would for a small village, and the random events that decide whether a tiny outbreak fizzles, as in How Epidemics Spread 6, are largely absent once the cases are many. The plan comparisons are still fair among themselves, but the narrow spreads should not be read as real-world certainty.

Judging a Plan: Stress-Test It

A plan that wins in the base case is only a candidate. The next step is to ask where it breaks. In the stress test below the plan is fixed (isolation plus a closure at 30 found cases) and the two numbers we know least about are varied: R0 (2.0, 2.5 or 3.5, all assumed) and how fast cases are found (from 35 per cent a day down to 5 per cent). Each cell is the median share ever infected over 60 runs.

# Stress-testing a plan: the same plan under different assumed values of the disease and of our own speed. import math import random def poisson(lam): if lam <= 0: return 0 if lam > 30: return max(0, round(random.gauss(lam, math.sqrt(lam)))) limit, k, p = math.exp(-lam), 0, 1.0 while True: p *= random.random() if p <= limit: return k k += 1 def binom(n, p): if n <= 0 or p <= 0: return 0 if p >= 1: return n if n < 40: return sum(1 for _ in range(n) if random.random() < p) return min(n, max(0, round(random.gauss(n * p, math.sqrt(n * p * (1 - p)))))) def attack_rate(R0, detect, trigger, cut=0.5, closure_days=60, n=100_000, seeds=10): """Share ever infected for one stochastic SEIR run (latent 3 d, infectious 5 d, both assumed).""" s, e, i = n - seeds, 0, seeds beta, found, left, used = R0 / 5.0, 0, 0, 0 for _ in range(300): factor = (1 - cut) if left > 0 else 1.0 if left > 0: left -= 1 used += 1 new_e = min(s, poisson(beta * factor * s * i / n)) new_i = binom(e, 1 / 3.0) new_f = binom(i, detect) new_r = binom(i - new_f, 1 / 5.0) s -= new_e e += new_e - new_i i += new_i - new_f - new_r found += new_f if used == 0 and left == 0 and found >= trigger: left = closure_days return (n - s) / n def median_attack(R0, detect, trigger, runs=60): vals = sorted(attack_rate(R0, detect, trigger) for _ in range(runs)) return vals[runs // 2] random.seed(21) print("Plan: isolate + closure triggered at 30 found cases. Median share ever infected.") print(f"{'':16s} {'R0 = 2.0':>10s} {'R0 = 2.5':>10s} {'R0 = 3.5':>10s}") for detect in (0.35, 0.25, 0.10, 0.05): cells = [median_attack(R0, detect, 30) * 100 for R0 in (2.0, 2.5, 3.5)] print(f"detect {detect:4.2f}/day " + " ".join(f"{c:9.1f}%" for c in cells)) # Output when run: # Plan: isolate + closure triggered at 30 found cases. Median share ever infected. # R0 = 2.0 R0 = 2.5 R0 = 3.5 # detect 0.35/day 0.0% 0.1% 0.2% # detect 0.25/day 0.1% 0.1% 71.9% # detect 0.10/day 0.3% 73.2% 89.5% # detect 0.05/day 68.0% 82.1% 92.1%

The plan looks safe, with 0.1 per cent infected, in the middle of the table, and then it falls off a cliff. At R0 = 3.5 a find rate of 25 per cent a day is not enough (71.9 per cent infected). At R0 = 2.5 a find rate of 10 per cent a day is not enough (73.2 per cent). Even at R0 = 2.0 a find rate of 5 per cent fails (68.0 per cent). There is no gentle middle: in this model the outcome is either a small outbreak or a large one, and which one you get depends on whether faster finding and the closure together push the effective reproduction number below 1, as in How Epidemics Spread 4 and How Epidemics Spread 9. Because the outcomes are one or the other, the median is a blunt summary; with real data you would report the share of runs that escape, as in How Epidemics Spread 9. The lesson for a planner is to work out how close to the edge you are, since a small error in R0 or in detection speed can flip the result completely, and these are the numbers hardest to measure early (How Epidemics Spread 8).

A Checklist for a Response Plan

The simulator answers a narrow question. A real plan needs the questions around it. This list is my own summary of the course, not an official protocol.

QuestionWhy it mattersWhere it came from
How fast is it growing, and how sure are we?doubling time and R0 set how little time there isHow Epidemics Spread 2, How Epidemics Spread 8
How many cases are we missing?testing bias distorts every curveHow Epidemics Spread 1, How Epidemics Spread 8
What does each measure buy, and for how long?a closure that ends leaves the susceptible people behindHow Epidemics Spread 9
Who is reached and who is missed?hubs and clusters matter more than averagesHow Epidemics Spread 7
What if the key number is wrong?plans fail at cliff edges, as the stress test showsthis capstone
What does it cost, and who decides the weight?the best plan changes with the value placed on a closed dayExercise 3
What is the exit?immunity, tracing capacity or a vaccine must change before a closure liftsHow Epidemics Spread 4, How Epidemics Spread 9
Common mistakes
Quoting one number from a model as if it were a forecast; judging a plan only in the base case and never stress-testing it; reading a median when the outcomes fall into two groups; trusting a narrow spread between runs that comes from the model’s own simplifications; treating the cost of a measure as zero, or choosing its weight without saying so; assuming every case is found and every isolation is perfect (both are optimistic here); copying the illustrative numbers in this chapter (R0 of 2.5, a 5-day infectious period, a 50 per cent cut) into a real decision; quoting the historical death tolls as if the sources agreed; and forgetting that a model can only answer the question it was built for.
Exercise 1

Using the simulator with slow isolation (10 per cent of the infectious found each day), a 50 per cent contact cut and a closure of 60 days, try triggers of 30, 100, 300, 1,000 and 3,000 found cases. Print the median share ever infected and the mean number of closed days over 100 runs each. Does an earlier or a later closure do better, and does either come close to stopping the epidemic?

📄 View solution
Exercise 2

With slow isolation (10 per cent a day) and a trigger of 100 found cases, try contact cuts of 30, 50 and 70 per cent and closure lengths of 30, 60 and 120 days. Print the median share ever infected and the cost as cut times mean closed days. Is there a threshold, and is a longer closure always worth its cost?

📄 View solution
Exercise 3

Score four plans with a loss equal to infections per 1,000 people plus w times the full-closure days, for w of 0.5, 2 and 8: isolation only, isolation plus a closure at 30 found cases, isolation plus a closure at 200, and slow finding (10 per cent a day) plus a closure at 30. Which plan is best for each w, and who should choose w?

📄 View solution

Quick Reference

  • Simulator: S, E, I, R in daily steps; Poisson new infections, binomial transitions; isolation finds a share of the infectious each day; a closure multiplies contacts by (1 − cut) for a set number of days after a trigger
  • Plan comparison: many runs, one change at a time, report a spread and the cost, state every assumed number
  • Isolation plus an early closure beat either alone in this model; a closure alone was only a delay
  • Stress test: vary the uncertain numbers (R0, detection speed); expect cliff edges, not gentle slopes
  • Median caution: if outcomes are either small or large, report the share of runs that escape
  • The weight on cost is a value judgement: the model cannot choose it
  • History: estimates conflict (Black Death, Broad Street, 1918); give ranges and say which source
  • Assumes: well-mixed population, perfect isolation, illustrative numbers throughout
Where to go from here
You now have the working parts of the field: counting cases, growth and doubling, compartment models, herd immunity, chance, networks, estimation and controls. To go further, pick one original source and read it in full rather than a summary; the paper on isolation and contact tracing that How Epidemics Spread 9 described is a good start. Then extend the simulator yourself. Add vaccination, as in How Epidemics Spread 4, or loss of immunity, as in How Epidemics Spread 5, or replace the well-mixed population with the contact network of How Epidemics Spread 7 and see which plans survive. Fit the model to a real epidemic curve using the methods of How Epidemics Spread 8, and always write down which numbers you assumed. A model is best treated as a way of asking sharper questions, not as a source of answers.