EpiAware EpiAware EpiAware
  • Approaches
    • Overview
    • Composed distributions
    • Composable Turing models
  • Tutorials
  • Docs
  • News
  • Gallery
  • Guides
    • Resources
    • Using Julia
    • FAQ
    • Contributing
    • Developer docs
  • Community
    • Get involved
    • Team
    • Funding
    • epinowcast & the forum

Composed distributions

Join the delays in a case’s history into one distribution that can simulate a case or be fitted to observed ones.
One object for the whole case history
Works with individual records
Same representation, multiple uses
composed_delays.jl
using ComposedDistributions, Distributions

# BDBV-inspired delay tree: parallel pathways
# with sequential, resolve, and compete nested together
tree = @uncertain compose((
    clinical = sequential(
        :onset_admit => Gamma(Normal(1.2, 0.2), 3.0),
        :admit_resolve => resolve(
            :death => (Gamma(2.0, 3.5), 0.3),
            :discharge => (Gamma(1.0, 8.0), 0.7))),
    surveillance = sequential(
        :onset_notif => Gamma(0.7, 20.0),
        :notif_compete => compete(
            :confirmed => Gamma(2.0, 1.0),
            :lost => Gamma(5.0, 0.5))),
))

# Inspect tree, params, and default priors
event_tree(tree)
params_table(tree)
priors = param_priors(tree)

# Draw a synthetic case, or evaluate a real one's likelihood
case = rand(tree)
logpdf(tree, case)

The approach

A case’s event history is a tree. There is a root event (onset), a branch for each later event (admission, notification), and a split where the path forks (death or discharge). Every node is a probability distribution over a delay, and the whole tree is itself a distribution.

The same object works in both directions. rand draws a full event path from the tree; logpdf gives the likelihood of an observed case. Both share one internal representation, so the model you build to simulate is the model you use to fit.

Four layers

The approach is built in four layers, each resting on the one before.

  • Leaves are any Distributions.jl UnivariateDistribution, used directly as the per-event delays, including Distributions.jl’s own truncated(...) and censored(...) wrappers — a leaf can be truncated or censored, but wrapping a whole tree the same way errors by design, since the composers read a wrapper’s structure to peel through to the free delay underneath.
  • Composers wire named leaves into an event tree.
  • Combination and lowering join or collapse whole delays with convolved, difference, and observed_distribution.
  • Parameters and edits read and reshape an assembled tree with params_table, param_priors, uncertain, update, prune, and splice.

Five composers

Five composers cover the ways an event history can branch.

  • sequential chains steps in series.
  • parallel fans branches off one shared origin.
  • resolve gives a one-of outcome as a fixed-probability mixture.
  • compete gives a one-of outcome as racing hazards.
  • choose picks a branch from a field in the data.

The compose front-end lowers a NamedTuple, a Tables.jl table, or a nested matrix onto the same stack, so a tree can be written by hand or built straight from a line list.

Handles partial line-list data

Line-list data is sparse: some delays are observed, some are censored, some are missing entirely. Each case supplies only the delays it has, and the tree accounts for the rest by integrating over the branches it did not see. Because each case is handled on its own, the approach works directly on individual-level data.

The same object handles complete cases, censored cases, and partially observed cases. The model does not change, only the delays each case supplies.

Censoring and modifiers compose as leaves

Real line-list delays are rarely clean, and the corrections are part of the model rather than a preprocessing step. CensoredDistributions.jl supplies censoring leaves for primary-event censoring, interval censoring, and truncation. ModifiedDistributions.jl supplies modifier leaves that rescale, weight, or transform a delay. Both are leaves like any other, so they slot into the tree and the composers peel through them to reach the free delay underneath.

Uncertainty in the parameters themselves

A delay taken from the literature rarely comes with exact parameters. uncertain wraps a leaf whose parameters are themselves distributions, nested to any depth, and the @uncertain macro reads a distribution passed as a constructor argument as that parameter’s prior, so @uncertain Gamma(Normal(2.0, 0.3), 1.0) reads the same as writing it out by hand. The result is still a univariate distribution, so it composes as a leaf everywhere, and has_uncertain guards against forgetting to pin it down before fitting.

uncertain is also the verb for promoting parameters on a tree already built: uncertain(tree; onset_admit = (shape = prior,)) targets one parameter, and bare uncertain(tree) promotes every free parameter with a default prior in one call.

One representation across the workflow

The tree is not tied to one package or one use. The same object that defines a delay distribution for a renewal model drops into a Turing fit unchanged, and the same structure works for differential-equation models. There is no translation step between the delay model, the infection model, and the observation model, because the composed object is the shared vocabulary.

Read the posterior back through the same tree

After fitting, the posterior is read back by applying the parameter draws to the same tree. update(template, chain) reduces the chain to a point summary and returns one fitted tree; update(template, table) does the same from a Tables.jl table of values or priors, for a bulk edit outside a sampling loop. Reading every draw rather than a point summary maps update over param_draws(template, chain), so every delay mean, convolution, or contrast still comes from the fitted object itself, one draw at a time. Nothing is recomputed in separate post-processing that could drift from the model.

Where next

  • Run it: the Tutorials gallery collects the runnable walkthroughs for this approach, starting with composing distributions.
  • The ComposedDistributions.jl documentation covers the full API and worked examples.
  • ConvolvedDistributions.jl handles the convolutions (sums) of delays that arise naturally in event trees.
  • ModifiedDistributions.jl lets you rescale, weight, or otherwise transform individual leaves without changing the tree structure.
  • CensoredDistributions.jl adds the censoring layer for real line-list data.
  • LoweredDistributions.jl lowers the same tree onto a phase-type chain or a CTMC, the bridge to compartmental models.
  • Fitting outside Turing, or fitting a plain struct alongside a tree? DistributionsInference.jl hosts the PPL-neutral log-density and readback that as_logdensity and as_turing build on: distribution_priors assembles priors the same way param_priors does for a tree, and distribution_params reads a fitted chain back to the estimated values by name, whatever fittable object it came from.
  • Ready to fit a full model with infection dynamics? See the composable Turing models approach.

EpiAware — a composable Julia ecosystem for infectious disease modelling.

  • Packages

  • Get involved

  • Funding

  • Edit this page
  • Report an issue