Skip to content

Analysis walkthrough

This page fits the BDBV delay + CFR model in BdbvLinelist.jl to the Rosello et al. 2015 Isiro deposit (n = 52) and renders the headline outputs. Three parametric families (LogNormal, Gamma, Weibull) are compared by WAIC; the Gamma fit is the canonical run. The marginal onset → death and onset → discharge distributions are derived in post-processing as Monte Carlo convolutions of the atomic components, enforcing the per-case natural-history identity at the population level. A logistic regression stratifies case-fatality by HCW status, case definition (Probable vs Confirmed), and standardised age.

Priors, the doubly-censored likelihood, and post-processing are detailed on the Model page. Caveats are on the Limitations page. Per-draw posterior CSV and figure bundle from this build is in the rolling main-latest release.

julia
using BdbvLinelist
using DataFrames
using DataFramesMeta
using Chain
using FlexiChains
using Statistics
using Printf
using Random
using CairoMakie
using Turing: @varname

Load the line list

load_linelist parses the bundled CSV from data/linelist.csv and scrubs the five admission-date encoding outliers and the one notification-date outlier described in Limitations. build_data re-encodes the per-pair delays into the vectors used by the doubly-censored likelihood.

julia
ll = load_linelist()
d  = build_data(ll)

@chain ll begin
    @rsubset !ismissing(:Date_of_onset_symp)
    @select :Person_ID :Date_of_onset_symp :Date_of_Hospitalisation :Date_of_Death :Date_hospital_discharge :Date_of_notification :is_hcw :Case_definition
    first(8)
end
8×8 DataFrame
RowPerson_IDDate_of_onset_sympDate_of_HospitalisationDate_of_DeathDate_hospital_dischargeDate_of_notificationis_hcwCase_definition
String15DateDate?Date?Date?Date?BoolString15
1Isi_2012_722012-08-272012-09-02missing2012-09-102012-09-01falseConfirmed
2Isi_2012_172012-08-092012-08-13missing2012-08-292012-08-13falseConfirmed
3Isi_2012_762012-07-19missing2012-07-29missing2012-09-10falseProbable
4Isi_2012_442012-08-052012-08-102012-08-18missing2012-09-03falseProbable
5Isi_2012_1372012-09-21missingmissingmissing2012-09-22trueConfirmed
6Isi_2012_1062012-07-252012-07-25missing2012-07-282012-09-10falseProbable
7Isi_2012_102012-07-272012-07-292012-07-31missingmissingfalseProbable
8Isi_2012_1082012-08-192012-08-242012-08-28missing2012-09-11falseProbable

Outbreak context

Per the Charniga et al. 2024 reporting checklist (item 12: provide the contextual information needed to interpret the delays). Sample size, observation window, demographics, case-definition mix and care-setting are summarised here; epidemic curve and control measures are below (Epidemic curve) and on the Limitations page (outbreak setting and ETC support).

julia
let onsets = collect(skipmissing(ll.Date_of_onset_symp)),
    ages   = collect(skipmissing(ll.Age)),
    sexes  = collect(skipmissing(ll.Sex))
    DataFrame(
        "Quantity" => [
            "Total cases", "Cases with onset date",
            "Onset window",
            "Median age (IQR), years",
            "Sex (Female / Male)",
            "HCW (yes / no)",
            "Case definition (Confirmed / Probable)",
            "Outcome (Dead / Alive / Unknown)",
        ],
        "Value" => [
            string(nrow(ll)),
            string(length(onsets)),
            @sprintf("%s to %s", minimum(onsets), maximum(onsets)),
            @sprintf("%.0f (%.0f – %.0f)",
                     quantile(ages, 0.5),
                     quantile(ages, 0.25),
                     quantile(ages, 0.75)),
            @sprintf("%d / %d",
                     count(==("Female"), sexes), count(==("Male"), sexes)),
            @sprintf("%d / %d", sum(ll.is_hcw), nrow(ll) - sum(ll.is_hcw)),
            @sprintf("%d / %d",
                     count(==("Confirmed"), skipmissing(ll.Case_definition)),
                     count(==("Probable"),  skipmissing(ll.Case_definition))),
            @sprintf("%d / %d / %d",
                     count(==("Dead"),  skipmissing(ll.Outcome)),
                     count(==("Alive"), skipmissing(ll.Outcome)),
                     count(o -> !(o in ("Dead", "Alive")),
                           skipmissing(ll.Outcome))),
        ],
    )
end
8×2 DataFrame
RowQuantityValue
StringString
1Total cases52
2Cases with onset date52
3Onset window2012-06-01 to 2012-11-08
4Median age (IQR), years40 (28 – 46)
5Sex (Female / Male)40 / 12
6HCW (yes / no)14 / 38
7Case definition (Confirmed / Probable)36 / 16
8Outcome (Dead / Alive / Unknown)28 / 24 / 0

Headline Gamma estimates

Fit all three families (Gamma wins on WAIC; comparison table and plot are in the Family comparison section below) and tabulate the four atomic delay components alongside Rosello et al. 2015 Table 5 empirical means. Rosello capped the raw delays at 30 days before computing those summary statistics — this is binding on onset → notification (where 9 of 38 cases exceed 30 d) and accounts for most of the difference there.

julia
# Suppress the package's stdout printing — we build clean DataFrame
# tables from the returned posterior vectors below. The fit itself
# emits a fair amount of Turing diagnostics (initial step size,
# sampling progress, the occasional divergent-transition warning)
# that aren't part of the narrative, so the call and its captured
# output are tucked into the dropdown below.
Fit: compare_families() — Turing diagnostics
julia
results = redirect_stdout(devnull) do
    compare_families()
end
┌ Info: Loaded line list
└   n_cases = 52
┌ Info: Fitting family
└   family = :lognormal
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Info: Found initial step size
└   ϵ = 0.8
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Warning: compute_waic: dropped non-finite log-likelihood draws
│   family = :lognormal
│   n_dropped = 66686
│   n_obs = 167
└ @ BdbvLinelist ~/work/bdbv-linelist-analysis/bdbv-linelist-analysis/src/postprocess.jl:448
┌ Info: Fit done
│   family = :lognormal
│   rhat = 1.008
│   ess = 3496
│   ndiv = 0
│   waic = 652838.5
└   p_waic = 325843.9
┌ Info: Fitting family
└   family = :gamma
┌ Info: Found initial step size
└   ϵ = 0.05
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 2 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Info: Fit done
│   family = :gamma
│   rhat = 1.007
│   ess = 3933
│   ndiv = 2
│   waic = 792.4
└   p_waic = 9.2
┌ Info: Fitting family
└   family = :weibull
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.003125
┌ Info: Found initial step size
└   ϵ = 0.025
┌ Info: Fit done
│   family = :weibull
│   rhat = 1.006
│   ess = 3762
│   ndiv = 0
│   waic = 793.6
└   p_waic = 9.2
Post-processing: summarise() on the Gamma fit
julia
chn_gamma = results[:gamma].chain
post = redirect_stdout(devnull) do
    summarise(chn_gamma, :gamma; d = d)
end

Local helper: format a vector as median (2.5% – 97.5%).

julia
qci(x) = (quantile(x, 0.025), quantile(x, 0.5), quantile(x, 0.975))

function fmt(x)
    lo, med, hi = qci(x)
    return @sprintf("%.2f (%.2f – %.2f)", med, lo, hi)
end

headline_estimates = DataFrame(
    "Delay" => [
        "Onset → admission",
        "Admission → death",
        "Admission → discharge",
        "Onset → notification",
    ],
    "n" => [
        length(d.onset_to_admit),
        length(d.admit_to_death),
        length(d.admit_to_discharge),
        length(d.onset_to_notif),
    ],
    "Gamma median (95% CrI), days" =>
        [fmt(post.median_oa), fmt(post.median_ad),
         fmt(post.median_ac), fmt(post.median_on)],
    "Gamma mean (95% CrI), days" =>
        [fmt(post.mean_oa),   fmt(post.mean_ad),
         fmt(post.mean_ac),   fmt(post.mean_on)],
    "Rosello mean" => [4.00, 7.59, 8.00, 8.83],
)
4×5 DataFrame
RowDelaynGamma median (95% CrI), daysGamma mean (95% CrI), daysRosello mean
StringInt64StringStringFloat64
1Onset → admission402.96 (2.06 – 4.01)4.03 (3.08 – 5.46)4.0
2Admission → death226.42 (4.47 – 8.70)7.60 (5.62 – 10.42)7.59
3Admission → discharge155.33 (2.82 – 8.87)7.70 (4.81 – 13.81)8.0
4Onset → notification3811.07 (6.43 – 17.39)19.82 (13.64 – 29.94)8.83

Per-draw posterior shape, scale and SD for each atomic Gamma delay are reported in Gamma shape, scale and SD per atomic delay further down the page, and as columns in posterior_gamma.csv.

Posterior predictive check (Gamma)

Four panels — one per atomic delay — overlaying the observed integer day histogram with the simulated double-interval-censored posterior predictive (median + 95% band). The dashed black line in each panel marks the Rosello et al. 2015 Table 5 mean (4.00, 7.59, 8.00, 8.83 d) for visual comparison.

julia
plot_ppc(chn_gamma, d, :gamma)

Prior versus posterior

Pairwise distributions of the four atomic Gamma log_mean parameters under the prior used in fitting and the resulting posterior. Diagonal panels show the marginals; off-diagonal panels show the pairwise contours. The shrinkage from prior (grey) to posterior (red) is the visual analogue of the prior-sensitivity table immediately below.

julia
using PairPlots

prior_vs_posterior = let
    posterior_log_means = (;
        oa = vec(collect(chn_gamma[@varname(dist_oa.log_mean)])),
        ad = vec(collect(chn_gamma[@varname(dist_ad.log_mean)])),
        ac = vec(collect(chn_gamma[@varname(dist_ac.log_mean)])),
        on = vec(collect(chn_gamma[@varname(dist_on.log_mean)])),
    )
    # Sample the prior directly: each delay's log_mean is
    # Normal(log(plausible_median_d), 1.0) at the default prior scale —
    # see `bdbv_model` in `src/model.jl`.
    S = length(posterior_log_means.oa)
    rng = Random.MersenneTwister(20260519)
    prior_log_means = (;
        oa = log(3.0)  .+ randn(rng, S),
        ad = log(6.0)  .+ randn(rng, S),
        ac = log(13.0) .+ randn(rng, S),
        on = log(7.0)  .+ randn(rng, S),
    )
    pairplot(
        PairPlots.Series(prior_log_means;     label = "prior",     color = :grey),
        PairPlots.Series(posterior_log_means; label = "posterior", color = :firebrick),
    )
end

Prior sensitivity

Refit the Gamma model under three prior-scale settings (0.5, 1.0 default, 2.0). The default 1.0 prior gives ≈ ×3-fold prior latitude on the central tendency of each delay; a 4× span across the sweep shifts posterior means by < 5%.

Fit: sensitivity() — three prior-scale refits
julia
sens = redirect_stdout(devnull) do
    sensitivity()
end
┌ Info: Prior sensitivity
└   prior_scale = 0.5
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Info: Prior sensitivity
└   prior_scale = 1.0
┌ Info: Found initial step size
└   ϵ = 0.05
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.4
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 2 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Info: Prior sensitivity
└   prior_scale = 2.0
┌ Info: Found initial step size
└   ϵ = 0.025
┌ Info: Found initial step size
└   ϵ = 0.2
┌ Info: Found initial step size
└   ϵ = 0.05
┌ Info: Found initial step size
└   ϵ = 0.025
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483
┌ Warning: There were 1 divergent transitions. Consider reparameterising your model or using a smaller step size. For adaptive samplers such as NUTS and HMCDA, consider increasing `target_accept`.
└ @ Turing.Inference ~/.julia/packages/Turing/4hMHm/src/mcmc/hmc.jl:483

Pull the per-draw posterior mean for each atomic delay out of each sensitivity chain. The Gamma submodel parametrises each delay as (log_mean, log_shape), so exp(log_mean) is the population mean.

julia
const DELAY_LM_VARS = (
    onset_to_admit     = @varname(dist_oa.log_mean),
    admit_to_death     = @varname(dist_ad.log_mean),
    admit_to_discharge = @varname(dist_ac.log_mean),
    onset_to_notif     = @varname(dist_on.log_mean),
)

chain_mean(chn, var) = exp.(vec(collect(chn[var])))

prior_sensitivity = DataFrame(
    delay = ["Onset → admission", "Admission → death",
             "Admission → discharge", "Onset → notification"],
    scale_05 = [fmt(chain_mean(sens[0.5].chain, v))
                for v in DELAY_LM_VARS],
    scale_10 = [fmt(chain_mean(sens[1.0].chain, v))
                for v in DELAY_LM_VARS],
    scale_20 = [fmt(chain_mean(sens[2.0].chain, v))
                for v in DELAY_LM_VARS],
)
4×4 DataFrame
Rowdelayscale_05scale_10scale_20
StringStringStringString
1Onset → admission3.94 (2.97 – 5.34)4.03 (3.08 – 5.46)4.04 (3.06 – 5.54)
2Admission → death7.47 (5.64 – 10.24)7.60 (5.62 – 10.42)7.64 (5.81 – 10.52)
3Admission → discharge8.39 (5.34 – 14.60)7.70 (4.81 – 13.81)7.52 (4.64 – 13.82)
4Onset → notification17.99 (13.04 – 25.70)19.82 (13.64 – 29.94)20.43 (13.95 – 31.31)

Length of stay in hospital

Time from admission to leaving hospital (admission → departure). The overall length of stay is a mixture of the fatal pathway (admission → death) and the survivor pathway (admission → discharge), weighted per posterior draw by the in-hospital fatality among admitted cases — Beta(1 + n_died, 1 + n_discharged) with 22 deaths and 15 discharges. The fatal and survivor rows are the corresponding atomic components; the overall row is the bed-occupancy-relevant marginal across both outcomes.

julia
length_of_stay = DataFrame(
    pathway = [
        "Fatal (admission → death)",
        "Survivor (admission → discharge)",
        "Overall (mixture)",
    ],
    median = [fmt(post.median_ad), fmt(post.median_ac), fmt(post.los_median)],
    mean   = [fmt(post.mean_ad),   fmt(post.mean_ac),   fmt(post.los_mean)],
    sd     = ["—",                 "—",                 fmt(post.los_sd)],
    P95    = ["—",                 "—",                 fmt(post.los_p95)],
)
3×5 DataFrame
RowpathwaymedianmeansdP95
StringStringStringStringString
1Fatal (admission → death)6.42 (4.47 – 8.70)7.60 (5.62 – 10.42)
2Survivor (admission → discharge)5.33 (2.82 – 8.87)7.70 (4.81 – 13.81)
3Overall (mixture)6.05 (4.40 – 7.99)7.73 (5.84 – 10.73)6.57 (4.54 – 12.32)20.15 (14.70 – 33.28)

Epidemic curve

Weekly onset counts with HCW subcounts stacked.

julia
plot_epi_curve(ll)

Early-phase growth rate

An exploratory exponential-growth fit to the rising phase of the weekly onset curve — week 1 (2012-05-28) through the peak week (2012-09-10), inclusive. The model is a Poisson regression log(λ_t) = α + r·t with weakly-informative Normal(0, 5) and Normal(0, 1) priors on α and r_week. Intended as a prior source for downstream re-applications (e.g. the outbreak-size work in epiforecasts/BVDOutbreakSize) rather than as a primary headline of this analysis. The CrI on r covers zero — Isiro was a slow, noisy rise — so use the posterior as a weakly-informative prior, not a tight constraint.

julia
growth = redirect_stdout(devnull) do
    fit_growth_rate(ll)
end
┌ Info: Found initial step size
└   ϵ = 6.103515625e-6
┌ Info: Found initial step size
└   ϵ = 0.0125

Doubling time log(2)/r is reported as a median only; its distribution is heavy-tailed because the posterior on r includes values close to zero. The credibility statement worth quoting is the CrI on r itself.

julia
growth_estimates = DataFrame(
    "Quantity" => [
        "Growth rate r (per week)",
        "Growth rate r (per day)",
        "Doubling time (days, median)",
        "P(r > 0)",
    ],
    "Posterior summary" => [
        fmt(growth.r_week),
        fmt(growth.r_day),
        @sprintf("%.1f", quantile(growth.doubling_time, 0.5)),
        @sprintf("%.2f", mean(growth.r_day .> 0)),
    ],
)
4×2 DataFrame
RowQuantityPosterior summary
StringString
1Growth rate r (per week)0.05 (-0.00 – 0.12)
2Growth rate r (per day)0.01 (-0.00 – 0.02)
3Doubling time (days, median)86.3
4P(r > 0)0.96

Recommended downstream prior on r (per day): Normal(mean(r_day), sd(r_day)) from the posterior above. A single-line summary intended for epiforecasts/BVDOutbreakSize and any other downstream model that needs an Isiro-anchored growth prior. See Limitations for caveats.

julia
prior_summary = @sprintf("Normal(%.4f, %.4f)",
                         mean(growth.r_day),
                         std(growth.r_day))
"Normal(0.0077, 0.0046)"

Family comparison

WAIC ranking and convergence diagnostics for each fit. Lower WAIC is better; ΔWAIC is relative to the best family.

julia
families = (:lognormal, :gamma, :weibull)
waics = [results[f].waic.waic for f in families]
best  = minimum(waics)

family_comparison = DataFrame(
    family      = collect(families),
    WAIC        = round.(waics, digits = 1),
    ΔWAIC       = round.(waics .- best, digits = 1),
    p_waic      = round.([results[f].waic.p_waic for f in families], digits = 1),
    max_Rhat    = round.([results[f].diag.rhat for f in families], digits = 3),
    min_ESS     = [round(Int, results[f].diag.ess) for f in families],
    n_divergent = [results[f].diag.ndiv for f in families],
)
3×7 DataFrame
RowfamilyWAICΔWAICp_waicmax_Rhatmin_ESSn_divergent
SymbolFloat64Float64Float64Float64Int64Int64
1lognormal6.52838e56.52046e53.25844e51.00834960
2gamma792.40.09.21.00739332
3weibull793.61.19.21.00637620

Side-by-side posterior-predictive comparison across families. One column per family, four rows (one per delay). The LogNormal tail-over-fit on the onset → notification panel is what drives the WAIC penalty. The black dashed line in each panel marks the Rosello et al. 2015 Table 5 empirical mean for that delay (computed after capping the raw delays at 30 days) — the onset → notification gap is the most visible consequence of that cap.

julia
chains_by_family = Dict(f => results[f].chain for f in families)
plot_family_comparison(chains_by_family, d)

Derived (convolved) marginals

Onset → death = (Onset → admission) ⊛ (Admission → death); Onset → discharge = (Onset → admission) ⊛ (Admission → discharge). Sampled per posterior draw (500 realisations per draw).

julia
convolved_marginals = DataFrame(
    marginal = [
        "Onset → death (oa ⊛ ad)",
        "Onset → discharge (oa ⊛ ac)",
    ],
    median = [fmt(post.od_median), fmt(post.oc_median)],
    mean   = [fmt(post.od_mean),   fmt(post.oc_mean)],
    sd     = [fmt(post.od_sd),     fmt(post.oc_sd)],
    P95    = [fmt(post.od_p95),    fmt(post.oc_p95)],
)
2×5 DataFrame
RowmarginalmedianmeansdP95
StringStringStringStringString
1Onset → death (oa ⊛ ad)10.48 (8.21 – 13.19)11.71 (9.41 – 14.87)6.54 (4.92 – 9.33)24.10 (19.07 – 32.45)
2Onset → discharge (oa ⊛ ac)9.69 (6.94 – 13.65)11.83 (8.62 – 18.01)8.53 (5.65 – 16.84)28.25 (19.71 – 51.85)

Gamma shape, scale and SD per atomic delay

Underlying-distribution parameters for downstream consumers that need to reconstruct each atomic Gamma delay rather than just its central tendency.

julia
gamma_parameters = DataFrame(
    "delay" => [
        "Onset → admission",
        "Admission → death",
        "Admission → discharge",
        "Onset → notification",
    ],
    "shape (95% CrI)" =>
        [fmt(post.shape_oa), fmt(post.shape_ad),
         fmt(post.shape_ac), fmt(post.shape_on)],
    "scale (95% CrI)" =>
        [fmt(post.scale_oa), fmt(post.scale_ad),
         fmt(post.scale_ac), fmt(post.scale_on)],
    "sd (95% CrI)" =>
        [fmt(post.sd_oa), fmt(post.sd_ad),
         fmt(post.sd_ac), fmt(post.sd_on)],
)
4×4 DataFrame
Rowdelayshape (95% CrI)scale (95% CrI)sd (95% CrI)
StringStringStringString
1Onset → admission1.18 (0.73 – 1.82)3.41 (2.08 – 6.22)3.70 (2.66 – 5.58)
2Admission → death2.07 (1.12 – 3.50)3.66 (2.06 – 7.32)5.26 (3.68 – 8.31)
3Admission → discharge1.00 (0.50 – 1.91)7.63 (3.56 – 21.46)7.61 (4.46 – 16.81)
4Onset → notification0.66 (0.43 – 0.95)29.97 (18.13 – 55.77)24.36 (16.42 – 39.31)

Stratified case-fatality

julia
cfr_table = DataFrame(
    stratum = [
        "Non-HCW, Confirmed (baseline)",
        "HCW, Confirmed",
        "Non-HCW, Probable",
        "HCW, Probable",
    ],
    CFR = [
        fmt(post.cfr_baseline),
        fmt(post.cfr_hcw_conf),
        fmt(post.cfr_nonhcw_prob),
        fmt(post.cfr_hcw_prob),
    ],
)
4×2 DataFrame
RowstratumCFR
StringString
1Non-HCW, Confirmed (baseline)0.48 (0.30 – 0.65)
2HCW, Confirmed0.23 (0.09 – 0.46)
3Non-HCW, Probable0.88 (0.69 – 0.96)
4HCW, Probable0.70 (0.38 – 0.91)

Logit-scale coefficients and odds ratios.

julia
function fmt_or(β)
    lo, med, hi = qci(β)
    log_or = @sprintf("%+.2f (%+.2f, %+.2f)", med, lo, hi)
    or     = @sprintf("%.2f (%.2f, %.2f)", exp(med), exp(lo), exp(hi))
    return log_or, or
end

logit_coefficients = DataFrame(
    map(((label, β),) -> begin
            log_or, or = fmt_or(β)
            (; coefficient = label, log_OR = log_or, OR = or)
        end,
        [("HCW status", post.β_hcw),
         ("Probable case definition", post.β_def),
         ("Standardised age", post.β_age)]),
)
3×3 DataFrame
Rowcoefficientlog_OROR
StringStringString
1HCW status-1.13 (-2.27, +0.00)0.32 (0.10, 1.00)
2Probable case definition+2.06 (+0.91, +3.35)7.88 (2.48, 28.50)
3Standardised age-0.29 (-0.91, +0.29)0.75 (0.40, 1.33)

See also