Skip to contents

The kinetics model was developed for the analysis in Russell et al., “Real-time estimation of immunological responses against emerging SARS-CoV-2 variants in the UK: a mathematical modelling study” (Lancet Infectious Diseases, 2024). This vignette reconstructs the Delta-wave analyses from the former epikinetics case study using the current package interface. The shorter Data, Covariates, Censoring, Fitting, and Diagnostics articles introduce the individual concepts; this page shows how they combine in a real analysis.

The aim is to reproduce the analysis rather than the exact appearance of the published multi-wave figures. The package includes the corresponding ba2.csv and xbb.csv data, so the same workflow can be repeated for those waves.

Fitting is disabled during an ordinary package build because the full example requires CmdStan and a substantive MCMC run. The figures shown in that case are reference figures generated by the same code from a completed fit. To refit the model and regenerate every figure from a source checkout, restart R, ensure CmdStan is available, and run:

Sys.setenv(EPIKINETICS_RUN_CASE_STUDY = "true")
rmarkdown::render("vignettes/case-study.Rmd")

Prepare and fit the Delta-wave data

The bundled data contain neutralisation measurements against Ancestral, Alpha, and Delta virus, together with vaccination/exposure dates and participant infection history.

library(epikinetics)
library(ggplot2)

case_palette <- c(
  Ancestral = "#0072B2",
  Alpha = "#D55E00",
  Delta = "#009E73"
)
case_theme <- theme_minimal(base_size = 11) +
  theme(
    panel.grid.minor = element_blank(),
    legend.position = "bottom",
    legend.title = element_text(face = "bold"),
    strip.text = element_text(face = "bold"),
    plot.title = element_text(face = "bold", size = 12),
    plot.margin = margin(8, 10, 8, 8)
  )

delta_data <- read.csv(
  system.file("extdata", "delta.csv", package = "epikinetics")
)

delta_prepared <- prepare_epikinetics_data(
  delta_data,
  formula = ~ infection_history,
  covariate_parameters = "all",
  participant_parameters = c(
    "baseline", "boost_rate", "early_waning_rate", "late_waning_rate"
  ),
  biomarker_order = c("Ancestral", "Alpha", "Delta"),
  lower_limit = 40,
  upper_limit = 2560
)

delta_prepared
#> Prepared epikinetics model data
#>   Observations: 2255
#>   Participants: 335
#>   Biomarkers:   3 (Ancestral, Alpha, Delta; explicit order)
#>   Covariates:   infection_history
#>   Effects on:  baseline, time_to_peak, waning_duration, boost_rate, early_waning_rate, late_waning_rate
#>   Random effects: baseline, boost_rate, early_waning_rate, late_waning_rate
#>   Censoring:    none=2003, left=126, right=126
#>   Time range:     0 to 578 since exposure
#>   Model scale:  log2(value / 1); range  2.321928 to 11.321928
#>   Exposure:     one fixed focal exposure per participant
summary(delta_prepared)
#> Prepared epikinetics model-data summary
#> observations participants   biomarkers   covariates 
#>         2255          335            3            1 
#> 
#> Ranges
#>             quantity  minimum    maximum
#>  time_since_exposure 0.000000  578.00000
#>             response 5.000000 2560.00000
#>       model_response 2.321928   11.32193
#> 
#> Censoring
#>  censoring observations
#>       none         2003
#>       left          126
#>      right          126
#> 
#> Participant observation counts
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
#>   3.000   5.000   6.000   6.731   9.000  14.000 
#> 
#> Formula: ~infection_history
#> Transformation: log2(value / 1)
#> Model-matrix columns: infection_historyPreviously infected (Pre-Omicron)
#> Formula affects: baseline, time_to_peak, waning_duration, boost_rate, early_waning_rate, late_waning_rate
#> Participant random effects: baseline, boost_rate, early_waning_rate, late_waning_rate
#> Biomarker order (explicit): Ancestral, Alpha, Delta
#> Factor reference levels: infection_history=Infection naive
#> 
#> Design-column mapping
#>                                       design_column              term
#>  infection_historyPreviously infected (Pre-Omicron) infection_history
#>          variables                             level reference_level
#>  infection_history Previously infected (Pre-Omicron) Infection naive
#>                                                                     label
#>  infection_history=Previously infected (Pre-Omicron) (vs Infection naive)
table(epikinetics_data(delta_prepared)$censoring)
#> 
#>  left  none right 
#>   126  2003   126
prediction_grid(delta_prepared)
#>   .profile                 infection_history
#> 1        1                   Infection naive
#> 2        2 Previously infected (Pre-Omicron)

The assay bounds here match the case-study analysis. Values at or below 40 are treated as left-censored and values at or above 2560 as right-censored. The formula uses ordinary treatment contrasts: the population parameters describe the reference infection-history category and the regression coefficients describe shifts from it. This replaces the redundant no-intercept encoding in the old vignette.

delta_fit <- fit_epikinetics(
  delta_prepared,
  chains = 4,
  parallel_chains = 4,
  threads_per_chain = 2,
  iter_warmup = 1000,
  iter_sampling = 1000,
  adapt_delta = 0.95,
  seed = 2026
)

diagnose_epikinetics(delta_fit)

Do not interpret the following summaries until all chains have completed and the sampler diagnostics are acceptable.

Conditional population trajectories

The first analysis shows the fitted population trajectory for each biomarker and infection-history category. These are conditional population curves: they include the fitted infection-history effects but no participant random effect.

delta_population <- predict(
  delta_fit,
  type = "population",
  times = 0:150,
  ndraws = 2000
)

head(delta_population)

plot(delta_population, central = "median") +
  labs(
    title = "Delta wave",
    x = "Time since focal exposure (days)",
    y = expression(paste("Neutralising titre (IC"[50], ")"))
  ) +
  scale_x_continuous(breaks = seq(0, 150, by = 30))
Conditional population trajectories after the focal exposure. Lines show posterior medians, ribbons show 95% credible intervals for the latent trajectories, and dashed lines mark assay limits.

Conditional population trajectories after the focal exposure. Lines show posterior medians, ribbons show 95% credible intervals for the latent trajectories, and dashed lines mark assay limits.

The line is the posterior median latent trajectory and the ribbon is its 95% credible interval. Biomarkers are overlaid by colour and infection-history categories define the panels. Dashed horizontal lines mark the censoring limits. Both posterior means and medians remain available in delta_population.

Peak and waning-switch values

The former case study called the second quantity a “set point.” In the fitted model it is more precisely the value at the transition from early to late waning: the late rate is estimated and is not fixed at zero.

posterior_parameters(level = "profile", summary = FALSE) already returns the derived response-scale values at the peak and at this waning-rate switch.

profile_draws <- posterior_parameters(
  delta_fit,
  level = "profile",
  summary = FALSE,
  ndraws = 2000
)

peak_switch_centres <- aggregate(
  cbind(peak_response, waning_change_response) ~
    biomarker + infection_history,
  data = profile_draws,
  FUN = median
)

figure_4_delta <- ggplot(
  profile_draws,
  aes(
    x = peak_response,
    y = waning_change_response,
    colour = biomarker
  )
) +
  stat_density_2d(
    aes(group = interaction(infection_history, biomarker)),
    bins = 5,
    linewidth = 0.4
  ) +
  geom_point(
    data = peak_switch_centres,
    colour = "black",
    size = 2,
    inherit.aes = FALSE,
    mapping = aes(
      x = peak_response,
      y = waning_change_response,
      shape = infection_history
    )
  ) +
  geom_vline(xintercept = 2560, linetype = "dashed", colour = "grey45") +
  geom_hline(yintercept = 2560, linetype = "dashed", colour = "grey45") +
  scale_x_continuous(trans = "log2") +
  scale_y_continuous(trans = "log2") +
  scale_colour_manual(values = case_palette) +
  labs(
    x = expression(paste("Titre at peak (IC"[50], ")")),
    y = expression(paste("Titre at waning-rate switch (IC"[50], ")")),
    colour = "Biomarker",
    shape = "Infection history"
  ) +
  case_theme

print(figure_4_delta)
Joint posterior distributions of the response at peak and at the transition from early to late waning. Contours distinguish biomarker and infection-history profiles; black points mark posterior medians.

Joint posterior distributions of the response at peak and at the transition from early to late waning. Contours distinguish biomarker and infection-history profiles; black points mark posterior medians.

The draw-level table can also be used to calculate the proportional change between the two points:

profile_draws$relative_change <- with(
  profile_draws,
  waning_change_response / peak_response
)
aggregate(
  relative_change ~ biomarker + infection_history,
  data = profile_draws,
  FUN = median
)

Calendar-time cohort trajectories

The old Figure 5 analysis placed fitted participant trajectories on the calendar using each participant’s focal-exposure date and then averaged them. The helper code below performs the same scientific operation using the public individual-prediction interface. It processes participants in chunks so the complete draw-by-participant-by-biomarker-by-time array is never held in memory.

When bootstrap_participants = TRUE, each posterior draw receives one participant bootstrap sample whose weights are retained across all dates and biomarkers. This keeps each bootstrapped participant’s complete trajectory together. The former implementation resampled independently within every date/biomarker/draw group, which added discontinuous Monte Carlo variation to the curve.

case_study_participants <- function(fit, minimum_followup = 50) {
  observations <- epikinetics_data(fit)
  exposure <- unique(observations[c("participant", "exposure_time")])
  if (anyDuplicated(exposure$participant)) {
    stop("Exposure time is not constant within participant.")
  }

  followup <- aggregate(
    observations$time_since_exposure,
    by = list(participant = observations$participant),
    FUN = max
  )
  names(followup)[2L] <- "max_followup"
  out <- merge(exposure, followup, by = "participant", sort = FALSE)
  out$max_followup <- pmax(out$max_followup, minimum_followup)
  out$exposure_date <- as.Date(out$exposure_time)
  if (anyNA(out$exposure_date)) {
    stop("This case-study helper requires date-like exposure times.")
  }
  out
}

case_study_weights <- function(draw_ids, participants, bootstrap, seed) {
  weights <- matrix(
    1,
    nrow = length(draw_ids),
    ncol = length(participants)
  )
  if (!bootstrap) return(weights)

  if (!is.null(seed)) set.seed(seed)
  for (draw in seq_along(draw_ids)) {
    weights[draw, ] <- tabulate(
      sample.int(
        length(participants),
        size = length(participants),
        replace = TRUE
      ),
      nbins = length(participants)
    )
  }
  weights
}

case_study_combine <- function(current, addition, groups) {
  if (is.null(current)) return(addition)
  combined <- rbind(current, addition)
  aggregate(
    combined[c("total", "count")],
    by = combined[groups],
    FUN = sum
  )
}

calendar_cohort_draws <- function(
    fit,
    ndraws = 250,
    exposure_shift = 0,
    participant_chunk_size = 5,
    minimum_followup = 50,
    bootstrap_participants = TRUE,
    seed = 1401) {
  participant_info <- case_study_participants(fit, minimum_followup)
  participants <- participant_info$participant
  chunks <- split(
    participants,
    ceiling(seq_along(participants) / participant_chunk_size)
  )
  times <- 0:max(participant_info$max_followup)
  accumulated <- NULL
  weights <- NULL
  draw_ids <- NULL
  biomarker_order <- NULL

  for (participant_chunk in chunks) {
    prediction <- predict(
      fit,
      type = "individual",
      participants = participant_chunk,
      times = times,
      summary = FALSE,
      ndraws = ndraws,
      max_rows = Inf
    )
    if (is.null(draw_ids)) {
      draw_ids <- unique(prediction$.draw)
      biomarker_order <- levels(prediction$biomarker)
      weights <- case_study_weights(
        draw_ids,
        participants,
        bootstrap_participants,
        seed
      )
    }

    rows <- match(prediction$participant, participant_info$participant)
    keep <- prediction$time <= participant_info$max_followup[rows]
    prediction <- prediction[keep, , drop = FALSE]
    rows <- rows[keep]
    prediction$calendar_date <-
      participant_info$exposure_date[rows] - exposure_shift + prediction$time
    prediction$.weight <- weights[cbind(
      match(prediction$.draw, draw_ids),
      match(prediction$participant, participants)
    )]

    partial <- aggregate(
      data.frame(
        total = prediction$estimate * prediction$.weight,
        count = prediction$.weight
      ),
      by = prediction[c(".draw", "biomarker", "calendar_date")],
      FUN = sum
    )
    accumulated <- case_study_combine(
      accumulated,
      partial,
      c(".draw", "biomarker", "calendar_date")
    )
  }

  accumulated <- accumulated[accumulated$count > 0, , drop = FALSE]
  accumulated$estimate <- accumulated$total / accumulated$count
  accumulated$exposure_shift <- exposure_shift
  accumulated$biomarker <- factor(
    as.character(accumulated$biomarker),
    levels = biomarker_order
  )
  accumulated[c(
    ".draw", "biomarker", "calendar_date", "exposure_shift", "estimate"
  )]
}

cohort_at_date_draws <- function(
    fit,
    calendar_date,
    exposure_shifts,
    ndraws = 100,
    participant_chunk_size = 10,
    minimum_followup = 50,
    bootstrap_participants = TRUE,
    seed = 1401) {
  participant_info <- case_study_participants(fit, minimum_followup)
  participants <- participant_info$participant
  requested <- expand.grid(
    participant = participants,
    exposure_shift = unique(exposure_shifts),
    KEEP.OUT.ATTRS = FALSE,
    stringsAsFactors = FALSE
  )
  participant_rows <- match(requested$participant, participants)
  requested$time <- as.numeric(
    as.Date(calendar_date) -
      (participant_info$exposure_date[participant_rows] -
         requested$exposure_shift)
  )
  requested$max_followup <-
    participant_info$max_followup[participant_rows]
  requested <- requested[
    requested$time >= 0 & requested$time <= requested$max_followup,
    c("participant", "exposure_shift", "time"),
    drop = FALSE
  ]
  times <- sort(unique(requested$time))
  chunks <- split(
    participants,
    ceiling(seq_along(participants) / participant_chunk_size)
  )
  accumulated <- NULL
  weights <- NULL
  draw_ids <- NULL
  biomarker_order <- NULL

  for (participant_chunk in chunks) {
    requested_chunk <- requested[
      requested$participant %in% participant_chunk,
      ,
      drop = FALSE
    ]
    if (!nrow(requested_chunk)) next

    prediction <- predict(
      fit,
      type = "individual",
      participants = participant_chunk,
      times = times,
      summary = FALSE,
      ndraws = ndraws,
      max_rows = Inf
    )
    prediction <- merge(
      prediction,
      requested_chunk,
      by = c("participant", "time"),
      sort = FALSE
    )
    if (is.null(draw_ids)) {
      draw_ids <- unique(prediction$.draw)
      biomarker_order <- levels(prediction$biomarker)
      weights <- case_study_weights(
        draw_ids,
        participants,
        bootstrap_participants,
        seed
      )
    }
    prediction$.weight <- weights[cbind(
      match(prediction$.draw, draw_ids),
      match(prediction$participant, participants)
    )]

    partial <- aggregate(
      data.frame(
        total = prediction$estimate * prediction$.weight,
        count = prediction$.weight
      ),
      by = prediction[c(".draw", "biomarker", "exposure_shift")],
      FUN = sum
    )
    accumulated <- case_study_combine(
      accumulated,
      partial,
      c(".draw", "biomarker", "exposure_shift")
    )
  }

  accumulated <- accumulated[accumulated$count > 0, , drop = FALSE]
  accumulated$estimate <- accumulated$total / accumulated$count
  accumulated$calendar_date <- as.Date(calendar_date)
  accumulated$biomarker <- factor(
    as.character(accumulated$biomarker),
    levels = biomarker_order
  )
  accumulated[c(
    ".draw", "biomarker", "calendar_date", "exposure_shift", "estimate"
  )]
}

summarise_case_study_draws <- function(
    draws,
    groups,
    probs = c(0.025, 0.975)) {
  key <- do.call(
    interaction,
    c(draws[groups], list(drop = TRUE, lex.order = TRUE))
  )
  pieces <- lapply(split(seq_len(nrow(draws)), key), function(rows) {
    values <- draws$estimate[rows]
    cbind(
      draws[rows[1L], groups, drop = FALSE],
      data.frame(
        mean = mean(values),
        median = median(values),
        lower = unname(quantile(values, probs[1L])),
        upper = unname(quantile(values, probs[2L]))
      )
    )
  })
  out <- do.call(rbind, pieces)
  rownames(out) <- NULL
  out
}

The Delta-wave calendar-time curve is then:

calendar_draws <- calendar_cohort_draws(
  delta_fit,
  ndraws = 250,
  participant_chunk_size = 5,
  bootstrap_participants = TRUE,
  seed = 1401
)
calendar_summary <- summarise_case_study_draws(
  calendar_draws,
  c("calendar_date", "biomarker", "exposure_shift")
)

delta_start <- min(as.Date(delta_data$day))
ba2_emergence <- as.Date("2022-01-24")
calendar_summary <- calendar_summary[
  calendar_summary$calendar_date >= delta_start &
    calendar_summary$calendar_date <= ba2_emergence,
  ,
  drop = FALSE
]

figure_5a_delta <- ggplot(
  calendar_summary,
  aes(x = calendar_date, colour = biomarker, fill = biomarker)
) +
  geom_ribbon(
    aes(ymin = lower, ymax = upper),
    alpha = 0.18,
    colour = NA
  ) +
  geom_line(aes(y = median), linewidth = 0.8) +
  scale_y_continuous(trans = "log2") +
  scale_colour_manual(values = case_palette) +
  scale_fill_manual(values = case_palette) +
  labs(
    title = "Fitted cohort-average neutralising titres",
    x = "Calendar date",
    y = expression(paste("Neutralising titre (IC"[50], ")")),
    colour = "Biomarker",
    fill = "Biomarker"
  ) +
  case_theme

figure_5a_delta
Calendar-time cohort averages of fitted participant latent trajectories. Lines show posterior medians and ribbons show 95% intervals including the optional participant bootstrap.

Calendar-time cohort averages of fitted participant latent trajectories. Lines show posterior medians and ribbons show 95% intervals including the optional participant bootstrap.

This is an average over fitted participant latent trajectories, not a posterior predictive distribution for future assay measurements. Its interval combines posterior uncertainty with the optional participant bootstrap.

Counterfactual exposure timing

The final analysis shifts each participant’s focal-exposure date and evaluates the fitted cohort at the date on which Delta emerged. It changes timing only; the fitted kinetic parameters, participant effects, and covariate values are held fixed. Positive shifts move exposure earlier, increasing the elapsed time at the target date.

time_shifts <- seq(-75, 75, by = 15)
delta_emergence <- as.Date("2021-05-07")

counterfactual_draws <- cohort_at_date_draws(
  delta_fit,
  calendar_date = delta_emergence,
  exposure_shifts = time_shifts,
  ndraws = 250,
  participant_chunk_size = 10,
  bootstrap_participants = TRUE,
  seed = 1401
)
counterfactual_summary <- summarise_case_study_draws(
  counterfactual_draws,
  c("calendar_date", "exposure_shift", "biomarker")
)

figure_5c_delta <- ggplot(
  counterfactual_summary,
  aes(x = exposure_shift, y = median, colour = biomarker)
) +
  geom_line(linewidth = 0.8) +
  geom_pointrange(aes(ymin = lower, ymax = upper)) +
  scale_x_continuous(breaks = time_shifts) +
  scale_y_continuous(trans = "log2") +
  scale_colour_manual(values = case_palette) +
  labs(
    title = "Counterfactual titres at Delta emergence",
    subtitle = "Positive shifts move the focal exposure earlier",
    x = "Exposure-date shift (days)",
    y = expression(paste("Neutralising titre (IC"[50], ")")),
    colour = "Biomarker"
  ) +
  case_theme

figure_5c_delta
Fitted cohort-average titres at Delta emergence under shifts in focal-exposure timing. Positive values move exposure earlier; intervals combine posterior uncertainty with the optional participant bootstrap.

Fitted cohort-average titres at Delta emergence under shifts in focal-exposure timing. Positive values move exposure earlier; intervals combine posterior uncertainty with the optional participant bootstrap.

This counterfactual should be interpreted narrowly. It assumes that moving the recorded exposure date does not alter the participant’s kinetic parameters, subsequent exposure history, eligibility for observation, or other covariates. It is a timing calculation under the fitted single-exposure model, not a causal model of a vaccination programme.

Extending the analysis to other waves

The package also bundles ba2.csv and xbb.csv. Each should be prepared and fitted separately because its focal exposure, available biomarkers, and infection-history categories differ. The same extraction and plotting code can then be applied to each fit, after supplying an appropriate biomarker order and assay limits. Combining wave-specific summaries should happen only after confirming that their response scales and scientific estimands are comparable.

The Kinetics model and statistical structure article gives the mathematical definition of the model used throughout this case study.