Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Type: Package
Package: serocalculator
Title: Estimating Infection Rates from Serological Data
Version: 1.4.0.9003
Version: 1.4.0.9004
Authors@R: c(
person("Kristina", "Lai", , "kwlai@ucdavis.edu", role = c("aut", "cre")),
person("Chris", "Orwa", role = "aut"),
Expand Down
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# serocalculator (development version)

## Documentation improvements

* Updated scrub typhus vignette with final AJTMH publication details and corrected biological noise calculation

# serocalculator 1.4.0

## New features
Expand Down
13 changes: 13 additions & 0 deletions inst/WORDLIST
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
AJTMH
Aga
Bogoch
CMD
Chiang
CodeFactor
Expand All @@ -13,12 +15,15 @@ HlyE
IgA
IgG
IgM
Katuwal
LV
Lik
Lotka
Melina
NIAID
NLL
Nadu
Nishan
OSF
Orientia
PRs
Expand All @@ -30,9 +35,12 @@ Rtools
SeroEpidemiology
Seroincidence
Serological
Shrestha
TW
Thapa
UC
Unif
Vaidya
Vellore
Volterra
al
Expand Down Expand Up @@ -67,6 +75,10 @@ isos
isotype
isotypes
kDa
kda
kdaIgG
kdaIgM
kilodalton
le
leq
linter
Expand Down Expand Up @@ -99,6 +111,7 @@ qmd
qquad
recombinant
renewcommand
responsed
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responsed is a misspelling (should be “responses”). It looks like this was added to bypass spell checking, but it’s better to fix the typo in the vignette and remove this entry from WORDLIST so genuine typos aren’t whitelisted.

Suggested change
responsed

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(see previous comment)

sectionally
sera
seroconversion
Expand Down
143 changes: 110 additions & 33 deletions vignettes/articles/scrubTyphus_example.Rmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: "Scrub Typhus Seroincidence Vignette"
author: "UC Davis Seroepidemiology Research Group (SERG)"
date: "First Published: 2024-AUG-01 | Updated: 2026-JAN-28"
output:
bookdown::html_document2:
base_format: rmarkdown::html_vignette
Expand All @@ -14,7 +15,9 @@ bibliography: ../references.bib
---
## Introduction

This vignette reproduces the analysis for: [**Estimating the seroincidence of scrub typhus using antibody dynamics following infection**](https://www.medrxiv.org/content/10.1101/2022.11.07.22282017v2) (@Aiemjoy_2024_scrub).
This vignette reproduces the analysis for: [**Estimating the Seroincidence of Scrub Typhus using Antibody Dynamics after Infection**](https://www.ajtmh.org/view/journals/tpmd/111/2/article-p267.xml) (@Aiemjoy_2024_scrub).

**Citation:** Aiemjoy, Kristen, Nishan Katuwal, Krista Vaidya, Sony Shrestha, Melina Thapa, Peter Teunis, Isaac I. Bogoch et al. "Estimating the Seroincidence of Scrub Typhus using Antibody Dynamics after Infection." The American Journal of Tropical Medicine and Hygiene 111, no. 2 (2024): 267. https://doi.org/10.4269/ajtmh.23-0475

## Methods

Expand All @@ -38,7 +41,7 @@ Given the seroresponse, this marginal distribution of antibody concentrations ca

## Scrub Typhus Seroincidence

Scrub typhus, a vector-borne bacterial infection, is an important but neglected disease globally. Accurately characterizing burden is challenging due to non-specific symptoms and limited diagnostics. Prior seroepidemiology studies have struggled to find consensus cutoffs that permit comparing estimates across contexts and time. In this study, we present a novel approach that does not require a cutoff and instead uses information about antibody kinetics after infection to estimate seroincidence. We use data from three cohorts of scrub typhus patients in Chiang Rai, Thailand, and Vellore, India to characterize antibody kinetics after infection and two population serosurveys in the Kathmandu valley, Nepal, and Tamil Nadu, India to estimate seroincidence. The samples were tested for IgM and IgG responses to Orientia tsutsugamushi-derived recombinant 56-kDa antigen using commercial ELISA kits. We used Bayesian hierarchical models to characterize antibody responses after scrub typhus infection and used the joint distributions of the peak antibody titers and decay rates to estimate population-level incidence rates in the cross-sectional serosurveys.
Scrub typhus, a vector-borne bacterial infection, is an important but neglected disease globally. Accurately characterizing burden is challenging due to non-specific symptoms and limited diagnostics. Prior seroepidemiology studies have struggled to find consensus cutoffs that permit comparing estimates across contexts and time. In this study, we present a novel approach that does not require a cutoff and instead uses information about antibody kinetics after infection to estimate seroincidence. We use data from three cohorts of scrub typhus patients in Chiang Rai, Thailand, and Vellore, India to characterize antibody kinetics after infection and two population serosurveys in the Kathmandu valley, Nepal, and Tamil Nadu, India to estimate seroincidence. The samples were tested for IgM and IgG responses to Orientia tsutsugamushi-derived recombinant 56-kDa antigen using commercial ELISA kits. These antigens (OT56kdaIgG and OT56kdaIgM) represent IgG and IgM responses to a 56 kilodalton antigen on the membrane of *Orientia tsutsugamushi* (OT) that have been found to be specific to this organism and are used in diagnosis. We used with-host Bayesian hierarchical models to characterize antibody responses after scrub typhus infection and used the joint distributions of the peak antibody titers and decay rates to estimate population-level incidence rates in the cross-sectional serosurveys.
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paragraph says “with-host Bayesian hierarchical models”, which appears to be a typo; it should be “within-host”. Since this is in the vignette narrative, please correct the wording rather than adding it to the spelling WORDLIST.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, or better: "within-host hierarchical models with a Bayesian inference framework"



```{r, include = FALSE}
Expand Down Expand Up @@ -94,7 +97,8 @@ curves <-
We can graph the decay curves with an `autoplot()` method:

```{r}
curves |> autoplot()
# Visualize curve parameters with custom colors
curves |> autoplot()
```


Expand Down Expand Up @@ -135,10 +139,13 @@ xs_data |> summary(strata = "country")
Let's also take a look at how antibody responses change by age.

```{r plot-age}
# Plot antibody responses by age
autoplot(object = xs_data, type = "age-scatter", strata = "country")
# Plot antibody responses by age with custom colors
autoplot(object = xs_data, type = "age-scatter", strata = "country") +
scale_color_manual(values = c("India" = "orange2", "Nepal" = "#39558CFF"))
```

As is clear from these data, the study in Nepal was performed in children, while the study in India was performed in adults, yet we can see that the values were higher on average and with less variation in Nepal.



### Compile noise parameters
Expand All @@ -160,45 +167,73 @@ Column Name | Description
*Note that variable names are case-sensitive.*

```{r message=FALSE, warning=FALSE}
# biologic noise
# Biologic noise calculation
# Note: There was an error in the SD calculation in the original paper.
# The correct approach is to use the sigma value directly from the mixture model,
# not sqrt(sigma). This vignette uses the corrected calculation.

set.seed(1234)


b_noise <- xs_data |>
group_by(antigen_iso) |>
filter(!is.na(value)) |>
filter(age < 40) |> # restrict to young ages to capture recent exposures
do({
set.seed(54321)
# Fit the mixture model
mixmod <- normalmixEM(.$value, k = 2, maxit = 1000)
# k is the number of components, adjust as necessary
# Assuming the first component is the lower distribution:
lower_mu <- mixmod$mu[1]
lower_sigma <- sqrt(mixmod$sigma[1])
# Calculate the 90th percentile of the lower distribution
percentile75 <- qnorm(
0.75,
lower_mu,
lower_sigma
group_by(antigen_iso) |>
group_modify(~{
mixmod <- mixtools::normalmixEM(.x$value, k = 2, maxit = 1000)

lower_k <- which.min(mixmod$mu) # pick the lower-mean component
lower_mu <- mixmod$mu[lower_k]
lower_sd <- mixmod$sigma[lower_k]

tibble::tibble(
percentile95 = qnorm(0.95, mean = lower_mu, sd = lower_sd)
)
Comment on lines +188 to 190
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biologic noise correction described in the PR uses the 90th percentile, but the code currently computes the 95th percentile (qnorm(0.95)) and names it percentile95. This changes the definition of ν and doesn’t match the stated correction; please update to the intended percentile (and variable name) consistently.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Copilot noticed that you had 90th percentile in the original Issue description: #498 (comment)

not sure what's correct here?

# Return the results
data.frame(antigen_iso = .$antigen_iso[1],
percentile75 = percentile75)
})
}) |>
ungroup()

# Biologic noise calculation (using children age <2 with lower liklihood of prior exposure)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says “children age <2” and “liklihood”, but the code filters age <5. Please align the comment with the code (and fix the spelling of “likelihood”) to avoid confusion about which age group is used.

Suggested change
# Biologic noise calculation (using children age <2 with lower liklihood of prior exposure)
# Biologic noise calculation (using children age <5 with lower likelihood of prior exposure)

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct?

b_noise_u5 <- xs_data |>
filter(!is.na(value), age <5) |>
group_by(antigen_iso) |>
group_modify(~{
mixmod <- mixtools::normalmixEM(.x$value, k = 2, maxit = 1000)

lower_k <- which.min(mixmod$mu) # pick the lower-mean component
lower_mu <- mixmod$mu[lower_k]
lower_sd <- mixmod$sigma[lower_k]

tibble::tibble(
percentile95 = qnorm(0.95, mean = lower_mu, sd = lower_sd)
)
}) |>
ungroup()


# define conditional parameters
# Define conditional parameters
noise <- data.frame(
antigen_iso = c("OT56kda_IgG", "OT56kda_IgM"),
nu = as.numeric(c(b_noise[2, 2], b_noise[1, 2])), # Biologic noise (nu)
nu_u5 = as.numeric(c(b_noise_u5[1, 2], b_noise_u5[2, 2])), # Biologic noise (nu)
Comment on lines 213 to +216
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The noise table assigns nu/nu_u5 by positional indexing (e.g., b_noise[2,2]), which is brittle and can silently mis-map values if group order changes. Instead, map/join by antigen_iso (or pivot wider) and then build noise from those keyed values. Also note nu_u5 is not a recognized column in noise_param_names and will be dropped during estimation, so it currently only affects the printed table (not the model inputs).

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might be where the NA comes from?

eps = c(0.2, 0.2), # M noise (eps)
y.low = c(0.2, 0.2), # low cutoff (llod)
y.high = c(200, 200)
y.high = c(4, 4)
) |> # high cutoff (y.high)
mutate(across(where(is.numeric), round, digits = 2))

```

The table below shows the noise parameters that will be used in the serocalculator function:

```{r}
# Display noise parameters table
knitr::kable(noise,
caption = "Noise parameters for scrub typhus seroincidence estimation",
col.names = c("Antigen-Isotype", "Biological Noise (ν)", "Biological Noise (ν), children < 5",
"Measurement Noise (ε)", "Lower Limit", "Upper Limit"))
```

## Estimate Seroincidence by study site
Now we are ready to begin estimating seroincidence. We will use `est.incidence.by` to calculate stratified seroincidence rates.
Now we are ready to begin estimating seroincidence using IgG responsed to 56kda. We will use `est.incidence.by` to calculate stratified seroincidence rates.
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in narrative: “IgG responsed” should be “IgG responses” (or “IgG response”). Fix the text and then remove “responsed” from inst/WORDLIST rather than whitelisting the misspelling.

Suggested change
Now we are ready to begin estimating seroincidence using IgG responsed to 56kda. We will use `est.incidence.by` to calculate stratified seroincidence rates.
Now we are ready to begin estimating seroincidence using IgG responses to 56kda. We will use `est.incidence.by` to calculate stratified seroincidence rates.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Copilot is correct on this one?



```{r estby}
Expand All @@ -216,6 +251,21 @@ est <- est_seroincidence_by(
summary(est)
```

### Compare seroincidence rates between countries

We can use the `compare_seroincidence()` function to perform statistical comparisons between countries and generate a nicely formatted table with p-values:

```{r}
# Compare seroincidence rates between countries
comparison <- compare_seroincidence(est)

# Display comparison table
knitr::kable(comparison,
digits = 4,
caption = "Statistical comparison of seroincidence rates between countries")
```



## Estimate Seroincidence by study site and age strata
Now we are ready to begin estimating seroincidence. We will use `est.incidence.by` to calculate stratified seroincidence rates.
Expand All @@ -236,13 +286,25 @@ est2 <- est_seroincidence_by(
summary(est2)
```

### Compare seroincidence rates between age groups and countries

```{r}
# Compare all pairs of strata
comparison2 <- compare_seroincidence(est2)

# Display comparison table
knitr::kable(comparison2,
digits = 4,
caption = "Statistical comparisons of seroincidence rates between strata")
```







Let's visualize our seroincidence estimates by strata.
Finally, let's summarize and visualize seroincidence rates by strata.


```{r}
Expand All @@ -258,6 +320,21 @@ est2df <- summary(est2)

est_comb <- rbind(estdf, est2df)

### Summary table with seroincidence rates and 95% CIs
summary_table <- est_comb |>
mutate(
`Seroincidence Rate` = sprintf("%.1f", incidence.rate*1000),
`95% CI` = sprintf("[%.1f, %.1f]", CI.lwr*1000, CI.upr*1000),
`Age Group` = ageQ
#`Standard Error` = sprintf("%.4f", SE)
) %>%
arrange(ageQ) %>%
select(country, `Age Group`, `Seroincidence Rate`, `95% CI`)
Comment on lines +330 to +332
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk switches from the project’s standard native pipe (|>) to %>%, which will be flagged by the repo’s pipe consistency linter (see .lintr.R pipe_consistency_linter(pipe = "|>") at ~line 63). Please convert these %>% steps to |> for consistency and to avoid CI lint failures.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


knitr::kable(summary_table,
caption = "Scrub typhus seroincidence rates by country, per 1000 person-years")


# Create barplot (rescale incidence rate and CIs)
ggplot(est_comb, aes(y = ageQ, x = incidence.rate * 1000, fill = country)) +
geom_bar(
Expand All @@ -266,14 +343,14 @@ ggplot(est_comb, aes(y = ageQ, x = incidence.rate * 1000, fill = country)) +
) +
geom_linerange(aes(xmin = CI.lwr * 1000, xmax = CI.upr * 1000),
position = position_dodge2(width = 0.8, preserve = "single")) +
labs(title = "Enteric Fever Seroincidence by Catchment Area",
x = "Seroincience rate per 1000 person-years",
y = "Catchment") +
labs(title = "Scrub Typhus Seroincidence by Country",
x = "Seroincidence rate per 1000 person-years",
y = "Age Group") +
theme_bw() +
facet_wrap(~ country) +
theme(axis.text.y = element_text(size = 11),
axis.text.x = element_text(size = 11)) +
scale_fill_manual(values = c("orange2", "#39558CFF", "red"))
scale_fill_manual(values = c("India" = "orange2", "Nepal" = "#39558CFF"))
```


Expand Down
12 changes: 8 additions & 4 deletions vignettes/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,15 @@ @article{Aiemjoy_2022_Lancet
}

@article{Aiemjoy_2024_scrub,
title={Estimating the seroincidence of scrub typhus using antibody dynamics following infection},
title={Estimating the Seroincidence of Scrub Typhus using Antibody Dynamics after Infection},
author={Aiemjoy, Kristen and Katuwal, Nishan and Vaidya, Krista and Shrestha, Sony and Thapa, Melina and Teunis, Peter and Bogoch, Isaac I and Trowbridge, Paul and Kantipong, Pacharee and Blacksell, Stuart D and others},
journal={American Journal of Tropical Medicine and Hygiene},
year={Accepted Feb 2024},

journal={The American Journal of Tropical Medicine and Hygiene},
volume={111},
number={2},
pages={267},
year={2024},
doi={10.4269/ajtmh.23-0475},
url={https://www.ajtmh.org/view/journals/tpmd/111/2/article-p267.xml}
}

@article{Aiemjoy_2022_SouthSudan,
Expand Down
Loading