
Revision processes: when a report is not yet a case
diseasenowcasting team
Source:vignettes/articles/Revision_processes.Rmd
Revision_processes.RmdThe problem
The nowcasts in vignette("introduction") assume a report
is a case. Real surveillance rarely works that way. A report
arrives provisionally and is later resolved: a
laboratory result comes back, a clinician reclassifies, a duplicate is
spotted. Either
- the result is positive and the case is confirmed, or
- the result is negative and the case is retracted — struck from the register.
If you nowcast the raw report counts you are nowcasting the wrong quantity: you will over-count by however many of today’s reports are about to be withdrawn. This vignette shows how to nowcast the number that actually matters — the cases that will still be there once the dust settles.
The one idea to take away
A missing resolution date does not mean “this one is fine”. It means not resolved yet.
That distinction is the whole model. A case reported this morning
with no retraction date is not evidence of anything: there has been no
time for a retraction. A case reported eight weeks ago with no
retraction date is strong evidence that it is genuine.
diseasenowcasting weighs each report by how long it has had
to be contradicted, which is why the answer is not simply “multiply by
the proportion that get retracted”.
You record it once; the package works out the rest
Different registers record different things, and you do not need to
change your data to fit the package. What you do need is to say,
on the tbl_now, which column holds the
revision date and which holds the outcome:
tbl_now(df, event_date = onset, report_date = reported,
revision_date = result, revision_type = outcome,
is_censored_revision = result_is_upper_bound)revision_type takes "confirmed",
"retracted" or "pending". From those values
nowcast() works out for itself which of three situations
you are in:
| Your register records | Inferred mode | The nowcast targets |
|---|---|---|
| only the retractions | retraction_only |
cases reported and never retracted |
| only the confirmations | confirmation_only |
cases that will eventually be confirmed |
| both outcomes | both |
cases whose result comes back positive |
There is no argument to pass and none to get wrong. Recording both outcomes is the most informative, because the model sees the outcome of each resolved report rather than having to infer it from how long the report has stood.
The mode is read from the whole dataset, not from
the as-of view, so it is a stable property of your data source and does
not change as you move now back through a backtest.
is_censored_revision is optional. When supplied, it
names a logical column whose TRUE rows record an upper
bound on the revision date rather than an exact date. Like all three
date columns and revision_type, this is attached to the
tbl_now; nowcast() does not accept a duplicate
column-name argument.
A revision date with no outcome is an error. The
report has resolved, but its sign is unknown, so it cannot enter either
lag law. Leave the date NA and mark the row
"pending" instead.
A worked example: retractions
We simulate a linelist in which 15% of reports are eventually retracted, a few days after they were filed. In practice this column comes from your register; here we build one so the truth is known.
set.seed(20260726)
origin <- as.Date("2024-01-01")
n_days <- 90
daily_mean <- 40 * exp(0.8 * sin(2 * pi * seq_len(n_days) / 60))
linelist <- lapply(seq_len(n_days), function(day) {
n_reports <- rpois(1, daily_mean[day] / 0.85)
if (n_reports == 0) return(NULL)
reporting_delay <- 1 + rpois(n_reports, 3)
genuine <- runif(n_reports) < 0.85 # 85% are real
retraction_lag <- 1 + rpois(n_reports, 2)
data.frame(
onset = origin + day - 1,
reported = origin + day - 1 + reporting_delay,
retracted = as.Date(ifelse(genuine, NA,
as.numeric(origin + day - 1 + reporting_delay + retraction_lag)),
origin = "1970-01-01"))
}) |> bind_rows()
now <- origin + n_days - 1
glimpse(linelist)
#> Rows: 5,703
#> Columns: 3
#> $ onset <date> 2024-01-01, 2024-01-01, 2024-01-01, 2024-01-01, 2024-01-01,…
#> $ reported <date> 2024-01-04, 2024-01-05, 2024-01-07, 2024-01-06, 2024-01-05,…
#> $ retracted <date> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, 202…Fold the outcome into the two columns a tbl_now
understands. Here only retractions are recorded, so every other row is
"pending" — not "confirmed", because
a report that has simply not been withdrawn has not been confirmed
either:
linelist <- linelist |>
mutate(revision_date = retracted,
revision_type = if_else(is.na(retracted), "pending", "retracted"))
dat <- tbl_now(linelist, event_date = onset, report_date = reported,
revision_date = revision_date, revision_type = revision_type,
data_type = "linelist", verbose = FALSE)Then just nowcast. There is nothing to tell it:
nc <- nowcast(dat, now = now)
nc#> ── diseasenowcasting ─────────────────────────────────── as of 2024-03-30 ──
#> Model: NegBin / HSGP / LogNormal
#> Resolution: modelling retractions; nowcasting cases reported and never retracted.
#> P(not retracted) = 0.851 — see `parameters()` for its interval.
nowcast() says in words what it is modelling and reports
the probability a report survives. For its uncertainty, use
parameters() — not tidy(),
which on a nowcast gives you the predicted counts:
parameters(nc) |> filter(type == "resolution")#> term estimate std.error conf.low conf.high type
#> prob_not_retracted 0.8512 NA 0.8399 0.8619 resolution
The true value was 0.85.
What you get that a naive analysis does not
Two tempting shortcuts, and why both fail:
- Nowcast every report. You target the gross report count, not the settled one, and over-shoot by the retraction rate.
- Delete the reports already known to be retracted. Now you under-shoot at recent event times, because the retractions that will cancel today’s reports have not arrived yet. This is the more seductive error: the intervals look reassuringly tight, and they are tight around the wrong number.
devel/benchmark_retraction.R runs all three on dengue,
mpox and COVID data with retractions injected at a known rate. The
resolution model is the only one whose intervals cover at close to their
nominal rate; deleting the known retractions covers about 2% of its own
95% intervals on COVID.
Confirmations instead
If your register records confirmations rather than retractions,
nothing changes in the call at all — only the values in
revision_type:
confirmed_dat <- linelist |>
mutate(revision_date = confirmed,
revision_type = if_else(is.na(confirmed), "pending", "confirmed")) |>
tbl_now(event_date = onset, report_date = reported,
revision_date = revision_date, revision_type = revision_type,
data_type = "linelist", verbose = FALSE)
nowcast(confirmed_dat, now = now)The evidence runs the other way, which is worth internalising. Under retraction, a report that has stood a long time is more likely genuine. Under confirmation, a report that has sat a long time unconfirmed is less likely ever to be confirmed. The package handles the flip; you only need to know which column you have.
One practical difference: a confirmation may land on the same day the case is reported (a same-day test result), and those rows are kept. A retraction on the same day describes a case that was never visible in any data extract, so those rows are dropped, with a message.
Recording both outcomes
If you know, for every resolved report, which way it went, supply both columns:
set.seed(11)
both <- lapply(seq_len(n_days), function(day) {
n_reports <- rpois(1, daily_mean[day] / 0.7)
if (n_reports == 0) return(NULL)
reporting_delay <- 1 + rpois(n_reports, 3)
positive <- runif(n_reports) < 0.7
result_lag <- rpois(n_reports, 2) # may be same-day
result_date <- origin + day - 1 + reporting_delay + result_lag
data.frame(
onset = origin + day - 1,
reported = origin + day - 1 + reporting_delay,
confirmed = as.Date(ifelse(positive, as.numeric(result_date), NA), origin = "1970-01-01"),
retracted = as.Date(ifelse(!positive, as.numeric(result_date), NA), origin = "1970-01-01"))
}) |> bind_rows()
both_dat <- both |>
mutate(revision_date = coalesce(confirmed, retracted),
revision_type = case_when(!is.na(confirmed) ~ "confirmed",
!is.na(retracted) ~ "retracted",
TRUE ~ "pending")) |>
tbl_now(event_date = onset, report_date = reported,
revision_date = revision_date, revision_type = revision_type,
data_type = "linelist", verbose = FALSE)
nowcast(both_dat, now = now)This is the most informative case, and the model simplifies: the probability a report is real becomes the plain proportion of resolved reports that came back positive, with no censoring correction needed. A pending report contributes only the fact that its result has not arrived.
A report resolves once, and the tbl_now
representation enforces that by construction: one date, one outcome.
There is no way to say “confirmed and retracted” any more.
One revision-delay law in the first prototype
revision_process() takes one
revision_delay. In mode = "confirmation_only"
it is the report-to-confirmation lag; in
mode = "retraction_only" it is the report-to-retraction
lag. When both outcomes are recorded, the same law is used for both
signs. Thus a pending report’s age tells us that its result has not
arrived, but—conditional on the shared-law assumption—does not favour
confirmation over retraction.
This restriction is deliberate for the first prototype. Separate positive and negative lag laws would form a competing-risks extension and would require enough resolved observations of both signs to identify two distributions. That extension is not part of the current public API.
Configuring the model
The defaults are chosen to work without tuning, but everything is
adjustable via revision_process():
model(
nb_likelihood(), hsgp_epidemic(), lognormal_delay(),
revision = revision_process(
revision_delay = dirichlet_revision(bins = 10), # shape of the lag
p = beta_prior(20, 3), # your own prior on p
stratified_p = TRUE # one p per stratum
)
)One-stage and two-stage inference
Both inference strategies support a revision process:
joint <- nowcast(dat, specification, type = "one_stage")
staged <- nowcast(dat, specification, type = "two_stage", K = 25)The one-stage fit estimates the event-to-report delay, epidemic
process, revision delay, and p in one objective. The
two-stage fit retains the package’s original stepwise boundary: Stage 1
estimates and imputes only the event-to-report delay; each Stage-2 fit
conditions on one such imputation and jointly estimates the epidemic
process, revision delay, and p. Pooling posterior draws
over the K Stage-2 fits propagates both sources of
uncertainty. There is deliberately no separate plug-in estimate of the
revision process.
-
revision_delay— how long results take to come back. Any delay family works. Preferdirichlet_revision()when counts are large: the correction is applied to every pending report, so a wrong shape biases the total by more than sampling noise. -
p— the probability a report resolves positive. Left alone it gets a weak prior centred on what the data show among reports with enough follow-up. The naive proportion under-states retraction, because recent reports have not had time to be withdrawn. -
stratified_p = TRUE— a separate probability per stratum, for when data quality genuinely differs between laboratories or regions. With sparse strata the shared value is safer. -
mode— leave it"auto"and the outcomes decide. Set it explicitly when you want the check rather than the inference: asserting a mode your data cannot support is an error, and asserting one when nothing has resolved yet fitspfrom its prior instead of reducing to the ordinary count model.
Data other than linelists
Everything above works unchanged if your data are
count-incidence: one row per distinct
(event date, report date, resolution date) with a case
count, NA marking the unresolved. The aggregated and
row-per-case forms give identical results.
count-cumulative streams use a different observation
process. Repeated net levels do not reveal which individual reports
remain pending, so they cannot identify p separately from a
conditional revision-delay distribution. Do not attach
revision_process() to them. Configure the unconditional
finite-age retraction kernel with cumulative_process()
instead:
model(
nb_likelihood(), ar1_epidemic(), lognormal_delay(),
cumulative = cumulative_process(
observation = "hurdle_ztnb", settlement = 26L
)
)The component reports the finite-horizon retraction mass, h_R, S_R,
q_C, and terminal retention. It never
labels one minus the retraction mass as an identified truth probability.
hurdle_ztpoisson is also available when the non-zero update
magnitude should be zero-truncated Poisson with no dispersion parameter.
See §9 of the Mathematics article.
Backtesting
backtest() needs nothing extra, and — importantly —
builds its eventual truth from the cases that settle
positive. Pending rows remain in each historical fitting vintage, but do
not enter the final confirmation-only truth. A laboratory confirmation
dated after a historical analysis date is masked back to pending for
that fit. Scoring against every reported row would evaluate a different
estimand and make a calibrated confirmation model look biased low:
backtest(dat, n_dates = 10) |> score()Where the mathematics lives
Section 8 of the Mathematics article derives all of this: the trajectory-type decomposition, why the resolution block is a mixture-cure likelihood, why the negative-binomial case needs no numerical integration, and how the predictive thins each pending report by its own survival probability.