Overview

Demographic and other covariate information is useful when infection rates or antibody kinetics are expected to differ between groups. serosolver treats these as separate choices. population_group defines groups with different infection-history priors and is used when summarising attack rates. The stratification column in par_tab defines demographic effects on antibody-model parameters such as antibody boosting or waning rates.

You can mix and match different covariates to affect different parameters. For example, location could define different infection rates by location, while vaccination status and age group could affect antibody kinetics. serosolver has a flexible interface for specifying these covariates. For example, fixed covariates can be included as variables in antibody_data or in a separate one-row-per-individual demographics table. Users can also allow covariate membership to vary over time using the demographics table with one row for each individual and possible exposure time combination, which is a suitable approach for covariates which change with time, such as treatment group or age.

This vignette uses a synthetic longitudinal dataset with continuous antibody measurements, five samples per individual, 25 possible exposure times, two infection-rate groups, fixed vaccination and location covariates, and a time-varying age group. Variant-specific parameters are introduced in a separate major section below; other extensions are covered in the advanced-features vignette.

When known stratification effects are needed for a simulation-recovery check, they can be supplied to simulate_data() in a coefficient_values data frame. Each row gives the base parameter, stratification variable, non-baseline level, biomarker group, and coefficient value. The same base par_tab is then used for fitting; serosolver() creates the coefficient rows from its stratification entries.

Package setup

## Install serosolver from GitHub when needed.
## remotes::install_github("seroanalytics/serosolver")
library(serosolver)
library(ggplot2)
library(dplyr)
library(tidyr)
library(doParallel)
library(coda)

Covariate stratification

Create and inspect the synthetic data

set.seed(123)

n_indiv <- 100
possible_exposure_times <- 1:25
sample_times <- c(5, 8, 11, 14, 17)

## Covariate factors are zero-based; population_group IDs are one-based.
## Create a version of the demographics data frame which has fixed covariate membership
fixed_demographics <- data.frame(
  individual = seq_len(n_indiv),
  birth = sample(1:5, n_indiv, replace = TRUE),
  vaccinated = rep(0:1, length.out = n_indiv),
  location = rep(0:1, each = n_indiv / 2)
) %>%
  mutate(population_group = location + 1L)

## Create a version of the demographics data frame with time-varying covariate membership
timevarying_demographics <- tidyr::expand_grid(
  fixed_demographics, time = possible_exposure_times
) %>%
  arrange(individual, time) %>%
  mutate(age_group = as.integer(time - birth >= 5))

## Use one stratified table both to simulate and to fit the data.
data(example_par_tab)
par_tab <- example_par_tab
par_tab$stratification <- NA_character_

## Simplify the kinetics model
par_tab[par_tab$names %in% c("cr_long", "cr_short", "wane_long"), "fixed"] <- 1
par_tab[par_tab$names %in% c("cr_long", "cr_short", "wane_long"), "values"] <- 0
par_tab[par_tab$names == "min_measurement", c("values", "lower_bound", "upper_bound")] <- 0
par_tab[par_tab$names == "max_measurement", c("values", "lower_bound", "upper_bound")] <- 10

## Stratify attack rates by population group
par_tab[par_tab$names %in% c(
  "infection_model_prior_shape1", "infection_model_prior_shape2"
), "stratification"] <- "population_group"

## Stratify short-term boosting magnitude by age group and vaccination status
par_tab[par_tab$names == "boost_short", "stratification"] <- "age_group, vaccinated"

## Set known, well-separated coefficient values for the simulation. The
## coefficient table uses the same parameter and biomarker-group labels as
## par_tab, but does not need the generated coefficient names.
coefficient_values <- data.frame(
  parameter = c("boost_short", "boost_short"),
  stratification = c("age_group", "vaccinated"),
  stratification_level = c(1, 1),
  biomarker_group = c(1, 1),
  value = c(1, -1)
)

sim_attack_rates <- simulate_attack_rates(
  possible_exposure_times, mean_par = c(0.05, 0.35),
  sd_par = c(0.05, 0.05), n_groups = 2
)

## Continuous observations use data_type = "continuous".
simulated_data <- simulate_data(
  par_tab = par_tab, n_indiv = n_indiv,
  possible_exposure_times = possible_exposure_times,
  measured_biomarker_ids = 25, sampling_times = sample_times,
  nsamps = length(sample_times), demographics = timevarying_demographics,
  attack_rates = sim_attack_rates, data_type = "continuous",
  coefficient_values = coefficient_values, verbose = FALSE
)
antibody_data <- check_data(simulated_data$antibody_data)
plot_antibody_data(
  antibody_data, possible_exposure_times = possible_exposure_times,
  n_indivs = 12, study_design = "longitudinal"
) +
  facet_wrap(~ individual, ncol = 4) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Ways to pass fixed covariates

Fixed demographic values can be included directly in antibody_data when they are constant for each individual.

fixed_covariates <- fixed_demographics %>%
  select(individual, vaccinated, location, population_group)
antibody_data_with_fixed_covariates <- antibody_data %>%
  left_join(fixed_covariates, by = "individual")
head(check_data(antibody_data_with_fixed_covariates))
#>   individual sample_time birth repeat_number biomarker_id biomarker_group
#> 1          1           5     3             1           25               1
#> 2          1           8     3             1           25               1
#> 3          1          11     3             1           25               1
#> 4          1          14     3             1           25               1
#> 5          1          17     3             1           25               1
#> 6          2           5     3             1           25               1
#>   measurement vaccinated location population_group
#> 1       0.000          0        0                1
#> 2       0.165          0        0                1
#> 3       0.000          0        0                1
#> 4       1.453          0        0                1
#> 5       0.000          0        0                1
#> 6       0.373          1        0                1

Alternatively, use a separate demographics table to pass any covariate information separately.

fixed_demographics_for_model <- fixed_demographics %>%
  select(individual, birth, vaccinated, location, population_group)
head(check_demographics(fixed_demographics_for_model))
#>   individual birth vaccinated location population_group
#> 1          1     3          0        0                1
#> 2          2     3          1        0                1
#> 3          3     2          0        0                1
#> 4          4     2          1        0                1
#> 5          5     3          0        0                1
#> 6          6     5          1        0                1

Time-varying demographic variables

A time-varying table has individual, birth, and time columns at a minimum, together with the covariates that change over time. Here, age_group is calculated from birth and model time, as individuals change age groups as time progresses. In this version, every individual must have a row for every possible exposure time.

head(check_demographics(timevarying_demographics))
#> # A tibble: 6 × 7
#>   individual birth vaccinated location population_group  time age_group
#>        <int> <int>      <int>    <int>            <int> <int>     <int>
#> 1          1     3          0        0                1     1         0
#> 2          1     3          0        0                1     2         0
#> 3          1     3          0        0                1     3         0
#> 4          1     3          0        0                1     4         0
#> 5          1     3          0        0                1     5         0
#> 6          1     3          0        0                1     6         0

Set up par_tab

par_tab is the model control table. In this example, the infection-history prior parameters are stratified by the population_group ID, while boost_short has additive antibody-kinetics effects for age_group and vaccinated.

check_par_tab(
  par_tab, mcmc = TRUE,
  possible_exposure_times = possible_exposure_times, version = 2
)
#>                           names values fixed lower_bound upper_bound
#> 1                    boost_long   2.00     0           0           8
#> 2                   boost_short   2.00     0           0           8
#> 3                   boost_delay   0.00     1           0          10
#> 4           antigenic_seniority   0.00     1           0           1
#> 5                     wane_long   0.00     1           0           1
#> 6                    wane_short   0.25     0           0           1
#> 7                 wane_maternal   0.00     1           0           8
#> 8                       cr_long   0.00     1           0           1
#> 9                      cr_short   0.00     1           0           1
#> 10              min_measurement   0.00     1           0           0
#> 11              max_measurement  10.00     1          10          10
#> 12                       obs_sd   1.00     0           0          25
#> 13                      fp_rate   0.00     1           0           1
#> 14 infection_model_prior_shape1   1.00     1           0        1000
#> 15 infection_model_prior_shape2  10.00     1           0        1000
#> 16  antibody_dependent_boosting   0.00     1           0           1
#> 17           exponential_waning   0.00     1           0           1
#>    lower_start upper_start par_type        stratification biomarker_group steps
#> 1        1.000       3e+00        1                  <NA>               1   0.1
#> 2        2.000       3e+00        1 age_group, vaccinated               1   0.1
#> 3        0.000       1e+01        1                  <NA>               1   0.1
#> 4        0.010       1e-01        1                  <NA>               1   0.1
#> 5        0.001       2e-02        1                  <NA>               1   0.1
#> 6        0.010       1e-01        1                  <NA>               1   0.1
#> 7        0.100       5e-01        1                  <NA>               1   0.1
#> 8        0.100       2e-01        1                  <NA>               1   0.1
#> 9        0.010       1e-01        1                  <NA>               1   0.1
#> 10       0.000       0e+00        1                  <NA>               1   0.1
#> 11       8.000       8e+00        1                  <NA>               1   0.1
#> 12       0.500       2e+00        1                  <NA>               1   0.1
#> 13       0.000       1e+00        1                  <NA>               1   0.1
#> 14       0.000       1e+03        2      population_group               1   0.1
#> 15       0.000       1e+03        2      population_group               1   0.1
#> 16       0.000       1e+00        1                  <NA>               1   0.1
#> 17       0.000       1e+00        0                  <NA>              NA   0.1
par_tab %>%
  filter(!is.na(stratification)) %>%
  select(names, stratification)
#>                          names        stratification
#> 1                  boost_short age_group, vaccinated
#> 2 infection_model_prior_shape1      population_group
#> 3 infection_model_prior_shape2      population_group
simulated_data$par_tab %>%
  filter(par_type == 4) %>%
  select(names, values, par_type, biomarker_group)
#>                                       names values par_type biomarker_group
#> 1  boost_short_biomarker_1_coef_age_group_1      1        4               1
#> 2 boost_short_biomarker_1_coef_vaccinated_1     -1        4               1
fixed_demographics %>%
  select(location, population_group) %>%
  distinct() %>%
  arrange(location)
#>   location population_group
#> 1        0                1
#> 2        1                2

Covariate levels used for parameter stratification are zero-based, with level 0 as the baseline. population_group is different: it is one-based. Here, location 0 maps to population group 1 and location 1 maps to population group 2.

How covariate coefficients are used

When a parameter is stratified, the value in its original par_tab row is the value for the level-0 baseline group. serosolver adds a par_type = 4 coefficient row for each additional level. For example, a coefficient for boost_short and vaccinated = 1 describes the change from the vaccinated = 0 value; it is not a second independent value for boost_short.

The coefficient is combined with the baseline value on a scale that keeps the parameter within its allowed range. If the parameter is unbounded, the value for group g is

xg=x0+βg, x_g = x_0 + \beta_g,

where x_0 is the baseline value and beta_g is the coefficient. For a positive parameter such as boost_short, the coefficient is added on the log scale:

xg=exp{log(x0)+βg}=x0exp(βg). x_g = \exp\{\log(x_0) + \beta_g\} = x_0\exp(\beta_g).

For a parameter bounded between 0 and 1, it is added on the logit scale:

xg=logit−1{logit(x0)+βg}. x_g = \operatorname{logit}^{-1} \{\operatorname{logit}(x_0) + \beta_g\}.

When more than one covariate is listed in stratification, the corresponding coefficients are added together on the relevant transformed scale. Every coefficient row added in this way must therefore be included in the prior function used by the Bayesian model.

For example, with age group a and vaccination status v, the combined effect can be written as

h(xa,v)=h(x0)+βaage+βvvaccinated, h(x_{a,v}) = h(x_0) + \beta^{\mathrm{age}}_a + \beta^{\mathrm{vaccinated}}_v,

where h is the identity, log, or logit transformation described above. The natural-scale value is obtained by applying the inverse transformation after the coefficients have been added.

Baseline antibody parameters describe the group coded 0 for each stratifying covariate. A coefficient describes the change relative to that baseline. population_group selects a group-specific infection-history prior; it is not an antibody-kinetics coefficient. Sparse groups can produce weakly identified coefficients, so the prior may contribute substantial uncertainty to the implied group differences.

Priors for stratification coefficients

Every coefficient row generated from par_tab$stratification is an estimated parameter and needs a prior. The following function identifies the added coefficient rows by their names and applies a weak standard normal prior to them. It is passed to both the prior-predictive and fitted serosolver() calls below.

prior_func <- function(par_tab) {
  par_names <- par_tab$names
  coef_pars <- which(grepl("coef", par_names))
  function(pars) {
    names(pars) <- par_names
    ## Place normal prior on the stratification coefficients
    prior_p <- sum(dnorm(pars[coef_pars], 0, 1, log = TRUE))
    prior_p <- prior_p + dlnorm(pars["boost_long"], log(2), 0.5, log = TRUE)
    prior_p <- prior_p + dlnorm(pars["boost_short"], log(2), 0.5, log = TRUE)
    prior_p <- prior_p + dbeta(pars["wane_short"], 4, 8, log = TRUE)
    prior_p <- prior_p + dlnorm(pars["obs_sd"], log(1), 0.5, log = TRUE)
    prior_p
  }
}

simulated_data$par_tab %>%
  filter(par_type == 4) %>%
  select(names, values)
#>                                       names values
#> 1  boost_short_biomarker_1_coef_age_group_1      1
#> 2 boost_short_biomarker_1_coef_vaccinated_1     -1

A standard normal prior on a coefficient for a positive parameter such as boost_short corresponds to a multiplicative effect on the natural scale.

coefficient_prior <- tibble(
  coefficient = rnorm(10000), multiplier = exp(coefficient)
)
ggplot(coefficient_prior, aes(multiplier)) +
  geom_histogram(bins = 60, fill = "grey70", colour = "white") +
  labs(x = "Multiplicative effect relative to baseline", y = "Prior draws") +
  theme_classic()

Combining covariates

Several covariates can be listed in one stratification entry, separated by a comma. For example, age_group, vaccinated tells serosolver to estimate one set of age-group coefficients and one set of vaccination coefficients for the parameter. These effects are added together on the appropriate transformed scale, so this does not automatically create a separate parameter for every age and vaccination combination. For example, the effect for an older, vaccinated individual is the baseline value plus the age effect plus the vaccination effect.

The infection-history priors are grouped using population_group, which is a single group identifier for each individual. If infection rates should differ according to a combination of fixed covariates, create one population_group column representing those combined groups before fitting. The mapping between the original covariates and the resulting group IDs should then be checked before the model is run.

fixed_demographics %>%
  count(population_group, location, vaccinated) %>%
  arrange(population_group, location, vaccinated)
#>   population_group location vaccinated  n
#> 1                1        0          0 25
#> 2                1        0          1 25
#> 3                2        1          0 25
#> 4                2        1          1 25
par_tab %>%
  filter(names == "boost_short" | grepl("infection_model_prior", names)) %>%
  select(names, stratification)
#>                          names        stratification
#> 1                  boost_short age_group, vaccinated
#> 2 infection_model_prior_shape1      population_group
#> 3 infection_model_prior_shape2      population_group

Fit and inspect the covariate-stratified model

The complete call below uses separate time-varying demographics, continuous observations, and the explicit prior_func. It is shown without evaluation so the full workflow is visible.

mcmc_pars <- c(
  adaptive_iterations = 5000, iterations = 10000, thin = 20,
  thin_inf_hist = 100, save_block = 1000
)
res <- serosolver(
  par_tab = fit_par_tab, antibody_data = antibody_data,
  demographics = timevarying_demographics,
  possible_exposure_times = possible_exposure_times,
  filename = "demographic_fit",
  prior_func = prior_func, data_type = "continuous", n_chains = 3,
  parallel = TRUE, mcmc_pars = mcmc_pars, verbose = TRUE
)

The prior-predictive results below show the range of values implied by the chosen priors for the main antibody parameters.

prior_res$all_diagnostics$theta_estimates %>%
  filter(names %in% c("boost_long", "boost_short", "wane_short")) %>%
  select(names, median, lower95_CrI, upper95_CrI)
#>          names median lower95_CrI upper95_CrI
#>         <char>  <num>       <num>       <num>
#> 1:  boost_long  1.982       0.868       4.938
#> 2: boost_short  1.986       0.743       4.908
#> 3:  wane_short  0.353       0.127       0.606

The fitted attack rates are compared with the known simulated values. The points and intervals are inferred from the antibody data; the simulated values provide a recovery check rather than additional fitted data.

plot_attack_rates(
  chains$inf_chain,
  true_ar = simulated_data$attack_rates %>% filter(is.finite(AR)),
  settings = serosolver_settings, by_group = TRUE, plot_den = FALSE
) + theme(axis.text.x = element_text(angle = 45, hjust = 1))

res$plot_antibody_model + coord_cartesian(ylim = c(0, 10))


coefficient_estimates <- res$all_diagnostics$theta_estimates %>%
  filter(grepl("coef", names)) %>%
  select(names, median, lower95_CrI, upper95_CrI, ess)
coefficient_truth <- simulated_data$par_tab %>%
  filter(par_type == 4) %>% select(names, true_value = values)
left_join(coefficient_estimates, coefficient_truth, by = "names")
#>                                        names median lower95_CrI upper95_CrI
#>                                       <char>  <num>       <num>       <num>
#> 1:  boost_short_biomarker_1_coef_age_group_1  1.005       0.499       1.401
#> 2: boost_short_biomarker_1_coef_vaccinated_1 -0.873      -1.309      -0.499
#>      ess true_value
#>    <num>      <num>
#> 1:  9.87          1
#> 2:  7.61         -1

The fitted kinetics can also be shown with the demographic groups overlaid in each panel. Set by_group = FALSE to colour the group-specific trajectories in the same panel, or by_group = TRUE to plot them as separate panels.

plot_estimated_antibody_model(
  chains$theta_chain, settings = serosolver_settings,
  solve_times = possible_exposure_times, data_type = "continuous", by_group = FALSE
) + coord_cartesian(ylim = c(0, 10))

Variant-stratified parameters

Warning: Variant stratification is not a generally supported package workflow. This implementation is a project-specific workaround used solely for the analysis in McCormack et al. (McCormack et al. 2026). Hence, the example code below is fairly customised and complicated. You have been warned!

The demographic examples above allow antibody parameters to differ according to who was infected, or according to a person’s demographic characteristics at the time of infection. A related but different question is whether the antibody response depends on which variant or strain caused the infection. This is useful when the same biomarker can be boosted by infections with several variants, and the model should estimate different kinetics for those variants.

Variant stratification is not a demographic grouping of individuals. Instead, it attaches a group to each possible infection time in the antigenic_map. The exposure_group column identifies the variant associated with that time, so the model can use the appropriate variant-specific parameters when an infection is assigned to that time. This example leaves demographic effects out of the variant fit so that the variant-specific parameterisation is shown on its own.

This section uses the same number of synthetic individuals and sample times as the first analysis, but does not use demographic or covariate stratification. It creates two antibody-parameter blocks with different short-term boosting values, and fits the simulated data to test whether those differences can be recovered.

The implementation of variant stratification currently reuses the biomarker_group structure that normally represents different observation types. This is a crude workaround developed for a specific project. It means that variant stratification cannot currently be combined with genuinely multiple observed biomarker groups in the same model. The code below therefore temporarily creates two groups for simulate_data(), then retains only group 1 as the observed data used for fitting. The second group is not a second biomarker measurement in this analysis.

There are three different group labels in this workflow. biomarker_group is normally used to identify separate observed data types, such as antibody and avidity measurements. In this variant-specific workaround it is instead reused in par_tab and the antigenic map to label separate antibody-parameter blocks. exposure_group is attached to the possible infection times in antigenic_map and tells the model which parameter block to use for an infection at that time. For example, if times 1–10 have exposure_group = 1 and times 11–25 have exposure_group = 2, an infection assigned to time 15 uses the parameters for exposure group 2. The duplicated map rows provide a corresponding map block for each parameter block; they do not represent separate observed biomarker measurements in the fitted model.

population_group has a different role. It is attached to people (or to people at particular times when demographics vary over time) and selects the infection-history prior used for that person. It is used for group-specific infection histories and attack-rate summaries; it does not identify the variant that caused an infection or select the variant-specific antibody parameters. In this example everyone is assigned to population group 1, so there is no demographic stratification in the variant fit.

Set up the variant map and parameter blocks

variant_map_one_group <- data.frame(
  x_coord = if_else(
    possible_exposure_times <= 12,
    possible_exposure_times / 12,
    2 + possible_exposure_times / 12
  ),
  y_coord = if_else(possible_exposure_times <= 12, 0, 2),
  inf_times = possible_exposure_times,
  exposure_group = if_else(possible_exposure_times <= 12, 1L, 2L),
  biomarker_group = 1L
)

## The antigenic map has one copy for each antibody-parameter block.
variant_antigenic_map <- bind_rows(
  variant_map_one_group,
  variant_map_one_group %>% mutate(biomarker_group = 2L)
)

## Use an unstratified table for the variant-specific example.
variant_base_par_tab <- par_tab
variant_base_par_tab$stratification <- NA_character_

## Duplicate the base antibody-parameter rows for the second variant block.
variant_par_rows <- variant_base_par_tab %>%
  filter(!names %in% c(
    "infection_model_prior_shape1", "infection_model_prior_shape2",
    "exponential_waning"
  )) %>%
  mutate(biomarker_group = 2L)
variant_par_tab <- bind_rows(variant_base_par_tab, variant_par_rows)
variant_par_tab[
  variant_par_tab$biomarker_group == 2 & variant_par_tab$names == "boost_short",
  "values"
] <- 3.5

variant_fit_par_tab <- variant_par_tab
variant_demographics <- timevarying_demographics %>%
  select(individual, birth, time) %>%
  mutate(population_group = 1L)
variant_sim_attack_rates <- simulate_attack_rates(
  possible_exposure_times, mean_par = 0.2, sd_par = 0.05, n_groups = 1
)

variant_simulated_data <- simulate_data(
  par_tab = variant_par_tab, n_indiv = n_indiv,
  antigenic_map = variant_antigenic_map,
  possible_exposure_times = possible_exposure_times,
  measured_biomarker_ids = possible_exposure_times,
  sampling_times = sample_times, nsamps = length(sample_times),
  demographics = variant_demographics,
  attack_rates = variant_sim_attack_rates,
  data_type = c("continuous", "continuous"),
  verbose = FALSE
)
## simulate_data creates both groups because variant_par_tab contains both
## parameter blocks. Retain one observed group for the fitted model.
variant_antibody_data <- variant_simulated_data$antibody_data %>%
  filter(biomarker_group == 1) %>%
  check_data()

The map assigns exposure groups to infection times rather than to people. In this workaround, biomarker_group labels the variant-specific parameter blocks rather than two distinct observed antibody measurements. Because simulate_data() uses the same labels when constructing simulated data, it initially returns rows for both labels. Only group 1 is retained as the single observed biomarker group for fitting; group 2 is the duplicated simulation block needed to implement the variant-specific parameterisation.

ggplot(variant_map_one_group, aes(x_coord, y_coord, colour = factor(exposure_group))) +
  geom_point(size = 2) +
  labs(colour = "Exposure group", x = "Antigenic x coordinate", y = "Antigenic y coordinate") +
  theme_classic()

variant_par_tab %>%
  filter(names == "boost_short") %>%
  select(names, biomarker_group, values)
#>         names biomarker_group values
#> 1 boost_short               1    2.0
#> 2 boost_short               2    3.5
variant_antibody_data %>%
  count(biomarker_group, sample_time) %>%
  summarise(n_groups = n_distinct(biomarker_group),
            n_samples = n_distinct(sample_time), .groups = "drop")
#>   n_groups n_samples
#> 1        1         5

The parameter values used to generate the two blocks are deliberately different. This makes the simulation-recovery comparison informative rather than testing two nearly identical models.

Fit the variant-stratified model

The complete variant-stratified fit is:

variant_mcmc_pars <- c(
  adaptive_iterations = 5000, iterations = 10000, thin = 20,
  thin_inf_hist = 100, save_block = 1000
)
variant_res <- serosolver(
  par_tab = variant_fit_par_tab,
  antibody_data = variant_antibody_data,
  demographics = variant_demographics,
  antigenic_map = variant_antigenic_map,
  possible_exposure_times = possible_exposure_times,
  filename = "variant_fit",
  prior_func = variant_prior_func,
  data_type = "continuous", n_chains = 3, parallel = TRUE,
  mcmc_pars = variant_mcmc_pars, verbose = TRUE
)

Simulation recovery

plot_attack_rates(
  variant_chains$inf_chain,
  true_ar = variant_simulated_data$attack_rates %>% filter(is.finite(AR)),
  settings = variant_settings, by_group = TRUE, plot_den = FALSE
) + theme(axis.text.x = element_text(angle = 45, hjust = 1))

The parameter table comparison focuses on the variant-specific boosting values. The duplicated names are made unique in the same order used in the MCMC output.

variant_truth <- variant_simulated_data$par_tab %>%
  filter(fixed == 0) %>%
  mutate(chain_name = make.unique(names)) %>%
  select(chain_name, names, biomarker_group, true = values)
variant_res$all_diagnostics$theta_estimates %>%
  rename(chain_name = names) %>%
  left_join(variant_truth, by = "chain_name") %>%
  filter(names == "boost_short") %>%
  select(names, biomarker_group, true, median, lower95_CrI, upper95_CrI) %>%
  knitr::kable(digits = 3)
names biomarker_group true median lower95_CrI upper95_CrI
boost_short 1 2.0 2.08 1.95 2.47
boost_short 2 3.5 3.94 3.59 4.32

The fitted chain contains two sets of antibody-kinetics parameters: one for each variant-specific block. Because this workaround stores the second block using the biomarker_group structure, its parameter names have a .1 suffix in the chain. The fitted antibody data still contain only one observed biomarker group, so the code below does not add a second set of observations or fit another model. Instead, it uses plot_estimated_antibody_model() to make two model-implied trajectories. It chooses one possible infection time from each exposure_group; the model then uses the corresponding fitted parameter block. The trajectories are combined in one plot, using one representative biomarker ID to keep the comparison readable.

variant_infection_times <- c(
  "Exposure group 1" = 6,
  "Exposure group 2" = 18
)
representative_biomarker_id <- max(
  as.numeric(as.character(variant_antibody_data$biomarker_id))
)

variant_kinetics <- lapply(seq_along(variant_infection_times), function(i) {
  infection_time <- variant_infection_times[i]
  plot_estimated_antibody_model(
    variant_chains$theta_chain,
    antibody_data = variant_antibody_data,
    demographics = variant_demographics,
    antigenic_map = variant_antigenic_map,
    possible_exposure_times = possible_exposure_times,
    par_tab = variant_fit_par_tab,
    solve_times = seq(infection_time, infection_time + 10, by = 0.1),
    set_infections = infection_time,
    data_type = "continuous", settings = variant_settings,
    by_group = TRUE, nsamp = 1000
  )$data %>%
    filter(as.numeric(as.character(biomarker_id)) == representative_biomarker_id) %>%
    mutate(
      time_since_infection = sample_time - infection_time,
      exposure_group = names(variant_infection_times)[i]
    )
}) %>%
  bind_rows()

ggplot(variant_kinetics,
       aes(time_since_infection, median,
           colour = exposure_group, fill = exposure_group)) +
  geom_ribbon(aes(ymin = lower, ymax = upper), alpha = 0.2, colour = NA) +
  geom_line() +
  coord_cartesian(ylim = c(0, 10)) +
  labs(
    x = "Time since infection",
    y = "Antibody level",
    colour = "Variant exposure group",
    fill = "Variant exposure group"
  ) +
  theme_classic()

References

This vignette focuses on demographic and covariate information, followed by the project-specific variant-stratification workaround. Related material is available in the following vignettes:

McCormack, Mhairi J., Louis Banda, Stephen Kasenda, Ellen C. Hughes, Amelia Crampin, Abena S. Amoah, Jonathan M. Read, Antonia Ho, Brian J. Willett, and James A. Hay. 2026. “SARS-CoV-2 neutralising antibody profiles reveal variant specific antibody dynamics and regional differences in infection histories in Malawi.” medRxiv. https://doi.org/10.64898/2026.04.16.26351029.