Skip to content

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 conceptComposed primitive
one outcome by a fixed probabilitya resolve node
rival risks, which-and-when hazard-drivena compete node
an event that only sometimes occursa no-event resolve branch
an outcome that continues into a further chaina resolve outcome holding a subtree
julia
using ComposedDistributions
using Distributions
using Random
import ComposedDistributions: rand_outcome

Fixed-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.

julia
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.

julia
mean(outcome)
2.55

rand_outcome draws which outcome occurs and its time as a compact pair, so a standalone draw tells you which outcome won.

julia
rand_outcome(Xoshiro(1), outcome)
(:death, 1.8400307468613506)

Over many draws the outcome frequencies match the fixed split.

julia
rng = Xoshiro(42)
draws = [first(rand_outcome(rng, outcome)) for _ in 1:5000]
count(==(:death), draws) / length(draws)     # ≈ cfr
0.2862

An 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.

julia
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.

julia
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.

julia
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.

julia
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.

julia
race_rng = Xoshiro(2024)
races = [first(rand_outcome(race_rng, racing))
         for _ in 1:5000]
count(==(:death), races) / length(races)
0.7416

Which 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.

julia
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.

julia
rand(Xoshiro(1), history)
(path_onset_admit = 4.356925919792406, path_admit_resolve = 1.2404385279078642)

Summary

  • resolve sets the outcome split as fixed probabilities; a NoEvent branch carries cases that never resolve.

  • compete derives the split from racing hazards, coupling which outcome occurs to when.

  • rand_outcome reads the sampled outcome and time; the marginal logpdf and mean treat the node as one time-to-resolution distribution.

  • A one_of node nests as a step in a larger composed tree.

Where next