NOTE THIS IS EXPERIMENTAL FOR NOW
library(multilevelcoda)
library(knitr)
library(data.table)
#> data.table 1.18.4 using 9 threads (see ?getDTthreads). Latest news: r-datatable.com
#>
#> Attaching package: 'data.table'
#>
#> The following object is masked from 'package:base':
#>
#> %notin%
library(cmdstanr)
#> This is cmdstanr version 0.9.0
#> - CmdStanR documentation and vignettes: mc-stan.org/cmdstanr
#> - CmdStan path: /Users/wileyj/.cmdstan/cmdstan-2.39.0
#> - CmdStan version: 2.39.0
library(brms)
#> Loading required package: Rcpp
#> Loading 'brms' package (version 2.23.0). Useful instructions
#> can be found by typing help('brms'). A more detailed introduction
#> to the package is available through vignette('brms_overview').
#>
#> Attaching package: 'brms'
#>
#> The following object is masked from 'package:stats':
#>
#> ar
library(JWileymisc)
#> Registered S3 method overwritten by 'lme4':
#> method from
#> na.action.merMod car
library(ggplot2)
options(digits = 3, pillar.sigfig = 3)This vignette shows how to use the data simulation features that are
part of the multilevelcoda package, particularly the
simulate_data() function. Data simulation is useful for
multiple purposes, such as:
- Power analysis and sample size planning.
- Method development and testing.
- Generating synthetic data to test models or write analysis code before data collection finishes.
The simulate_data() function is designed to support
these efforts. Broadly, simulate_data() builds an indexed
design, runs named generators in order, and returns an
mlsim_data object with major pieces:
-
data: the generateddata.table. -
metadata: design metadata such as group sizes, time indexing, and generator order. -
generator_specs: the generator settings. -
generator_metadata: e.g., parameters, random effects, covariance matrices, helper columns.
The first part of this vignette covers study designs and the predictor generators. The second part turns to model-based outcomes: we simulate outcomes from known parameters, fit the matching models, and check how well the true values are recovered.
Note that while part of the aspirations of data simulation here are to support things like Monte Carlo power analyses and simulation studies, these remain difficult for two reasons. Firstly, specifying all necessary parameters is not trivial, and it is our experience as researchers that such information is unlikely to be available prior to data collection. Secondly, Bayesian multilevel models are rather computationally demanding. Many random effects are possible, even sensible, and so at least in 2026, the computational demands of simulating and running hundreds of models exceeds most local devices and would likely require access to a high performance server.
Features at a Glance
The following tables summarise current features.
Design options
Design options control the highest level structure of the simulated data.
| Option | Use |
|---|---|
n |
Total rows for a single-level design, or total rows to divide across groups if multilevel. |
n_groups |
Number of groups in a grouped or multilevel design. |
n_per_group |
Group sizes: scalar, vector, function, or count-distribution list (the latter support unequal or variable sample sizes per group as is common in real data). |
group_id |
Name of the grouping column. |
obs_id |
Name of the within-design observation index. |
time_id |
Optional time column name. |
time_values |
Optional time vector or function, including Date/POSIXct values. |
time_truncate |
Whether shared time values are truncated for shorter groups. |
seed |
Reproducible simulation seed that restores the caller RNG state. |
Generator families
Generators add simulated data columns. Each generator draws its own
values. However, gen_outcome() can generate outcomes that
depend on columns created by earlier generators through the simulation
context. The current generators are listed below.
| Generator | Role |
|---|---|
gen_mvn() |
Univariate and multivariate Gaussian predictors, including ILR compositions and back-transforms to original compositional scale. |
gen_categorical() |
Binary, unordered categorical, and ordered categorical variables. |
gen_binomial() |
Binomial counts with logit-scale multilevel support. |
gen_poisson() |
Poisson counts with log-scale multilevel support. |
gen_negbin() |
Negative-binomial counts with optional location-scale multilevel support. |
gen_gamma() |
Positive continuous variables with optional location-scale multilevel support. |
gen_beta() |
Bounded (0, 1) continuous variables with optional location-scale support. |
gen_custom() |
User-supplied generators that receive the active simulation context. |
gen_outcome() |
Gaussian, dynamic VAR(1), ILR compositional, and GLM-family (Poisson, binomial, negative binomial, gamma, beta) outcomes generated from prior predictors. |
Advanced simulation options
These are some of the features supported by generators. Not all features are necessarily supported by all generators.
| Feature | Use |
|---|---|
level |
Choose row-level, group-level, or multilevel generation. |
fixed_intercept |
Set the fixed location or link-scale intercept in any predictor generator. |
random_cov |
Add group-specific random intercepts; a joint location-scale matrix also adds scale random effects. |
residual_cov |
Set row-level residual variation for Gaussian and MVN generators. |
scale_fixed_intercept |
Enable a scale model and set its fixed log-scale intercept (log residual SD, size, shape, or precision). |
compositional, parts, sbp,
total, keep_ilr
|
Generate compositions from ILR coordinates and store SBP metadata. |
between(), within(),
ar1()
|
Build outcome models from labelled predictor components (including ILR coordinates of compositional predictors) and residual VAR(1) dynamics. |
Study Designs: Groups, Sizes, and Time
To start, let’s look at a simple, single level example. Here we generate one normally distributed variable, one categorical variable with three levels, and one binary categorical variable. Each variable in this first example is generated independently of the others.
single <- simulate_data(
n = 20,
seed = 2026,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
edu = gen_categorical("edu", categories = c("< Bachelor", "Bachelor", "> Bachelor"),
fixed_intercept = c("Bachelor" = 0, "> Bachelor" = log(4 / 3))),
tx = gen_categorical("tx", categories = c("control", "treatment"), fixed_intercept = stats::qlogis(.5))
)
)
kable(single$data)| obs_id | x | edu | tx |
|---|---|---|---|
| 1 | 0.521 | Bachelor | control |
| 2 | -1.080 | > Bachelor | treatment |
| 3 | 0.139 | Bachelor | treatment |
| 4 | -0.085 | Bachelor | control |
| 5 | -0.667 | < Bachelor | control |
| 6 | -2.516 | < Bachelor | control |
| 7 | -0.735 | > Bachelor | treatment |
| 8 | -1.020 | < Bachelor | control |
| 9 | 0.114 | Bachelor | treatment |
| 10 | -0.474 | < Bachelor | treatment |
| 11 | -0.408 | > Bachelor | treatment |
| 12 | -0.730 | > Bachelor | control |
| 13 | -0.221 | > Bachelor | treatment |
| 14 | -0.226 | > Bachelor | treatment |
| 15 | -2.547 | Bachelor | control |
| 16 | 1.347 | > Bachelor | treatment |
| 17 | 0.616 | > Bachelor | treatment |
| 18 | 0.218 | < Bachelor | treatment |
| 19 | -0.805 | > Bachelor | control |
| 20 | 0.690 | < Bachelor | treatment |
single
#> <mlsim_data>
#> rows: 20
#> columns: 4 (generated: 3)
#> seed: 2026
#> grouping: none
#> generators: 3
#>
#> Generators:
#> generator distribution level vars
#> x mvn single x
#> edu categorical single edu
#> tx categorical single txPrinting an mlsim_data object gives an overview of the
design. If we want the design and generator metadata as
data.table objects, we can use summary().
summary(single)$design
#> n n_cols n_generated_cols n_groups group_id group_size_min group_size_median
#> <int> <int> <int> <int> <char> <int> <num>
#> 1: 20 4 3 NA <NA> NA NA
#> group_size_max obs_id time_id seed n_generators
#> <int> <char> <char> <int> <int>
#> 1: NA obs_id <NA> 2026 3
summary(single)$generators
#> generator distribution level vars n_vars parameter_level parameter_count
#> <char> <char> <char> <char> <int> <char> <int>
#> 1: x mvn single x 1 row 20
#> 2: edu categorical single edu 1 row 20
#> 3: tx categorical single tx 1 row 20
#> has_row_parameters has_group_parameters has_fixed_parameters has_random_cov
#> <lgcl> <lgcl> <lgcl> <lgcl>
#> 1: TRUE FALSE TRUE FALSE
#> 2: TRUE FALSE TRUE FALSE
#> 3: TRUE FALSE TRUE FALSE
#> has_random_effects has_residuals has_scale_model has_composition has_custom_output
#> <lgcl> <lgcl> <lgcl> <lgcl> <lgcl>
#> 1: FALSE TRUE FALSE FALSE FALSE
#> 2: FALSE FALSE FALSE FALSE FALSE
#> 3: FALSE FALSE FALSE FALSE FALSEWe can generate multilevel data as well. A single scalar
n_per_group creates a balanced grouped design, with exactly
the same number of observations per group. To show it more easily, we
make just one variable.
balanced <- simulate_data(
n_groups = 3,
n_per_group = 4,
seed = 2026,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1)
)
)
kable(balanced$data)| group_id | obs_id | x |
|---|---|---|
| 1 | 1 | 0.521 |
| 1 | 2 | -1.080 |
| 1 | 3 | 0.139 |
| 1 | 4 | -0.085 |
| 2 | 1 | -0.667 |
| 2 | 2 | -2.516 |
| 2 | 3 | -0.735 |
| 2 | 4 | -1.020 |
| 3 | 1 | 0.114 |
| 3 | 2 | -0.474 |
| 3 | 3 | -0.408 |
| 3 | 4 | -0.730 |
However, while we generate the labels for multilevel data, the
variable x was still single level, Gaussian data. To
address this, we speicfy the parameters of a random intercept only
multilevel model, which is used to generate matching data. The
fixed_intercept is the overall mean,
random_cov is the group-level variance, and
residual_cov is the residual variance. The
level argument controls where a variable varies:
level = "multilevel" gives row-level values with
group-specific random effects, and level = "level2" gives
one value per group, constant within groups but varying between them.
The same approach broadly applies to other generator families.
Note that in the data output are the simulated data as well as the “true” between and within values. These are useful because in a multilevel structure, with a small, finite number of samples for a participant, the sample mean can differ from that person’s true mean. These values instead show the “true” values. This can be important for subsequent simulations (e.g., outcome generation where we would want it generated from the truth, not from the sample).
balanced2 <- simulate_data(
n_groups = 3,
n_per_group = 4,
seed = 2026,
generators = list(
x = gen_mvn("x", level = "multilevel",
fixed_intercept = 0, random_cov = 1, residual_cov = .01),
y = gen_mvn("y", level = "level2", fixed_intercept = 0, residual_cov = 1)
)
)
kable(balanced2$data)| group_id | obs_id | x | x_between | x_within | .mlsim_x_random_intercept | y |
|---|---|---|---|---|---|---|
| 1 | 1 | 0.512 | 0.521 | -0.008 | 0.521 | 1.347 |
| 1 | 2 | 0.454 | 0.521 | -0.067 | 0.521 | 1.347 |
| 1 | 3 | 0.269 | 0.521 | -0.252 | 0.521 | 1.347 |
| 1 | 4 | 0.447 | 0.521 | -0.074 | 0.521 | 1.347 |
| 2 | 1 | -1.182 | -1.080 | -0.102 | -1.080 | 0.616 |
| 2 | 2 | -1.068 | -1.080 | 0.011 | -1.080 | 0.616 |
| 2 | 3 | -1.127 | -1.080 | -0.047 | -1.080 | 0.616 |
| 2 | 4 | -1.121 | -1.080 | -0.041 | -1.080 | 0.616 |
| 3 | 1 | 0.066 | 0.139 | -0.073 | 0.139 | 0.218 |
| 3 | 2 | 0.117 | 0.139 | -0.022 | 0.139 | 0.218 |
| 3 | 3 | 0.117 | 0.139 | -0.023 | 0.139 | 0.218 |
| 3 | 4 | -0.115 | 0.139 | -0.255 | 0.139 | 0.218 |
If we specify n_per_group as a vector, we can get an
unbalanced design.
unbalanced <- simulate_data(
n_groups = 4,
n_per_group = c(3, 5, 4, 2),
seed = 2026,
generators = list(
x = gen_mvn("x", level = "multilevel",
fixed_intercept = 0, random_cov = 1, residual_cov = .01),
y = gen_mvn("y", level = "level2", fixed_intercept = 0, residual_cov = 1)
)
)
kable(unbalanced$data)| group_id | obs_id | x | x_between | x_within | .mlsim_x_random_intercept | y |
|---|---|---|---|---|---|---|
| 1 | 1 | 0.454 | 0.521 | -0.067 | 0.521 | -0.805 |
| 1 | 2 | 0.269 | 0.521 | -0.252 | 0.521 | -0.805 |
| 1 | 3 | 0.447 | 0.521 | -0.074 | 0.521 | -0.805 |
| 2 | 1 | -1.182 | -1.080 | -0.102 | -1.080 | 0.690 |
| 2 | 2 | -1.068 | -1.080 | 0.011 | -1.080 | 0.690 |
| 2 | 3 | -1.127 | -1.080 | -0.047 | -1.080 | 0.690 |
| 2 | 4 | -1.121 | -1.080 | -0.041 | -1.080 | 0.690 |
| 2 | 5 | -1.153 | -1.080 | -0.073 | -1.080 | 0.690 |
| 3 | 1 | 0.117 | 0.139 | -0.022 | 0.139 | -0.329 |
| 3 | 2 | 0.117 | 0.139 | -0.023 | 0.139 | -0.329 |
| 3 | 3 | -0.115 | 0.139 | -0.255 | 0.139 | -0.329 |
| 3 | 4 | 0.274 | 0.139 | 0.135 | 0.139 | -0.329 |
| 4 | 1 | -0.023 | -0.085 | 0.062 | -0.085 | -0.165 |
| 4 | 2 | -0.063 | -0.085 | 0.022 | -0.085 | -0.165 |
Group sizes can also be drawn from a count distribution. We give a list with the distribution name and its parameters, along with optional minimum and maximum sizes. This is an easier way to specify that there may be differences without having to manually specify the number for each participant.
drawn_sizes <- simulate_data(
n_groups = 5,
n_per_group = list(
distribution = "poisson",
lambda = 4,
minimum = 2,
maximum = 6
),
seed = 2026,
generators = list(
x = gen_mvn("x", level = "multilevel",
fixed_intercept = 0, random_cov = 1, residual_cov = .01),
y = gen_mvn("y", level = "level2", fixed_intercept = 0, residual_cov = 1)
)
)
kable(drawn_sizes$data)| group_id | obs_id | x | x_between | x_within | .mlsim_x_random_intercept | y |
|---|---|---|---|---|---|---|
| 1 | 1 | -1.994 | -1.958 | -0.037 | -1.958 | 0.339 |
| 1 | 2 | -2.261 | -1.958 | -0.304 | -1.958 | 0.339 |
| 1 | 3 | -2.169 | -1.958 | -0.211 | -1.958 | 0.339 |
| 1 | 4 | -1.998 | -1.958 | -0.040 | -1.958 | 0.339 |
| 1 | 5 | -2.112 | -1.958 | -0.154 | -1.958 | 0.339 |
| 2 | 1 | 0.995 | 1.085 | -0.089 | 1.085 | 1.207 |
| 2 | 2 | 0.977 | 1.085 | -0.108 | 1.085 | 1.207 |
| 2 | 3 | 1.111 | 1.085 | 0.026 | 1.085 | 1.207 |
| 2 | 4 | 0.992 | 1.085 | -0.093 | 1.085 | 1.207 |
| 3 | 1 | -0.010 | 0.204 | -0.214 | 0.204 | 0.571 |
| 3 | 2 | 0.185 | 0.204 | -0.019 | 0.204 | 0.571 |
| 4 | 1 | 0.495 | 0.501 | -0.006 | 0.501 | -1.686 |
| 4 | 2 | 0.488 | 0.501 | -0.013 | 0.501 | -1.686 |
| 4 | 3 | 0.616 | 0.501 | 0.115 | 0.501 | -1.686 |
| 5 | 1 | 1.015 | 1.030 | -0.015 | 1.030 | -0.838 |
| 5 | 2 | 0.848 | 1.030 | -0.182 | 1.030 | -0.838 |
| 5 | 3 | 0.976 | 1.030 | -0.054 | 1.030 | -0.838 |
| 5 | 4 | 0.958 | 1.030 | -0.072 | 1.030 | -0.838 |
Longitudinal designs can add a time index. When groups have different
lengths, time_truncate = TRUE uses the first values for
shorter groups. Adding a time index may not matter for some designs, but
it is useful for creating lags and similar derived variables later.
dated <- simulate_data(
n_groups = 3,
n_per_group = c(5, 3, 4),
time_id = "date",
time_values = as.Date("2026-01-01") + 0:4,
seed = 2026,
generators = list(
x = gen_mvn("x", level = "multilevel",
fixed_intercept = 0, random_cov = 1, residual_cov = .01),
y = gen_mvn("y", level = "level2", fixed_intercept = 0, residual_cov = 1)
)
)
kable(dated$data)| group_id | obs_id | date | x | x_between | x_within | .mlsim_x_random_intercept | y |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 2026-01-01 | 0.512 | 0.521 | -0.008 | 0.521 | 1.347 |
| 1 | 2 | 2026-01-02 | 0.454 | 0.521 | -0.067 | 0.521 | 1.347 |
| 1 | 3 | 2026-01-03 | 0.269 | 0.521 | -0.252 | 0.521 | 1.347 |
| 1 | 4 | 2026-01-04 | 0.447 | 0.521 | -0.074 | 0.521 | 1.347 |
| 1 | 5 | 2026-01-05 | 0.419 | 0.521 | -0.102 | 0.521 | 1.347 |
| 2 | 1 | 2026-01-01 | -1.068 | -1.080 | 0.011 | -1.080 | 0.616 |
| 2 | 2 | 2026-01-02 | -1.127 | -1.080 | -0.047 | -1.080 | 0.616 |
| 2 | 3 | 2026-01-03 | -1.121 | -1.080 | -0.041 | -1.080 | 0.616 |
| 3 | 1 | 2026-01-01 | 0.066 | 0.139 | -0.073 | 0.139 | 0.218 |
| 3 | 2 | 2026-01-02 | 0.117 | 0.139 | -0.022 | 0.139 | 0.218 |
| 3 | 3 | 2026-01-03 | 0.117 | 0.139 | -0.023 | 0.139 | 0.218 |
| 3 | 4 | 2026-01-04 | -0.115 | 0.139 | -0.255 | 0.139 | 0.218 |
Distribution Families
All predictor generators use fixed location or link-scale intercepts, and the multilevel forms add random covariance terms. Here is a quick demonstration of different distribution families that we support with single level data.
families <- simulate_data(
n = 10,
seed = 2026,
generators = list(
normal = gen_mvn("normal", fixed_intercept = 5, residual_cov = 1),
successes = gen_binomial("successes", size = 5, fixed_intercept = stats::qlogis(0.40)),
visits = gen_poisson("visits", fixed_intercept = log(2)),
events = gen_negbin("events", fixed_intercept = log(2), scale_fixed_intercept = log(3)),
cost = gen_gamma("cost", fixed_intercept = log(20), scale_fixed_intercept = log(4)),
adherence = gen_beta("adherence", fixed_intercept = stats::qlogis(0.70), scale_fixed_intercept = log(12))
)
)
kable(families$data)| obs_id | normal | successes | visits | events | cost | adherence |
|---|---|---|---|---|---|---|
| 1 | 5.52 | 2 | 4 | 1 | 10.63 | 0.875 |
| 2 | 3.92 | 2 | 1 | 0 | 36.47 | 0.822 |
| 3 | 5.14 | 1 | 3 | 5 | 38.06 | 0.657 |
| 4 | 4.92 | 0 | 0 | 4 | 41.29 | 0.664 |
| 5 | 4.33 | 2 | 2 | 2 | 33.78 | 0.500 |
| 6 | 2.48 | 1 | 2 | 4 | 10.91 | 0.766 |
| 7 | 4.26 | 2 | 1 | 0 | 24.79 | 0.897 |
| 8 | 3.98 | 1 | 2 | 2 | 13.94 | 0.841 |
| 9 | 5.11 | 0 | 3 | 1 | 14.41 | 0.568 |
| 10 | 4.53 | 2 | 2 | 2 | 8.27 | 0.846 |
Here is an example of a multilevel count generator.
link_scale_counts <- simulate_data(
n_groups = 3,
n_per_group = 5,
seed = 2026,
generators = list(
count = gen_poisson(
"count",
level = "multilevel",
fixed_intercept = log(2.5),
random_cov = 0.20
)
)
)
link_scale_counts
#> <mlsim_data>
#> rows: 15
#> columns: 4 (generated: 2)
#> seed: 2026
#> grouping: group_id (3 groups; size min/median/max: 5/5/5)
#> generators: 1
#>
#> Generators:
#> generator distribution level vars
#> count poisson multilevel countCategorical Variables
gen_categorical() supports binary variables, labeled
categories, ordered factors, character output, and integer codes.
categorical <- simulate_data(
n = 12,
seed = 2026,
generators = list(
binary_factor = gen_categorical("binary_factor", fixed_intercept = stats::qlogis(0.35)),
education = gen_categorical(
"education",
categories = c("< bachelor", "bachelor", "> bachelor"),
fixed_intercept = c("bachelor" = log(0.35 / 0.45), "> bachelor" = log(0.20 / 0.45)),
ordered = TRUE
),
education_code = gen_categorical(
"education_code",
categories = c("arts", "science", "psychology"),
fixed_intercept = c("science" = log(0.35 / 0.45), "psychology" = log(0.20 / 0.45)),
output = "integer"
)
)
)
kable(categorical$data)| obs_id | binary_factor | education | education_code |
|---|---|---|---|
| 1 | 1 | < bachelor | 0 |
| 2 | 0 | > bachelor | 0 |
| 3 | 0 | < bachelor | 0 |
| 4 | 0 | < bachelor | 0 |
| 5 | 0 | bachelor | 0 |
| 6 | 0 | < bachelor | 1 |
| 7 | 0 | < bachelor | 2 |
| 8 | 1 | < bachelor | 0 |
| 9 | 0 | < bachelor | 1 |
| 10 | 0 | < bachelor | 0 |
| 11 | 0 | < bachelor | 1 |
| 12 | 1 | < bachelor | 0 |
Multilevel categorical generators use baseline-category logits and optional group random effects. With two categories, this is effectively a multilevel Bernoulli model. With more than two categories, it is a multilevel multinomial model with the first category as the baseline. The random intercepts can have their own variances and covariances, and a full random effect covariance matrix must be specified. Use 0s on the off diagonal for uncorrelated random intercepts.
categorical_ml <- simulate_data(
n_groups = 4,
n_per_group = 4,
seed = 2026,
generators = list(
education = gen_categorical(
"education",
level = "multilevel",
categories = c("< bachelor", "bachelor", "> bachelor"),
fixed_intercept = c("bachelor" = 0.15, "> bachelor" = -0.55),
random_cov = matrix(c(0.20, 0.04, 0.04, 0.12), nrow = 2),
ordered = TRUE
)
)
)
kable(categorical_ml$data)| group_id | obs_id | education | .mlsim_education_random_intercept_bachelor | .mlsim_education_random_intercept_X..bachelor |
|---|---|---|---|---|
| 1 | 1 | bachelor | -0.306 | 0.105 |
| 1 | 2 | < bachelor | -0.306 | 0.105 |
| 1 | 3 | < bachelor | -0.306 | 0.105 |
| 1 | 4 | < bachelor | -0.306 | 0.105 |
| 2 | 1 | bachelor | 0.155 | 0.940 |
| 2 | 2 | bachelor | 0.155 | 0.940 |
| 2 | 3 | < bachelor | 0.155 | 0.940 |
| 2 | 4 | < bachelor | 0.155 | 0.940 |
| 3 | 1 | bachelor | -0.150 | 0.194 |
| 3 | 2 | < bachelor | -0.150 | 0.194 |
| 3 | 3 | bachelor | -0.150 | 0.194 |
| 3 | 4 | < bachelor | -0.150 | 0.194 |
| 4 | 1 | < bachelor | -0.089 | 0.318 |
| 4 | 2 | bachelor | -0.089 | 0.318 |
| 4 | 3 | > bachelor | -0.089 | 0.318 |
| 4 | 4 | < bachelor | -0.089 | 0.318 |
Correlated and Compositional Predictors
gen_mvn() simulates correlated blocks of variables. With
compositional = TRUE, the MVN columns are interpreted as
ILR coordinates and back-transformed into compositional parts. Here we
are back to single level data for the first illustration. With MVN data,
we need to specify the residual covariance matrix, which is used to
control any correlation.
correlated_comp <- simulate_data(
n = 8,
seed = 2026,
generators = list(
affect_block = gen_mvn(
c("affect", "energy"),
fixed_intercept = c(0, 1),
residual_cov = matrix(c(1.0, 0.5, 0.5, 1.2), nrow = 2)
),
time_use = gen_mvn(
c("ilr_1", "ilr_2"),
fixed_intercept = c(0.4, -1.5),
residual_cov = diag(c(0.05, 0.05)),
compositional = TRUE,
parts = c("sleep", "active", "sedentary"),
total = 24,
keep_ilr = TRUE
)
)
)
kable(correlated_comp$data)| obs_id | affect | energy | ilr_1 | ilr_2 | sleep | active | sedentary |
|---|---|---|---|---|---|---|---|
| 1 | 0.351 | 1.566 | 0.389 | -1.36 | 8.38 | 1.99 | 13.6 |
| 2 | -0.587 | -0.290 | -0.027 | -1.45 | 5.64 | 2.09 | 16.3 |
| 3 | 0.355 | 0.938 | 0.013 | -1.68 | 5.30 | 1.59 | 17.1 |
| 4 | 0.366 | 0.561 | 0.387 | -1.35 | 8.41 | 2.02 | 13.6 |
| 5 | -0.405 | 0.238 | 0.256 | -1.57 | 6.93 | 1.67 | 15.4 |
| 6 | -1.890 | -1.579 | 0.014 | -1.54 | 5.65 | 1.87 | 16.5 |
| 7 | 0.922 | -0.962 | 0.518 | -1.81 | 7.86 | 1.16 | 15.0 |
| 8 | -1.621 | 0.655 | 0.363 | -1.17 | 8.73 | 2.44 | 12.8 |
correlated_comp$generator_metadata$time_use$sbp
#> sleep active sedentary
#> balance_1 1 -1 -1
#> balance_2 0 1 -1
correlated_comp$generator_metadata$time_use$ilr_coordinate_map
#> ilr sbp_row positive_parts negative_parts
#> <char> <int> <AsIs> <AsIs>
#> 1: ilr_1 1 sleep active,sedentary
#> 2: ilr_2 2 active sedentaryIf we only want the parts in the output, we can set
keep_ilr = FALSE.
parts_only <- simulate_data(
n = 5,
seed = 2026,
generators = list(
time_use = gen_mvn(
c("z1", "z2"),
fixed_intercept = c(0.4, -1.5),
residual_cov = diag(c(0.05, 0.05)),
compositional = TRUE,
parts = c("sleep", "active", "sedentary"),
total = 24,
keep_ilr = FALSE
)
)
)
kable(parts_only$data)| obs_id | sleep | active | sedentary |
|---|---|---|---|
| 1 | 12.41 | 1.44 | 10.2 |
| 2 | 8.38 | 1.23 | 14.4 |
| 3 | 9.70 | 1.59 | 12.7 |
| 4 | 7.83 | 1.69 | 14.5 |
| 5 | 8.29 | 1.39 | 14.3 |
Location-Scale Predictors
scale_fixed_intercept enables a scale model and sets its
fixed log-scale intercept. For a univariate gen_mvn()
generator, the scale is the log residual standard deviation. For the
negative binomial, gamma, and beta generators, it is the size, shape,
and precision, respectively.
On its own, scale_fixed_intercept gives every group the
same scale. To make groups differ not only in their average level
(location) but also in their variability (scale), we supply a
joint location-scale random_cov with dimension
twice the number of variables, ordered with all location intercepts
first and all scale intercepts second. A location-only
random_cov leaves the scale constant between groups even
when scale_fixed_intercept is set. The generators below are
univariate, so their 2x2 random_cov matrices are the joint
form: one location row and one scale row each, and both location and
scale vary by group.
There are two places to inspect the group-varying scale that was
actually generated: the realized scale parameters in
generator_metadata (shown below), and the group-level scale
draws themselves, which appear in sim$data as
.mlsim_<variable>_scale_random_intercept columns.
location_scale <- simulate_data(
n_groups = 4,
n_per_group = 5,
seed = 2026,
generators = list(
symptom = gen_mvn(
"symptom",
level = "multilevel",
fixed_intercept = 10,
scale_fixed_intercept = log(1.2),
random_cov = matrix(
c(1.00, 0.15,
0.15, 0.08),
nrow = 2
)
),
event_count = gen_negbin(
"event_count",
level = "multilevel",
fixed_intercept = log(2),
scale_fixed_intercept = log(6),
random_cov = matrix(
c(0.20, 0.03,
0.03, 0.06),
nrow = 2
)
)
)
)
kable(data.table(
group_id = location_scale$data$group_id,
symptom_residual_sd = location_scale$generator_metadata$symptom$residual_sd[, "symptom"],
event_count_size = location_scale$generator_metadata$event_count$size
))| group_id | symptom_residual_sd | event_count_size |
|---|---|---|
| 1 | 1.29 | 5.99 |
| 1 | 1.29 | 5.99 |
| 1 | 1.29 | 5.99 |
| 1 | 1.29 | 5.99 |
| 1 | 1.29 | 5.99 |
| 2 | 2.57 | 4.75 |
| 2 | 2.57 | 4.75 |
| 2 | 2.57 | 4.75 |
| 2 | 2.57 | 4.75 |
| 2 | 2.57 | 4.75 |
| 3 | 1.39 | 6.04 |
| 3 | 1.39 | 6.04 |
| 3 | 1.39 | 6.04 |
| 3 | 1.39 | 6.04 |
| 3 | 1.39 | 6.04 |
| 4 | 1.54 | 4.54 |
| 4 | 1.54 | 4.54 |
| 4 | 1.54 | 4.54 |
| 4 | 1.54 | 4.54 |
| 4 | 1.54 | 4.54 |
Custom Generators
Sometimes the built-in generators are not enough. Custom generators
receive the simulation context and return generated data, column names,
and optional metadata. They can use the active design columns,
previously generated variables, and any extra arguments supplied to
gen_custom(). Their metadata is captured in the same way as
for the built-in generators.
Below is an examplethat can be useful when trying to simulate
synthetic data for use in building or testing data analysis code. For
example, this could support pre-registration, registered reports where
data analysis code is written in advance, or power analyses where there
is pilot data on variables that follow distributions that do not
conveniently match any of the currently supported distributions. We use
density_inversion() function which first calculates the
density of a set of values passed in, and then inverts to generate
synthetic data with approximately the same density. We apply it to the
iris data built into R for which the petal
lengths are famously bimodal. While not exact, we can see that the
simulated data broadly captures and reflects the petal length data
present in the iris dataset. You may want more metadata
saved, but here we just save the source (density inversion) and the
number of pilot samples passed in.
custom_sampler <- function(context, vars, level, pilot_values) {
values <- density_inversion(pilot_values, n = context$n_rows,
KDEn = 50)
list(
data = data.frame(values = values),
names = vars,
metadata = list(source = "density inversion", n_pilot = length(pilot_values))
)
}
custom <- simulate_data(
n = 500,
seed = 2026,
generators = list(
custom = gen_custom(
"custom",
generator = custom_sampler,
pilot_values = iris$Petal.Length
)
)
)
dcompare <- data.table(
value = c(iris$Petal.Length, custom$data$custom),
group = rep(c("original", "simulated"), times = c(
length(iris$Petal.Length),
length(custom$data$custom)
)))
ggplot(dcompare, aes(value, colour = group, linetype = group)) +
geom_density(linewidth = 1) + theme_classic()
plot of chunk custom-generators
A First Outcome Model and Parameter Recovery
So far, we have only simulated predictors. gen_outcome()
adds a model-based outcome generator to the same
simulate_data() workflow.
The rest of this vignette focuses on the workflow that builds on it: simulate data with known parameters, prepare an analysis data set, fit the matching model, and check that the true values are recovered.
Before the more complex models in the later sections, here is the
complete loop on a simple example: a Gaussian outcome with one predictor
and a random intercept. We need to specify all of the parameters and
parameter matrices. The location matrix has one row per term and one
column per outcome, the scale matrix holds the log residual standard
deviation, and the random-effect covariance uses structured names to
label which parameter each random effect belongs to. Even in this simple
example, the number of parameters and the specific structure to hold
them is rather complicated. The next section will show how
gen_template() can create these structures for us, which is
probably easier for most use cases.
first_random_name <- "location|outcome=y|term=(Intercept)"
first_params <- list(
location = list(beta = matrix(
c(2, 0.5),
nrow = 2,
dimnames = list(c("(Intercept)", "x"), "y")
)),
scale = list(beta = matrix(
log(0.8),
nrow = 1,
dimnames = list("(Intercept)", "y")
)),
random = list(ID = list(covariance = matrix(
0.3^2,
dimnames = list(first_random_name, first_random_name)
)))
)
first_sim <- simulate_data(
n_groups = 200,
n_per_group = 10,
group_id = "ID",
seed = 2026,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
y = gen_outcome(y ~ x + (1 | ID), scale = sigma ~ 1, params = first_params)
)
)
kable(head(first_sim$data))| ID | obs_id | x | y |
|---|---|---|---|
| 1 | 1 | 0.521 | 1.76 |
| 1 | 2 | -1.080 | 2.40 |
| 1 | 3 | 0.139 | 1.11 |
| 1 | 4 | -0.085 | 1.40 |
| 1 | 5 | -0.667 | 1.59 |
| 1 | 6 | -2.516 | 1.57 |
prep_sim_analysis() turns the simulated data into an
analysis-ready data set and infers the matching brms
formula. It also returns the true parameter values in
$truth, labeled with the parameter names the fitted model
will use. The goal of this is to support cases where we want to test if
the true value was recovered (e.g., simulation studies on bias, coverage
rates).
first_analysis <- prep_sim_analysis(first_sim)
first_analysis$formula
#> y ~ x + (1 | ID)
#> sigma ~ 1There is no composition in this example, so we fit the model directly
with brms.
first_fit <- brm(
formula = first_analysis$formula,
data = first_analysis$data,
backend = "cmdstanr",
seed = 2026,
chains = 2,
cores = 2,
iter = 1000,
refresh = 0,
silent = 2
)This object is a regular brms object so we could
summary, predict, etc However, there are some more helper functions that
may be useful here. sim_recovery() joins the fitted
estimates with the simulation truth by parameter name, and adds bias and
interval-coverage columns.
first_recovery <- sim_recovery(first_fit, first_analysis)
kable(first_recovery[, !"simulator_name"], digits = 3)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 2.000 | 1.977 | 0.029 | 1.918 | 2.035 | -0.023 | TRUE |
| fixed | NA | x | 0.500 | 0.495 | 0.020 | 0.455 | 0.533 | -0.005 | TRUE |
| fixed | NA | sigma_Intercept | -0.223 | -0.218 | 0.017 | -0.251 | -0.185 | 0.005 | TRUE |
| random_sd | ID | Intercept | 0.300 | 0.304 | 0.026 | 0.259 | 0.356 | 0.004 | TRUE |
Each row is one model parameter. The truth column holds
the generating value, bias is the difference between the
posterior estimate and the truth, and covered indicates
whether the truth falls inside the credible interval. In this simple
model the analysis matches the data-generating process, so each fitted
parameter estimates exactly the quantity the simulator recorded. That
will not always hold: as we get into time series and multilevel
predictors below, the analysis we fit no longer matches the simulation
model exactly, and some parameters then estimate a related quantity
instead.
Dynamic Outcome Simulation
A major use case we are interested in for multilevelcoda
are intensive longitudinal studies, such as wearables or diet or time
use measured in people across consecutive days, for example for 1-2
weeks. In these cases, the data are multilevel, but importantly, they
also are time series. Even if not all analyses are fundamentally focused
on or even include a time series component, the underlying data would or
could. To support that and simulate data that ideally match real data as
much as feasible, we support dynamic outcome simulation, or outcomes
that are assumed to be time series.
gen_outcome() supports residual autoregressive dynamics
and multivariate compositional outcomes, which we combine in this
section. As before, we focus on the default
family = "gaussian".
For Gaussian outcomes, the scale model is required. Use
scale = sigma ~ 1 for a constant conditional standard
deviation. Dynamics are supported through an autoregressive component,
implemented using ar1(). When ar1() is
present, the scale model controls the variability of the
innovations. When ar1() is absent, it controls
ordinary residual variability. Note that the AR process is applied to
the residual ILR states, not to lagged observed outcomes.
ar1() is currently only supported for
family = "gaussian". This is because building models with
residual autoregression and innovations becomes substantially more
complicated for categorical data and other not Gaussian
distributions.
When ar1() is present, burnin is required.
Each series starts with zero residuals and is iterated
burnin steps before the first observed row. Do not use
burnin = 0 or other small values with ar1().
If the burn-in is too short, the first observations will be
under-dispersed relative to the stationary distribution of the AR
process. Choose a burn-in long enough for the process to forget its
start. A simple decision rule can be at least
log(0.01) / log(rho) steps, where rho is the
largest spectral radius of the AR matrices, which works out to about 90
steps at rho = 0.95. Models without ar1() have
no burn-in phase, so burnin can simply be omitted.
In the first outcome example, we wrote the parameter matrices by
hand. However, that is quite difficult with many names. To help with
this, gen_template() can be used. Basically, it is a
stand-in for gen_outcome() which does not require
parameters to be specified but will generate the full structure so we
can extract the necessary parameter matrices. We run the template with
the same formulas, grouping structure, and composition settings that we
plan to use in the final simulation. The design size does not need to
match: a small template design is enough to obtain the parameter
structure, which we then fill in and reuse for the full-size
simulation.
Here we see our first dynamic outcome formula. The setup here is a 3
component compositional dyanmic outcome. Much of the formula is a
“standard” multivariate, multilevel location scale formula specification
for brms. What is new is the use of ar1().
This gets parsed by multilevelcoda and automatically
expanded to mean the lag1 outcome variable, and this can be done as
fixed and random effects, as done below. Now once we have the template,
we can extract the relevant parameter matrices and then specify the
parameter values we want to use for the simulation. This saves us
manually writing / figuring out the structure and the very specific
names needed for simulate_data().
dynamic_outcome_formula <- mvbind(ilr1, ilr2) ~ ar1() + (1 + ar1() | ID)
dynamic_scale_formula <- sigma ~ 1 + (1 | ID)
dynamic_composition <- list(parts = c("sleep", "activity", "sedentary"), total = 24)
dynamic_template <- simulate_data(
n_groups = 4,
n_per_group = 5,
group_id = "ID",
time_id = "day",
seed = 2026,
generators = list(
outcome_template = gen_template(
dynamic_outcome_formula,
scale = dynamic_scale_formula,
composition = dynamic_composition,
burnin = 20
)
)
)
dynamic_params <- dynamic_template$generator_metadata$outcome_template$params
dynamic_params$location$beta["(Intercept)", ] <- c(0.2, -1.5)
dynamic_params$scale$beta["(Intercept)", ] <- log(c(0.2, 0.15))
dynamic_params$ar$beta["ar1()", , ] <- matrix(c(0.50, 0.10, 0.10, 0.30), 2, 2, byrow = TRUE)
dynamic_random_names <- rownames(dynamic_params$random$ID$covariance)
dynamic_random_sd_values <- c(
"location|outcome=ilr1|term=(Intercept)" = 0.20,
"location|outcome=ilr2|term=(Intercept)" = 0.18,
"ar|term=ar1()|to=ilr1|from=ilr1" = 0.02,
"ar|term=ar1()|to=ilr1|from=ilr2" = 0.01,
"ar|term=ar1()|to=ilr2|from=ilr1" = 0.01,
"ar|term=ar1()|to=ilr2|from=ilr2" = 0.02,
"scale|outcome=ilr1|term=(Intercept)" = 0.16,
"scale|outcome=ilr2|term=(Intercept)" = 0.14
)
dynamic_random_sd <- dynamic_random_sd_values[dynamic_random_names]
## we initialise all the random effect correlations at 0
dynamic_random_cor <- diag(length(dynamic_random_names))
dimnames(dynamic_random_cor) <- list(dynamic_random_names, dynamic_random_names)
set_dynamic_random_cor <- function(x, y, value) {
dynamic_random_cor[x, y] <<- value
dynamic_random_cor[y, x] <<- value
}
## we set a few select correlations to non zero values
set_dynamic_random_cor(
"location|outcome=ilr1|term=(Intercept)",
"ar|term=ar1()|to=ilr1|from=ilr1",
0.20
)
set_dynamic_random_cor(
"ar|term=ar1()|to=ilr1|from=ilr1",
"ar|term=ar1()|to=ilr1|from=ilr2",
0.15
)
set_dynamic_random_cor(
"location|outcome=ilr2|term=(Intercept)",
"ar|term=ar1()|to=ilr2|from=ilr1",
0.20
)
set_dynamic_random_cor(
"ar|term=ar1()|to=ilr2|from=ilr1",
"ar|term=ar1()|to=ilr2|from=ilr2",
0.15
)
if (anyNA(dynamic_random_sd) ||
min(eigen(dynamic_random_cor, symmetric = TRUE, only.values = TRUE)$values) <= 0) {
stop("The dynamic outcome random-effect covariance is not valid.")
}
dynamic_params$random$ID$covariance <- diag(dynamic_random_sd) %*%
dynamic_random_cor %*%
diag(dynamic_random_sd)
dimnames(dynamic_params$random$ID$covariance) <- list(dynamic_random_names, dynamic_random_names)We use clearly visible dynamics for autoregression to make the
centering comparison later in this section clearer. The fixed
autoregressive (diagonal) coefficients are 0.50 and 0.30, and the
cross-lagged (off-diagonal) coefficients are 0.10. The off diagonal
coefficients could be zero, but keeping them probably makes some sense
as it is plausible, depending on the sequential binary partition used,
that one ILR would have some predictive utility to the subsequent value
of other ILRs, especially as two ILRs commonly will include values from
at least one common component. In any case, these values give the fixed
AR matrix a spectral radius of about 0.54. A burn-in of 150 steps is far
above the log(0.01) / log(rho) approximate rule. For
simulating individual datasets, we do not see a reason not to put a very
generous burnin because the computational cost, especially vis a vis the
cost of fitting any analytic model to such complex data is negligible in
our view. That said, it does add some time just to the data simulation,
and we can see that if a very large number of people or hundreds or
thousdands of datasets were being simulated, there may be some utility
in striving for the “minimal” burnin needed. Another reason for our
larger choice is that with random slopes, while the fixed matrix may
have a low spectral radius, unless we had distinct burnins for different
people, some participants, due to random effects, could have a larger
spectral radius.
We keep the group-level AR standard deviations small: 0.02 for the autoregressive terms and 0.01 for the cross-lagged terms. Larger random slopes can push individual AR matrices toward the stationarity boundary.
If we get towards a stationary point, that will trigger resampling and truncate the random-effect distribution (because internally, the code will ensure that stationary matrices for any given participant is not used). Finally, we simulate 50 observations per person. Series length matters here and probably quite a bit. We simulate data based on a residual process. However, for analyses it is quite common to just use a lagged outcome value possibly person mean centred. In these cases, it becomes much more precise as the time series length increases, because otherwise, we are centering on something rather noisy.
dynamic_comp <- simulate_data(
n_groups = 200,
n_per_group = 50,
group_id = "ID",
time_id = "day",
seed = 2026,
generators = list(
outcome = gen_outcome(
dynamic_outcome_formula,
scale = dynamic_scale_formula,
params = dynamic_params,
composition = dynamic_composition,
burnin = 150
)
)
)
kable(head(dynamic_comp$data[, c("ID", "day", "ilr1", "ilr2", "sleep", "sedentary", "activity"), with = FALSE]))| ID | day | ilr1 | ilr2 | sleep | sedentary | activity |
|---|---|---|---|---|---|---|
| 1 | 1 | 0.600 | -1.43 | 9.62 | 12.7 | 1.68 |
| 1 | 2 | 0.461 | -1.14 | 9.48 | 12.1 | 2.40 |
| 1 | 3 | 0.292 | -1.32 | 7.86 | 14.0 | 2.16 |
| 1 | 4 | 0.230 | -1.14 | 7.93 | 13.4 | 2.67 |
| 1 | 5 | 0.454 | -1.36 | 8.82 | 13.2 | 1.93 |
| 1 | 6 | 0.521 | -1.12 | 9.96 | 11.7 | 2.38 |
Before fitting anything, we can run a few quick checks on the simulation. For example, we can look at the realized maximum spectral radius across the individual AR matrices, which should be below 1. Here it is around 0.6, only a little above the fixed-matrix value of 0.54, because we kept the AR random effects small.
dynamic_comp$generator_metadata$outcome$ar$stability$max_spectral_radius_overall
#> [1] 0.596Next, we prepare the generated data into an analysis data set that
can be passed to brmcoda().
prep_sim_analysis() rebuilds a complr object
using the same SBP basis as the simulator and translates
ar1() into within-person centered, lag 1 ILR predictors.
The fitted model includes random level terms, random inertia terms
through the lagged ILR slopes, and random conditional variability
through response-specific sigma models.
The lag columns are built by the exported lag_by_time()
helper. These are time-based lags on time_id, so a
row’s lag comes from the observation at time - time_step.
This matters when working with real data that has skipped time points,
such as a missed diary day. The row after a gap gets an NA
lag. The step is inferred as the smallest positive within-person time
difference, or it can be set explicitly with
prep_sim_analysis(sim, time_step = ). The number of
gap-affected rows is recorded in metadata$lag_gaps.
lag_by_time() can also be used directly on observed data
sets to construct lags for hand-built analysis models.
By default, prep_sim_analysis() emits brms
ID-linked random effects, such as (1 | p1 | ID), whenever
the same grouping factor appears in both the mean and the scale
formulas. The simulator draws those group-level effects from one joint
covariance, and the linked syntax lets the analysis model estimate their
correlations. Here, that joins all eight random effects (mean levels,
lagged slopes, and sigma intercepts for both responses)
into a single correlated block, which matches the generative model. One
practical caveat is that large linked random-effect blocks are
demanding. For very large designs, we can opt out with
prep_sim_analysis(sim, link_random = FALSE), which emits
separate, uncorrelated random-effect blocks instead. Of course, if the
simulation included correlations (any non zero true correlations), this
will result in some degree of bias. Of course, even this may be
interesting to study. The possible number of random effects grows very
quickly. For example, with a 5 part composition, there are 4 ILR terms,
resulting in: 4 location intercepts, 4 scale intercepts, 4
autoregressive slopes, and 12 cross-lagged slopes for a total of 24
parameters that could be all random effects and if so a 24 x 24 random
effect covariance matrix would have 300 parameters (24 variances and 276
unique covariances). Such a large matrix would be extremely unstable
with small sample sizes. Even with large sample sizes, it would likely
be very computationally demanding. So an interesting simulation question
could be: how much bias in the fixed effect autoregression estimates is
introduced by forcing the random effect covariances to be zero?
In any case, back to our actual example, we can prepare the analysis and see the columns and formulae. All of the data and appropriate names for the data and formulae are generated automatically.
dynamic_analysis <- prep_sim_analysis(dynamic_comp, centering = "latent")
dynamic_analysis$metadata$lag_columns
#> [1] "lag_z1_1_latent" "lag_z2_1_latent"
dynamic_analysis$formula
#> z1_1 ~ lag_z1_1_latent + lag_z2_1_latent + (1 + lag_z1_1_latent + lag_z2_1_latent | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)
#> z2_1 ~ lag_z1_1_latent + lag_z2_1_latent + (1 + lag_z1_1_latent + lag_z2_1_latent | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)These are large models, with 10,000 rows, a bivariate response, and a
linked random-effect block. To speed things up, the fits below use
brms within-chain threading on top of the two parallel
chains, via threads = brms::threading(4, static = TRUE).
Using static = TRUE keeps the results reproducible for a
given number of threads.
dynamic_fit <- brmcoda(
complr = dynamic_analysis$complr,
formula = dynamic_analysis$formula,
backend = "cmdstanr",
seed = 2026,
chains = 2,
cores = 2,
threads = brms::threading(4, static = TRUE),
iter = 1000,
refresh = 0
)
summary(dynamic_fit)
#> Warning: Parts of the model have not converged (some Rhats are > 1.05). Be careful when
#> analysing the results! We recommend running more iterations and/or setting stronger
#> priors.
#> Family: MV(gaussian, gaussian)
#> Links: mu = identity; sigma = log
#> mu = identity; sigma = log
#> Formula: z1_1 ~ lag_z1_1_latent + lag_z2_1_latent + (1 + lag_z1_1_latent + lag_z2_1_latent | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)
#> z2_1 ~ lag_z1_1_latent + lag_z2_1_latent + (1 + lag_z1_1_latent + lag_z2_1_latent | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)
#> Data: complr$dataout (Number of observations: 9800)
#> Draws: 2 chains, each with iter = 1000; warmup = 500; thin = 1;
#> total post-warmup draws = 1000
#>
#> Multilevel Hyperparameters:
#> ~ID (Number of levels: 200)
#> Estimate Est.Error l-95% CI u-95% CI Rhat
#> sd(z11_Intercept) 0.20 0.01 0.18 0.21 1.01
#> sd(z11_lag_z1_1_latent) 0.03 0.02 0.00 0.06 1.03
#> sd(z11_lag_z2_1_latent) 0.04 0.02 0.00 0.08 1.05
#> sd(sigma_z11_Intercept) 0.15 0.01 0.13 0.18 1.00
#> sd(z21_Intercept) 0.19 0.01 0.17 0.21 1.00
#> sd(z21_lag_z1_1_latent) 0.01 0.01 0.00 0.04 1.01
#> sd(z21_lag_z2_1_latent) 0.02 0.02 0.00 0.06 1.00
#> sd(sigma_z21_Intercept) 0.14 0.01 0.12 0.16 1.00
#> cor(z11_Intercept,z11_lag_z1_1_latent) 0.22 0.26 -0.36 0.67 1.00
#> cor(z11_Intercept,z11_lag_z2_1_latent) 0.35 0.26 -0.23 0.76 1.00
#> cor(z11_lag_z1_1_latent,z11_lag_z2_1_latent) 0.11 0.33 -0.55 0.70 1.01
#> cor(z11_Intercept,sigma_z11_Intercept) -0.16 0.08 -0.31 -0.00 1.00
#> cor(z11_lag_z1_1_latent,sigma_z11_Intercept) -0.03 0.28 -0.53 0.54 1.03
#> cor(z11_lag_z2_1_latent,sigma_z11_Intercept) 0.11 0.28 -0.46 0.62 1.09
#> cor(z11_Intercept,z21_Intercept) -0.07 0.07 -0.21 0.07 1.01
#> cor(z11_lag_z1_1_latent,z21_Intercept) -0.08 0.21 -0.66 0.30 1.02
#> cor(z11_lag_z2_1_latent,z21_Intercept) -0.04 0.25 -0.44 0.57 1.10
#> cor(sigma_z11_Intercept,z21_Intercept) 0.07 0.08 -0.08 0.23 1.01
#> cor(z11_Intercept,z21_lag_z1_1_latent) -0.12 0.29 -0.64 0.46 1.01
#> cor(z11_lag_z1_1_latent,z21_lag_z1_1_latent) -0.06 0.32 -0.66 0.60 1.00
#> cor(z11_lag_z2_1_latent,z21_lag_z1_1_latent) -0.03 0.33 -0.66 0.60 1.00
#> cor(sigma_z11_Intercept,z21_lag_z1_1_latent) -0.06 0.31 -0.62 0.56 1.00
#> cor(z21_Intercept,z21_lag_z1_1_latent) -0.03 0.28 -0.55 0.55 1.00
#> cor(z11_Intercept,z21_lag_z2_1_latent) 0.16 0.30 -0.48 0.68 1.00
#> cor(z11_lag_z1_1_latent,z21_lag_z2_1_latent) 0.08 0.33 -0.57 0.65 1.01
#> cor(z11_lag_z2_1_latent,z21_lag_z2_1_latent) 0.09 0.35 -0.58 0.75 1.01
#> cor(sigma_z11_Intercept,z21_lag_z2_1_latent) -0.04 0.28 -0.57 0.52 1.00
#> cor(z21_Intercept,z21_lag_z2_1_latent) -0.03 0.30 -0.61 0.53 1.00
#> cor(z21_lag_z1_1_latent,z21_lag_z2_1_latent) -0.04 0.33 -0.64 0.62 1.00
#> cor(z11_Intercept,sigma_z21_Intercept) 0.17 0.09 0.01 0.34 1.00
#> cor(z11_lag_z1_1_latent,sigma_z21_Intercept) 0.16 0.29 -0.43 0.66 1.08
#> cor(z11_lag_z2_1_latent,sigma_z21_Intercept) -0.02 0.28 -0.54 0.58 1.05
#> cor(sigma_z11_Intercept,sigma_z21_Intercept) 0.19 0.10 -0.02 0.38 1.00
#> cor(z21_Intercept,sigma_z21_Intercept) -0.09 0.09 -0.26 0.08 1.01
#> cor(z21_lag_z1_1_latent,sigma_z21_Intercept) -0.17 0.28 -0.66 0.41 1.02
#> cor(z21_lag_z2_1_latent,sigma_z21_Intercept) -0.04 0.29 -0.63 0.52 1.02
#> Bulk_ESS Tail_ESS
#> sd(z11_Intercept) 70 245
#> sd(z11_lag_z1_1_latent) 71 348
#> sd(z11_lag_z2_1_latent) 43 360
#> sd(sigma_z11_Intercept) 449 683
#> sd(z21_Intercept) 288 394
#> sd(z21_lag_z1_1_latent) 244 557
#> sd(z21_lag_z2_1_latent) 251 445
#> sd(sigma_z21_Intercept) 559 816
#> cor(z11_Intercept,z11_lag_z1_1_latent) 1162 718
#> cor(z11_Intercept,z11_lag_z2_1_latent) 849 449
#> cor(z11_lag_z1_1_latent,z11_lag_z2_1_latent) 360 455
#> cor(z11_Intercept,sigma_z11_Intercept) 564 683
#> cor(z11_lag_z1_1_latent,sigma_z11_Intercept) 31 77
#> cor(z11_lag_z2_1_latent,sigma_z11_Intercept) 18 46
#> cor(z11_Intercept,z21_Intercept) 90 245
#> cor(z11_lag_z1_1_latent,z21_Intercept) 24 48
#> cor(z11_lag_z2_1_latent,z21_Intercept) 23 37
#> cor(sigma_z11_Intercept,z21_Intercept) 126 234
#> cor(z11_Intercept,z21_lag_z1_1_latent) 1845 468
#> cor(z11_lag_z1_1_latent,z21_lag_z1_1_latent) 560 590
#> cor(z11_lag_z2_1_latent,z21_lag_z1_1_latent) 898 793
#> cor(sigma_z11_Intercept,z21_lag_z1_1_latent) 1380 826
#> cor(z21_Intercept,z21_lag_z1_1_latent) 1559 744
#> cor(z11_Intercept,z21_lag_z2_1_latent) 1243 719
#> cor(z11_lag_z1_1_latent,z21_lag_z2_1_latent) 759 705
#> cor(z11_lag_z2_1_latent,z21_lag_z2_1_latent) 303 538
#> cor(sigma_z11_Intercept,z21_lag_z2_1_latent) 1100 912
#> cor(z21_Intercept,z21_lag_z2_1_latent) 1486 685
#> cor(z21_lag_z1_1_latent,z21_lag_z2_1_latent) 890 768
#> cor(z11_Intercept,sigma_z21_Intercept) 780 652
#> cor(z11_lag_z1_1_latent,sigma_z21_Intercept) 26 61
#> cor(z11_lag_z2_1_latent,sigma_z21_Intercept) 35 38
#> cor(sigma_z11_Intercept,sigma_z21_Intercept) 466 657
#> cor(z21_Intercept,sigma_z21_Intercept) 1011 841
#> cor(z21_lag_z1_1_latent,sigma_z21_Intercept) 58 87
#> cor(z21_lag_z2_1_latent,sigma_z21_Intercept) 69 310
#>
#> Regression Coefficients:
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
#> z11_Intercept 0.23 0.01 0.20 0.25 1.01 43 112
#> sigma_z11_Intercept -1.61 0.01 -1.64 -1.59 1.00 421 643
#> z21_Intercept -1.52 0.01 -1.54 -1.49 1.01 213 481
#> sigma_z21_Intercept -1.90 0.01 -1.93 -1.88 1.01 856 796
#> z11_lag_z1_1_latent 0.47 0.01 0.46 0.49 1.00 1474 584
#> z11_lag_z2_1_latent 0.10 0.01 0.08 0.13 1.00 970 805
#> z21_lag_z1_1_latent 0.10 0.01 0.08 0.11 1.00 1941 657
#> z21_lag_z2_1_latent 0.27 0.01 0.25 0.29 1.00 2349 787
#>
#> Residual Correlations:
#> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
#> rescor(z11,z21) -0.00 0.01 -0.02 0.02 1.00 1624 702
#>
#> Draws were sampled using sample(hmc). For each parameter, Bulk_ESS
#> and Tail_ESS are effective sample size measures, and Rhat is the potential
#> scale reduction factor on split chains (at convergence, Rhat = 1).As in our first outcome example, the last step of a recovery study is
aligning the fitted estimates with the simulation truth. That alignment
does more work here. The simulator labels its parameters with structured
names, for example ar|term=ar1()|to=ilr1|from=ilr2.
However, brms reports estimates under its own coefficient
names, here z11_lag_z2_1_within, and matching the two by
hand is error-prone. To avoid this, prep_sim_analysis()
returns the truth table pre-aligned to the analysis model’s parameter
names in $truth. sim_recovery() then joins it
with the fitted model’s posterior summaries (fixef(),
VarCorr(), and the residual correlations) by name, and adds
bias and interval-coverage columns. Random effects are reported on the
standard deviation and correlation scale used by
VarCorr().
dynamic_recovery <- sim_recovery(dynamic_fit, dynamic_analysis)
kable(dynamic_recovery[, !"simulator_name"], digits = 3)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | z11_Intercept | 0.20 | 0.225 | 0.013 | 0.201 | 0.248 | 0.025 | FALSE |
| fixed | NA | z21_Intercept | -1.50 | -1.515 | 0.013 | -1.540 | -1.489 | -0.015 | TRUE |
| fixed | NA | sigma_z11_Intercept | -1.61 | -1.614 | 0.013 | -1.639 | -1.588 | -0.005 | TRUE |
| fixed | NA | sigma_z21_Intercept | -1.90 | -1.905 | 0.012 | -1.927 | -1.882 | -0.008 | TRUE |
| fixed | NA | z11_lag_z1_1_latent | 0.50 | 0.475 | 0.009 | 0.456 | 0.492 | -0.025 | FALSE |
| fixed | NA | z11_lag_z2_1_latent | 0.10 | 0.104 | 0.013 | 0.079 | 0.131 | 0.004 | TRUE |
| fixed | NA | z21_lag_z1_1_latent | 0.10 | 0.097 | 0.007 | 0.083 | 0.111 | -0.003 | TRUE |
| fixed | NA | z21_lag_z2_1_latent | 0.30 | 0.273 | 0.010 | 0.252 | 0.293 | -0.027 | FALSE |
| random_sd | ID | z11_Intercept | 0.20 | 0.196 | 0.010 | 0.179 | 0.214 | -0.004 | TRUE |
| random_sd | ID | z21_Intercept | 0.18 | 0.185 | 0.010 | 0.167 | 0.207 | 0.005 | TRUE |
| random_sd | ID | z11_lag_z1_1_latent | 0.02 | 0.025 | 0.016 | 0.002 | 0.059 | 0.005 | TRUE |
| random_sd | ID | z11_lag_z2_1_latent | 0.01 | 0.036 | 0.021 | 0.002 | 0.079 | 0.026 | TRUE |
| random_sd | ID | z21_lag_z1_1_latent | 0.01 | 0.014 | 0.011 | 0.001 | 0.040 | 0.004 | TRUE |
| random_sd | ID | z21_lag_z2_1_latent | 0.02 | 0.021 | 0.015 | 0.001 | 0.058 | 0.001 | TRUE |
| random_sd | ID | sigma_z11_Intercept | 0.16 | 0.152 | 0.012 | 0.131 | 0.176 | -0.008 | TRUE |
| random_sd | ID | sigma_z21_Intercept | 0.14 | 0.140 | 0.011 | 0.119 | 0.161 | 0.000 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_Intercept) | 0.00 | -0.072 | 0.071 | -0.214 | 0.073 | -0.072 | TRUE |
| random_cor | ID | cor(z11_Intercept,z11_lag_z1_1_latent) | 0.20 | 0.218 | 0.258 | -0.362 | 0.672 | 0.018 | TRUE |
| random_cor | ID | cor(z11_Intercept,z11_lag_z2_1_latent) | 0.00 | 0.345 | 0.257 | -0.228 | 0.757 | 0.345 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_lag_z1_1_latent) | 0.00 | -0.125 | 0.289 | -0.636 | 0.458 | -0.125 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_lag_z2_1_latent) | 0.00 | 0.162 | 0.300 | -0.482 | 0.682 | 0.162 | TRUE |
| random_cor | ID | cor(z11_Intercept,sigma_z11_Intercept) | 0.00 | -0.156 | 0.080 | -0.306 | -0.001 | -0.156 | FALSE |
| random_cor | ID | cor(z11_Intercept,sigma_z21_Intercept) | 0.00 | 0.171 | 0.085 | 0.011 | 0.344 | 0.171 | FALSE |
| random_cor | ID | cor(z21_Intercept,z11_lag_z1_1_latent) | 0.00 | -0.084 | 0.213 | -0.658 | 0.300 | -0.084 | TRUE |
| random_cor | ID | cor(z21_Intercept,z11_lag_z2_1_latent) | 0.00 | -0.042 | 0.251 | -0.439 | 0.574 | -0.042 | TRUE |
| random_cor | ID | cor(z21_Intercept,z21_lag_z1_1_latent) | 0.20 | -0.026 | 0.279 | -0.554 | 0.549 | -0.226 | TRUE |
| random_cor | ID | cor(z21_Intercept,z21_lag_z2_1_latent) | 0.00 | -0.029 | 0.296 | -0.610 | 0.527 | -0.029 | TRUE |
| random_cor | ID | cor(z21_Intercept,sigma_z11_Intercept) | 0.00 | 0.067 | 0.081 | -0.081 | 0.226 | 0.067 | TRUE |
| random_cor | ID | cor(z21_Intercept,sigma_z21_Intercept) | 0.00 | -0.093 | 0.089 | -0.258 | 0.085 | -0.093 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1_latent,z11_lag_z2_1_latent) | 0.15 | 0.113 | 0.326 | -0.546 | 0.702 | -0.037 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1_latent,z21_lag_z1_1_latent) | 0.00 | -0.055 | 0.324 | -0.662 | 0.597 | -0.055 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1_latent,z21_lag_z2_1_latent) | 0.00 | 0.081 | 0.326 | -0.574 | 0.646 | 0.081 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1_latent,sigma_z11_Intercept) | 0.00 | -0.035 | 0.278 | -0.526 | 0.538 | -0.035 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1_latent,sigma_z21_Intercept) | 0.00 | 0.158 | 0.294 | -0.431 | 0.663 | 0.158 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1_latent,z21_lag_z1_1_latent) | 0.00 | -0.028 | 0.331 | -0.658 | 0.597 | -0.028 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1_latent,z21_lag_z2_1_latent) | 0.00 | 0.085 | 0.351 | -0.583 | 0.749 | 0.085 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1_latent,sigma_z11_Intercept) | 0.00 | 0.114 | 0.283 | -0.462 | 0.622 | 0.114 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1_latent,sigma_z21_Intercept) | 0.00 | -0.016 | 0.279 | -0.542 | 0.575 | -0.016 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1_latent,z21_lag_z2_1_latent) | 0.15 | -0.040 | 0.328 | -0.636 | 0.617 | -0.190 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1_latent,sigma_z11_Intercept) | 0.00 | -0.064 | 0.305 | -0.621 | 0.558 | -0.064 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1_latent,sigma_z21_Intercept) | 0.00 | -0.167 | 0.281 | -0.663 | 0.409 | -0.167 | TRUE |
| random_cor | ID | cor(z21_lag_z2_1_latent,sigma_z11_Intercept) | 0.00 | -0.043 | 0.283 | -0.570 | 0.522 | -0.043 | TRUE |
| random_cor | ID | cor(z21_lag_z2_1_latent,sigma_z21_Intercept) | 0.00 | -0.035 | 0.290 | -0.626 | 0.517 | -0.035 | TRUE |
| random_cor | ID | cor(sigma_z11_Intercept,sigma_z21_Intercept) | 0.00 | 0.186 | 0.102 | -0.016 | 0.379 | 0.186 | TRUE |
| rescor | NA | rescor(z11,z21) | 0.00 | -0.001 | 0.010 | -0.019 | 0.018 | -0.001 | TRUE |
Most parameters are recovered well, but two patterns are worth noting. First, the AR “inertia” coefficients are estimated somewhat below their true values, with credible intervals that can exclude the truth. Second, the location random-intercept standard deviations are estimated above their true values. Under person-mean centering (the default), the fitted intercept is each person’s observed mean. The variance of those observed means is the trait variance plus the variance of each person’s sample mean of the autoregressive residual, which inflates the estimated standard deviation.
Both patterns are expected and are not bugs in the simulator or the
model. prep_sim_analysis() deliberately builds the
pragmatic analysis model that researchers typically fit to
observed data: ar1() becomes person-mean-centered lagged
observed responses, whereas gen_outcome()
simulates a latent residual VAR(1). Lagged regression on observed scores
with person-mean centering is known to attenuate inertia and cross-lag
estimates relative to the latent generating values, especially with
short series (Nickell bias; see Hamaker & Grasman, 2014, and the
“Pragmatic default estimator” section of
?prep_sim_analysis). Note that in
prep_sim_analysis(), we used
centering = "latent" to build the lagged predictors, this
means that the person mean centering is done on the true latent person
means, rather than observed person means. However, these are still not
the same as the latent residuals.
This mismatch is intentional as latent residual VAR(1) models, at
least that can be easily fit, have various limitations (e.g., in our
simulation we support the autoregressive effects being moderated by
other variables). Because of this, once ar1() is in the
model, to our knowledge no parameter is guaranteed to keep its
generating estimand, including the location effects: the observed lag
carries the lagged mean structure as well as the lagged residual, and
person-mean centering does not generally remove it. Static models are
the more forgiving case. There most parameters do estimate the quantity
the simulator recorded, and those are the ones where systematic bias
would indicate a problem.
The next section fits the non-centered alternative to the same simulated data, which shows how much of the AR attenuation comes from the centering itself.
To Center or Not to Center: Non-Centered Lagged Predictors
The person-mean-centered lagged predictor used above is only one of
two common ways to parametrize a multilevel autoregressive model.
Hamaker & Grasman (2014) compare them directly. With cluster-mean
centering (CMC, the prep_sim_analysis() default also called
person-mean centred), the average autoregressive coefficient is
attenuated toward zero. Regressing on the raw, non-centred
lagged outcome (NC) instead recovers the average autoregressive
coefficient nearly unbiased.
The trade-off is the interpretability of the level parameters. In the NC parametrization, the model intercept corresponds to (multivariate: ) rather than the person mean . As a result, the location intercepts, and their random-effect standard deviations and correlations, no longer estimate the simulated data’s mean-structure truth. Hamaker & Grasman’s practical recommendation is therefore to fit both parametrizations: NC when the average inertia and cross-lag effects are the parameters of interest, and CMC when person means must remain meaningful, for example for interpretable intercepts or for cross-level effects of person-level predictors on .
prep_sim_analysis() supports the NC parametrization
through lag_center = "none", which builds raw
lag_<response> columns instead of the
person-mean-centered lag_<response>_within columns.
The caveat above does not change: with ar1() in the model,
no parameter is guaranteed to keep its generating estimand under either
parametrization. What changes is which parameters we expect to be
recovered nearly without bias. Under NC, the AR rows. Under CMC, the
intercepts and means. We reuse the simulated data from above, so the two
analysis models see exactly the same observations.
dynamic_analysis_nc <- prep_sim_analysis(dynamic_comp, lag_center = "none")
dynamic_analysis_nc$metadata$lag_columns
#> [1] "lag_z1_1" "lag_z2_1"
dynamic_analysis_nc$formula
#> z1_1 ~ lag_z1_1 + lag_z2_1 + (1 + lag_z1_1 + lag_z2_1 | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)
#> z2_1 ~ lag_z1_1 + lag_z2_1 + (1 + lag_z1_1 + lag_z2_1 | p1 | ID)
#> sigma ~ 1 + (1 | p1 | ID)
dynamic_fit_nc <- brmcoda(
complr = dynamic_analysis_nc$complr,
formula = dynamic_analysis_nc$formula,
backend = "cmdstanr",
seed = 2026,
chains = 2,
cores = 2,
threads = brms::threading(4, static = TRUE),
iter = 1000,
refresh = 0
)The full recovery table for the non-centered model parallels the one
shown above for the centered default. Note the
lag_<response> parameter names, rather than
lag_<response>_within:
dynamic_recovery_nc <- sim_recovery(dynamic_fit_nc, dynamic_analysis_nc)
kable(dynamic_recovery_nc[, !"simulator_name"], digits = 3)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | z11_Intercept | 0.20 | 0.270 | 0.020 | 0.234 | 0.311 | 0.070 | FALSE |
| fixed | NA | z21_Intercept | -1.50 | -1.082 | 0.018 | -1.119 | -1.048 | 0.418 | FALSE |
| fixed | NA | sigma_z11_Intercept | -1.61 | -1.613 | 0.013 | -1.639 | -1.588 | -0.003 | TRUE |
| fixed | NA | sigma_z21_Intercept | -1.90 | -1.905 | 0.012 | -1.929 | -1.882 | -0.008 | TRUE |
| fixed | NA | z11_lag_z1_1 | 0.50 | 0.508 | 0.010 | 0.489 | 0.527 | 0.008 | TRUE |
| fixed | NA | z11_lag_z2_1 | 0.10 | 0.108 | 0.012 | 0.085 | 0.132 | 0.008 | TRUE |
| fixed | NA | z21_lag_z1_1 | 0.10 | 0.098 | 0.007 | 0.085 | 0.112 | -0.002 | TRUE |
| fixed | NA | z21_lag_z2_1 | 0.30 | 0.300 | 0.011 | 0.279 | 0.321 | 0.000 | TRUE |
| random_sd | ID | z11_Intercept | 0.20 | 0.105 | 0.012 | 0.086 | 0.135 | -0.095 | FALSE |
| random_sd | ID | z21_Intercept | 0.18 | 0.132 | 0.011 | 0.110 | 0.155 | -0.048 | FALSE |
| random_sd | ID | z11_lag_z1_1 | 0.02 | 0.018 | 0.013 | 0.000 | 0.049 | -0.002 | TRUE |
| random_sd | ID | z11_lag_z2_1 | 0.01 | 0.013 | 0.010 | 0.001 | 0.036 | 0.003 | TRUE |
| random_sd | ID | z21_lag_z1_1 | 0.01 | 0.012 | 0.010 | 0.000 | 0.036 | 0.002 | TRUE |
| random_sd | ID | z21_lag_z2_1 | 0.02 | 0.016 | 0.011 | 0.001 | 0.039 | -0.004 | TRUE |
| random_sd | ID | sigma_z11_Intercept | 0.16 | 0.151 | 0.011 | 0.129 | 0.174 | -0.009 | TRUE |
| random_sd | ID | sigma_z21_Intercept | 0.14 | 0.139 | 0.011 | 0.119 | 0.160 | -0.001 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_Intercept) | 0.00 | -0.305 | 0.107 | -0.490 | -0.087 | -0.305 | FALSE |
| random_cor | ID | cor(z11_Intercept,z11_lag_z1_1) | 0.20 | 0.137 | 0.302 | -0.495 | 0.698 | -0.063 | TRUE |
| random_cor | ID | cor(z11_Intercept,z11_lag_z2_1) | 0.00 | 0.258 | 0.357 | -0.489 | 0.808 | 0.258 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_lag_z1_1) | 0.00 | -0.099 | 0.284 | -0.628 | 0.478 | -0.099 | TRUE |
| random_cor | ID | cor(z11_Intercept,z21_lag_z2_1) | 0.00 | 0.192 | 0.312 | -0.463 | 0.717 | 0.192 | TRUE |
| random_cor | ID | cor(z11_Intercept,sigma_z11_Intercept) | 0.00 | -0.157 | 0.106 | -0.350 | 0.062 | -0.157 | TRUE |
| random_cor | ID | cor(z11_Intercept,sigma_z21_Intercept) | 0.00 | 0.175 | 0.112 | -0.071 | 0.383 | 0.175 | TRUE |
| random_cor | ID | cor(z21_Intercept,z11_lag_z1_1) | 0.00 | -0.129 | 0.268 | -0.687 | 0.389 | -0.129 | TRUE |
| random_cor | ID | cor(z21_Intercept,z11_lag_z2_1) | 0.00 | 0.167 | 0.286 | -0.450 | 0.659 | 0.167 | TRUE |
| random_cor | ID | cor(z21_Intercept,z21_lag_z1_1) | 0.20 | 0.013 | 0.306 | -0.592 | 0.599 | -0.187 | TRUE |
| random_cor | ID | cor(z21_Intercept,z21_lag_z2_1) | 0.00 | 0.050 | 0.303 | -0.576 | 0.606 | 0.050 | TRUE |
| random_cor | ID | cor(z21_Intercept,sigma_z11_Intercept) | 0.00 | 0.080 | 0.101 | -0.140 | 0.269 | 0.080 | TRUE |
| random_cor | ID | cor(z21_Intercept,sigma_z21_Intercept) | 0.00 | -0.121 | 0.103 | -0.326 | 0.075 | -0.121 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1,z11_lag_z2_1) | 0.15 | 0.006 | 0.337 | -0.610 | 0.639 | -0.144 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1,z21_lag_z1_1) | 0.00 | -0.014 | 0.329 | -0.602 | 0.623 | -0.014 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1,z21_lag_z2_1) | 0.00 | 0.076 | 0.338 | -0.582 | 0.696 | 0.076 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1,sigma_z11_Intercept) | 0.00 | -0.214 | 0.302 | -0.750 | 0.396 | -0.214 | TRUE |
| random_cor | ID | cor(z11_lag_z1_1,sigma_z21_Intercept) | 0.00 | 0.048 | 0.266 | -0.534 | 0.533 | 0.048 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1,z21_lag_z1_1) | 0.00 | -0.010 | 0.331 | -0.620 | 0.631 | -0.010 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1,z21_lag_z2_1) | 0.00 | 0.028 | 0.325 | -0.567 | 0.650 | 0.028 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1,sigma_z11_Intercept) | 0.00 | -0.052 | 0.322 | -0.656 | 0.558 | -0.052 | TRUE |
| random_cor | ID | cor(z11_lag_z2_1,sigma_z21_Intercept) | 0.00 | 0.046 | 0.329 | -0.572 | 0.672 | 0.046 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1,z21_lag_z2_1) | 0.15 | -0.006 | 0.319 | -0.631 | 0.588 | -0.156 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1,sigma_z11_Intercept) | 0.00 | -0.059 | 0.314 | -0.651 | 0.572 | -0.059 | TRUE |
| random_cor | ID | cor(z21_lag_z1_1,sigma_z21_Intercept) | 0.00 | -0.091 | 0.294 | -0.633 | 0.506 | -0.091 | TRUE |
| random_cor | ID | cor(z21_lag_z2_1,sigma_z11_Intercept) | 0.00 | -0.104 | 0.306 | -0.665 | 0.533 | -0.104 | TRUE |
| random_cor | ID | cor(z21_lag_z2_1,sigma_z21_Intercept) | 0.00 | -0.034 | 0.286 | -0.578 | 0.522 | -0.034 | TRUE |
| random_cor | ID | cor(sigma_z11_Intercept,sigma_z21_Intercept) | 0.00 | 0.179 | 0.103 | -0.040 | 0.378 | 0.179 | TRUE |
| rescor | NA | rescor(z11,z21) | 0.00 | 0.000 | 0.011 | -0.021 | 0.021 | 0.000 | TRUE |
The two recovery tables label the same simulator parameters with
different analysis-model names, lag_z1_1_within versus
lag_z1_1. However, the simulator_name column
is identical in both, so we can use it as a stable key for a
side-by-side comparison of the autoregressive fixed effects:
ar_cmc <- dynamic_recovery[type == "fixed" & startsWith(simulator_name, "ar:")]
ar_nc <- dynamic_recovery_nc[type == "fixed" & startsWith(simulator_name, "ar:")]
ar_comparison <- merge(
ar_cmc[, .(simulator_name, truth, estimate_cmc = estimate,
ci_cmc = sprintf("[%.3f, %.3f]", lower, upper), bias_cmc = bias)],
ar_nc[, .(simulator_name, estimate_nc = estimate,
ci_nc = sprintf("[%.3f, %.3f]", lower, upper), bias_nc = bias)],
by = "simulator_name"
)
kable(ar_comparison, digits = 3)| simulator_name | truth | estimate_cmc | ci_cmc | bias_cmc | estimate_nc | ci_nc | bias_nc |
|---|---|---|---|---|---|---|---|
| ar:ar1()@to=ilr1,from=ilr1 | 0.5 | 0.475 | [0.456, 0.492] | -0.025 | 0.508 | [0.489, 0.527] | 0.008 |
| ar:ar1()@to=ilr1,from=ilr2 | 0.1 | 0.104 | [0.079, 0.131] | 0.004 | 0.108 | [0.085, 0.132] | 0.008 |
| ar:ar1()@to=ilr2,from=ilr1 | 0.1 | 0.097 | [0.083, 0.111] | -0.003 | 0.098 | [0.085, 0.112] | -0.002 |
| ar:ar1()@to=ilr2,from=ilr2 | 0.3 | 0.273 | [0.252, 0.293] | -0.027 | 0.300 | [0.279, 0.321] | 0.000 |
The inertia (diagonal) coefficients show the pattern Hamaker & Grasman (2014) predict. The CMC estimates sit below their true values, while the NC estimates land close to the truth, with credible intervals that cover it.
The level parameters tell a different story, and neither parametrization recovers them exactly. Longer series shrink the CMC inflation, because the residual mean averages away as grows, but they do not change the NC shrinkage. So, generally the CMC will be closer than the NC for the intercepts.
level_cmc <- dynamic_recovery[
type %in% c("fixed", "random_sd") & grepl("^location", simulator_name)]
level_nc <- dynamic_recovery_nc[
type %in% c("fixed", "random_sd") & grepl("^location", simulator_name)]
level_comparison <- merge(
level_cmc[, .(simulator_name, type, truth, estimate_cmc = estimate,
ci_cmc = sprintf("[%.3f, %.3f]", lower, upper), bias_cmc = bias)],
level_nc[, .(simulator_name, estimate_nc = estimate,
ci_nc = sprintf("[%.3f, %.3f]", lower, upper), bias_nc = bias)],
by = "simulator_name"
)
kable(level_comparison, digits = 3)| simulator_name | type | truth | estimate_cmc | ci_cmc | bias_cmc | estimate_nc | ci_nc | bias_nc |
|---|---|---|---|---|---|---|---|---|
| location:(Intercept)@ilr1 | fixed | 0.20 | 0.225 | [0.201, 0.248] | 0.025 | 0.270 | [0.234, 0.311] | 0.070 |
| location:(Intercept)@ilr2 | fixed | -1.50 | -1.515 | [-1.540, -1.489] | -0.015 | -1.082 | [-1.119, -1.048] | 0.418 |
| location|outcome=ilr1|term=(Intercept) | random_sd | 0.20 | 0.196 | [0.179, 0.214] | -0.004 | 0.105 | [0.086, 0.135] | -0.095 |
| location|outcome=ilr2|term=(Intercept) | random_sd | 0.18 | 0.185 | [0.167, 0.207] | 0.005 | 0.132 | [0.110, 0.155] | -0.048 |
Which one to fit, or whether to fit both as Hamaker & Grasman
(2014) recommend, depends on which parameters matter to you. See the
“Pragmatic default estimator” section of ?prep_sim_analysis
for more on which parameters keep their generating estimand under each
parametrization.
Simulating the Between-Within (brmcoda) Model
The between-within multilevelcoda model (see the earlier
vignettes on multilevel models with compositional predictors and on
substitution analyses) predicts a scalar outcome from the between- and
within-person ILR coordinates of a composition. We can simulate this
model directly. A multilevel compositional gen_mvn()
generator emits the latent decomposition of each ILR coordinate as
visible columns (ilr1_between, ilr1_within,
and so on) and the true between composition on the parts scale
(sleep_between, …), and it labels the ILR coordinates with
column roles. between() and within() terms in
a gen_outcome() formula then resolve to those latent
components.
As before, gen_template() provides the exact parameter
structure required. Note the role-based names, such as
between(ilr1), in the location matrix.
bw_formula <- y ~ between(ilr1) + between(ilr2) +
within(ilr1) + within(ilr2) + (1 | ID)
bw_generator <- function() {
gen_mvn(
c("ilr1", "ilr2"),
level = "multilevel",
fixed_intercept = c(0.2, -0.1),
random_cov = diag(2) * 0.2,
residual_cov = diag(2) * 0.4,
compositional = TRUE,
parts = c("sleep", "active", "sedentary"),
total = 24
)
}
bw_template <- simulate_data(
n_groups = 150,
n_per_group = 10,
group_id = "ID",
seed = 2026,
generators = list(
sleep_comp = bw_generator(),
outcome_template = gen_template(bw_formula, scale = sigma ~ 1)
)
)
bw_params <- bw_template$generator_metadata$outcome_template$params
bw_params$location$beta[, "y"] <- c(0, 0.5, -0.3, 0.2, 0.1)
bw_params$scale$beta["(Intercept)", "y"] <- log(0.3)
bw_params$random$ID$covariance[] <- 0.1
bw_params$location$beta
#> y
#> (Intercept) 0.0
#> between(ilr1) 0.5
#> between(ilr2) -0.3
#> within(ilr1) 0.2
#> within(ilr2) 0.1
bw_sim <- simulate_data(
n_groups = 150,
n_per_group = 10,
group_id = "ID",
seed = 2026,
generators = list(
sleep_comp = bw_generator(),
y = gen_outcome(bw_formula, scale = sigma ~ 1, params = bw_params)
)
)
kable(head(bw_sim$data[, c(
"ID", "sleep", "active", "sedentary",
"ilr1_between", "ilr1_within", "sleep_between", "y"
), with = FALSE]))| ID | sleep | active | sedentary | ilr1_between | ilr1_within | sleep_between | y |
|---|---|---|---|---|---|---|---|
| 1 | 16.30 | 3.96 | 3.75 | 0.189 | 0.989 | 9.25 | -0.071 |
| 1 | 11.90 | 6.12 | 5.98 | 0.189 | 0.363 | 9.25 | 0.297 |
| 1 | 9.46 | 5.22 | 9.32 | 0.189 | 0.060 | 9.25 | 0.904 |
| 1 | 11.86 | 8.98 | 3.16 | 0.189 | 0.465 | 9.25 | -0.123 |
| 1 | 4.20 | 7.19 | 12.62 | 0.189 | -0.858 | 9.25 | 0.481 |
| 1 | 8.44 | 5.90 | 9.65 | 0.189 | -0.098 | 9.25 | 0.435 |
prep_sim_analysis() recognises that
between() and within() reference ILR
coordinates of a compositional predictor. Instead of generic person-mean
centered columns, it builds a complr() object for the
predictor composition, using the simulator’s parts, SBP, and total, and
maps the terms to the standard multilevelcoda between and
within coordinates bz* and wz*. The returned
complr object plugs straight into brmcoda().
Because the fitted model is a regular brmcoda model,
substitution() works on it too.
bw_analysis <- prep_sim_analysis(bw_sim)
bw_analysis$metadata$special_term_map
#> between(ilr1) between(ilr2) within(ilr1) within(ilr2)
#> "bz1_1" "bz2_1" "wz1_1" "wz2_1"
bw_analysis$formula
#> y ~ bz1_1 + bz2_1 + wz1_1 + wz2_1 + (1 | ID)
#> sigma ~ 1
bw_fit <- brmcoda(
complr = bw_analysis$complr,
formula = bw_analysis$formula,
backend = "cmdstanr",
seed = 2026,
chains = 2,
cores = 2,
iter = 1000,
refresh = 0
)sim_recovery() works unchanged here. The truth table
maps the role-based simulator names (between(ilr1), …) to
the fitted bz* and wz* coefficients, and it
also covers the random-intercept standard deviation automatically.
bw_recovery <- sim_recovery(bw_fit, bw_analysis)
kable(bw_recovery[, !"simulator_name"], digits = 3)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 0.000 | 0.006 | 0.032 | -0.055 | 0.072 | 0.006 | TRUE |
| fixed | NA | bz1_1 | 0.500 | 0.409 | 0.066 | 0.271 | 0.539 | -0.091 | TRUE |
| fixed | NA | bz2_1 | -0.300 | -0.292 | 0.057 | -0.402 | -0.187 | 0.008 | TRUE |
| fixed | NA | wz1_1 | 0.200 | 0.209 | 0.014 | 0.182 | 0.236 | 0.009 | TRUE |
| fixed | NA | wz2_1 | 0.100 | 0.092 | 0.013 | 0.066 | 0.115 | -0.008 | TRUE |
| fixed | NA | sigma_Intercept | -1.204 | -1.191 | 0.019 | -1.227 | -1.156 | 0.013 | TRUE |
| random_sd | ID | Intercept | 0.316 | 0.321 | 0.022 | 0.281 | 0.367 | 0.005 | TRUE |
The same caveat about what is being estimated applies here as for the
dynamic model. The fitted bz* coefficients use the ILR of
each person’s closed arithmetic-mean composition, computed from
the observed parts. The simulation truth between(ilr) is
instead the latent group-level ILR mean. These differ, and observed
between-person predictors are biased for latent between-person effects
when the series are short (Ludtke et al., 2008). Within-person effects
are typically recovered well. See the “Pragmatic default estimator”
section of ?prep_sim_analysis for details.
However, if desired we can use the simulated latent variables directly.
bw_analysis_latent <- prep_sim_analysis(bw_sim, centering = "latent")
bw_fit_latent <- brmcoda(
complr = bw_analysis_latent$complr,
formula = bw_analysis_latent$formula,
backend = "cmdstanr",
seed = 2026,
chains = 2,
cores = 2,
iter = 1000,
refresh = 0
)And the recovery
bw_recovery_latent <- sim_recovery(bw_fit_latent, bw_analysis_latent)
kable(bw_recovery_latent[, !"simulator_name"], digits = 3)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 0.000 | -0.005 | 0.028 | -0.061 | 0.053 | -0.005 | TRUE |
| fixed | NA | bz1_1 | 0.500 | 0.453 | 0.072 | 0.310 | 0.594 | -0.047 | TRUE |
| fixed | NA | bz2_1 | -0.300 | -0.338 | 0.064 | -0.454 | -0.211 | -0.038 | TRUE |
| fixed | NA | wz1_1 | 0.200 | 0.209 | 0.014 | 0.183 | 0.237 | 0.009 | TRUE |
| fixed | NA | wz2_1 | 0.100 | 0.092 | 0.013 | 0.067 | 0.116 | -0.008 | TRUE |
| fixed | NA | sigma_Intercept | -1.204 | -1.190 | 0.019 | -1.229 | -1.153 | 0.014 | TRUE |
| random_sd | ID | Intercept | 0.316 | 0.309 | 0.020 | 0.270 | 0.348 | -0.008 | TRUE |
The bz* rows are the ones to compare against the
previous table. Here they estimate the latent between-person composition
the simulator generated from, rather than an observed proxy for it, so
any remaining bias in them is not attributable to the
arithmetic-versus-geometric centering gap. The wz* rows
were already recovered well and change little.
Finally, because the fit is a regular brmcoda model with
a complr object, substitution analyses work directly on the
simulated data:
bw_sub <- substitution(bw_fit, delta = 1, level = "within")
summary(bw_sub)
#> Estimate Est.Error CI_low CI_high Delta From To Level Reference Resp
#> <num> <num> <num> <num> <num> <char> <char> <char> <char> <char>
#> 1: 0.02 0 0.02 0.03 1 active sleep within grandmean y
#> 2: 0.04 0 0.03 0.04 1 sedentary sleep within grandmean y
#> 3: -0.02 0 -0.03 -0.02 1 sleep active within grandmean y
#> 4: 0.02 0 0.01 0.02 1 sedentary active within grandmean y
#> 5: -0.04 0 -0.04 -0.03 1 sleep sedentary within grandmean y
#> 6: -0.01 0 -0.02 -0.01 1 active sedentary within grandmean yNon-Gaussian Outcomes
Beyond the Gaussian default, gen_outcome() supports five
univariate GLM-style families: "poisson",
"binomial", "negbin", "gamma",
and "beta". The location formula works exactly as before,
with ordinary terms, between(), within(), and
one grouping term. It defines the linear predictor on the family link
scale. That is a log link for Poisson, negative binomial, and gamma, and
a logit link for binomial and beta.
The scale formula uses the family’s brms
distributional-parameter name on the left-hand side, so the simulation
formula matches the analysis formula:
-
"negbin":scale = shape ~ ...models the log size. -
"gamma":scale = shape ~ ...models the log shape. -
"beta":scale = phi ~ ...models the log precision. -
"poisson"and"binomial"have no auxiliary parameter, soscalemust be omitted.
Binomial outcomes require trials (a scalar, vector,
function of n, or count-distribution list). The resolved
trial sizes are written into the simulated data as a
<outcome>_trials column so that the analysis model
can use y | trials(y_trials).
There are two restrictions to keep in mind. First, ar1()
is currently only supported for family = "gaussian", and
combining it with any other family is an error. Second, because the
links are nonlinear, the fixed effects are conditional
(subject-specific) effects when random effects are present. The marginal
mean of the outcome is not the inverse link of the fixed-effect
predictor alone. For example, the marginal probability of a binary
outcome is not plogis(b0) under random intercepts. Do not
validate simulated data against marginal summaries. The recovery checks
below fit the matching conditional model instead.
The examples below simulate each family with known parameters, show
the brms formula that prep_sim_analysis()
infers, and briefly fit each model to check that the true values are
recovered. A single simulated data set only identifies parameters up to
sampling error, so the tables report 95% credible intervals around each
estimate next to the true value, rather than expecting point
equality.
glm_intercept_beta <- function(value, terms = "(Intercept)") {
matrix(value, nrow = length(terms), dimnames = list(terms, "y"))
}
glm_fit <- function(analysis, seed) {
brm(
formula = analysis$formula,
data = analysis$data,
backend = "cmdstanr",
chains = 2,
iter = 1000,
seed = seed,
refresh = 0,
silent = 2
)
}
glm_show_recovery <- function(fit, analysis) {
kable(sim_recovery(fit, analysis)[, !"simulator_name"], digits = 3)
}Poisson
We start with a multilevel Poisson outcome with a row-level predictor and a random intercept. The random intercept enters the log-linear predictor, so counts are conditionally Poisson given the group effect.
poisson_random_name <- "location|outcome=y|term=(Intercept)"
poisson_random_cov <- matrix(
0.4^2,
dimnames = list(poisson_random_name, poisson_random_name)
)
poisson_params <- list(
location = list(beta = glm_intercept_beta(c(1, 0.3), c("(Intercept)", "x"))),
random = list(ID = list(covariance = poisson_random_cov))
)
poisson_sim <- simulate_data(
n_groups = 100,
n_per_group = 10,
group_id = "ID",
seed = 2027,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
y = gen_outcome(
y ~ x + (1 | ID),
params = poisson_params,
family = "poisson"
)
)
)
poisson_analysis <- prep_sim_analysis(poisson_sim)
kable(head(poisson_sim$data))| ID | obs_id | x | y |
|---|---|---|---|
| 1 | 1 | -0.935 | 0 |
| 1 | 2 | -0.694 | 1 |
| 1 | 3 | -0.548 | 3 |
| 1 | 4 | 0.766 | 3 |
| 1 | 5 | 1.852 | 4 |
| 1 | 6 | 0.754 | 1 |
poisson_analysis$formula
#> y ~ x + (1 | ID)
poisson_fit <- glm_fit(poisson_analysis, seed = 2027)
glm_show_recovery(poisson_fit, poisson_analysis)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 1.0 | 0.932 | 0.045 | 0.842 | 1.013 | -0.068 | TRUE |
| fixed | NA | x | 0.3 | 0.311 | 0.019 | 0.272 | 0.347 | 0.011 | TRUE |
| random_sd | ID | Intercept | 0.4 | 0.435 | 0.038 | 0.366 | 0.516 | 0.035 | TRUE |
The random-intercept standard deviation (true value 0.4) is included in the recovery table automatically.
Binomial
Binomial outcomes need trials. Here every row has ten
trials. The resolved sizes are stored in the generated
y_trials column, which prep_sim_analysis()
wires into the trials() term of the analysis formula.
binomial_params <- list(
location = list(beta = glm_intercept_beta(c(-0.5, 0.4), c("(Intercept)", "x")))
)
binomial_sim <- simulate_data(
n = 500,
seed = 2028,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
y = gen_outcome(
y ~ x,
params = binomial_params,
family = "binomial",
trials = 10
)
)
)
binomial_analysis <- prep_sim_analysis(binomial_sim)
kable(head(binomial_sim$data))| obs_id | x | y | y_trials |
|---|---|---|---|
| 1 | 0.096 | 3 | 10 |
| 2 | 0.292 | 5 | 10 |
| 3 | -2.573 | 2 | 10 |
| 4 | 1.898 | 5 | 10 |
| 5 | 2.186 | 6 | 10 |
| 6 | 0.002 | 5 | 10 |
binomial_analysis$formula
#> y | trials(y_trials) ~ x
binomial_fit <- glm_fit(binomial_analysis, seed = 2028)
glm_show_recovery(binomial_fit, binomial_analysis)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | -0.5 | -0.497 | 0.03 | -0.555 | -0.441 | 0.003 | TRUE |
| fixed | NA | x | 0.4 | 0.401 | 0.03 | 0.341 | 0.460 | 0.001 | TRUE |
Negative Binomial
The negative binomial family uses scale = shape ~ ... as
the log size. The same shape name appears as the
distributional formula in the inferred brms model, so the
simulated auxiliary parameter is directly comparable to the fitted
shape_Intercept.
negbin_params <- list(
location = list(beta = glm_intercept_beta(c(1, 0.3), c("(Intercept)", "x"))),
scale = list(beta = glm_intercept_beta(log(2)))
)
negbin_sim <- simulate_data(
n = 500,
seed = 2038,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
y = gen_outcome(
y ~ x,
scale = shape ~ 1,
params = negbin_params,
family = "negbin"
)
)
)
negbin_analysis <- prep_sim_analysis(negbin_sim)
kable(head(negbin_sim$data))| obs_id | x | y |
|---|---|---|
| 1 | 0.177 | 0 |
| 2 | 1.145 | 0 |
| 3 | 1.197 | 7 |
| 4 | 1.097 | 10 |
| 5 | 1.902 | 7 |
| 6 | -1.700 | 1 |
negbin_analysis$formula
#> y ~ x
#> shape ~ 1
negbin_fit <- glm_fit(negbin_analysis, seed = 2038)
glm_show_recovery(negbin_fit, negbin_analysis)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 1.000 | 1.020 | 0.043 | 0.930 | 1.102 | 0.020 | TRUE |
| fixed | NA | x | 0.300 | 0.304 | 0.040 | 0.222 | 0.380 | 0.004 | TRUE |
| fixed | NA | shape_Intercept | 0.693 | 0.625 | 0.115 | 0.412 | 0.856 | -0.068 | TRUE |
Gamma
Gamma outcomes use a log link for the mean and
scale = shape ~ ... as the log shape. Draws use rate
shape / mu, so the simulated mean is
exp(eta).
gamma_params <- list(
location = list(beta = glm_intercept_beta(c(0.5, 0.2), c("(Intercept)", "x"))),
scale = list(beta = glm_intercept_beta(log(3)))
)
gamma_sim <- simulate_data(
n = 500,
seed = 2030,
generators = list(
x = gen_mvn("x", fixed_intercept = 0, residual_cov = 1),
y = gen_outcome(
y ~ x,
scale = shape ~ 1,
params = gamma_params,
family = "gamma"
)
)
)
gamma_analysis <- prep_sim_analysis(gamma_sim)
kable(head(gamma_sim$data))| obs_id | x | y |
|---|---|---|
| 1 | 1.226 | 1.183 |
| 2 | -1.026 | 1.727 |
| 3 | -1.339 | 2.081 |
| 4 | 0.579 | 1.347 |
| 5 | 1.018 | 4.489 |
| 6 | 0.194 | 0.931 |
gamma_analysis$formula
#> y ~ x
#> shape ~ 1
gamma_fit <- glm_fit(gamma_analysis, seed = 2030)
glm_show_recovery(gamma_fit, gamma_analysis)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 0.5 | 0.516 | 0.025 | 0.466 | 0.565 | 0.016 | TRUE |
| fixed | NA | x | 0.2 | 0.201 | 0.023 | 0.159 | 0.242 | 0.001 | TRUE |
| fixed | NA | shape_Intercept | 1.1 | 1.145 | 0.061 | 1.027 | 1.265 | 0.046 | TRUE |
Beta
Beta outcomes use a logit link for the mean and
scale = phi ~ ... as the log precision. This example puts
correlated random intercepts on both the mean and the precision. The
simulator draws all of a group’s random effects from one joint
covariance. prep_sim_analysis() therefore emits
brms ID-linked random effects, (1 | p1 | ID),
whenever the same grouping factor appears in both formulas, which lets
the fitted model estimate the cross-parameter correlation.
beta_random_names <- c(
"location|outcome=y|term=(Intercept)",
"phi|outcome=y|term=(Intercept)"
)
beta_random_sd <- c(0.3, 0.2)
beta_random_cor <- matrix(c(1, 0.3, 0.3, 1), 2, 2)
beta_random_cov <- diag(beta_random_sd) %*% beta_random_cor %*% diag(beta_random_sd)
dimnames(beta_random_cov) <- list(beta_random_names, beta_random_names)
beta_params <- list(
location = list(beta = glm_intercept_beta(0.2)),
scale = list(beta = glm_intercept_beta(log(20))),
random = list(ID = list(covariance = beta_random_cov))
)
beta_sim <- simulate_data(
n_groups = 150,
n_per_group = 8,
group_id = "ID",
seed = 2031,
generators = list(
y = gen_outcome(
y ~ 1 + (1 | ID),
scale = phi ~ 1 + (1 | ID),
params = beta_params,
family = "beta"
)
)
)
beta_analysis <- prep_sim_analysis(beta_sim)
kable(head(beta_sim$data))| ID | obs_id | y |
|---|---|---|
| 1 | 1 | 0.621 |
| 1 | 2 | 0.513 |
| 1 | 3 | 0.532 |
| 1 | 4 | 0.655 |
| 1 | 5 | 0.758 |
| 1 | 6 | 0.455 |
beta_analysis$formula
#> y ~ 1 + (1 | p1 | ID)
#> phi ~ 1 + (1 | p1 | ID)
beta_fit <- glm_fit(beta_analysis, seed = 2031)
glm_show_recovery(beta_fit, beta_analysis)| type | group | parameter | truth | estimate | est_error | lower | upper | bias | covered |
|---|---|---|---|---|---|---|---|---|---|
| fixed | NA | Intercept | 0.2 | 0.210 | 0.029 | 0.154 | 0.265 | 0.010 | TRUE |
| fixed | NA | phi_Intercept | 3.0 | 2.972 | 0.049 | 2.876 | 3.072 | -0.023 | TRUE |
| random_sd | ID | Intercept | 0.3 | 0.336 | 0.024 | 0.291 | 0.385 | 0.036 | TRUE |
| random_sd | ID | phi_Intercept | 0.2 | 0.166 | 0.081 | 0.022 | 0.328 | -0.034 | TRUE |
| random_cor | ID | cor(Intercept,phi_Intercept) | 0.3 | 0.484 | 0.297 | -0.169 | 0.966 | 0.184 | TRUE |
The random-effect standard deviations (true values 0.3 for the mean
intercept and 0.2 for the phi intercept) and their
correlation (true value 0.3) all appear in the recovery table. This
works because the ID-linked random effects make the cross-parameter
correlation estimable.