Delayed Birth-Death Process

using Random
using Distributions
using CompetingClocks

Birth-death processes are a fundamental type of stochastic process, and are the building block of many more complicated models. Here we demonstrate how to use CompetingClocks to build a very simple simulation of a birth-death process where birth occurs according to an exponential (Markov) clock, but death occurs according to a Weibull distribution. We compare ensemble results to the known stationary distribution. Such models have been considered many times in the literature, but a recent reference is in "Stochastic description of delayed systems" by Lafuerza and Toral.

Model structure

The model will be stored in a struct with a type parameter that is a subtype of ContinuousUnivariateDistribution, which is the distribution type for the clock associated to death. We also define a function initialize_model! which enables a single clock for the birth event, and for each individual in the initial population, enables a death clock for that individual.

Known results tell us that the mean of the stationary distribution will be the birth rate multiplied by the average duration alive, which we use to choose the number of individuals in the initial population. We expect the model to fluctuate randomly around this value.

mutable struct ConstantBirth{T <: ContinuousUnivariateDistribution}
    birth_rate::Float64
    death_distribution::T
    next_name::Int64
    alive::Int64
    when::Float64
end


function initialize_model!(model, sampler, rng)
    enable!(sampler, 1, Exponential(1.0 / model.birth_rate), 0.0, 0.0, rng)

    initial_population = model.birth_rate * mean(model.death_distribution)

    for name_id in 1:Int(round(initial_population))
        past_birth = rand(rng, model.death_distribution)
        enable!(sampler, name_id, model.death_distribution, -past_birth, 0.0, rng)
        model.next_name = name_id + 1
        model.alive += 1
    end
end;

Model update

There's two classes of events that can occur in this model. Birth is always assigned to key 1. When it fires, we disable and enable the birth process to reset it, and then enable a death clock for the new individual. If the firing event was death, we simply disable the clock. We return the integrated population over time from the step! method to check simulation results.

function step!(model::ConstantBirth, sampler::SSA{K,T}, when::T, which::K, rng) where {K,T}
    if which == 1
        disable!(sampler, 1, when)
        enable!(sampler, 1, Exponential(1.0 / model.birth_rate), when, when, rng)

        name_id = model.next_name
        enable!(sampler, name_id, model.death_distribution, when, when, rng)
        model.next_name += 1
        model.alive += 1
    else
        disable!(sampler, which, when)
        model.alive -= 1
    end
    duration = when - model.when
    model.when = when
    model.alive * duration
end;

Our run function is simple. We use the FirstToFire sampler, but any sampler from CompetingClocks capable of supporting non-Exponential distributions can be used.

function run_constant_birth(rng, max_step = 10000)
    birth_rate = 117.0
    death_rate = Weibull(2.0, 80)
    model = ConstantBirth(birth_rate, death_rate, 2, 0, 0.0)

    sampler = FirstToFire{Int,Float64}()
    initialize_model!(model, sampler, rng)
    # Begin by dropping a few events to account for burn-in.
    when = 0.0
    (when, which) = next(sampler, when, rng)
    while when < 1e4
        step!(model, sampler, when, which, rng)
        (when, which) = next(sampler, when, rng)
    end

    # Then collect statistics.
    total::Float64 = 0.0
    start_time = when
    for _ in 1:max_step
        total += step!(model, sampler, when, which, rng)
        (when, which) = next(sampler, when, rng)
    end
    steady_state = model.birth_rate * mean(model.death_distribution)

    observed_state = total / (when - start_time)
    (steady_state, observed_state)
end;

Simulation

We check below that as we increase the data collected, it gets closer to the expected average with smaller standard deviation.

function multiple_runs(trial_cnt = 20, max_step = 1000)
    rng = Xoshiro(837100235)
    trials = zeros(Float64, trial_cnt)
    single_expected = 0.0
    Threads.@threads for trial_idx in 1:trial_cnt
        expected, observed = run_constant_birth(rng, max_step)
        trials[trial_idx] = observed
        single_expected = expected
    end
    (single_expected,
     (mean(trials) - single_expected) / single_expected,
     std(trials) / single_expected)
end

multiple_runs(20, 100)
(8295.084022237816, 0.008025814220693235, 0.018347178373091466)
multiple_runs(20, 1000)
(8295.084022237816, -0.0011113782851605522, 0.012126041783397188)
multiple_runs(20, 10000)
(8295.084022237816, -0.0033833323814249122, 0.012100328263793862)

This page was generated using Literate.jl.