How Exponentially Tilted Primary Events Affect Observed Delay Distributions

Introduction

What are we going to do in this exercise

We'll demonstrate how epidemic growth creates additional bias in observed delay distributions through primary event censoring - distinct from truncation bias. We'll cover:

  1. Exponentially tilted vs Uniform primary events

  2. Double interval censoring effects

  3. Impact on delay distributions

What might I need to know before starting

This tutorial builds on Getting Started with CensoredDistributions.jl and focusses on exponentially tilted primary events - when primary events occur non-uniformly within observation windows (due here to exponential dynamics).

During epidemic growth/decline, primary events don't occur uniformly within our observation window:

  • Growth phase: Recent primary events over-represented → shorter observed delays

  • Decline phase: Older primary events more represented → longer observed delays

  • Steady state: Uniform timing → minimal additional bias

Setup

Packages used

begin
    using CensoredDistributions
    using Distributions
    using Plots
    using StatsPlots
    using DataFramesMeta
    using Statistics
    using Random

    Random.seed!(123)
end
TaskLocalRNG()

Simulated scenarios

We'll examine epidemic phase bias using a realistic scenario:

  • True incubation period: Gamma(4.0, 1.5) with mean 6.0 days

  • Primary event windows: 7-day observation periods

  • Growth rate scenarios: r ∈ {-10%, -5%, 0%, +5%, +10%}

true_delay = Gamma(4.0, 1.5);

Part 1: Exponentially Tilted vs Uniform Primary Events

First, we compare how Exponentially tilted distributions with different growth rates shape primary event timing compared to uniform (steady state) patterns.

begin
    # Create scenario data directly in DataFrame
    window_length = 7
    scenarios_df = @chain DataFrame(
        name = ["Decline 10%", "Decline 5%", "Steady", "Growth 5%", "Growth 10%"],
        r = [-0.10, -0.05, 0.0, 0.05, 0.10],
        color = [:darkgreen, :lightgreen, :blue, :orange, :red]
    ) begin
        @transform :window = window_length
    end

    # Create primary event distributions
    @chain scenarios_df begin
        @transform! :primary_event = ExponentiallyTilted.(0.0, :window, :r)
        @transform! :uniform_ref = Uniform.(0.0, :window)
    end
end
namercolorwindowprimary_eventuniform_ref
1"Decline 10%"-0.1:darkgreen7ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=-0.1)Distributions.Uniform{Float64}(a=0.0, b=7.0)
2"Decline 5%"-0.05:lightgreen7ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=-0.05)Distributions.Uniform{Float64}(a=0.0, b=7.0)
3"Steady"0.0:blue7ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=0.0)Distributions.Uniform{Float64}(a=0.0, b=7.0)
4"Growth 5%"0.05:orange7ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=0.05)Distributions.Uniform{Float64}(a=0.0, b=7.0)
5"Growth 10%"0.1:red7ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=0.1)Distributions.Uniform{Float64}(a=0.0, b=7.0)

Created 5 epidemic scenarios for comparison. Now lets plot the primary event timing distributions.

begin
    # Compare ExponentiallyTilted vs Uniform primary events
    x_primary = range(0, window_length, length = 100)

    p1 = plot(title = "Primary Event Timing: ExponentiallyTilted vs Uniform",
        xlabel = "Days before observation end", ylabel = "Probability density",
        size = (700, 400), legend = :topright)

    # Plot ExponentiallyTilted distributions
    for row in eachrow(scenarios_df)
        y_exponential = pdf.(row.primary_event, x_primary)
        plot!(p1, x_primary, y_exponential,
            label = "$(row.name) (r=$(row.r))",
            color = row.color, linewidth = 3)
    end

    # Add uniform reference (steady state comparison)
    uniform_ref = Uniform(0.0, window_length)
    y_uniform = pdf.(uniform_ref, x_primary)
    plot!(p1, x_primary, y_uniform,
        label = "Uniform (reference)", color = :black,
        linestyle = :dash, linewidth = 2)

    p1
end

Part 2: Double Interval Censoring - Primary + Secondary Windows

Now we demonstrate the double interval censoring concept: primary events occur within surveillance-defined primary windows, but during epidemics their timing distribution becomes non-uniform (ExponentiallyTilted), then we observe delays within secondary windows (surveillance window minus primary event time). For the secondary even the distribution doesn't impact what we observe (see references for why).

begin
    # Demonstrate effective censoring windows from combining primary + secondary
    n_samples = 1000000
    secondary_window = 7.0  # Secondary window length

    # DataFramesMeta lacks nest/unnest so we have to get clunky
    censoring_windows_df = @chain scenarios_df begin
        vcat([DataFrame(
                  name = fill(row.name, n_samples),
                  r = fill(row.r, n_samples),
                  color = fill(row.color, n_samples),
                  sample_id = 1:n_samples,
                  primary_time = rand(row.primary_event, n_samples)
              ) for row in eachrow(_)]...)
        @transform :effective_window = secondary_window .- :primary_time
    end
end
namercolorsample_idprimary_timeeffective_window
1"Decline 10%"-0.1:darkgreen16.346210.653787
2"Decline 10%"-0.1:darkgreen23.456823.54318
3"Decline 10%"-0.1:darkgreen31.800085.19992
4"Decline 10%"-0.1:darkgreen43.08293.9171
5"Decline 10%"-0.1:darkgreen55.464011.53599
6"Decline 10%"-0.1:darkgreen60.2080776.79192
7"Decline 10%"-0.1:darkgreen72.66824.3318
8"Decline 10%"-0.1:darkgreen82.015814.98419
9"Decline 10%"-0.1:darkgreen90.5282216.47178
10"Decline 10%"-0.1:darkgreen104.502172.49783
...
5000000"Growth 10%"0.1:red10000005.367821.63218

Now we can plot the distribution of effective censoring windows by scenario.

begin
    # Plot distribution of effective censoring windows by scenario
    p2 = plot(
        title = "Distribution of Effective Censoring Windows by Epidemic Phase",
        xlabel = "Effective censoring window (days)",
        ylabel = "Density",
        size = (700, 400),
        legend = :topright
    )

    # Plot density for each scenario
    for scenario_name in unique(censoring_windows_df.name)
        scenario_data = @subset(censoring_windows_df, :name .== scenario_name)
        scenario_color = scenario_data.color[1]

        # Use StatsPlots density plot
        density!(p2, scenario_data.effective_window,
            label = scenario_name,
            color = scenario_color,
            linewidth = 3,
            alpha = 0.7)
    end

    p2
end

Part 3: Impact on Censored Delay Distributions

Now we examine how epidemic phase bias affects the delays we might observe. We start with the primary censored delay distributions. Note that most of the time, we won't observe these continuous distributions as the secondary event will also be censored.

begin
    # Setup the primary censored distributions
    @chain scenarios_df begin
        @transform! :censored_dist = primary_censored.(Ref(true_delay), :primary_event)
    end

    # Visualise observed vs true delay distributions
    x_delay = range(0, 15, length = 100)

    p3 = plot(title = "Observed vs True Delay Distributions",
        xlabel = "Delay (days)", ylabel = "Probability density",
        size = (700, 400))

    # True distribution (reference)
    y_true = pdf.(true_delay, x_delay)
    plot!(p3, x_delay, y_true,
        label = "True distribution", color = :black,
        linestyle = :dash, linewidth = 3)

    # Observed distributions for each epidemic phase
    for row in eachrow(scenarios_df)
        y_obs = pdf.(row.censored_dist, x_delay)
        plot!(p3, x_delay, y_obs,
            label = "$(row.name) (r=$(row.r))",
            color = row.color, linewidth = 2)
    end

    p3
end

We can also plot the impact on the double censored distribution (which is what we would often observe). Here we show the CDF.

begin
    # Setup the double interval censored distributions
    @chain scenarios_df begin
        @rtransform! :double_censored_dist = double_interval_censored(
            true_delay; primary_event = :primary_event,
            interval = secondary_window
        )
    end

    # Visualise double vs single censored delay distributions
    x_delay_d = range(0, 40, length = 100)

    # True distribution (reference)
    y_true_d = cdf.(true_delay, x_delay_d)
    p4 = plot(title = "Double vs Single Interval Censored Distributions",
        xlabel = "Delay (days)", ylabel = "Probability density",
        size = (700, 400))

    plot!(p4, x_delay_d, y_true_d,
        label = "True distribution", color = :black,
        linestyle = :dash, linewidth = 3)

    # Double censored distributions for each epidemic phase
    for row in eachrow(scenarios_df)
        y_double = cdf.(row.double_censored_dist, x_delay_d)
        plot!(p4, x_delay_d, y_double,
            label = "$(row.name) Double (r=$(row.r))",
            color = row.color, linewidth = 2, linestyle = :solid)
    end

    p4
end

Key Insights

  1. Primary event bias is distinct from truncation bias:

    • Occurs regardless of the primary event distribution but is more complex when the primary event distribution is non-uniform.

    • Growth phase: Recent primary events over-represented → shorter observed delays

    • Decline phase: Older primary events more represented → longer observed delays

  2. Two key factors control bias magnitude:

    • Growth rate magnitude: Stronger growth (larger |r|) creates more divergence from the uniform case

    • Window length: Longer windows increases divergence for same growth rate

  3. When to use ExponentiallyTilted:

    • Most important when primary censoring intervals are wide (multi-day windows)

    • For daily primary censoring and moderate epidemic growth, Uniform distributions are often a reasonable approximation

    • Key benefit of Uniform: Analytical solutions available that are much faster computationally

    • Use ExponentiallyTilted when precision is critical, the computational cost is acceptable, and the growth rate is relatively well known.

References

  • Park et al. (2024): "Estimating epidemiological delay distributions for infectious diseases"

  • Charniga et al. (2024): "Primary event censoring in infectious disease surveillance"

  • SISMID Tutorial: Interactive bias demonstrations