Skip to content

Event skeletons: reusable topologies with @events

Introduction

The composers in Composing distributions build one tree in one call: name each branch, wire it, and its distributions are baked in on the spot. That is fine for a single fit, but a natural-history topology is often shared: the same onset-admission-outcome pathway describes several pathogens, or the same reporting structure repeats across regions, each time with different delay parameters. Rebuilding the nested sequential/resolve calls for every variant duplicates the topology and buries it inside the distribution literals.

@events separates the two: write the topology once as a readable operator diagram, an EventSkeleton with named holes and no distributions, then fill the holes with update as many times as there are settings. This tutorial builds a hospital pathway skeleton and fills it several ways: two pathogens, a racing-hazard outcome, a parallel branch, a nested outcome path, a delay tied across branches, a literature-uncertain delay, and a region-varying delay. It then edits a filled tree and bulk-updates it from its parameter table. It builds on Composing distributions and Competing outcomes.

julia
using ComposedDistributions
using ComposedDistributions: update
using Distributions
using Random

The operator diagram

A hospital pathway: onset, then an admission delay, then a death-or-discharge split. (\to) chains events, and | marks the outcome split; the whole diagram is the topology, no distributions yet.

julia
skeleton = @events begin
    onset  admission  (death | discharge)
end
EventSkeleton(onset → admission → (death | discharge))

@events lowers this straight to an EventSkeleton: a bare identifier becomes a named hole (the fill key), and show renders it back as the same operator diagram.

julia
skeleton
EventSkeleton(onset → admission → (death | discharge))

Filling the skeleton

update(skeleton; name = fill, ...) substitutes every hole and builds the concrete tree through the ordinary composer verbs: the chain becomes a Sequential and the | group a Resolve or Compete, decided by the fill value type (see Competing outcomes). Filling death with a (dist, prob) tuple and leaving discharge bare — the residual form used in Composing distributions — makes this a fixed-probability Resolve, the death probability the case-fatality ratio.

julia
cfr = 0.12

pathogen_a = update(skeleton;
    onset = Gamma(2.0, 1.0),
    admission = LogNormal(0.5, 0.4),
    death = (Gamma(1.5, 1.0), cfr),
    discharge = Gamma(2.0, 1.5))
Sequential (3 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
└─ death_or_discharge: Resolve (2 outcomes)
   ├─ death (p = 0.12): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
   └─ discharge (p = 0.88): Distributions.Gamma{Float64}(α=2.0, θ=1.5)

The tree prints as the sequential-of-resolve it built; the nested one_of step is auto-named from its branches (death_or_discharge), so a fill names only the branch holes, never the group.

julia
pathogen_a
Sequential (3 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
└─ death_or_discharge: Resolve (2 outcomes)
   ├─ death (p = 0.12): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
   └─ discharge (p = 0.88): Distributions.Gamma{Float64}(α=2.0, θ=1.5)

It is a distribution like any composed tree: rand simulates a record, and logpdf scores one straight back.

julia
record = rand(Xoshiro(1), pathogen_a)

logpdf(pathogen_a, record)
-4.505429199602361

event reaches the resolve node by its auto-name to read the outcome split directly.

julia
outcome = event(pathogen_a, :death_or_discharge)

probs(outcome)
(death = 0.12, discharge = 0.88)

Reusing the skeleton across settings

The point of separating topology from fill: the same skeleton builds a second pathogen's tree with no change to the diagram, only the numbers.

julia
pathogen_b = update(skeleton;
    onset = Gamma(2.3, 0.9),
    admission = LogNormal(0.7, 0.3),
    death = (Gamma(1.7, 1.1), 0.28),
    discharge = Gamma(2.2, 1.3))

(pathogen_a = mean(pathogen_a), pathogen_b = mean(pathogen_b))
(pathogen_a = 6.606038430750074, pathogen_b = 6.759241434980728)

Every fill still goes through the same validation: an unfilled hole is rejected and named in the error, so a copy-pasted fill missing a branch fails loudly rather than silently building a half-specified tree.

julia
incomplete = try
    update(skeleton; onset = Gamma(2.0, 1.0), admission = LogNormal(0.5, 0.4))
    nothing
catch err
    err
end

sprint(showerror, incomplete)
"ArgumentError: event skeleton hole `death` was not filled; pass `death = <dist>` (holes: [:onset, :admission, :death, :discharge])"

A racing-hazard fill

The same | group becomes a Compete instead when every branch is filled with a bare distribution, no probabilities: the split then follows from which cause-specific delay fires first, not a fixed rate.

julia
racing = update(skeleton;
    onset = Gamma(2.0, 1.0),
    admission = LogNormal(0.5, 0.4),
    death = Gamma(1.5, 1.0),
    discharge = Gamma(2.0, 1.5))

event(racing, :death_or_discharge)
Compete (2 racing outcomes)
├─ death (racing): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ discharge (racing): Distributions.Gamma{Float64}(α=2.0, θ=1.5)

Parallel branches

& runs branches off one shared origin instead of chaining them; a notification delay alongside the admission delay is one more hole, not a nested tree written by hand.

julia
parallel_skeleton = @events begin
    onset  (admission & notification)
end

parallel_tree = update(parallel_skeleton;
    onset = Gamma(2.0, 1.0),
    admission = LogNormal(0.5, 0.4),
    notification = Gamma(1.0, 1.0))

parallel_tree
Sequential (2 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
└─ admission_and_notification: Parallel (2 branches)
   ├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
   └─ notification: Distributions.Gamma{Float64}(α=1.0, θ=1.0)

The parallel group is auto-named the same way, joining its branch names with _and_, so event reaches into it by that name.

julia
event(parallel_tree, :admission_and_notification, :notification)
Distributions.Gamma{Float64}(α=1.0, θ=1.0)

Nesting a chain inside an outcome

An outcome is not always a single leaf: a death arm might itself run through an intensive-care step first. A parenthesised chain nests straight inside a | branch, so the topology stays one diagram.

julia
nested_skeleton = @events begin
    onset  admission  ((icu  death) | discharge)
end
EventSkeleton(onset → admission → (icu → death | discharge))

A branch that is itself a multi-step chain has no single probability to carry, so the fill can only read it as a racing hazard: a | group with any nested chain builds a Compete, and the death arm contributes its own icu and death steps.

julia
nested_tree = update(nested_skeleton;
    onset = Gamma(2.0, 1.0),
    admission = LogNormal(0.5, 0.4),
    icu = Gamma(1.2, 1.0),
    death = Gamma(1.5, 1.0),
    discharge = Gamma(2.0, 1.5))

event(nested_tree, :death_or_discharge, :death)
Sequential (2 steps)
├─ icu: Distributions.Gamma{Float64}(α=1.2, θ=1.0)
└─ death: Distributions.Gamma{Float64}(α=1.5, θ=1.0)

Tying a delay across branches

When two branches share one delay parameter, fill both holes with a shared leaf carrying the same tag. The two occurrences are then treated as one free parameter: params_table lists the delay group once rather than once per branch.

julia
tied = update(parallel_skeleton;
    onset = Gamma(2.0, 1.0),
    admission = shared(:delay, Gamma(1.0, 1.0)),
    notification = shared(:delay, Gamma(1.0, 1.0)))

params_table(tied)
params_table (4 rows)
  edge   param  value  support     prior
  ─────  ─────  ─────  ──────────  ─────
  onset  shape  2.0    (0.0, Inf)       
  onset  scale  1.0    (0.0, Inf)       
  delay  shape  1.0    (0.0, Inf)       
  delay  scale  1.0    (0.0, Inf)

An uncertain fill

A fill value is any valid leaf, not only a plain distribution: an @uncertain leaf carries literature uncertainty on a parameter, and it fills a hole exactly like a bare distribution. Here the onset delay's shape is uncertain rather than fixed at 2.0.

julia
uncertain_tree = update(skeleton;
    onset = (@uncertain Gamma(Normal(2.0, 0.3), 1.0)),
    admission = LogNormal(0.5, 0.4),
    death = (Gamma(1.5, 1.0), cfr),
    discharge = Gamma(2.0, 1.5))

has_uncertain(uncertain_tree)
true

params_table carries the attached prior on its prior column, same as any other uncertain leaf; nothing about @events changes how the estimation surface reads it.

julia
params_table(uncertain_tree)
params_table (10 rows)
  edge                             param      value  support     prior                                      
  ───────────────────────────────  ─────────  ─────  ──────────  ───────────────────────────────────────────
  onset                            shape      1.0    (0.0, Inf)  Distributions.Normal{Float64}(μ=2.0, σ=0.3)
  onset                            scale      1.0    (0.0, Inf)                                             
  admission                        mu         0.5    (0.0, Inf)                                             
  admission                        sigma      0.4    (0.0, Inf)                                             
  death_or_discharge.death         shape      1.5    (0.0, Inf)                                             
  death_or_discharge.death         scale      1.0    (0.0, Inf)                                             
  death_or_discharge.discharge     shape      2.0    (0.0, Inf)                                             
  death_or_discharge.discharge     scale      1.5    (0.0, Inf)                                             
  death_or_discharge.branch_probs  death      0.12   (0.0, 1.0)                                             
  death_or_discharge.branch_probs  discharge  0.88   (0.0, 1.0)

A region-varying fill

A delay that changes with a covariate fills a hole with a varying leaf: a map from the covariate level to a distribution, plus a reference used until the tree is resolved.

julia
regional = varying(
    region -> region == :south ? Gamma(2.4, 1.0) : Gamma(2.0, 1.0);
    covariate = :region, reference = Gamma(2.0, 1.0))

varying_tree = update(skeleton;
    onset = regional,
    admission = LogNormal(0.5, 0.4),
    death = (Gamma(1.5, 1.0), cfr),
    discharge = Gamma(2.0, 1.5))

has_varying(varying_tree)
true

instantiate resolves the tree against a Context, looking the covariate up and swapping in the level's distribution.

julia
south = instantiate(varying_tree, Context(region = :south))

event(south, :onset)
Distributions.Gamma{Float64}(α=2.4, θ=1.0)

Editing a filled tree

A filled skeleton is an ordinary composed tree, so the topology edits apply to it directly: reuse a skeleton for the common case, then adjust the one tree that differs rather than writing a new diagram. splice inserts a step around a node, here a reporting delay after admission.

julia
with_report = splice(pathogen_a, :admission;
    after = :admission_report => Gamma(1.0, 2.0))

event(with_report, :admission, :admission_report)
Distributions.Gamma{Float64}(α=1.0, θ=2.0)

update(tree, path => node) replaces a node in place, keeping the shape; here it retunes the admission delay.

julia
retuned = update(pathogen_a, :admission => LogNormal(0.7, 0.3))

event(retuned, :admission)
Distributions.LogNormal{Float64}(μ=0.7, σ=0.3)

prune drops an outcome and renormalises the remaining branch probabilities. A Resolve keeps at least two outcomes, so pruning needs a three-way split to drop from.

julia
triage_skeleton = @events begin
    onset  admission  (death | discharge | transfer)
end

triage = update(triage_skeleton;
    onset = Gamma(2.0, 1.0),
    admission = LogNormal(0.5, 0.4),
    death = (Gamma(1.5, 1.0), 0.12),
    discharge = (Gamma(2.0, 1.5), 0.68),
    transfer = (Gamma(1.0, 1.0), 0.20))

dropped = prune(triage, :death_or_discharge_or_transfer, :transfer)

probs(event(dropped, :death_or_discharge_or_transfer))
(death = 0.15, discharge = 0.85)

Bulk edits through the parameter table

params_table is a Tables.jl table, and update(tree, table) folds one back in, so the table is a round-trip edit surface: read it, change a value (or a whole column, in a DataFrame or a spreadsheet), and apply it in one call. Re-applying the unchanged table is a no-op.

julia
tbl = params_table(pathogen_a)

update(pathogen_a, tbl) == pathogen_a
true

Editing the value column and folding the table back sets the matching parameters, here bumping the onset shape to 3.0.

julia
tbl.value[1] = 3.0

bumped = update(pathogen_a, tbl)

event(bumped, :onset)
Distributions.Gamma{Float64}(α=3.0, θ=1.0)

Summary

  • @events lowers a /|/& operator diagram to an EventSkeleton: names and structure, no distributions.

  • update(skeleton; name = fill, ...) fills every hole and builds the concrete tree through the ordinary composer verbs, validating that every hole is filled and every fill key names a hole.

  • The | group becomes a Resolve or a Compete depending on whether the fill values carry probabilities; a branch that is itself a nested chain has no probability to carry, so it forces a Compete.

  • A fill value is any valid leaf — a plain distribution, a shared leaf, an @uncertain leaf, a varying leaf, or a pre-built subtree — so one skeleton reuses across pathogens, regions or scenarios by filling it differently each time.

  • A filled skeleton is an ordinary composed tree, so splice, prune and update edit it, and params_table with update(tree, table) is a round-trip bulk-edit surface.

  • A group's auto-name (_or_ for one_of, _and_ for parallel) is structural; a fill names only the branch holes, never the group.

Where next