The Gillespie algorithm from scratch

R
stochastic simulation
population models
ecology tutorial
Build the exact Gillespie stochastic simulation algorithm in base R, validate it against a Poisson stationary distribution, and measure what exactness costs.
Author

Tidy Ecology

Published

2026-07-28

A colleague counting great crested newts in a set of garden ponds once made a remark that stuck with me. Her spreadsheet had a column of integers, one per pond per visit, and every entry had been obtained by a person crouching in the dark with a torch. There was no version of her data in which a pond contained four and a half newts. The model she had been handed, on the other hand, was a differential equation whose solution passed smoothly through four and a half on its way from four to five, and it did so at every instant, in every pond, forever.

That mismatch is not pedantry. A pond with three newts and a pond with a smooth population density of three behave differently in exactly the situation an ecologist cares about most: near zero, where one animal more or less decides whether the local population persists. The differential equation cannot go extinct. It approaches zero and stays positive. The pond either has newts or it does not.

The fix is to simulate the thing that actually happens. Populations change by whole animals, one event at a time: a birth, a death, an immigrant arriving, an infection passing from one host to another. Between events nothing changes at all. If you are willing to say that the rate of each kind of event depends only on the current counts, then there is an algorithm that samples exactly from the process those rates define, with no time step, no discretisation and no approximation anywhere in it. It is about fifteen lines of base R. Daniel Gillespie published it for chemical kinetics in 1976 and 1977, and it has been carrying ecological models ever since.

This blog has used pieces of the machinery before without ever building the whole thing. The epidemic post Seasonality and recurrent epidemics runs a fixed-step tau leaping sampler in its critical community size section, which is an approximation to what we are about to build, and the coalescent posts draw exponential waiting times for a single kind of event. What has never appeared here is the exact algorithm as an object in its own right: the propensity vector, the exponential time to the next event of any type, the categorical draw for which type it was, and the argument for why that pair of draws is exact rather than merely good. That is what this post builds, and then it spends most of its length checking the result against mathematics that is known in closed form.

Everything here is base R plus ggplot2 for the pictures. The run sizes are deliberately modest, a few thousand short runs rather than a few hundred thousand, so the whole post knits in well under a minute on one core.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"),
          legend.position = "bottom")
}

The two draws that make it exact

Write the state of the system as a vector of counts, \(x\). Give yourself a list of event types. Event \(j\) changes the state by a fixed integer vector \(\nu_j\) (one birth adds one to a count, one infection moves a host from susceptible to infected) and happens at a rate \(a_j(x)\) that depends on the current state and nothing else. That last clause is the whole modelling assumption. Ecologists usually call \(a_j\) a rate; the chemistry literature calls it a propensity, and the word is useful because it keeps you aware that the quantity is a rate per unit time for the whole system, not a per capita rate.

Now ask what happens next. Each event type \(j\), considered on its own with the state held fixed, fires after an exponential waiting time with rate \(a_j(x)\). The first thing to happen is the minimum of those independent exponentials. Two facts about exponentials do all the work. The minimum of independent exponential variables with rates \(a_1, a_2, \ldots\) is itself exponential with rate \(a_0 = \sum_j a_j\). And the probability that the minimum was achieved by type \(j\) is \(a_j / a_0\), independently of the value of the minimum.

So you do not have to draw one waiting time per event type. You draw the time to the next event of any type from an exponential with the total rate, then you draw which type it was from a categorical distribution with probabilities proportional to the individual rates. Update the state, update the clock, recompute the rates, repeat. Nothing is discretised and nothing is linearised. The sampler steps from one event to the next and lands exactly on the jump times of the process.

The exponential is not a modelling choice you make on top of the rates; it follows from them. If the rate of an event is a constant \(a\) for as long as the state does not change, then the probability of surviving a further interval without that event is the same whether you have already waited a long time or no time at all, and the only distribution with that property is the exponential. That is the whole content of the constant rate assumption, and it is also where the modelling risk sits. An animal whose death rate rises with its age, a host whose infectious period is roughly a fixed number of days rather than a random exponential one, a season that turns: none of those fit, and the sampler will happily produce exact draws from the wrong process if you give it one. Coming back to that is the closing point of the post.

There is one more thing to notice about the pair of draws, because it explains why nothing is lost by collapsing the event types into a total. The waiting time and the identity of the event are independent. Knowing that you waited a long time tells you nothing about which event finally fired, so the categorical draw does not have to be conditioned on the time you drew first. That independence is a property of competing exponentials specifically, and it is the reason this algorithm is two lines rather than a nested calculation.

Here is the direct method. The rates argument is a function from state to propensity vector, nu is a matrix with one row per event type, and the function records the whole path so we can plot it.

gillespie <- function(x0, nu, rates, t_max, cap = 20000L) {
  x <- x0; tnow <- 0; k <- 0L
  tvec <- numeric(cap + 1L); xmat <- matrix(0, cap + 1L, length(x0))
  tvec[1L] <- 0; xmat[1L, ] <- x0
  while (k < cap) {
    a <- rates(x); a0 <- sum(a)
    if (a0 <= 0) break
    tnow <- tnow - log(runif(1)) / a0
    if (tnow > t_max) break
    j <- which(cumsum(a) >= runif(1) * a0)[1L]
    x <- x + nu[j, ]; k <- k + 1L
    tvec[k + 1L] <- tnow; xmat[k + 1L, ] <- x
  }
  list(t = tvec[1:(k + 1L)], x = xmat[1:(k + 1L), , drop = FALSE], events = k)
}

Two lines deserve a second look. The waiting time is drawn as -log(runif(1)) / a0, which is inverse transform sampling for the exponential and is what rexp does internally; writing it out keeps the total rate a0 visible in the place where it belongs. The event type is drawn as which(cumsum(a) >= runif(1) * a0)[1L], which is the categorical draw done by walking up the cumulative rate vector until you pass a uniform point on the interval from zero to the total rate. That is the entire algorithm. There is no step size to choose, no tolerance, no solver.

The loop stops for one of three reasons: the clock has passed t_max, the total rate has fallen to zero (an absorbing state, which for a population model means extinction), or the event budget cap is exhausted. The budget exists only so that a badly parameterised model cannot run forever; every model below is checked to finish well inside it.

The first system to try is the simplest one that has a non-trivial answer. Immigrants arrive at a constant rate imm, independent of how many animals are already present, and each animal present dies at rate mu. Two event types, two lines of propensity. The deterministic version, \(dN/dt = \mathrm{imm} - \mu N\), is a first-order linear equation whose solution from an empty patch is \(N(t) = (\mathrm{imm}/\mu)(1 - e^{-\mu t})\).

imm <- 2; mu <- 0.5; t_obs <- 16
nu_id <- matrix(c(1, -1), ncol = 1)
rates_id <- function(x) c(imm, mu * x[1])

set.seed(20260728)
one_run <- gillespie(c(0), nu_id, rates_id, t_obs, cap = 500L)
path <- data.frame(t = one_run$t, n = one_run$x[, 1])
path <- rbind(path, data.frame(t = t_obs, n = path$n[nrow(path)]))
det_curve <- data.frame(t = seq(0, t_obs, length.out = 400))
det_curve$n <- (imm / mu) * (1 - exp(-mu * det_curve$t))

empty_at <- one_run$t[one_run$x[, 1] == 0][-1]
print(round(c(immigration_rate = imm, per_capita_death_rate = mu,
              observation_time = t_obs,
              events_in_run = one_run$events,
              first_event_time = one_run$t[2],
              peak_count = max(one_run$x[, 1]),
              time_of_peak = one_run$t[which.max(one_run$x[, 1])],
              times_patch_empty = length(empty_at),
              first_empty = empty_at[1], second_empty = empty_at[2],
              deterministic_at_second_empty =
                (imm / mu) * (1 - exp(-mu * empty_at[2])),
              final_count = path$n[nrow(path)],
              deterministic_end = (imm / mu) * (1 - exp(-mu * t_obs))), 4))
             immigration_rate         per_capita_death_rate 
                       2.0000                        0.5000 
             observation_time                 events_in_run 
                      16.0000                       45.0000 
             first_event_time                    peak_count 
                       0.5387                        7.0000 
                 time_of_peak             times_patch_empty 
                       4.7103                        2.0000 
                  first_empty                  second_empty 
                       0.7651                       10.6606 
deterministic_at_second_empty                   final_count 
                       3.9806                        3.0000 
            deterministic_end 
                       3.9987 
ggplot() +
  geom_step(data = path, aes(t, n, colour = "one exact realisation"),
            linewidth = 0.7) +
  geom_line(data = det_curve, aes(t, n, colour = "deterministic solution"),
            linewidth = 1.1) +
  scale_colour_manual(values = c("one exact realisation" = te_pal$green,
                                 "deterministic solution" = te_pal$clay),
                      name = NULL) +
  labs(title = "Whole animals, one event at a time",
       x = "time", y = "count") +
  theme_te()
Time on the horizontal axis from zero to sixteen, count on the vertical axis from zero to seven. A green staircase starts at zero, dips back to zero just before time one, climbs in unit steps to a peak of seven near time five, then falls back and wanders between zero and five for the rest of the run, touching zero again near time eleven and ending at three. A smooth clay curve rises steeply from zero, bends over by about time five and flattens at four, crossing the staircase repeatedly.
Figure 1: One exact realisation of an immigration and death process (green steps) against the deterministic solution of the same rates (clay curve). The simulation moves only at event times and only by whole animals, and it empties the patch twice.

The realisation drawn here took 45 events to cross the 16 time units, and its first event landed at time 0.5387. It peaked at 7 animals at time 4.7103 and ended at 3, while the deterministic curve ends at 3.9987. Individually those numbers say nothing. What matters is the shape: the green path is flat between events and jumps by exactly one when an event happens, and it does not converge to anything, it keeps wandering. The clay curve is the average behaviour and no individual patch follows it.

One feature of the green path is worth pointing at directly, because it is the whole reason for the post. The count returned to zero twice, at time 0.7651 and again at time 10.6606. The patch was empty. Under immigration that is a temporary state and colonists arrive again, but in a model without immigration zero is absorbing and the run is over. The deterministic curve passes through 3.9806 animals at the second of those moments and has no way to express what happened.

Drawing the same law a different way

If the two-draw argument is right, then a sampler built the other way round has to give the same answer. The first-reaction method takes the description literally: draw one exponential waiting time per event type, find the smallest, advance the clock by it and fire that event. It throws away the other draws. It is wasteful, and Gillespie presented it mainly to motivate the direct method, but it is a genuinely different sampler and a good test.

gillespie_fr <- function(x0, nu, rates, t_max, cap = 20000L) {
  x <- x0; tnow <- 0; k <- 0L
  tvec <- numeric(cap + 1L); xmat <- matrix(0, cap + 1L, length(x0))
  tvec[1L] <- 0; xmat[1L, ] <- x0
  while (k < cap) {
    a <- rates(x)
    tau <- -log(runif(length(a))) / a          # Inf where a is zero
    j <- which.min(tau)
    if (!is.finite(tau[j])) break
    tnow <- tnow + tau[j]
    if (tnow > t_max) break
    x <- x + nu[j, ]; k <- k + 1L
    tvec[k + 1L] <- tnow; xmat[k + 1L, ] <- x
  }
  list(t = tvec[1:(k + 1L)], x = xmat[1:(k + 1L), , drop = FALSE], events = k)
}

To compare them we need a quantity with a distribution known in closed form. Take a pure death process: ten animals, each dying independently at rate mu_d, no births and no immigration. The time until the last one dies is the maximum of ten independent exponential lifetimes, so its distribution function is \(F(t) = (1 - e^{-\mu t})^{10}\) exactly. Its mean is \(\mu^{-1}\sum_{k=1}^{10} 1/k\) and its variance is \(\mu^{-2}\sum_{k=1}^{10} 1/k^2\), because the process spends an \(\mathrm{Exp}(k\mu)\) holding time in each state \(k\).

mu_d <- 0.4; n0_d <- 10; n_run_m <- 4000
nu_death <- matrix(-1, nrow = 1, ncol = 1)
rates_death <- function(x) c(mu_d * x[1])

ext_time <- function(fun, n_run) {
  vapply(seq_len(n_run), function(i) {
    r <- fun(c(n0_d), nu_death, rates_death, Inf, cap = 50L)
    r$t[length(r$t)]
  }, numeric(1))
}

set.seed(31082604)
e_dir <- ext_time(gillespie, n_run_m)
e_fr  <- ext_time(gillespie_fr, n_run_m)

cdf_ext <- function(z) (1 - exp(-mu_d * z))^n0_d
mean_exact <- sum(1 / (mu_d * (1:n0_d)))
sd_exact <- sqrt(sum(1 / (mu_d * (1:n0_d))^2))

kolm_p <- function(z) 2 * sum((-1)^(0:99) * exp(-2 * (1:100)^2 * z^2))
ks_one <- function(v, cdf) {
  n <- length(v); u <- sort(cdf(v))
  max(max((1:n) / n - u), max(u - (0:(n - 1)) / n))
}
ks_two <- function(v1, v2) {
  z <- sort(c(v1, v2)); max(abs(ecdf(v1)(z) - ecdf(v2)(z)))
}

d_dir <- ks_one(e_dir, cdf_ext)
d_fr <- ks_one(e_fr, cdf_ext)
d_two <- ks_two(e_dir, e_fr)

print(round(c(runs_per_method = n_run_m,
              mean_direct = mean(e_dir), mean_first = mean(e_fr),
              mean_exact = mean_exact,
              sd_direct = sd(e_dir), sd_first = sd(e_fr),
              sd_exact = sd_exact), 4))
runs_per_method     mean_direct      mean_first      mean_exact       sd_direct 
      4000.0000          7.2532          7.3083          7.3224          3.0970 
       sd_first        sd_exact 
         3.0820          3.1122 
print(round(c(D_direct_vs_exact = d_dir,
              p_direct = kolm_p(sqrt(n_run_m) * d_dir),
              D_first_vs_exact = d_fr,
              p_first = kolm_p(sqrt(n_run_m) * d_fr),
              D_direct_vs_first = d_two,
              p_two_sample = kolm_p(sqrt(n_run_m / 2) * d_two)), 4))
D_direct_vs_exact          p_direct  D_first_vs_exact           p_first 
           0.0161            0.2486            0.0108            0.7433 
D_direct_vs_first      p_two_sample 
           0.0217            0.3004 

With 4000 runs per method the direct sampler gives a mean extinction time of 7.2532 and the first-reaction sampler 7.3083, against the exact 7.3224. The standard deviations are 3.097 and 3.082 against the exact 3.1122. The Kolmogorov-Smirnov distance from the exact distribution function is 0.0161 for the direct method (p = 0.2486) and 0.0108 for first reaction (p = 0.7433). The two-sample distance between the samplers is 0.0217, p = 0.3004.

Three comparisons, none of them showing a discrepancy, and two of them against mathematics rather than against another simulation. That second point is the one to hold on to. A simulation checked only against another simulation can be wrong in the same way twice. The pure death process was chosen because its answer is a formula, and the formula does not care how you sampled.

The first-reaction sampler is doing real extra work for that agreement. It draws one uniform per event type per step, so with two event types it burns twice as many random numbers as the direct method and throws half of them away, and with twenty event types it burns twenty times as many. The waste is not just arithmetic: the draws it discards contain information, because an exponential that did not fire this step is still a valid partial waiting time for the next step once you rescale it. The next-reaction method exploits exactly that, keeping a priority queue of scheduled firing times and only recomputing the propensities that the last event actually changed. For a model with two or three event types, which covers most of what an ecologist writes down, the direct method is short enough and fast enough that the extra machinery is not worth it.

The p-values here are also a reminder about what a passed test means. Under the null the p-value is uniform on the unit interval, so a single value near 0.2486 or near one carries the same information: nothing detected at this sample size. A Kolmogorov-Smirnov test on 4000 draws would see a shift in the mean of roughly a few percent. A bug that shifted the mean by half a percent would sail straight through, which is why the next section compares a whole distribution cell by cell instead of testing it.

The calibration line: an exact stationary distribution

The immigration and death process has a property that makes it the best available test bed for an exact sampler. Started from an empty patch, the count at time \(t\) is exactly Poisson with mean \((\mathrm{imm}/\mu)(1 - e^{-\mu t})\), for every \(t\), not just in the limit. As \(t\) grows the mean approaches \(\mathrm{imm}/\mu\) and the stationary distribution is Poisson with that mean. There is nothing asymptotic about the claim and nothing to fit. If the sampler is exact, the histogram of final counts across independent runs is a Poisson sample, and any departure is Monte Carlo noise.

The comparison to make is not a test statistic but the distribution itself, cell by cell, against the noise you would expect if the sampler were perfect. That last part matters: a total variation distance of a hundredth means nothing until you know how big the total variation distance is between a perfect Poisson sample of the same size and the Poisson law it came from. So we simulate that reference too, by drawing Poisson samples of the same size with rpois and computing the same distance.

n_run_v <- 3000
set.seed(48151623)
fin <- numeric(n_run_v); ev_id <- numeric(n_run_v)
for (i in seq_len(n_run_v)) {
  r <- gillespie(c(0), nu_id, rates_id, t_obs, cap = 1000L)
  fin[i] <- r$x[nrow(r$x), 1]
  ev_id[i] <- r$events
}

lam <- imm / mu
lam_t <- lam * (1 - exp(-mu * t_obs))
k_max <- 13
obs_p <- tabulate(pmin(fin, k_max) + 1L, nbins = k_max + 1L) / n_run_v
exa_p <- dpois(0:k_max, lam)
exa_p[k_max + 1L] <- 1 - sum(exa_p[1:k_max])       # last cell is the upper tail
tvd <- 0.5 * sum(abs(obs_p - exa_p))
max_cell <- max(abs(obs_p - exa_p))

n_null <- 300
set.seed(90210777)
tv_null <- replicate(n_null, {
  z <- rpois(n_run_v, lam)
  0.5 * sum(abs(tabulate(pmin(z, k_max) + 1L, nbins = k_max + 1L) / n_run_v - exa_p))
})

print(round(c(runs = n_run_v, null_replicates = n_null,
              mean_events_per_run = mean(ev_id),
              exact_mean_at_t = lam_t, stationary_mean = lam,
              simulated_mean = mean(fin), simulated_variance = var(fin),
              total_variation = tvd, largest_cell_gap = max_cell,
              null_tv_mean = mean(tv_null),
              null_tv_q95 = unname(quantile(tv_null, 0.95))), 4))
               runs     null_replicates mean_events_per_run     exact_mean_at_t 
          3000.0000            300.0000             59.6663              3.9987 
    stationary_mean      simulated_mean  simulated_variance     total_variation 
             4.0000              4.0363              4.1537              0.0170 
   largest_cell_gap        null_tv_mean         null_tv_q95 
             0.0102              0.0216              0.0307 
print(data.frame(n = 0:k_max,
                 simulated = round(obs_p, 4),
                 poisson = round(exa_p, 4),
                 difference = round(obs_p - exa_p, 4)))
    n simulated poisson difference
1   0    0.0200  0.0183     0.0017
2   1    0.0780  0.0733     0.0047
3   2    0.1363  0.1465    -0.0102
4   3    0.1927  0.1954    -0.0027
5   4    0.1930  0.1954    -0.0024
6   5    0.1557  0.1563    -0.0006
7   6    0.1040  0.1042    -0.0002
8   7    0.0637  0.0595     0.0041
9   8    0.0340  0.0298     0.0042
10  9    0.0143  0.0132     0.0011
11 10    0.0063  0.0053     0.0010
12 11    0.0010  0.0019    -0.0009
13 12    0.0007  0.0006     0.0000
14 13    0.0003  0.0003     0.0001

The simulated mean is 4.0363 against the exact stationary 4, and against 3.9987, which is the exact mean at the observation time 16 and sits a thousandth below the stationary value. The simulated variance is 4.1537; for a Poisson distribution the variance equals the mean, and that is a second check the sampler was not asked to pass.

The distributional numbers are the point of the section. The total variation distance between the simulated distribution and the exact Poisson is 0.017, and the largest single cell discrepancy is 0.0102. Set against that, 300 perfect Poisson samples of the same size sit at a mean total variation distance of 0.0216 from the same exact law, with a ninety-fifth percentile of 0.0307. The sampler’s distance is smaller than the average distance achieved by a sample that came from the exact law by construction. There is no room left in that comparison for a systematic error: the discrepancy is pure sample size.

This is the chunk to copy when you write your own sampler for a model that nobody has tested. Strip your model back to a special case with a closed form, run the sampler on the special case, and compare the whole distribution against the formula with a noise reference computed the same way. A sampler that passes this on immigration and death, and passes the death process test above, has its exponential draws, its categorical draw and its bookkeeping all working.

pois_df <- data.frame(n = 0:k_max, simulated = obs_p, exact = exa_p)

ggplot(pois_df, aes(n)) +
  geom_col(aes(y = simulated, fill = "simulated"), width = 0.7) +
  geom_point(aes(y = exact, colour = "exact Poisson"), size = 2.4) +
  scale_fill_manual(values = c("simulated" = te_pal$green), name = NULL) +
  scale_colour_manual(values = c("exact Poisson" = te_pal$clay), name = NULL) +
  scale_x_continuous(breaks = 0:k_max) +
  labs(title = "The sampler against a closed form",
       x = "count at the observation time", y = "probability") +
  theme_te()
Counts zero to thirteen on the horizontal axis, probability on the vertical axis from zero to about 0.2. Green bars rise to a broad flat peak at three and four, then fall away smoothly to near zero by count nine. Clay points sit almost exactly on top of every bar; the largest visible mismatch is at count two, where the point sits about a hundredth above the bar.
Figure 2: Simulated stationary distribution of the immigration and death process (green bars, 3000 independent runs) against the exact Poisson law with the same mean (clay points). The rightmost cell holds the whole upper tail.

Nothing in the picture is meant to be surprising, and that is the use of it. When you later change the model and the bars stop sitting under the points, you know the change is in the model and not in the sampler.

A stochastic logistic population

Now a model with a nonlinearity. Births happen at rate \(bN\) and deaths at rate \(dN + (b - d)N^2/K\), so that the expected rate of change is \(rN(1 - N/K)\) with \(r = b - d\): the logistic equation, recovered exactly as the mean field limit. The extra deaths are the density dependence, and they are the only nonlinear term in the model.

The usual claim about this model is that the ensemble mean of the stochastic runs is not the deterministic solution, and the usual explanation is Jensen’s inequality: the death rate contains \(N^2\), and the expectation of \(N^2\) exceeds the square of the expectation by the variance, so the mean population feels more density dependence than the deterministic population does. That is true. The question this section actually answers is how much of the gap it accounts for, and the answer was not what I expected.

b_r <- 1; d_r <- 0.5; k_cap <- 40; n_start <- 4; t_end <- 20; n_run_l <- 500
nu_log <- matrix(c(1, -1), ncol = 1)
rates_log <- function(x) c(b_r * x[1], d_r * x[1] + (b_r - d_r) * x[1]^2 / k_cap)
t_grid <- seq(0, t_end, by = 0.25)

set.seed(70415529)
traj <- matrix(0, n_run_l, length(t_grid)); ev_log <- numeric(n_run_l)
for (i in seq_len(n_run_l)) {
  r <- gillespie(c(n_start), nu_log, rates_log, t_end, cap = 6000L)
  traj[i, ] <- r$x[findInterval(t_grid, r$t), 1]
  ev_log[i] <- r$events
}

r_growth <- b_r - d_r
det_n <- k_cap / (1 + (k_cap / n_start - 1) * exp(-r_growth * t_grid))
mean_all <- colMeans(traj)
alive <- traj[, ncol(traj)] > 0
mean_alive <- colMeans(traj[alive, , drop = FALSE])
gap_all <- det_n - mean_all
gap_alive <- det_n - mean_alive

late <- t_grid >= 14
pooled <- as.vector(traj[alive, late])
qs_mean <- mean(pooled); qs_var <- var(pooled)

last <- length(t_grid)
se_all <- sd(traj[, last]) / sqrt(n_run_l)
se_alive <- sd(traj[alive, last]) / sqrt(sum(alive))
early <- which(t_grid == 2)
gap_pct <- round(100 * gap_all[last] / det_n[last], 1)
gap_se_ratio <- round(gap_all[last] / se_all, 1)
pct_extinction <- round(100 * (1 - gap_alive[last] / gap_all[last]), 1)

print(round(c(runs = n_run_l, birth_coefficient = b_r, death_coefficient = d_r,
              carrying_capacity = k_cap, start_count = n_start,
              end_time = t_end, early_time = t_grid[early],
              mean_events_per_run = mean(ev_log),
              extinct_fraction = mean(!alive),
              extinct_percent = 100 * mean(!alive),
              extinct_se = sqrt(mean(!alive) * mean(alive) / n_run_l),
              linear_prediction = (d_r / b_r)^n_start), 4))
               runs   birth_coefficient   death_coefficient   carrying_capacity 
           500.0000              1.0000              0.5000             40.0000 
        start_count            end_time          early_time mean_events_per_run 
             4.0000             20.0000              2.0000           1021.4320 
   extinct_fraction     extinct_percent          extinct_se   linear_prediction 
             0.0700              7.0000              0.0114              0.0625 
print(round(c(deterministic_end = det_n[last],
              ensemble_mean_end = mean_all[last], se_ensemble = se_all,
              survivor_mean_end = mean_alive[last], se_survivors = se_alive,
              sd_survivors_end = sd(traj[alive, last]),
              gap_end = gap_all[last],
              gap_percent_of_deterministic = gap_pct,
              gap_in_standard_errors = gap_se_ratio,
              gap_end_survivors = gap_alive[last],
              share_from_extinction = 1 - gap_alive[last] / gap_all[last],
              percent_from_extinction = pct_extinction,
              largest_gap = max(gap_all),
              time_of_largest_gap = t_grid[which.max(gap_all)],
              gap_at_time_2 = gap_all[early],
              se_at_time_2 = sd(traj[, early]) / sqrt(n_run_l)), 4))
           deterministic_end            ensemble_mean_end 
                     39.9837                      34.4880 
                 se_ensemble            survivor_mean_end 
                      0.5702                      37.0839 
                se_survivors             sd_survivors_end 
                      0.4104                       8.8507 
                     gap_end gap_percent_of_deterministic 
                      5.4957                      13.7000 
      gap_in_standard_errors            gap_end_survivors 
                      9.6000                       2.8998 
       share_from_extinction      percent_from_extinction 
                      0.4723                      47.2000 
                 largest_gap          time_of_largest_gap 
                      6.8151                      12.0000 
               gap_at_time_2                 se_at_time_2 
                     -0.3312                       0.2699 
print(round(c(quasi_stationary_mean = qs_mean,
              quasi_stationary_variance = qs_var,
              observed_deficit = k_cap - qs_mean,
              moment_prediction = qs_var / qs_mean,
              deficit_shortfall = (k_cap - qs_mean) - qs_var / qs_mean), 4))
    quasi_stationary_mean quasi_stationary_variance          observed_deficit 
                  36.9593                   85.0962                    3.0407 
        moment_prediction         deficit_shortfall 
                   2.3024                    0.7383 

Start with the ensemble. At the end of the run the deterministic solution sits at 39.9837, effectively at the carrying capacity 40, while the mean over all 500 stochastic runs is 34.488 with a standard error of 0.5702. The gap is 5.4957 animals, about 13.7 per cent of the deterministic value and 9.6 standard errors wide, so it is a real feature and not sampling noise.

The gap is not constant over time. It reaches its largest value, 6.8151 animals, at time 12, by which point the deterministic curve has essentially arrived at capacity while the ensemble mean is still climbing, and it then settles back to about 5.4957. Early on the sign is not even reliable: at time 2 the measured gap is -0.3312 animals against a standard error of 0.2699, which is a little over one standard error on the wrong side of zero. While the population is small the density dependent term has almost nothing to act on and the ensemble mean and the deterministic curve are not separated by anything this experiment can resolve.

Now take the end gap apart, and this is where the plan I started with turned out to be only half right. Of the 500 runs, 7 per cent had gone extinct by the end, and those runs contribute zeros to the ensemble mean. Restricting the average to runs still alive at the end gives 37.0839 animals (standard error 0.4104), which leaves a gap of 2.8998. So 47.2 per cent of the visible gap is extinct runs and the rest is the curvature of the death rate. The standard explanation, that the mean population feels more density dependence than the deterministic population because the expectation of \(N^2\) exceeds the square of the expectation, accounts for barely half of what you see when you plot the ensemble mean.

The two mechanisms are not even active at the same time. Curvature bites when the population is large and the variance is large, which here is late. Extinction happens in the first few time units, when the population is 4 animals and a short run of bad luck is cheap. Averaging over the whole ensemble mixes a late effect with an early one and reports a single number that describes neither.

The extinction fraction is worth a number of its own, with the honest verdict attached. A pure birth and death process with these rates and no density dependence goes extinct from 4 animals with probability \((d/b)^{4} =\) 0.0625. Measured here it is 0.07 with a standard error of 0.0114. Density dependence adds deaths at every population size, so the true value should be above the linear prediction, but the difference measured here is less than one standard error and this experiment does not detect it. Saying so is more useful than rounding the agreement up into a confirmation.

The curvature part can be checked against mathematics rather than asserted. Taking expectations in the model gives \(\frac{d}{dt}E[N] = rE[N] - rE[N^2]/K\), and writing \(E[N^2] = m^2 + v\) for a mean \(m\) and variance \(v\) that have stopped moving turns the stationarity condition into \(K - m = v/m\): the deficit below carrying capacity should equal the variance divided by the mean. Pooling the survivors over the last quarter of the run gives a mean of 36.9593 and a variance of 85.0962, so the identity predicts a deficit of 2.3024 against an observed 3.0407. It gets about three quarters of the deficit and it should not get all of it: the derivation treats the survivor distribution as stationary and ignores the probability leaking away into extinction, and conditioning on survival adds a term that pushes the deficit up. The sign of the shortfall is the one that correction predicts, which is a weak check but not a vacuous one.

show_id <- 1:60
spag <- data.frame(
  t = rep(t_grid, length(show_id)),
  n = as.vector(t(traj[show_id, ])),
  id = rep(show_id, each = length(t_grid)))

summ <- rbind(
  data.frame(t = t_grid, n = det_n, series = "deterministic logistic"),
  data.frame(t = t_grid, n = mean_all, series = "mean of all runs"),
  data.frame(t = t_grid, n = mean_alive, series = "mean of surviving runs"))

ggplot() +
  geom_step(data = spag, aes(t, n, group = id),
            colour = te_pal$sage, alpha = 0.5, linewidth = 0.3) +
  geom_line(data = summ, aes(t, n, colour = series), linewidth = 1.1) +
  scale_colour_manual(
    values = c("deterministic logistic" = te_pal$clay,
               "mean of all runs" = te_pal$green,
               "mean of surviving runs" = te_pal$gold),
    name = NULL) +
  guides(colour = guide_legend(nrow = 2)) +
  labs(title = "The average run is not the average model",
       x = "time", y = "population size") +
  theme_te()
Time zero to twenty on the horizontal axis, population size zero to about eighty on the vertical. A dense band of thin sage staircases rises from four and spreads into a noisy band centred in the upper thirties, with a few paths dropping to zero and staying there and one straying near eighty. A clay curve rises smoothly through the band and flattens at forty. A green curve and a gold curve rise more slowly, fall below the clay curve from about time three onwards and level off below it, the green one lower than the gold.
Figure 3: Sixty of the 500 exact logistic runs (thin sage steps) with three summary curves: the deterministic logistic solution in clay, the mean over all runs in green, and the mean over runs still alive at the end in gold. Both means fall below the deterministic curve from the middle of the climb onwards.

The picture makes the decomposition visible. The sage band is wide, and a few of the paths in it never left the floor. The gold curve, which averages only the survivors, sits closer to the clay deterministic curve than the green one does, and the distance still left between gold and clay at the right hand edge is the curvature term on its own. Neither summary is the deterministic solution, and neither is a typical run: at time 20 the surviving runs have a standard deviation of 8.8507 animals around a mean of 37.0839, so quoting either mean as if it were the population trajectory throws away the spread that a manager would actually act on.

Final size, which the deterministic model cannot give you

The last model is the standard stochastic SIR with a fixed host population. Susceptibles become infected at rate \(\beta S I / N\) and infected hosts recover at rate \(\gamma I\); two event types, and the same engine. With \(R_0 = \beta / \gamma\) above one the deterministic model always produces an outbreak of a size given by the final size equation \(1 - z = e^{-R_0 z}\), and always the same one. The stochastic model does something the deterministic model has no way to represent: sometimes the index case recovers before infecting anyone and nothing happens at all.

Early in an outbreak, when \(S\) is close to \(N\), each infected host is the root of an approximate branching process that produces offspring at rate \(\beta\) and stops at rate \(\gamma\), giving a mean offspring number of \(R_0\). The extinction probability of that branching process from one root is \(1/R_0\), and from \(i_0\) independent roots it is \(R_0^{-i_0}\). That prediction is available to test.

n_host <- 100; r0 <- 2; gam <- 1; bet <- r0 * gam; n_run_s <- 2000
nu_sir <- matrix(c(-1, 1, 0, -1), nrow = 2, byrow = TRUE)
rates_sir <- function(x) c(bet * x[1] * x[2] / n_host, gam * x[2])

final_size <- function(i0, n_run) {
  out <- matrix(0, n_run, 2)
  for (i in seq_len(n_run)) {
    r <- gillespie(c(n_host - i0, i0), nu_sir, rates_sir, Inf, cap = 260L)
    out[i, ] <- c((n_host - i0) - r$x[nrow(r$x), 1], r$events)
  }
  colnames(out) <- c("size", "events")
  out
}

set.seed(60219384)
sir1 <- final_size(1, n_run_s)
sir2 <- final_size(2, n_run_s)

thr <- 10
fz1 <- mean(sir1[, "size"] < thr); fz2 <- mean(sir2[, "size"] < thr)

# deterministic final size: s_inf solves s = s0 * exp(-R0 * (1 - s))
s_start <- (n_host - 1) / n_host
s_inf <- uniroot(function(s) s - s_start * exp(-r0 * (1 - s)), c(1e-6, 0.98))$root
det_size <- n_host * (s_start - s_inf)
big1 <- sir1[sir1[, "size"] >= thr, "size"]

print(round(c(hosts = n_host, R0 = r0, recovery_rate = gam,
              runs_each = n_run_s, fizzle_threshold = thr,
              fizzle_i0_1 = fz1, predicted_i0_1 = 1 / r0,
              se_i0_1 = sqrt(fz1 * (1 - fz1) / n_run_s),
              fizzle_i0_2 = fz2, predicted_i0_2 = 1 / r0^2,
              se_i0_2 = sqrt(fz2 * (1 - fz2) / n_run_s)), 4))
           hosts               R0    recovery_rate        runs_each 
        100.0000           2.0000           1.0000        2000.0000 
fizzle_threshold      fizzle_i0_1   predicted_i0_1          se_i0_1 
         10.0000           0.5020           0.5000           0.0112 
     fizzle_i0_2   predicted_i0_2          se_i0_2 
          0.2505           0.2500           0.0097 
gap_top <- 30
print(round(c(gap_upper_edge = gap_top,
              runs_in_the_gap = sum(sir1[, "size"] >= thr & sir1[, "size"] <= gap_top),
              mean_size_given_takeoff = mean(big1),
              se_size_given_takeoff = sd(big1) / sqrt(length(big1)),
              deterministic_final_size = det_size,
              deterministic_fraction = s_start - s_inf,
              shortfall_of_takeoff_runs = det_size - mean(big1),
              mean_events_i0_1 = mean(sir1[, "events"]),
              mean_events_i0_2 = mean(sir2[, "events"])), 4))
           gap_upper_edge           runs_in_the_gap   mean_size_given_takeoff 
                  30.0000                   40.0000                   74.6878 
    se_size_given_takeoff  deterministic_final_size    deterministic_fraction 
                   0.4969                   79.0231                    0.7902 
shortfall_of_takeoff_runs          mean_events_i0_1          mean_events_i0_2 
                   4.3354                   76.3280                  113.7740 

With one index case in a population of 100 and \(R_0 = 2\), the fraction of runs that fizzled out below 10 cases is 0.502, standard error 0.0112, against the branching process prediction 0.5. With two index cases it is 0.2505, standard error 0.0097, against 0.25. The exponent is doing exactly what the theory says: two independent introductions both have to fail, and squaring a half gives a quarter.

That agreement is better than it has any right to be, and the reason deserves stating rather than celebrating. The branching approximation ignores the depletion of susceptibles, which makes take-off slightly harder than the approximation says, so the true fizzle probability should be a little above \(1/R_0\). At 100 hosts and 2000 runs that bias is smaller than the standard error of 0.0112, so this experiment cannot see it. The right reading is that the prediction survives at this precision, not that the correction is zero.

The threshold of 10 cases is not a delicate choice, because there is almost nothing to cut through. Only 40 of the 2000 runs finished with a final size between 10 and 30 cases. The distribution has a spike at the bottom and a broad hump near the top and hardly anything in between, so any threshold in that empty region gives the same answer.

The runs that did take off infected 74.6878 hosts on average, with a standard error of 0.4969. The deterministic final size equation, solved for the same starting condition, gives 79.0231 hosts. Conditioning on take-off therefore does not recover the deterministic answer either: it falls short by 4.3354 hosts, many standard errors below, because an outbreak that survives its first few generations still loses cases to chance on the way up and arrives at the peak with a slightly smaller infected pool than the mean field equation assumes.

fs_df <- rbind(
  data.frame(size = sir1[, "size"], panel = "one index case"),
  data.frame(size = sir2[, "size"], panel = "two index cases"))
lab_df <- data.frame(
  panel = c("one index case", "two index cases"),
  txt = c(sprintf("fizzle %.4f (predicted %.4f)", fz1, 1 / r0),
          sprintf("fizzle %.4f (predicted %.4f)", fz2, 1 / r0^2)))

ggplot(fs_df, aes(size)) +
  geom_histogram(binwidth = 4, fill = te_pal$green, colour = te_pal$paper,
                 linewidth = 0.2) +
  geom_vline(xintercept = thr, colour = te_pal$clay, linetype = 2,
             linewidth = 0.7) +
  geom_text(data = lab_df, aes(x = 50, y = Inf, label = txt),
            vjust = 1.6, colour = te_pal$ink, size = 3.4) +
  facet_wrap(~ panel) +
  labs(title = "Two outcomes, not one",
       x = "final size (hosts infected)", y = "number of runs") +
  theme_te()
Two panels side by side, final size zero to one hundred on the horizontal axis and count of runs on the vertical. Each panel has a tall green spike at the far left near zero cases and a broad low hump between about fifty and ninety-five cases, with an almost empty valley between them. The spike in the left panel is roughly twice as tall as the spike in the right panel, while the hump in the right panel is correspondingly taller.
Figure 4: Distribution of epidemic final size from 2000 exact runs with one index case (left) and two (right). Both panels are strongly bimodal; the dashed clay line is the fizzle threshold of ten cases and the label gives the measured and predicted fizzle probability.

The deterministic SIR has no way to produce this picture. It gives one number, the right hand hump, and it gives it with certainty. Half of the realisations here never got there. For a manager deciding whether to act on a single detected case, the useful output is not the size of the outbreak that would follow but the probability that there will be an outbreak at all, and that probability is a property of the stochastic model only.

What exactness costs

The algorithm is exact because it visits every event. That is also the whole of its cost. The number of iterations is the number of events, and the number of events does not care how long the simulated interval is in isolation: it is the total rate integrated over that interval. Double the population and you double the rate of births and the rate of deaths, so you double the work for the same simulated time.

Never measure this with a clock. A timing number depends on the machine, the other things the machine is doing, and the phase of the moon, and it will be wrong the first time somebody else runs the code. The event count is a property of the model and the seed, and it is reproducible.

set.seed(11235813)
k_grid <- c(10, 20, 40, 80, 160)
n_rep_c <- 40
t_cost <- 10
cost_tab <- data.frame(
  K = k_grid,
  mean_events = sapply(k_grid, function(kk) {
    rl <- function(x) c(b_r * x[1], d_r * x[1] + (b_r - d_r) * x[1]^2 / kk)
    mean(replicate(n_rep_c,
      gillespie(c(kk), nu_log, rl, t_cost, cap = 20000L)$events))
  }))
cost_tab$events_per_K <- round(cost_tab$mean_events / cost_tab$K, 2)
cost_tab$mean_events <- round(cost_tab$mean_events, 1)
print(cost_tab)
    K mean_events events_per_K
1  10       179.6        17.96
2  20       365.0        18.25
3  40       756.2        18.91
4  80      1566.8        19.59
5 160      3213.5        20.08
print(round(c(replicates_per_K = n_rep_c, simulated_time = t_cost,
              ratio_160_to_10 = cost_tab$mean_events[5] / cost_tab$mean_events[1],
              ratio_of_K = k_grid[5] / k_grid[1],
              large_K_limit = (b_r + d_r + (b_r - d_r)) * t_cost), 4))
replicates_per_K   simulated_time  ratio_160_to_10       ratio_of_K 
         40.0000          10.0000          17.8925          16.0000 
   large_K_limit 
         20.0000 

10 time units of simulation starting at carrying capacity cost 179.6 events on average when \(K\) is 10, and 3213.5 events when \(K\) is 160. The ratio of work is 17.8925 for a population ratio of 16, and the events per unit of \(K\) rise from 17.96 to 20.08 across the range. Both facts say the same thing. The cost is proportional to the population size, with a mild correction at small \(K\): at \(K\) = 10 a population sits proportionally further below its capacity than at \(K\) = 160, so it generates fewer events per animal. The large population limit is \((b + d + r)K\) per unit time, which is 20 events per unit of \(K\) over 10 time units, and the measured 20.08 is approaching it from below.

The rest of the post makes the same point without being asked. An immigration and death run to time 16, hovering around four animals, averaged 59.6663 events. A logistic run to time 20 with a capacity of 40 averaged 1021.432, roughly seventeen times the work for a population an order of magnitude larger held for a little longer. The epidemic runs are the interesting case: with one index case they averaged 76.328 events and with two index cases 113.774, and the difference is not that two infectives spread faster but that half as many runs die out in the first few events. Cheap runs are cheap because nothing happens in them, which is also why the total cost of a stochastic study is hard to guess in advance.

That is the practical limit of the method. A model of a few hundred individuals is comfortable. A model of a hundred thousand, run for a season, is not: you would be asking base R for tens of millions of loop iterations to get one trajectory. This is precisely the situation where tau leaping earns its keep, holding the propensities fixed over a short window and drawing a Poisson number of firings of each event type instead of stepping through them one at a time. That is an approximation, it has an error you can measure, and it is the subject of the next post. The point of building the exact algorithm first is that you now have something to measure the approximation against.

What to take away

The exact algorithm is short enough to write from memory: total rate, exponential waiting time, categorical choice of event, update, repeat. The value of writing it yourself is that every assumption is visible in the rates function, and there is no step size hiding an error term. When it matters that populations are integers, and it matters most near zero, this is the sampler that gets the answer right rather than nearly right.

The measurements here also make a case for how to check any sampler. Almost every comparison in this post is against a closed form rather than against another simulation: the maximum of ten exponentials for the pure death process, the Poisson law for immigration and death, where the simulated distribution sat 0.017 in total variation from the exact law while perfect samples of the same size sit at 0.0216 on average, the branching process fizzle probability for the epidemic, and the moment identity for the logistic. Only one comparison, direct method against first reaction, put two samplers side by side, and that is the weakest of the set as well as the one most people actually run.

The honest limit is the one that no amount of exactness fixes. The algorithm samples exactly from the model it is given, and the model is a claim that between events the rate of every event type is a fixed function of the current counts; nothing in this post tests that claim against a real population, where rates vary with weather, age, spatial arrangement and the memory of what happened last month. The stochastic logistic reproduced its own moment identity to within 0.7383 animals, with the shortfall pointing the way the neglected boundary term predicts. That says the code does what the model says. It says nothing at all about whether a pond of newts obeys the model. Exact simulation of a wrong model is still a wrong answer, delivered one event at a time.

References

Gillespie DT 1976 Journal of Computational Physics 22(4):403-434 (10.1016/0021-9991(76)90041-3)

Gillespie DT 1977 The Journal of Physical Chemistry 81(25):2340-2361 (10.1021/j100540a008)

Gillespie DT 2001 The Journal of Chemical Physics 115(4):1716-1733 (10.1063/1.1378322)

Anderson RM, May RM 1991 Infectious Diseases of Humans: Dynamics and Control (ISBN 978-0-19-854040-3)

Nasell I 2001 Journal of Theoretical Biology 211(1):11-27 (10.1006/jtbi.2001.2328)

Keeling MJ, Rohani P 2008 Modeling Infectious Diseases in Humans and Animals (ISBN 978-0-691-11617-4)

Black AJ, McKane AJ 2012 Trends in Ecology and Evolution 27(6):337-345 (10.1016/j.tree.2012.01.014)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.