Introduction to baytaAAR

Load libraries

library(baytaAAR)
library(ggplot2)
library(bayesplot)
library(tidybayes)
library(flexsurv)

Introduction

baytaAAR implements latent trait analysis within a Bayesian Markov Chain Monte Carlo (MCMC) framework. It is intended to estimate the age-of-death of adult individuals for whom one or several ordinal traits related to the human aging process have been assessed. It produces probability densities for the individual ages but also for the respective population as a whole. baytaAAR has been introduced and tested by Müller-Scheeßel et al. (2026), and there the basic idea of the model is illustrated in the following figure:

The figure schematically visualizes the ordered probit regression as a latent trait approach with age-at-death estimated from a Gompertz distribution (grey area). The green Gaussian distributions symbolize the transitions between the trait stages (here, arbitrarily, 5 stages, therefore 4 transitions). The regression line with its parameters intercept (\(\beta_0\)) and slope (\(\beta_1\)) determines the position of the latent trait variable. Please note that age is log-scaled, so the regression line appears curved. In practical applications, the latent trait variable does not need to be centered as it is here and the transitions are unlikely to be as evenly spaced as shown here.

More in-depth information on the background of and the rationale behind baytaAAR can be found in the vignettes. One of them discusses a real-life use-case with partly known ages-at-death.

Data layout

As input, the function bay.ta() assumes a matrix() of trait expressions. In its simplest form, this may contain only one column with a single trait as in the following example (“auricular surface”):

data(sorsum_as, package = "baytaAAR")
head(sorsum_as)
#>     id auricular_surface
#> 1  700                 4
#> 4  704                 5
#> 9  710                 8
#> 10 711                 4
#> 16 717                 6
#> 17 719                 3

The example stems from the neolithic gallery grave Sorsum in Hessen/Germany. The auricular surface was the trait which was possible to assess most often (data from Moser et al. 2025, table S69), and it was assessed according to the method by Lovejoy et al (1985) which comprises eight levels. An overview of the distribution of the levels is given in the following plot:

Level 5 is the most numerous while levels 1 and 2 are only present once each. More on data input can be found in the vignette("data_preparation").

Running a first analysis

We will now run a first analysis with bay.ta() with the Sorsum data. We will leave most parameters at the default values and only change the minimum age to 18 and the thinning interval (thinsteps) to 1000. This number is multiplied with the number of saved steps (1000) and the result divided by the number of chains (default = 3) to obtain the total number of iterations, minus the number of iterations used for burning-in. We also set a seed for reproducibility.

sorsum_as_res <- bay.ta(
  method = sorsum_as[,2],
  minimum_age = 18,
  thinSteps = 1000,
  numSavedSteps = 1000,
  seed = 1234
)
#> Starting Time: 08 Jun 2026 09:39:47
#> Defining model
#> Building model
#> Setting data and initial values
#> Running calculate on model
#>   [Note] Any error reports that follow may simply reflect missing values in model variables.
#> Checking model sizes and dimensions
#> Checking model calculations
#> Compiling
#>   [Note] This may take a minute.
#>   [Note] Use 'showCompilerOutput = TRUE' to see C++ compilation details.
#> ===== Monitors =====
#> thin = 1: a, age.s, b, beta, beta0, thresh
#> ===== Samplers =====
#> RW sampler (85)
#>   - age[]  (38 elements)
#>   - beta[]  (1 element)
#>   - beta0[]  (1 element)
#>   - b
#>   - thresh[]  (6 elements)
#>   - ystar[]  (38 elements)
#> Compiling
#>   [Note] This may take a minute.
#>   [Note] Use 'showCompilerOutput = TRUE' to see C++ compilation details.
#> running chain 1...
#> |-------------|-------------|-------------|-------------|
#> |-------------------------------------------------------|
#> running chain 2...
#> |-------------|-------------|-------------|-------------|
#> |-------------------------------------------------------|
#> running chain 3...
#> |-------------|-------------|-------------|-------------|
#> |-------------------------------------------------------|
#> Execution Time: 42.07 seconds

The analysis takes about 1 minute, depending on the computer power. The console output informs about the monitored parameters (a, age.s, b, beta, beta0, thresh), the sampled nodes (age, beta, beta0, b, thresh and ystar), their number and the used sampler which is RW for Metropolis-Hastings sampling. The model output (sorsum_as_res in this case) is a matrix with the number of chains (three in the default case).

The output can be processed with any function able to deal with coda::mcmc.lists() but baytaAAR provides some functions for convenience to quickly achieve diagnostic results. One of them is the function diagnostic.summary which does exactly what its name suggests: a summary of diagnostic measures of the parameters.

sorsum_as_res_diag <- diagnostic.summary(sorsum_as_res) 
sorsum_as_res_diag |> head(10) |> knitr::kable(digits = 4)
PSRF Point est. PSRF Upper C.I. Mean Median Mode ESS MCSE HDImass HDIlow HDIhigh
M 1.0235 1.0748 55.4075 60.1760 70.5845 620.9 0.7614 0.95 17.1554 81.7739
a 1.0226 1.0740 0.0091 0.0073 0.0029 621.7 0.0003 0.95 0.0002 0.0231
age.s[1] 1.0086 1.0309 45.1235 43.6152 40.7268 708.6 0.4788 0.95 20.8396 67.7356
age.s[2] 1.0041 1.0208 57.7845 57.2079 53.0281 666.3 0.5238 0.95 31.7521 81.9953
age.s[3] 1.0341 1.1155 82.9289 83.5910 82.9695 860.7 0.3295 0.95 65.2826 99.9182
age.s[4] 1.0123 1.0340 45.6070 44.4307 38.5401 647.7 0.5230 0.95 20.6297 70.8874
age.s[5] 1.0088 1.0322 67.1531 67.3303 72.0938 528.2 0.5269 0.95 44.4429 90.0163
age.s[6] 1.0083 1.0162 33.2717 30.7858 24.9060 627.2 0.4364 0.95 18.0831 55.3287
age.s[7] 1.0063 1.0238 73.2174 74.1248 74.9703 711.1 0.4262 0.95 52.1496 94.1982
age.s[8] 1.0015 1.0061 45.0487 43.3770 35.5461 671.8 0.4876 0.95 24.6571 70.3490

The diagnostic table gives a first impression of the result, above the first ten rows are shown. diagnostics.max.min(), another convenience function, displays minimum/maximum values of the two quality measures Potential scale reduction factor (PSRF, also called Gelman-Rubin statistic), a measure of chain mixing and the Effective sample size (ESS), a measure of autocorrelation. The PSRF value should be below 1.1, and the ESS value larger than 10,000 [Kruschke (2015), 181; 184].

diagnostics.max.min(sorsum_as_res_diag)
#>   PSRF_max PSRF_upper_max ESS_min
#> 1 1.070302       1.228565      33

From the output of diagnostics.max.min() it is clear that these values are not yet reached and that therefore more iterations would be necessary.

Another measure to assess the quality of the simulations are so-called ‘trace-plots’. They illustrate the mixing of the chains which ideally should be nearly indistinguishable. For this, we use the function bayesplot::mcmc_trace from the R package bayesplot (Gabry et al. 2019). We also switch to the color scheme viridis which makes distinguishing between chains easier.

bayesplot::color_scheme_set("viridis")
bayesplot::mcmc_trace(sorsum_as_res, 
                      pars = c("age.s[1]", "beta[1]"), n_warmup = 300,
                      facet_args = list(nrow = 1, labeller = label_parsed))

The left panel gives an impression how well-mixed chains should look like for the first of the estimated ages. On contrast, the right panel illustrates chains for the parameter beta, the slope of the latent linear regression function, that have not yet well-mixed. Longer chains are clearly necessary. This can also be illustrated with a plot showing the Potential scale reduction factor (PSRF), already mentioned above:

coda::gelman.plot(sorsum_as_res[, c("b", "beta[1]")])

Ideally, the value of the PSRF should converge to 1, but stay below 1.1. On the left panel for the parameter b, this is clearly the case, but not so on the right panel, again for the beta parameter (please note the difference in scale of the y-axis!). This therefore also indicates that more iterations are required.

From the minimum ESS value of around 25 (see above), it can be surmised that roughly 400 times more iterations are needed to get to a value of 10,000. This would increase runtime proportionally (approximately 200–250 minutes, equaling about 4 hours). However, because the resulting file would also be 400 times larger, some degree of thinning is necessary, so saving only, say, every 10,000th step (= thinning of 10,000). This in turn might increase autocorrelation which in turn will force you to run the model even longer.

In the current vignette, we forego this step at the moment. Instead, we inspect the seven thresholds between the eight levels. For this, we use the internal function threshold.chains() to compute the threshold values for each iteration. The computation of the thresholds on the age-scale is done outside of the MCMC simulation to reduce computational cost and memory usage. The function returns a coda::mcmc.list() which is further processed with first diagnostic.summary() and then threshold.matrix(). The latter is again a convenience function to extract mean thresholds values from the output of diagnostic.summary() which is particularly handy when dealing with several traits.

thresholds <- threshold.chains(sorsum_as_res)
thresh_diag <- diagnostic.summary(thresholds)
threshold.matrix(thresh_diag) |> data.frame() |> knitr::kable(digits = 1)
X1 X2 X3 X4 X5 X6 X7
18 21.5 29.3 49.4 70.4 76.8 83.8

The thresholds of this trait (auricular surface) are unevenly spaced within the age range of 18 and 100 years. The probability distribution of the thresholds can conveniently visualised with e.g. the R-package bayesplot. To avoid too glaring colors, we switch the color scheme again:

bayesplot::color_scheme_set("gray")
bayesplot::mcmc_areas_ridges(thresholds, prob = 0.8, point_est = c("median"), 
                      border_size = 0.2) + 
  theme_light() + xlim(18,100) + labs(x = "\nage-at-death (years)")

The resulting plot shows impressively the considerable overlap between the thresholds, even when only the 80%-credible level is shown (grey shaded areas), but also again that the thresholds are not evenly spaced. Interesting would be a comparison with the thresholds of this trait computed for other populations, keeping in mind that the current values are not reliable as the quality measures were not yet met.

The function age.estim.summary() conveniently provides summaries of age-related quantities like the mean estimated age, the mean of the highest density interval of the estimated ages as well as the parameters \(\alpha\), \(\beta\), and - derived from these two – the modal age M:

age.estim.summary(sorsum_as_res_diag) |> knitr::kable(digits = 3)
Mean Median Mode 0.025 0.975
b 0.044 0.041 0.029 0.020 0.075
a 0.009 0.007 0.003 0.000 0.023
M 55.407 60.176 70.585 17.155 81.774
age_mean 56.467 57.851 58.185 23.859 82.933
hdi_diff 41.918 45.582 46.995 17.680 50.300

The above table demonstrates that it makes a difference whether you choose Mean, Median or Mode as the measure of the mean.

To illustrate the distribution of some of the individual ages, this time we rely on the functionality of the R package tidybayes (Kay 2024). Its function tidybayes::spread_draws() allows to subset the chains in a single step to extract the estimated ages (age.s). We limit here the age estimates to the first seven.

sorsum_as_res |> tidybayes::spread_draws(age.s[age_number])  |> 
  subset(age_number < 8) |>
  ggplot(aes(y =  as.factor(age_number), x = age.s)) +
  tidybayes::stat_halfeye(
    .width = 0.95, point_interval = mode_hdi, fill = "lightgrey") + 
  scale_x_continuous(breaks = seq(10,100,10), limits = c(18, 100)) +
  labs( x = "\nModal age-at-death (years)", y = "Individual no.\n" ) + 
  theme_light() +
  theme(panel.grid.minor.x = element_blank(), text = element_text(size = 12))

The plots of the first seven age estimates look similar to the threshold plots and allow immediate assessment of the spread of the probability distribution of the age estimates. So, for example, for the first individual with stage 4 of the auricular surface, the age mode is at about 40 years, but the 95%-credible range is between about 25 to 70 years.

Together with the individual age-at-death estimates, bay.ta() also estimates the parameters of the underlying Gompertz function, \(\alpha\) and \(\beta\):

ggplot() + ylab("density\n") + 
  geom_function(fun =  function(x) 
    flexsurv::dgompertz(x - 18, sorsum_as_res_diag["b",3],
                        sorsum_as_res_diag["a",3])) +
  xlab("\nAge in years") + theme_light() +
  scale_x_continuous(breaks = seq(10,100,10), limits = c(18, 100)) +
  theme(panel.grid.minor.x = element_blank(), text = element_text(size = 12))

The Gompertz function provides a different perspective on the mortality structure of the population studied as it does not depend on individual point estimates of ages like, for example, Kaplan-Meier-diagrams. Please note that the maximum of the curve coincides with the arithmetic mean of the modal age M (55 years) in the table above.

Running the analysis with JAGS

Provided JAGS is installed, bay.ta() can also be run with JAGS. For this, it is sufficient to set the parameter framework to JAGS. All other parameters remain unchanged.

sorsum_as_res <- bay.ta(
  framework = "JAGS",
  method = sorsum_as[,2],
  minimum_age = 18,
  thinSteps = 100,
  numSavedSteps = 5000,
  seed = 1234
)

The JAGS model needs a little bit longer than the NIMBLE model but if you run the diagnostics you will see that the overall performance is superior. At this point, we leave the further analysis following the same steps as above to the reader.

Going further

More vignettes explain framework decisions (vignette("computation_framework")), detail the model in mathematical terms (vignette("mathematical_background")), demonstrate how data sets with known age-at-death can be dealt with (vignette("known_age")), provide a thoroughly worked example (vignette("articles/worked_example")) and show how the posterior probability densities can be grouped (vignette("articles/groupings")).


References

Gabry, Jonah, Daniel Simpson, Aki Vehtari, Michael Betancourt, and Andrew Gelman. 2019. “Visualization in Bayesian Workflow.” Journal of the Royal Statistical Society A 182: 389–402. https://doi.org/10.1111/rssa.12378.
Kay, Matthew. 2024. tidybayes: Tidy Data and Geoms for Bayesian Models. https://doi.org/10.5281/zenodo.1308151.
Kruschke, John K. 2015. Doing Bayesian data analysis: a tutorial with R, JAGS, and Stan. Amsterdam: Academic Press.
Lovejoy, C. Owen, Richard S. Meindl, Thomas R. Pryzbeck, and Robert P. Mensforth. 1985. Chronological metamorphosis of the auricular surface of the ilium: A new method for the determination of adult skeletal age at death.” American Journal of Physical Anthropology 68 (1): 15–28. https://doi.org/10.1002/ajpa.1330680103.
Moser, Daria, Sandra L. Pichler, Ana Luisa Santos, Florian Klimscha, and Katharina Fuchs. 2025. The uncertain death. Estimating mortality structure by random sampling.” International Journal of Osteoarchaeology 35 (1): e3363. https://doi.org/10.1002/oa.3363.
Müller-Scheeßel, Nils, Christoph Rinne, and Katharina Fuchs. 2026. A Fully Bayesian Approach to Adult Skeletal Age Estimation: Multivariate Latent Trait Modeling with Markov Chain Monte Carlo Sampling.” American Journal of Biological Anthropology 190 (2): e70289. https://doi.org/10.1002/ajpa.70289.