Competing outcomes: resolve versus compete
Introduction
A natural history often ends in one of several mutually exclusive outcomes: a case recovers or dies, an infection is detected or missed. ComposedDistributions expresses this as a one_of node and offers two flavours that differ in where the outcome split comes from. resolve sets the split as fixed probabilities; compete derives it from racing hazards, so which outcome occurs is coupled to when.
This tutorial builds both over the same two outcomes and shows when to reach for each. It builds on Composing distributions.
| Modelling concept | Composed primitive |
|---|---|
| one outcome by a fixed probability | a resolve node |
| rival risks, which-and-when hazard-driven | a compete node |
| an event that only sometimes occurs | a no-event resolve branch |
| an outcome that continues into a further chain | a resolve outcome holding a subtree |
using ComposedDistributions
using Distributions
using Random
import ComposedDistributions: rand_outcomeFixed-probability resolution
resolve sets the outcome split directly. A death-versus-recovery split makes the death probability the case-fatality ratio; the last outcome's probability may be omitted as the residual.
cfr = 0.3
outcome = resolve(:death => (Gamma(1.5, 1.0), cfr),
:recover => Gamma(2.0, 1.5))Resolve (2 outcomes)
├─ death (p = 0.3): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ recover (p = 0.7): Distributions.Gamma{Float64}(α=2.0, θ=1.5)Its marginal is the time to resolution, whichever outcome occurs.
mean(outcome)2.55rand_outcome draws which outcome occurs and its time as a compact pair, so a standalone draw tells you which outcome won.
rand_outcome(Xoshiro(1), outcome)(:death, 1.8400307468613506)Over many draws the outcome frequencies match the fixed split.
rng = Xoshiro(42)
draws = [first(rand_outcome(rng, outcome)) for _ in 1:5000]
count(==(:death), draws) / length(draws) # ≈ cfr0.2862An outcome that only sometimes occurs
A NoEvent branch carries the mass of cases that never resolve, so its probability is the residual and a draw of that branch has no event time.
with_survivors = resolve(:death => (Gamma(1.5, 1.0), 0.2),
:recover => (Gamma(2.0, 1.5), 0.5), :survive => NoEvent())Resolve (3 outcomes)
├─ death (p = 0.2): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
├─ recover (p = 0.5): Distributions.Gamma{Float64}(α=2.0, θ=1.5)
└─ survive (p = 0.30000000000000004): NoEvent()A no-event draw returns a missing time.
rand_outcome(Xoshiro(4), with_survivors)(:survive, missing)Racing hazards
compete takes bare outcomes with no probabilities: the cause-specific delays race, the first to fire wins, and the split is derived from the hazards.
racing = compete(:death => Gamma(1.5, 1.0), :recover => Gamma(2.0, 1.5))Compete (2 racing outcomes)
├─ death (racing): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ recover (racing): Distributions.Gamma{Float64}(α=2.0, θ=1.5)The marginal any-event time is the min of the racing delays, so its survival is the product of the per-cause survivals.
t = 3.0
(racing_ccdf = ccdf(racing, t),
product_ccdf = ccdf(Gamma(1.5, 1.0), t) * ccdf(Gamma(2.0, 1.5), t))(racing_ccdf = 0.04531440427588506, product_ccdf = 0.045314404275885074)Because the split follows from the delays, the death frequency is a consequence of the hazards rather than a set parameter.
race_rng = Xoshiro(2024)
races = [first(rand_outcome(race_rng, racing))
for _ in 1:5000]
count(==(:death), races) / length(races)0.7416Which to use
Reach for resolve when the outcome split is a fixed probability independent of timing, such as a known case-fatality ratio. Reach for compete when rival risks act on a shared clock, so the split follows from the delays.
Nesting a resolution in a natural history
A one_of node is a valid step in a larger tree, so a chain can end in a resolution: onset, then admission, then a death-versus-recovery outcome.
history = compose((
path = sequential(:onset_admit => LogNormal(1.5, 0.4),
:admit_resolve => outcome),))
event_names(history)(:onset, :admit, :death, :recover)A draw fills the origin, the admission, and the resolution.
rand(Xoshiro(1), history)(path_onset_admit = 4.356925919792406, path_admit_resolve = 1.2404385279078642)Summary
resolvesets the outcome split as fixed probabilities; aNoEventbranch carries cases that never resolve.competederives the split from racing hazards, coupling which outcome occurs to when.rand_outcomereads the sampled outcome and time; the marginallogpdfandmeantreat the node as one time-to-resolution distribution.A one_of node nests as a step in a larger composed tree.
Where next
Composing distributions covers all five composers and the structural edits.
Delay chains and the linear chain trick shows the conjunctive
Sequentialchain in depth.