2026-08-10
Typhoid conjugate vaccines (TCVs) are 80-90% effective and WHO-recommended since 2018, but few countries have adopted them into routine immunization.
A major barrier is the lack of burden data: most low- and middle-income countries lack robust typhoid surveillance and have little or no incidence data.
These gaps block applications for vaccine funding, widening equity gaps in access to effective vaccines.
The Seroepidemiology and Environmental Surveillance for Enteric Fever (SEES) study collected cross-sectional serosurveys across multiple countries to estimate typhoid incidence from antibody data (Aiemjoy et al. 2022).
Definition (Population incidence rate)
Definition 1 (Population incidence rate) The incidence rate of a disease over a specific time period is the rate at which individuals in a population are acquiring the disease during that time period (Noordzij et al. 2010).
Example (Population incidence rate)
Example 1 (Population incidence rate) If there are 10 new cases of typhoid in a population of 1000 persons during a one month time period, then the incidence rate for that time period is 10 new cases per 1000 persons per month.
More precisely, the incidence rate at time \(t\) is the rate of new infections per person at risk:
\[\lambda_t = \frac{1}{N(t)}\,\frac{d}{dt}\,\mathbb{E}[C(t)]\]
where \(C(t)\) is the cumulative number of infections and \(N(t)\) the number of individuals at risk at time \(t\).
In both definitions, the units for an incidence rate are “# new infections per # persons at risk per time duration”; for example, “new infections per 1000 persons per year”.
For convenience, we can rescale the incidence rate to make it easier to understand; for example, we might express incidence as “# new infections per 1000 persons per year” or “# new infections per 100,000 persons per day”, etc.
From the perspective of an individual in the population:
the incidence rate (at a given time point \(t\)) is the instantaneous probability density of becoming infected at that time point, given that they are at risk at that time point.
That is, the incidence rate is a hazard rate.
Notation: let’s use \(\lambda_{t}\) to denote the incidence rate at time \(t\).
Incidence rates can be estimated from longitudinal cohort studies, but cohort studies are:
Incidence rates can also be estimated from clinical case rates, but clinical case rates undercount:
See Hay et al. (2024) for an overview.
Easily and reproducibly translate quantitative antibody responses at the population level into meaningful and accurate epidemiological measures of infection burden.
Using antibody levels to recover infection times is hard, because antibody responses:
We recruit participants from the population of interest.
For each survey participant, we measure antibody levels \((Y)\) for the disease of interest
Each participant was most recently infected at some time \((T)\) prior to when we measured their antibodies.
Definition (Constant-incidence model)
Definition 2 (Constant-incidence model) In the constant-incidence model:
incidence is constant over time and homogeneous across the population, so a single number describes the whole sample, \[\lambda_{i,t} = \lambda, \quad \forall i,t\] (subpopulations can be analyzed separately to make this more plausible); and
there is no lasting immunity: participants are always at risk of a new infection, regardless of how recently they were last infected.
Proposition (Distribution of time since last infection)
Proposition 1 (Distribution of time since last infection) Under the constant-incidence model (Definition 2), a participant’s time since last infection, \(T\), is exponentially distributed with rate \(\lambda\), truncated at their age \(a\): no one can have been infected before they were born, so the density is supported on \([0,a]\) and the remaining probability, \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}\), falls on “never infected”:
\[ \operatorname{p}(T=t|A=a) = 1_{t \in[0,a]} \textcolor{red}{\lambda \operatorname{exp}\mathopen{}\left\{-\lambda t\right\}\mathclose{}} + 1_{t = \text{NA}} \operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{} \]
Its rate parameter is exactly the incidence rate of Definition 1, and that identity is what makes \(T\) worth recovering.
Several approaches could estimate \(\lambda\) from the data. serocalculator uses maximum likelihood: it reports the value of \(\lambda\) that makes the observed data most probable. Stating that precisely takes three definitions.
Definition (Likelihood)
Definition 3 (Likelihood) The likelihood is the probability (or density) of the observed data, read as a function of the parameter rather than of the data:
\[\mathscr{L}(\lambda) \stackrel{\text{def}}{=}\operatorname{p}(\text{data} \mid \lambda)\]
Definition (Log-likelihood)
Definition 4 (Log-likelihood) The log-likelihood is the log of the likelihood (Definition 3):
\[\ell(\lambda) \stackrel{\text{def}}{=}\operatorname{log}\mathopen{}\left\{\mathscr{L}(\lambda)\right\}\mathclose{}\]
Definition (Score function)
Definition 5 (Score function) The score function is the derivative of the log-likelihood with respect to the parameter (in general, its gradient):
\[\ell'(\lambda) \stackrel{\text{def}}{=}\frac{\partial}{\partial \lambda}\ell(\lambda)\]
A maximum likelihood estimate \(\hat\lambda\) solves the score equation \(\ell'(\hat\lambda) = 0\).
If the infection times \(T\) were observed directly, these would all take a simple closed form:
\[ \begin{aligned} \mathscr{L}^*(\lambda) &= \prod_{i=1}^n \operatorname{p}(T=t_i \mid \lambda) \\&= \prod_{i=1}^n \lambda \operatorname{exp}\mathopen{}\left\{-\lambda t_i\right\}\mathclose{} \end{aligned} \]
\[ \begin{aligned} \ell^*(\lambda) &= \operatorname{log}\mathopen{}\left\{\mathscr{L}^*(\lambda)\right\}\mathclose{} \\&= \sum_{i=1}^n \mathopen{}\left(\operatorname{log}\mathopen{}\left\{\lambda\right\}\mathclose{} -\lambda t_i\right)\mathclose{} \end{aligned} \]
\[\ell^{*'}(\lambda) = \sum_{i=1}^n \mathopen{}\left(\mathopen{}\left(\lambda\right)^{-1}\mathclose{} - t_i\right)\mathclose{}\]
Proposition (MLE when infection times are fully observed)
Proposition 2 (MLE when infection times are fully observed) If the latent infection times \(t_1, \ldots, t_n\) were fully observed, solving the score equation (Definition 5) would estimate \(\lambda\) as the reciprocal of their mean:
\[ \begin{aligned} \hat{\lambda}_{\text{ML}} &= \frac{n}{\sum_{i=1}^n t_i} \\&= \frac{1}{\bar{t}} \end{aligned} \]
library(serodynamics)
library(serocalculator)
library(dplyr)
antibodies <- c("HlyE_IgA", "HlyE_IgG")
set.seed(1)
sim_case_data <-
serocalculator::typhoid_curves_nostrat_100 |>
sim_case_data(n = 5,
antigen_isos = antibodies,
max_n_obs = 20, followup_interval = 14)
t1 <- sim_case_data$timeindays
loglik0 <- function(lambda) {
sum(
dexp(t1, rate = lambda, log = TRUE)
)
}
loglik1 <- Vectorize(loglik0, vectorize.args = "lambda")
library(ggplot2)
ggplot() +
geom_function(fun = loglik1) +
xlim(0, .1) +
theme_bw() +
xlab("lambda") +
ylab("log-likelihood")How precisely the data pin down \(\hat\lambda\) depends on how sharply the log-likelihood peaks at its maximum, which takes two more definitions to make precise.
Definition (Hessian)
Definition 6 (Hessian) The Hessian is the second derivative of the log-likelihood with respect to the parameter (in general, the matrix of its second partial derivatives):
\[\ell''(\lambda) \stackrel{\text{def}}{=}\frac{\partial^2}{\partial \lambda^2}\ell(\lambda)\]
Definition (Observed information)
Definition 7 (Observed information) The observed information is the negative Hessian (Definition 6):
\[I(\lambda) \stackrel{\text{def}}{=}-\ell''(\lambda)\]
A sharper peak at the maximum means a larger observed information.
Theorem (Asymptotic variance of the MLE)
Theorem 1 (Asymptotic variance of the MLE) Under standard regularity conditions, the variance of a maximum likelihood estimate is approximately the inverse of the observed information (Definition 7) at that estimate:
\[\mathop{\widehat{\operatorname{Var}}}\nolimits\mathopen{}\left(\hat\lambda\right)\mathclose{} \approx \mathopen{}\left[I(\hat\lambda)\right]\mathclose{}^{-1}\]
The standard error is the square root of that variance, so:
more curvature \(\rightarrow\) a sharper likelihood peak \(\rightarrow\) more observed information \(\rightarrow\) smaller standard errors.
Example (Latent-time exponential model)
Example 2 (Latent-time exponential model) For the latent-time model of Proposition 2, the log-likelihood is \[\ell^*(\lambda) = n\operatorname{log}\mathopen{}\left\{\lambda\right\}\mathclose{} - \lambda\sum_{i=1}^n t_i\]
so its score (Definition 5) and Hessian (Definition 6) are
\[ \begin{aligned} \ell^{*\prime}(\lambda) &= \frac{n}{\lambda} - \sum_{i=1}^n t_i \\ \ell^{*\prime\prime}(\lambda) &= -\frac{n}{\lambda^2} \end{aligned} \]
and its observed information (Definition 7) is \(-\ell^{*\prime\prime}(\lambda) = n/\lambda^2\).
At the MLE \(\hat\lambda = 1/\bar t\), Theorem 1 then gives
\[ \begin{aligned} \mathop{\widehat{\operatorname{Var}}}\nolimits\mathopen{}\left(\hat\lambda\right)\mathclose{} &\approx \left[-\ell^{*\prime\prime}(\hat\lambda)\right]^{-1} \\&= \frac{\hat\lambda^2}{n} \\ \mathop{\widehat{\operatorname{SE}}}\nolimits\mathopen{}\left(\hat\lambda\right)\mathclose{} &\approx \frac{\hat\lambda}{\sqrt{n}} \end{aligned} \]
so the standard error shrinks like \(1/\sqrt{n}\): quadrupling the sample size halves it.
case_data <-
serodynamics_example(
"SEES_Case_Nepal_ForSeroKinetics_02-13-2025.csv"
) |>
readr::read_csv() |>
dplyr::mutate(
.by = person_id,
visit_num = dplyr::row_number()
) |>
as_case_data(
id_var = "person_id",
biomarker_var = "antigen_iso",
value_var = "result",
time_in_days = "dayssincefeveronset"
)
most_obs <-
case_data |>
count(id) |>
arrange(desc(n)) |>
head(10)
case_data |>
semi_join(most_obs, by = "id") |>
autoplot(alpha = .5, log_x = FALSE)Proposition (Marginal likelihood of one participant’s data)
Proposition 3 (Marginal likelihood of one participant’s data) Substituting \(\operatorname{p}(Y=y,T=t) = \operatorname{p}(Y=y|T=t)\,\operatorname{p}(T=t)\) into the expression for \(\operatorname{p}(Y=y)\) above gives one participant’s contribution:
\[\operatorname{p}(Y=y) = \int_t \operatorname{p}(Y=y|T=t)\,\operatorname{p}(T=t)\, dt\]
That integral is shorthand, because \(T\) is not purely continuous: it has the mixed distribution of Proposition 1, continuous on \([0,a]\) plus an atom at \(T=\text{NA}\). Written out over both parts, the integral is
\[ \operatorname{p}(Y=y) = \int_0^a \operatorname{p}(Y=y\mid T=t)\,\lambda\operatorname{exp}\mathopen{}\left\{-\lambda t\right\}\mathclose{}\,dt \;+\; \operatorname{p}(Y=y\mid T=\text{NA})\cdot \operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{} \]
The first term is the contribution from subjects infected at least once; the second is the contribution from subjects never infected.
Proposition (Full-sample likelihood, assuming independent participants)
Proposition 4 (Full-sample likelihood, assuming independent participants) Write \(\mathscr{L}_i\) and \(\ell_i\) for participant \(i\)’s own likelihood and log-likelihood, which is Proposition 3 evaluated at \(y = y_i\):
\[ \begin{aligned} \mathscr{L}_i(\lambda) &= \operatorname{p}(Y = y_i) \\ \ell_i(\lambda) &= \operatorname{log}\mathopen{}\left\{\mathscr{L}_i(\lambda)\right\}\mathclose{} \end{aligned} \]
If participants are independent of one another, the likelihood (Definition 3) of the whole sample \(\tilde{y} = (y_1, y_2, \ldots, y_n)\) is their product:
\[\mathscr{L}(\lambda) = \prod_{i=1}^n \mathscr{L}_i(\lambda)\]
\[ \begin{aligned} \ell(\lambda) &= \operatorname{log}\mathopen{}\left\{\prod_{i=1}^n \mathscr{L}_i(\lambda)\right\}\mathclose{} \\&= \sum_{i=1}^n \ell_i(\lambda) \end{aligned} \]
In many survey designs, observations are clustered (e.g., multiple individuals from the same household, school, or geographic area). Observations within the same cluster are often more similar to each other than to observations from different clusters, which violates the independence assumption of Proposition 4. The estimate \(\hat\lambda\) itself remains usable, but the inverse-information standard error of Theorem 1 no longer applies to it.
When observations are clustered:
Usually rather than always: the direction follows the sign of the within-cluster correlation, which is positive in most survey designs but need not be.
To account for within-cluster correlation, serocalculator implements the sandwich estimator — also known as the Huber-White robust variance estimator (Huber 1967; White 1980), generalized to clustered/grouped data by Liang and Zeger (1986).
Definition (Sandwich variance estimator)
Definition 8 (Sandwich variance estimator) Let \(C\) be the total number of non-overlapping clusters in the sample, and let \(H = \ell''(\hat\lambda)\) be the Hessian (Definition 6) at the MLE. Write \(U_i\) for observation \(i\)’s score contribution (Definition 5) — the gradient of its own log-likelihood \(\ell_i\) (Proposition 4) — and \(U_c\) for the total over the observations in cluster \(c\):
\[ \begin{aligned} U_i &= \nabla_\lambda \ell_i(\lambda) \\ U_c &= \sum_{i \in c} U_i \end{aligned} \]
where \(\nabla_\lambda\) denotes the gradient with respect to \(\lambda\). These are gradients of the same \(\ell_i\) that Proposition 4 defines: the full-sample log-likelihood sums \(\ell_i\) over every observation, and the sandwich accumulates their gradients cluster by cluster. The cluster-robust variance-covariance matrix for the parameter estimates is then
\[ \begin{aligned} V_{\text{robust}} &= H^{-1} B H^{-1} \\ B &= \sum_{c=1}^C U_c U_c^T \end{aligned} \]
with \(B\) the “meat” of the sandwich.
Users can specify clustering using the cluster_var parameter:
When cluster-robust standard errors are used, the summary() output indicates this with se_type = "cluster-robust".
cluster_var affects only the variance, not the likelihood being maximizedNotation:
Model:
Once the pathogen is cleared (\(x(t) = 0\)), the antibody concentration decays:
The serum antibody response \(y(t)\) can be written as
\[ y(t) = y_{+}(t) + y_{-}(t) \]
where
\[ \begin{align} y_{+}(t) & = y_{0}\text{e}^{\mu t}[0\leq t <t_{1}]\\ y_{-}(t) & = y_{1}\left(1+(r-1)y_{1}^{r-1}\alpha(t-t_{1})\right)^{-\frac{1}{r-1}}[t_{1}\le t < \infty] \end{align} \]
During infection (\(t < t_1\)): the antibody equation \(y'(t) = \mu\, y(t)\) gives exponential growth, \(y(t) = y_0\, e^{\mu t}\).
The pathogen equation sets the peak time \(t_1\): circulating antibodies inactivate the pathogen (the \(-\beta y(t)\) term), driving \(x(t)\) to zero at \(t_1\). Once the pathogen is cleared, stimulation stops and decay begins, so the peak antibody level is \(y_1 = y_0\, e^{\mu t_1}\).
After the peak (\(t \ge t_1\)): power-law decay, \(y'(t) = -\alpha y(t)^r\).
library(serocalculator)
library(dplyr)
library(ggplot2)
cur_ai <- "HlyE_IgG"
# `typhoid_curves_nostrat_100` bundles antibody response-curve parameters
# for typhoid, in long format with one MCMC draw per `iter` (`iter` runs
# 1:100; see `?typhoid_curves_nostrat_100`); keep the first 49 draws.
curves_all <-
typhoid_curves_nostrat_100 |>
filter(iter < 50)
curve1 <-
curves_all |>
filter(
iter == 5,
antigen_iso == cur_ai
)
curve1 |>
# Relabel as the (only) curve to plot.
mutate(iter = 1) |>
serocalculator:::plot_curve_params_one_ab(
log_y = FALSE
) +
xlim(0, 100) +
theme_minimal() +
geom_vline(
aes(
xintercept = curve1$t1,
col = "t1"
)
) +
geom_hline(
aes(
yintercept = curve1$y0,
col = "y0"
)
) +
geom_hline(
aes(
yintercept = curve1$y1,
col = "y1"
)
) +
geom_point(
data = curve1,
aes(
x = t1,
y = y1,
col = "(t1,y1)"
)
) +
theme(legend.position = "bottom") +
labs(col = "")Interactive Shiny app: https://ucdserg.shinyapps.io/antibody-kinetics-model-2/
Notation:
Model:
\(\nu\) needs to be pre-estimated using negative controls, typically using the 95th percentile of the distribution of antibody responses to the antigen-isotype in a population with no exposure.
There are also some other sources of noise in our bioassays; user differences in pipetting technique, random ELISA plate effects, etc. This noise can cause both overcount and undercount. We can also estimate the magnitude of this noise source and include it in \(p(Y=y|T=t)\).
Measurement noise, \(\varepsilon\) (“epsilon”), represents measurement error from the laboratory testing process.
Unlike biological noise, measurement noise is multiplicative: the error scales with the true concentration (equivalently, it is additive on a log scale), and it has mean zero, so on average it neither inflates nor deflates the measured concentration.
Notation:
Model:
Here \(\varepsilon\) is the bound on the relative error, not a coefficient of variation (CV). Assay precision is often reported as a CV, the ratio of the standard deviation to the mean for replicates, ideally measured across plates rather than within the same plate. Under this uniform model the CV equals \(\varepsilon/\sqrt{3}\), so a measured CV corresponds to \(\varepsilon = \sqrt{3}\,\text{CV}\).
In practice both noise sources are usually present at once. The two are applied in sequence (Teunis and Eijkeren 2020): biological noise is added to the true concentration, and measurement noise then scales the result.
Model:
This is the model serocalculator uses whenever a noise_params row has both \(\nu > 0\) and \(\varepsilon > 0\); setting either width to zero recovers the corresponding single-source model above.
A subject who has never been infected has true antibody concentration \(y_\text{true} = 0\); the probability of never having been infected by age \(a\) is \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}\). The two noise sources treat such a subject very differently (Teunis and Eijkeren 2020):
Biological noise is additive, so a never-infected subject is measured as \(y_\text{obs} = 0 + \epsilon_b \sim \text{Unif}(0, \nu)\). Cross-reactivity can register a positive signal even when there was no true response, so never-infected subjects still contribute a spread-out distribution of small positive values, not a single spike at zero.
Measurement noise is multiplicative, so a never-infected subject is measured as \(y_\text{obs} = 0 \cdot (1 + \xi) = 0\). A relative error cannot move a true zero, so measurement noise alone leaves these subjects exactly at zero.
So the probability mass at \(y = 0\) from never-infected subjects (with weight \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}\)) survives measurement noise but is smeared into small positive values by biological noise. This also explains why the biological-noise width \(\nu\) can be estimated from a known unexposed population: those subjects are essentially all never-infected, so their measured antibody levels are, to good approximation, draws from the biological-noise distribution itself.
Under both noise sources together, the never-infected contribution is neither of these two single-source shapes. Substituting \(y_\text{true} = 0\) into the combined model gives
\[ y_\text{obs} = \epsilon_b (1 + \xi), \qquad \epsilon_b \sim \text{Unif}(0, \nu), \quad \xi \sim \text{Unif}(-\varepsilon, \varepsilon), \]
a product of two independent uniform random variables, not itself uniform. Following the same conditioning argument Teunis and van Eijkeren use to derive their combined-noise density (Teunis and Eijkeren 2020), its density (the never-infected contribution to the overall observed density, including the never-infected weight \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}\)) is
\[ \rho_{bm}(y \mid \text{never-infected}) = \begin{cases} \dfrac{\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}}{2\varepsilon\nu} \log\!\left(\dfrac{1+\varepsilon}{1-\varepsilon}\right) & 0 < y \le \nu(1-\varepsilon) \\[8pt] \dfrac{\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}}{2\varepsilon\nu} \log\!\left(\dfrac{\nu(1+\varepsilon)}{y}\right) & \nu(1-\varepsilon) < y < \nu(1+\varepsilon) \\[8pt] 0 & y \ge \nu(1+\varepsilon) \end{cases} \]
The first piece matches Teunis and van Eijkeren’s Equation 19: it is flat (constant in \(y\)) over most of its support, then tapers to zero by \(y = \nu(1+\varepsilon)\). Integrating both pieces over their full range recovers exactly \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}\), the never-infected probability, confirming the density is correctly normalized. As \(\varepsilon \to 0\) the flat piece reduces to \(\operatorname{exp}\mathopen{}\left\{-\lambda a\right\}\mathclose{}/\nu\), the biological-noise-only density on \([0, \nu]\), matching the single-source case above.
The figure above draws one curve to name the parameters. The fitted model is really a posterior sample of such curves, and autoplot() on a curve_params object shows their spread:
curves holds 49 draws per antigen-isotype here, since it was thinned to iter < 50 when it was loaded.
A serosurvey usually measures more than one antigen-isotype per participant. The current model treats biomarkers as conditionally independent given the time since infection, so their response densities multiply inside each person’s integral over \(t\):
\[ \operatorname{p}(Y_1=y_1, Y_2=y_2) = \int_t \operatorname{p}(Y_1=y_1|T=t)\,\operatorname{p}(Y_2=y_2|T=t)\,\operatorname{p}_\lambda(T=t)\, dt \]
Note that this is not the product of the two single-biomarker likelihoods: the shared latent infection time \(t\) couples them.
log_likelihood() evaluates the log-likelihood at a given \(\lambda\), which lets us check that claim numerically:
ll_iga <- log_likelihood(
pop_data = xs_data, curve_params = curves, noise_params = noise,
antigen_isos = "HlyE_IgA", lambda = 0.1
)
ll_igg <- log_likelihood(
pop_data = xs_data, curve_params = curves, noise_params = noise,
antigen_isos = "HlyE_IgG", lambda = 0.1
)
ll_both <- log_likelihood(
pop_data = xs_data, curve_params = curves, noise_params = noise,
antigen_isos = c("HlyE_IgG", "HlyE_IgA"), lambda = 0.1
)
c(IgA = ll_iga, IgG = ll_igg, sum = ll_iga + ll_igg, joint = ll_both) IgA IgG sum joint
-3801.688 -4918.504 -8720.192 -8720.192
The two-biomarker value is exactly the sum of the separate ones. That is worth pausing on, because the formula above says it should not be: one integral over a shared \(t\) does not factor into two separate integrals.
What it tells us is that log_likelihood() currently combines biomarkers by summing their individual log-likelihoods, which is the stronger assumption that the biomarkers are independent unconditionally, each with its own latent infection time. est_seroincidence() optimizes through the same code path, so this is the estimation model rather than a quirk of this one function.
Whether the formula or the implementation should change is an open question. Until it is settled, read a multi-biomarker standard error as assuming independence across biomarkers.
Using both biomarkers concentrates the log-likelihood around its maximum, and so shrinks the standard error, relative to either biomarker alone (Figure 6):
lik_HlyE_IgA <- graph_loglik( # nolint: object_name_linter.
pop_data = xs_data,
curve_params = curves,
noise_params = noise,
antigen_isos = "HlyE_IgA",
log_x = TRUE
)
lik_HlyE_IgG <- graph_loglik( # nolint: object_name_linter.
previous_plot = lik_HlyE_IgA,
pop_data = xs_data,
curve_params = curves,
noise_params = noise,
antigen_isos = "HlyE_IgG",
log_x = TRUE
)
lik_both <- graph_loglik(
previous_plot = lik_HlyE_IgG,
pop_data = xs_data,
curve_params = curves,
noise_params = noise,
antigen_isos = c("HlyE_IgG", "HlyE_IgA"),
log_x = TRUE
)
print(lik_both)Antibody-decay kinetics are not identical across populations. Estimated seroresponse parameters vary by country, age, and serotype (Typhi vs Paratyphi A).
serodynamicsThe decay curves above are themselves estimated from longitudinal antibody measurements on confirmed cases — individuals with a known infection date who are sampled repeatedly afterward. Fitting the two-phase model to those data yields, for each antigen-isotype, the parameters the incidence model needs:
serodynamics packageserodynamics is an open-source R package that fits this two-phase within-host kinetics model with a Bayesian hierarchical model, sampled by MCMC (via JAGS). The hierarchical structure stabilizes individual-level estimates by borrowing strength across participants — valuable when longitudinal data are sparse.
serodynamics workflowlibrary(serodynamics)
# Longitudinal confirmed-case data (example data ships with the package)
data("nepal_sees")
# Fit the two-phase model by MCMC (JAGS); slow, so not run here
fit <- run_mod(data = nepal_sees, with_post = TRUE)
# Check convergence, then export serocalculator-ready curve parameters
plot_jags_Rhat(fit)
curve_params <- postprocess_jags_output(fit)The package ships a cached example fit; plot_jags_dens() shows the posterior densities of the five kinetic parameters (here HlyE IgG, Typhi), overlaid by MCMC chain:
serodynamics (upstream): longitudinal confirmed-case data \(\rightarrow\) estimated antibody-decay curve parametersserocalculator (downstream): a cross-sectional serosurvey plus those curve parameters \(\rightarrow\) a seroincidence estimateThe output of serodynamics feeds directly into serocalculator’s est_seroincidence_by().
The seroresponse model \(\operatorname{p}(Y=y|T=t)\) is not a single fixed curve. serodynamics returns a posterior sample of curve-parameter sets \(\theta^{(1)},\dots,\theta^{(M)}\) (each \(\theta^{(k)} = (y_0, y_1, t_1, \alpha, r)^{(k)}\)), which together capture both
The incidence likelihood averages over these draws (Monte Carlo integration), so each person’s contribution becomes
\[ \operatorname{p}(Y=y) \approx \frac{1}{M}\sum_{k=1}^{M} \int_t \operatorname{p}(Y=y|T=t, \theta^{(k)})\; \operatorname{p}_\lambda(T=t)\, dt \]
The longitudinal model distinguishes two kinds of variation:
Thus, even without an explicit time-varying residual variance \(\sigma^2(t)\), the variance around the population mean trajectory can vary with \(t\):
\[ \operatorname{Var}(\log Y(t)\mid T=t) = \sigma_{\log Y}^2 + \operatorname{Var}_{\theta}\!\left[\log y(t;\theta)\right]. \]
serocalculatorThe methods in this lecture are implemented in the open-source serocalculator R package.
The rest of this section walks through the whole estimation pipeline on simulated data, where the true incidence rate is known and the estimate can be checked against it. Section 8 then repeats the exercise across many simulated populations at once.
sim_pop_data() simulates infections as a Poisson process with rate lambda, then generates each person’s antibody levels from the seroresponse model:
sim_lambda <- 0.2 # true incidence rate, per person-year
sim_lifespan <- c(0, 10) # age range of the simulated survey
sim_n <- 100 # number of participants
# Antibody-decay parameters: `typhoid_curves_nostrat_100`, the same bundled
# sample used for the antibody-response curves earlier in this article.
sim_curve_params <-
typhoid_curves_nostrat_100 |>
dplyr::filter(iter < 50, antigen_iso %in% antibodies)
# Biological-noise limits used when *generating* the data
sim_noise_limits <- rbind(
"HlyE_IgA" = c(min = 0, max = 0.5),
"HlyE_IgG" = c(min = 0, max = 0.5)
)
set.seed(54321)
sim_data <- sim_pop_data(
curve_params = sim_curve_params,
lambda = sim_lambda,
n_samples = sim_n,
age_range = sim_lifespan,
antigen_isos = antibodies,
n_mcmc_samples = 0,
renew_params = FALSE,
add_noise = TRUE,
noise_limits = sim_noise_limits,
format = "long"
)Estimation needs its own noise parameters, matching Section 5.6. Here they are declared directly, consistent with the noise used to generate the data above:
est_seroincidence() maximizes the log-likelihood \(\log\mathcal{L}(\lambda)\) derived above:
# A tibble: 1 × 11
est.start incidence.rate SE CI.lwr CI.upr se_type coverage log.lik
<dbl> <dbl> <dbl> <dbl> <dbl> <chr> <dbl> <dbl>
1 0.1 0.194 0.0213 0.157 0.241 standard 0.95 -469.
# ℹ 3 more variables: iterations <int>, antigen.isos <chr>,
# nlm.convergence.code <ord>
The true rate used to generate these data was 0.2 infections per person-year.
autoplot() draws the log-likelihood curve, with the maximum marked:
The same call works on a real serosurvey. est_seroincidence_by() estimates separately within each stratum, and summary() reports each stratum’s \(\hat\lambda\) with a standard error and confidence interval (cluster-robust, if cluster_var was supplied):
# Load antibody-decay curve parameters and cross-sectional population data
curves <- "https://osf.io/download/rtw5k/" |> load_sr_params()
xs_data <- "https://osf.io/download/n6cp3/" |> load_pop_data()
noise <- url("https://osf.io/download/hqy4v/") |> readRDS()
# Visualize the cross-sectional antibody distribution
xs_data |> autoplot(strata = "Country", type = "density")
# Estimate incidence, stratified by country and age group
est <- est_seroincidence_by(
pop_data = xs_data,
sr_params = curves,
noise_params = noise,
strata = c("Country", "ageCat"),
antigen_isos = c("HlyE_IgG", "HlyE_IgA")
)
summary(est)Stratifying this way also makes the constant-and-homogeneous-incidence assumption more plausible within each stratum, and lets each stratum use the seroresponse parameters appropriate to it.
A point-and-click interface is available at https://ucdserg.shinyapps.io/shiny_serocalculator/.
The single survey above recovered a rate close to the one that generated it. To check that this was not luck, we simulate many clusters across a range of known incidence rates and ask whether the estimates track them.
sim_lambdas <- c(.05, .1, .15, .2, .5, .8) # true incidence rates
sim_nclus <- 20 # clusters per rate
sim_df <- sim_pop_data_multi_cached(
curve_params = sim_curve_params,
lambdas = sim_lambdas,
nclus = sim_nclus,
sample_sizes = sim_n,
age_range = sim_lifespan,
antigen_isos = antibodies,
renew_params = FALSE,
add_noise = TRUE,
noise_limits = sim_noise_limits,
format = "long",
cache_path = sim_cache,
cache_id = "sim_df"
)sim_df |>
ggplot() +
aes(x = as.factor(cluster), y = value) +
geom_beeswarm(size = .2, alpha = .3, aes(color = antigen_iso)) +
geom_boxplot(outlier.colour = NA, fill = NA) +
scale_y_log10() +
facet_wrap(~ antigen_iso + lambda.sim, nrow = 2) +
theme_linedraw() +
theme(legend.position = "bottom") +
labs(x = "cluster")ests <- est_seroincidence_by_cached(
pop_data = sim_df,
sr_params = sim_curve_params,
noise_params = sim_noise_params,
strata = c("sample_size", "lambda.sim", "cluster"),
curve_strata_varnames = NULL,
noise_strata_varnames = NULL,
antigen_isos = antibodies,
# build_graph = TRUE attaches a log-likelihood plot to every stratum's
# fit, which grows large across many strata; re-fit individual curves on
# demand instead (see below).
build_graph = FALSE,
cache_path = sim_cache,
cache_id = "ests"
)
ests_summary <- ests |> summary()The cached fit above carries no log-likelihood curves, so the few clusters we want to look at are re-fit with build_graph = TRUE. Strata are selected by the columns that define them, because stratum labels are assigned positionally and so are not stable across a subset:
stratum_ids <- ests_summary |>
tibble::as_tibble() |>
dplyr::select(sample_size, lambda.sim, cluster)
ests_first_five <- refit_strata(
pop_data = sim_df,
strata_ids = stratum_ids |> dplyr::slice(1:5),
sr_params = sim_curve_params,
noise_params = sim_noise_params,
curve_strata_varnames = NULL,
noise_strata_varnames = NULL,
antigen_isos = antibodies,
num_cores = 1
)nlm() convergenceest_seroincidence() maximizes the log-likelihood numerically with stats::nlm(). Exit codes 3 to 5 flag a possible non-convergence, so they are worth checking before trusting any of the estimates:
[1] 13
Warning: Some strata are completely missing one or more biomarkers.
, , cluster = 1, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 100 0
, , cluster = 2, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 0 100
, , cluster = 5, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 0 100
, , cluster = 7, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 0 0
, , cluster = 8, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 0 0
, , cluster = 9, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 0 0
, , cluster = 12, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 100 0
, , cluster = 14, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
, , cluster = 15, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 0 0
, , cluster = 16, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
, , cluster = 20, antigen_iso = HlyE_IgA
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
, , cluster = 1, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 100 0
, , cluster = 2, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 0 100
, , cluster = 5, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 0 100
, , cluster = 7, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 0 0
, , cluster = 8, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 0 0
, , cluster = 9, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 0 0
, , cluster = 12, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 100 100 0
, , cluster = 14, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
, , cluster = 15, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 100 0 0 0
, , cluster = 16, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
, , cluster = 20, antigen_iso = HlyE_IgG
lambda.sim
sample_size 0.1 0.15 0.2 0.8
100 0 0 100 0
Warning: `nlm()` may not have reached the maximum likelihood estimate.
`nlm()` completed with the following convergence code:
3: Last global step failed to locate a point lower than x. Either x is an
approximate local minimum of the function, the function is too non-linear for
this algorithm, or `stepmin` in `est_seroincidence()` (a.k.a. `steptol` in
`nlm()`) is too large.
`nlm()` may not have reached the maximum likelihood estimate.
`nlm()` completed with the following convergence code:
3: Last global step failed to locate a point lower than x. Either x is an
approximate local minimum of the function, the function is too non-linear for
this algorithm, or `stepmin` in `est_seroincidence()` (a.k.a. `steptol` in
`nlm()`) is too large.
analyze_sims() summarizes the simulation results across clusters:
# A tibble: 6 × 8
lambda.sim sample_size Bias Mean_Est_SE Empirical_SE RMSE Mean_CI_Width
<dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 0.05 100 0.00292 0.00905 0.0102 0.0103 0.0362
2 0.1 100 0.00894 0.0144 0.0204 0.0218 0.0571
3 0.15 100 0.0222 0.0199 0.0183 0.0285 0.0786
4 0.2 100 0.0269 0.0242 0.0319 0.0411 0.0957
5 0.5 100 0.0857 0.0568 0.0758 0.113 0.224
6 0.8 100 0.172 0.0989 0.115 0.205 0.390
# ℹ 1 more variable: CI_Coverage <dbl>
Everything above sets renew_params = FALSE, matching what the estimation method assumes. Setting it to TRUE — drawing fresh seroresponse parameters at every infection — is more realistic, and is not accounted for by the current method, so estimates can be biased in high-incidence populations:
sim_df_renew <- sim_pop_data_multi_cached(
curve_params = sim_curve_params,
lambdas = sim_lambdas,
nclus = sim_nclus,
sample_sizes = sim_n,
age_range = sim_lifespan,
antigen_isos = antibodies,
renew_params = TRUE,
add_noise = TRUE,
noise_limits = sim_noise_limits,
format = "long",
cache_path = sim_cache,
cache_id = "sim_df_renew"
)
ests_renew_summary <-
est_seroincidence_by_cached(
pop_data = sim_df_renew,
sr_params = sim_curve_params,
noise_params = sim_noise_params,
strata = c("sample_size", "lambda.sim", "cluster"),
curve_strata_varnames = NULL,
noise_strata_varnames = NULL,
antigen_isos = antibodies,
build_graph = FALSE,
cache_path = sim_cache,
cache_id = "ests_renew"
) |>
summary()Extending and improving existing Shiny apps for these methods (e.g., the serocalculator Shiny app, source)
Multivariate modeling of biomarkers (relaxing conditional independence)
A graphical (Shiny) app for serodynamics
Modeling time-varying incidence rates
Accounting for re-exposure
Accounting for latent immunocompromised subpopulations
Calibrating to population demographics
With several biomarkers (e.g. HlyE IgA and IgG), the current method treats them as conditionally independent given the time since infection, so the joint likelihood factors into a product of per-biomarker terms:
\[ \operatorname{p}(Y_1 = y_1, Y_2 = y_2 \mid T=t) = \operatorname{p}(Y_1 = y_1 \mid T=t)\,\operatorname{p}(Y_2 = y_2 \mid T=t) \]
Kwan Ho Lee (UCD-SERG) is relaxing this to allow covariance among biomarkers — a multivariate seroresponse model with a Kronecker-structured covariance, fit in Stan (UCD-SERG/shigella#13).