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:
Exponentially tilted vs Uniform primary events
Double interval censoring effects
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
name | r | color | window | primary_event | uniform_ref | |
---|---|---|---|---|---|---|
1 | "Decline 10%" | -0.1 | :darkgreen | 7 | ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=-0.1) | Distributions.Uniform{Float64}(a=0.0, b=7.0) |
2 | "Decline 5%" | -0.05 | :lightgreen | 7 | ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=-0.05) | Distributions.Uniform{Float64}(a=0.0, b=7.0) |
3 | "Steady" | 0.0 | :blue | 7 | ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=0.0) | Distributions.Uniform{Float64}(a=0.0, b=7.0) |
4 | "Growth 5%" | 0.05 | :orange | 7 | ExponentiallyTilted{Float64}(min=0.0, max=7.0, r=0.05) | Distributions.Uniform{Float64}(a=0.0, b=7.0) |
5 | "Growth 10%" | 0.1 | :red | 7 | ExponentiallyTilted{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
name | r | color | sample_id | primary_time | effective_window | |
---|---|---|---|---|---|---|
1 | "Decline 10%" | -0.1 | :darkgreen | 1 | 6.34621 | 0.653787 |
2 | "Decline 10%" | -0.1 | :darkgreen | 2 | 3.45682 | 3.54318 |
3 | "Decline 10%" | -0.1 | :darkgreen | 3 | 1.80008 | 5.19992 |
4 | "Decline 10%" | -0.1 | :darkgreen | 4 | 3.0829 | 3.9171 |
5 | "Decline 10%" | -0.1 | :darkgreen | 5 | 5.46401 | 1.53599 |
6 | "Decline 10%" | -0.1 | :darkgreen | 6 | 0.208077 | 6.79192 |
7 | "Decline 10%" | -0.1 | :darkgreen | 7 | 2.6682 | 4.3318 |
8 | "Decline 10%" | -0.1 | :darkgreen | 8 | 2.01581 | 4.98419 |
9 | "Decline 10%" | -0.1 | :darkgreen | 9 | 0.528221 | 6.47178 |
10 | "Decline 10%" | -0.1 | :darkgreen | 10 | 4.50217 | 2.49783 |
... | ||||||
5000000 | "Growth 10%" | 0.1 | :red | 1000000 | 5.36782 | 1.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
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
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
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