| Title: | Real-Time Case Fatality Ratio via a Mixture-Cure Survival Model |
|---|---|
| Description: | Estimates a real-time case fatality ratio (CFR) from line-list data using a Bayesian mixture-cure survival model, registered as an 'epidist' model type so the CFR and the onset-to-death delay both take 'brms' formulas. Each case is fatal with probability equal to the CFR and, when fatal, dies at an interval-censored onset-to-death delay; cases still alive at the observation cut-off are right-censored, which corrects the downward bias of the naive deaths / cases ratio in real time. The delay is co-estimated using the 'primarycensored' censored-distribution machinery. |
| Authors: | Sebastian Funk [aut, cre] |
| Maintainer: | Sebastian Funk <sebastian.funk@lshtm.ac.uk> |
| License: | MIT + file LICENSE |
| Version: | 0.4.0 |
| Built: | 2026-10-13 00:20:35 UTC |
| Source: | https://github.com/epiforecasts/cfrnow |
epidist_cure_model from a line list or prepare_cfr_data() outputProduces the one-row-per-case data frame the model needs: y (a delay for
deaths and recoveries, the follow-up time for survivors), an outcome code,
and the primary (pwindow) and secondary (swindow) censoring widths. When
the input carries timed recoveries the result is flagged for the two-outcome
fit via a use_recovery attribute.
as_epidist_cure_model(data)as_epidist_cure_model(data)
data |
Either the list returned by |
A data frame of class epidist_cure_model.
Estimates a real-time case fatality ratio from line-list data. Each case is
fatal with probability prob and, when fatal, dies at an onset-to-death
delay; cases still alive at the cut-off are right-censored, correcting the
downward bias of the naive deaths / cases ratio. The delay and the prob
prior are given as distspec distributions.
fit_cfr( data, delay = LogNormal(meanlog = Normal(2, 1), sdlog = Normal(0.5, 0.3)), prob_prior = Beta(1, 1), recovery_delay = NULL, loss_prior = NULL, formula = mu ~ 1, ..., cfr_prior = lifecycle::deprecated() )fit_cfr( data, delay = LogNormal(meanlog = Normal(2, 1), sdlog = Normal(0.5, 0.3)), prob_prior = Beta(1, 1), recovery_delay = NULL, loss_prior = NULL, formula = mu ~ 1, ..., cfr_prior = lifecycle::deprecated() )
data |
Output of |
delay |
Onset-to-death delay as a distspec distribution
( |
prob_prior |
Prior on |
recovery_delay |
Optional onset-to-recovery delay (same form as |
loss_prior |
Optional prior on the probability that a case is lost to
follow-up, so its outcome never reaches the line list. A
|
formula |
A |
... |
Passed to |
cfr_prior |
The delay's native parameters may each be a fixed number (held fixed; fixing
the whole delay gives the Ghani/Nishiura estimator) or a Normal() prior
(co-estimated). The family (LogNormal(), Gamma() or Weibull()) sets the
delay distribution. prob_prior is a Beta(); it matters because prob is
weakly identified early on (Beta(1, 1) is uniform, Beta(1, 9) favours a
low probability, Beta(6.6, 13.4) suits a high-fatality pathogen).
loss_prior adds a probability that a case is lost to follow-up, so its
outcome never reaches the line list. A recorded death or recovery then also
says the case was kept, and a case still unresolved at the cut-off was
either lost or genuinely unresolved. Without it, a case unresolved far
longer than the delays allow has almost no probability, which pulls the
delay's tail out or stops the sampler from starting.
Whether loss can be estimated depends on how it relates to the outcome. One
probability for both (loss_prior = Beta(1, 1)) is identified by the
recorded deaths, the recorded recoveries and the cases that stay unresolved,
and it assumes a lost case's CFR matches everyone else's. Fixing one half
(list(death = 0, recovery = Beta(1, 1)), where a death is always written
down and a discharge may not be) is identified too. Estimating both leaves
one parameter unidentified: the priors decide the split and fit_cfr()
warns, so treat that fit as a sensitivity analysis. A loss_prior needs
recorded recoveries and a recovery_delay to time them, because only timed
recoveries separate loss from the outcome probability; without them,
fit_cfr() stops.
Where a case has a date it was last known unresolved, censoring it there
through prepare_cfr_data()'s last_contact_date uses that timing instead
of inferring the loss.
A delay's max truncates it: the likelihood renormalises the distribution
over [0, max) days, matching how distspec discretises the same
object, so a delay recorded as max days or longer has no probability. The
estimated parameters still describe the untruncated family, which is what
summary() reports as delay_mean and delay_sd. A recorded delay outside
the bound, or a case unresolved for as long as every bound that could still
apply to it, cannot come from such a model, so fit_cfr() stops rather than
letting the sampler fail.
The model is fitted through epidist::epidist(), so covariates (or a smooth
time effect) can be put on prob or the delay through formula, e.g.
formula = brms::bf(mu ~ 1, prob ~ age). When the line list carries recovery
dates, pass a recovery_delay to fit the two-outcome model that also times
recoveries.
prob is a case fatality ratio when the line list runs from onset to death,
as prepare_cfr_data() expects; fed a line list with a different outcome
(e.g. hospitalisation), the same model fits that outcome's probability
instead.
A brmsfit with class cfrnow_fit; summarise with summary().
Other fit:
posterior_cumulative_incidence(),
posterior_prob_death(),
pp_check_cfr(),
summary.cfrnow_fit()
## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) otd <- LogNormal(meanlog = Normal(2.41, 0.2), sdlog = Normal(0.51, 0.15)) # co-estimated delay fit <- fit_cfr(d, delay = otd, prob_prior = Beta(1, 1), backend = "cmdstanr") summary(fit) # fixed delay (Ghani/Nishiura), a gamma family, and a prob covariate fit_cfr(d, delay = LogNormal(meanlog = 2.41, sdlog = 0.51)) fit_cfr(d, delay = Gamma(shape = Normal(3.3, 1), rate = Normal(0.26, 0.08))) fit_cfr(d, delay = otd, formula = brms::bf(mu ~ 1, prob ~ group)) ## End(Not run)## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) otd <- LogNormal(meanlog = Normal(2.41, 0.2), sdlog = Normal(0.51, 0.15)) # co-estimated delay fit <- fit_cfr(d, delay = otd, prob_prior = Beta(1, 1), backend = "cmdstanr") summary(fit) # fixed delay (Ghani/Nishiura), a gamma family, and a prob covariate fit_cfr(d, delay = LogNormal(meanlog = 2.41, sdlog = 0.51)) fit_cfr(d, delay = Gamma(shape = Normal(3.3, 1), rate = Normal(0.26, 0.08))) fit_cfr(d, delay = otd, formula = brms::bf(mu ~ 1, prob ~ group)) ## End(Not run)
Turns a fit's outcome probability and delay distributions into the
quantities a competing-risks analysis reports, for each covariate pattern
in newdata. The cumulative incidence of death and of recovery by time t
since onset are
where p is prob and F_D and F_R are the distribution functions of
the death and recovery delays, censored for the day of onset and truncated
at each delay's max as in the likelihood.
posterior_cumulative_incidence( object, newdata = NULL, times = NULL, type = c("cumulative_incidence", "hazard", "subdistribution_hazard"), pwindow = 1 )posterior_cumulative_incidence( object, newdata = NULL, times = NULL, type = c("cumulative_incidence", "hazard", "subdistribution_hazard"), pwindow = 1 )
object |
A |
newdata |
A data frame with the covariates of the |
times |
Days since onset at which to evaluate. Defaults to every day from 0 to the longest follow-up in the fitted data. |
type |
|
pwindow |
Width of the onset window, in days, that the delays are censored over. Defaults to a single day. |
The hazards are daily, matching the daily resolution of the data. The
cause-specific hazard of death on day t is the probability of dying
during [t, t + 1) among cases still unresolved at t,
and the subdistribution (Fine-Gray) hazard keeps cases that have recovered in the risk set,
Recovery works the same way. Hazard ratios between two covariate patterns
are ratios of these draws. They vary with t, since the model does not
assume proportional hazards; covariates act on prob and on the delays
separately.
A fit without a recovery_delay returns death only. Its survivors never
resolve in the model, so the cause-specific and subdistribution hazards
coincide. Loss to follow-up only changes which outcomes get recorded and
plays no part here.
A data frame with one row per posterior draw, row of newdata,
time and outcome: .draw, row (the row of newdata), time,
outcome ("death" or "recovery") and value. A hazard is NA at a
time when every case has already resolved.
Other fit:
fit_cfr(),
posterior_prob_death(),
pp_check_cfr(),
summary.cfrnow_fit()
## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) ll$group <- sample(c("a", "b"), nrow(ll), replace = TRUE) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5, covariates = "group" ) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15)), formula = brms::bf(mu ~ group, prob ~ group) ) groups <- data.frame(group = c("a", "b")) # cumulative incidence of death by day 7, 14 and 28 in each group ci <- posterior_cumulative_incidence(fit, groups, times = c(7, 14, 28)) aggregate(value ~ row + time, ci, stats::median) # cause-specific hazard ratio of death, group b vs a, over time h <- posterior_cumulative_incidence(fit, groups, times = 0:28, type = "hazard" ) a <- h[h$outcome == "death" & h$row == 1, ] b <- h[h$outcome == "death" & h$row == 2, ] a$hr <- b$value / a$value aggregate(hr ~ time, a, stats::median) ## End(Not run)## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) ll$group <- sample(c("a", "b"), nrow(ll), replace = TRUE) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5, covariates = "group" ) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15)), formula = brms::bf(mu ~ group, prob ~ group) ) groups <- data.frame(group = c("a", "b")) # cumulative incidence of death by day 7, 14 and 28 in each group ci <- posterior_cumulative_incidence(fit, groups, times = c(7, 14, 28)) aggregate(value ~ row + time, ci, stats::median) # cause-specific hazard ratio of death, group b vs a, over time h <- posterior_cumulative_incidence(fit, groups, times = 0:28, type = "hazard" ) a <- h[h$outcome == "death" & h$row == 1, ] b <- h[h$outcome == "death" & h$row == 2, ] a$hr <- b$value / a$value aggregate(hr ~ time, a, stats::median) ## End(Not run)
brms::posterior_linpred() gives each case's prob, the probability of
death before anything about the case's outcome has been observed. For a
case still unresolved at the cut-off after y days of follow-up, this
updates prob with that follow-up by Bayes' rule (the censored branch of
the cure likelihood):
where S_D and S_R are the survivor functions of the death and recovery
delays, censored for the day of onset and truncated at each delay's max,
and S_R = 1 when the fit has no recovery_delay. A fit with a
loss_prior replaces each survivor function S with l + (1 - l) S, where
l is the probability that a case with that outcome is lost to follow-up.
A case already resolved by the cut-off has a deterministic pi: 1 for an
observed death, 0 for a recovery or a resolved non-death.
posterior_prob_death(object)posterior_prob_death(object)
object |
A |
A case with little follow-up (small y) has S_D(y) close to 1, so its
pi sits close to its (covariate-specific) prob; only once it has been
followed for close to the typical delay does pi move away from prob
towards 0 or 1. Averaging pi over cases grouped by onset date therefore
gives the expected fatal fraction for each onset period without needing a
time trend in prob. For intervals on the fraction that actually dies, draw
each case's outcome from its pi before averaging. Estimates for the most
recent periods, whose cases have had little follow-up, lean towards the
fitted prob for those cases' covariates rather than their eventual
outcome.
Works for any of the supported delay families and any prob or delay
formula, since the per-case parameters come from posterior_linpred().
A draws_df with one pi[<i>] column per case, in the row order
of object$data, holding posterior draws of the probability of death.
Other fit:
fit_cfr(),
posterior_cumulative_incidence(),
pp_check_cfr(),
summary.cfrnow_fit()
## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15))) pi <- posterior_prob_death(fit) # real-time CFR by onset week: draw each case's outcome from its pi, then # take the fatal fraction of the cases in each week pi <- posterior::as_draws_matrix(pi) fatal <- matrix(rbinom(length(pi), 1, pi), nrow(pi)) week <- cut(fit$cfrnow$onset, "week") by_week <- vapply(split(seq_along(week), week), function(i) { rowMeans(fatal[, i, drop = FALSE]) }, numeric(nrow(fatal))) apply(by_week, 2, stats::quantile, probs = c(0.025, 0.5, 0.975)) ## End(Not run)## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15))) pi <- posterior_prob_death(fit) # real-time CFR by onset week: draw each case's outcome from its pi, then # take the fatal fraction of the cases in each week pi <- posterior::as_draws_matrix(pi) fatal <- matrix(rbinom(length(pi), 1, pi), nrow(pi)) week <- cut(fit$cfrnow$onset, "week") by_week <- vapply(split(seq_along(week), week), function(i) { rowMeans(fatal[, i, drop = FALSE]) }, numeric(nrow(fatal))) apply(by_week, 2, stats::quantile, probs = c(0.025, 0.5, 0.975)) ## End(Not run)
Draws replicate line-list outcomes from the posterior and compares them with the data the model was fit to, replaying the real-time observation process (the onset-to-event timing and the cut-off censoring). Two checks are available: the count of observed deaths by the cut-off (plus recoveries for a two-outcome fit), and the distribution of the observed onset-to-death delays.
pp_check_cfr(object, type = c("counts", "delay"), ndraws = 100)pp_check_cfr(object, type = c("counts", "delay"), ndraws = 100)
object |
A |
type |
Which check to plot: |
ndraws |
Number of posterior draws to replicate over. Defaults to 100, or fewer if the fit has fewer draws. |
The check reuses the fit's own posterior draws of the CFR and the delay, so
it works for covariate and time-varying prob ~ ... fits as well as
intercept-only ones. It needs the observation cut-off, which fit_cfr()
records when the data come from prepare_cfr_data(); each case's follow-up
horizon is built from its own cut-off, so a per-case obs_time (e.g. one
cut-off per reporting site) is replayed correctly. A retrospective fit
(obs_time = NULL) has no truncation to replay, so every fatal case shows up
as a death.
Only the quantities the model generates are checked: the observed death (and recovery) counts and the observed onset-to-death delays. The split of the remaining cases into censored versus untimed-resolved is not part of the generative model, so it is left out.
The check is stochastic: it subsamples posterior draws and simulates
outcomes, so call set.seed() beforehand for reproducible plots.
A ggplot object.
Other fit:
fit_cfr(),
posterior_cumulative_incidence(),
posterior_prob_death(),
summary.cfrnow_fit()
## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15))) pp_check_cfr(fit, type = "counts") pp_check_cfr(fit, type = "delay") ## End(Not run)## Not run: ll <- simulate_linelist(n = 500, cfr = 0.4, delay = LogNormal(2.4, 0.5)) d <- prepare_cfr_data(ll, obs_time = max(ll$onset_date) - 5) fit <- fit_cfr(d, delay = LogNormal(Normal(2.4, 0.2), Normal(0.5, 0.15))) pp_check_cfr(fit, type = "counts") pp_check_cfr(fit, type = "delay") ## End(Not run)
Classifies each case at an observation cut-off into an observed death (with
an interval-censored onset-to-death delay), a resolved non-death, or a
right-censored survivor still unresolved at the cut-off, and returns the
pieces fit_cfr() passes to Stan. A case counts as resolved non-death if it
has a recovery_date on or before the cut-off (or, in a retrospective fit,
if it simply never died); such a case contributes the cure term rather than
being censored, so recording recoveries tightens the estimate.
prepare_cfr_data( linelist, obs_time = NULL, covariates = character(), t0 = NULL, max_delay = 60, last_contact_date = NULL )prepare_cfr_data( linelist, obs_time = NULL, covariates = character(), t0 = NULL, max_delay = 60, last_contact_date = NULL )
linelist |
A data frame with an |
obs_time |
Real-time cut-off, or |
covariates |
Character vector of |
t0 |
Optional time origin ( |
max_delay |
Plausibility filter for data-entry errors, in days: a death
record implying a negative onset-to-death delay, or one longer than
|
last_contact_date |
Optional column name in |
Onset is taken over the day-window [onset_lower, onset_upper] when those
columns are present, defaulting to a one-day window at onset_date for a
case whose window is missing (NA), or for every case when the columns are
absent. In real time, a window that closes after the case's cut-off is cut
back to the cut-off, since the case was already known then. Deaths and
recoveries are recorded to the day.
Records that cannot be used are dropped with a warning: a missing onset, an
inverted onset window (onset_upper < onset_lower), a death with an
impossible onset-to-death delay (negative, or longer than max_delay), or a
recovery dated before onset. In real time, a case whose onset falls after
its own obs_time is not yet known and is excluded with a message.
Data often reaches the analyst at different times by site, so obs_time may
give each case its own cut-off rather than one shared by the whole line
list: a case's deaths, recoveries, follow-up and exclusion are all judged
against its own cut-off, not the latest one in the data.
A cfrnow_data list with the aggregated model inputs (n_death,
death_delay, death_width, n_recovery, recovery_delay,
recovery_width, n_cens, censor_time, censor_width, n_resolved,
n_cases, n_deaths, n_recoveries, t0, obs_time, one per kept case)
and a cases data frame with one row per kept case (y, outcome,
pwindow, swindow, onset, obs_time, follow_up (days watched,
Inf in a retrospective fit) and any requested covariates), which
as_epidist_cure_model() turns into the model frame.
ll <- simulate_linelist(n = 50, delay = LogNormal(2.4, 0.5)) prepare_cfr_data(ll, obs_time = as.Date("2026-02-01"))ll <- simulate_linelist(n = 50, delay = LogNormal(2.4, 0.5)) prepare_cfr_data(ll, obs_time = as.Date("2026-02-01"))
Draws onset dates over a window, marks each case fatal with probability
cfr, and gives fatal cases an onset-to-death delay drawn from delay. Pass
a recovery delay to also simulate onset-to-recovery times for the non-fatal
cases and add a recovery_date column. Delays are distspec distributions
with fixed parameters, matching what fit_cfr() takes; you can specify them
by mean and sd (e.g. LogNormal(mean = 12.75, sd = 7)). Returns a
line list with onset_date, death_date (NA for non-fatal cases) and,
when recovery is given, recovery_date (NA for fatal cases). The full,
untruncated outcomes are simulated; pass the result to prepare_cfr_data()
with an obs_time to induce the real-time truncation.
simulate_linelist( n = 200, cfr = 0.5, delay, recovery = NULL, onset_start = as.Date("2026-01-01"), onset_days = 60 )simulate_linelist( n = 200, cfr = 0.5, delay, recovery = NULL, onset_start = as.Date("2026-01-01"), onset_days = 60 )
n |
Number of cases. |
cfr |
True case fatality ratio. |
delay |
Onset-to-death delay: a distspec distribution
( |
recovery |
Optional onset-to-recovery delay (same form as |
onset_start |
First possible onset date. |
onset_days |
Width of the onset window (days); onsets are uniform over it. |
Data are generated to match the model's daily interval-censoring: each case's true onset falls uniformly within its recorded day, and the event day is the floor of the continuous onset-plus-delay time. So the recorded day-level delays are exactly a doubly-interval-censored draw, which makes the simulator suitable for checking calibration, not only rough recovery.
A data frame with onset_date, death_date and, if recovery is
given, recovery_date.
simulate_linelist( n = 5, cfr = 0.6, delay = LogNormal(mean = 12.75, sd = 7) )simulate_linelist( n = 5, cfr = 0.6, delay = LogNormal(mean = 12.75, sd = 7) )
Reports the corrected CFR and the onset-to-death delay (mean and sd, in days)
as posterior quantiles with convergence diagnostics (rhat, ess_bulk). A
fit with a loss_prior also reports the estimated probability of being lost
to follow-up, as loss, or as loss_death and loss_recovery when the two
outcomes have their own. The
naive deaths / cases ratio is returned as an attribute; in real time it
underestimates the corrected CFR because not every fatal case has died by the
cut-off.
## S3 method for class 'cfrnow_fit' summary( object, probs = c(0.025, 0.5, 0.975), info_tol = 0.9, ascertainment_ratio = 1, ... ) ## S3 method for class 'cfrnow_fit' print(x, ...)## S3 method for class 'cfrnow_fit' summary( object, probs = c(0.025, 0.5, 0.975), info_tol = 0.9, ascertainment_ratio = 1, ... ) ## S3 method for class 'cfrnow_fit' print(x, ...)
object |
A |
probs |
Quantiles to report. |
info_tol |
Low-information threshold: flag when the CFR posterior sd is more than this fraction of the prior sd. Defaults to 0.9. |
ascertainment_ratio |
Ratio |
... |
Unused. |
x |
A |
When few deaths have resolved (a young outbreak) the CFR is only weakly
identified and its posterior stays close to the prior. This is reported via
the cfr_low_information attribute: TRUE when the CFR posterior sd exceeds
info_tol times the prior sd.
For a prob ~ group fit the CFR varies by group, so one prob[<group>] row
is reported per group (grouping predictors must be factors or characters),
and the cfr_low_information flag is NA (only defined for a single CFR).
The CFR the model fits is the fatality risk among ascertained cases. When
ascertainment is outcome-dependent – fatal and non-fatal cases entering the
line list at different rates – this differs from the population CFR.
ascertainment_ratio (r) is the ratio of the ascertainment probability of
fatal to non-fatal cases; the reported CFR is shifted on the logit scale by
-log(r), so r > 1 (fatal cases over-ascertained) lowers it and r < 1
(e.g. deaths not linked back to cases) raises it. It is supplied, not fitted,
and defaults to 1 (no correction); because the correction is a post-hoc logit
shift, sweep a range of r to show its leverage rather than trusting a
single value.
A data frame with one row per quantity: prob (or one
prob[<group>] row per group for a prob ~ group fit), delay_mean and
delay_sd, recovery_mean and recovery_sd for a two-outcome fit, and
loss (or loss_death and loss_recovery) for a fit with an estimated
loss_prior. It carries naive_cfr, n_cases, n_deaths,
cfr_prior_sd, cfr_low_information and ascertainment_ratio
attributes.
Other fit:
fit_cfr(),
posterior_cumulative_incidence(),
posterior_prob_death(),
pp_check_cfr()