Simulation-based calibration (Talts et al. 2018; Modrák et al. 2023) is the check that a model's likelihood, its Stan code and its post-processing agree with each other. If they do, the rank of a parameter drawn from the prior, among the posterior draws of a fit to data simulated from that draw, is uniform over the simulations. A rank histogram that is not flat says the implementation is wrong; a flat one says nothing about whether the model is a good one.
Usage
sbc(
model,
formula,
data,
prior = NULL,
n_sims = 100,
level = c("population", "sd"),
generator = NULL,
...,
seed = NULL,
file = NULL,
refit = c("on_change", "never", "always"),
cores_per_fit = NULL,
thin_ranks = NULL,
keep_fits = FALSE,
cache_mode = c("none", "results"),
cache_location = NULL,
.fitter = NULL
)Arguments
- model
A
bmmodel, built with the column namesdatauses.- formula
The
bmmformulaunder check. Without agenerator, every parameter formula must be intercept-only, with or without one(1 | id)or(1 | p | id)term — the shapesrecovery_formula()writes. Anything else is an error naming the term: the default generator maps a prior draw ontosimulate_recovery()'s arguments, and that map is exact for those shapes and guesswork for a covariate or a task factor. With ageneratorany formula works, as long as every parameter that has a group term has the same one, written as a bare column name. A customm3's activation formulas are not parameter formulas: the check skips them, and the default generator reads them (seeformulainsimulate_recovery()).- data
The design to simulate over, and only that: the subjects are the unique values of the grouping column and the trials are the rows each of them has. The response values are ignored, because the generator replaces them. Without a
generator, every subject must have the same number of rows. The simulated data sets carry the same subject labels asdata, which is what lets a subject truth meet its ownr_draw. Without agenerator, a model whose built-in generator reads a trial design (seetrial_designinsimulate_recovery()) takes it fromdata: the columns that generator reads, per subject and indata's order, so every simulated data set has the trials ofdata. So does a count model (sdt_yn,sdt_mafc,sdt_ranking,ezdm,m3with numbers of options): each simulated row holds the trials its row ofdataholds, read from the model'sn_trialscolumn, or form3andsdt_rankingfrom the row's total over the response columns, and rows may hold different numbers;sdt_rankingwhosemnames a column also keeps each row's set size. A count that cannot be simulated (not a whole number, or below 3 forezdm) is an error before the prior is fitted. With agenerator,datais handed to it as it is, columns and all.- prior
A
brmsprior, orNULLfor bmm's defaults. This is the prior that is calibrated, soNULLcalibrates bmm's own. A list is an error:prior_check()is what compares prior sets.- n_sims
How many data sets to simulate and fit. Twenty is enough to see a run through; a verdict needs enough ranks to read a histogram over about twenty bins, which is what the default is for. It is also
n_simsmodel fits, so a default run is hours of Stan and belongs in a script rather than at a prompt.- level
Which draws are ranked:
"population"theb_<par>_Interceptdraws,"sd"thesd_<group>__<par>_Interceptdraws,"cor"thecor_<group>__…draws and"subject"ther_<group>__<par>[<id>,Intercept]draws, one per subject and varying parameter."population"cannot be dropped. A level the formula cannot produce —"sd"or"subject"without a group term,"cor"without a correlated one — is dropped with a message."subject"is off by default because it addsn_subjectstimes the number of varying parameters to the variables SBC ranks and plots: twenty subjects and two parameters are forty more rank histograms. It is the level that checks the partial pooling, and the one where a units error would show — see Details.levelselects what is ranked, not what is drawn: everything the formula implies is always drawn from the prior and simulated from, because the ranks are only uniform when the data come from the joint prior. Holding the between-subject SDs at zero while the fitted model has a prior on them would take the population ranks down with it.With a
generator, each level means every draw of that class for the model's free parameters, read off the prior fit rather than built from the formula:"population"everyb_<par>_*(so a cell-means formula'sb_kappa_task1and a covariate'sb_kappa_cond),"sd"everysd_<group>__<par>_*,"cor"everycor_<group>__*and"subject"everyr_<group>__<par>[*]. A level the prior fit has no draw of is dropped with a message.- generator
NULLsimulates each data set withsimulate_recovery(), which is exact for the intercept-only formulas above and requires them. A function lifts that restriction: it is called once per data set asgenerator(draws, data), wheredrawsis that data set's prior draw as a named numeric vector under the fit's own draw names — everyb_,sd_,cor_andr_draw of the free parameters, whether or not it is ranked, because a generator handed less than the fit puts a prior on would simulate from a point mass where the fit has a prior — anddataisdataas given. It returns the data frame to fit, which must carry the model's response columns and, when the formula has a group term, the grouping column with the labels ofdataif the subject level is ranked. bmmtools never interprets the row, so the truth names equal the draw names by construction. Note that brms'sr_draws are deviations from the intercept: a generator that builds a subject's value adds them to the population value itself.- ...
Passed to the fitter, for the prior fit and for every data set fit:
chains,iter,backend,init,control.cores,sample_prior,file,file_refitandfile_compressare refused, each with a message saying where it belongs. Use cmdstanr as thebackend(or none, with cmdstanr installed and nobrms.backendoption): it compiles the model once for every data set, while rstan compiles again for each fit, 19 to 23 s per fit in a measurement onmixture2p; see "Backend" inrecovery_grid(), which also namesoptions(cmdstanr_write_stan_file_dir = )for runs resumed in a new session.- seed
Applied around the prior-draw subsample and around
SBC::generate_datasets(), so the same seed gives the same data sets, and passed to the fitter of the prior fit, where it entersfit_cached()'s key.NULLleaves the random number generator alone and is recorded asNA. The data set fits take no seed of their own, since one seed across fits would correlate them; SBC'sfuture.seedgives each its own stream, and those streams start fromseed, becauseSBC::compute_SBC()runs under it. So the same seed gives the same fits and ranks too, except for results SBC's own cache (cache_mode = "results") reuses from a run made before bmmtools seeded them.- file
Where to cache the prior fit, as in
prior_check().NULLuses a temporary file. The data set fits are cached by SBC throughcache_mode, not byfit_cached().- refit
Passed to
fit_cached()for the prior fit.- cores_per_fit
Cores for each data set fit.
NULLleavesSBC::compute_SBC()'s own default.- thin_ranks
Thinning before ranking.
NULLleaves SBC's default for a function backend.- keep_fits
TRUEkeeps every fit in the result, which is hundreds of MB for a default run.FALSEstill keeps the ranks and the convergence diagnostics.- cache_mode, cache_location
Passed to
SBC::compute_SBC()."results"needs acache_location.- .fitter
The fitting function,
bmm::bmm()by default. Used for the prior fit and inside the backend; tests inject a stand-in so that nothing is compiled.
Value
SBC::compute_SBC()'s SBC_results, unchanged apart from one
attribute, bmmtools_sbc, holding model, n_sims, level,
variables (the draw names that were ranked), seed (NA when
none was given), prior (brms::prior_summary() of the prior fit,
which is what the draws came from), layout, diagnostics and
generator ("simulate_recovery" or "user").
Details
sbc() computes no ranks. It fits the prior once, turns its draws
into data sets through simulate_recovery(), builds the backend that
fits each of them, and hands both to SBC::compute_SBC(). What comes
back is SBC's own SBC_results, so SBC::plot_rank_hist(),
SBC::plot_ecdf_diff(), SBC::plot_coverage() and results$stats
all work as SBC documents them.
Two convergence bars apply to the same fits. bmmtools warns once on
its own Rhat 1.05 (see check_convergence()) together with SBC's
rank-ESS 0.5, records both counts in the attribute, and names SBC's
stricter Rhat 1.01 in the message. An ESS below half the maximum rank
skews the ranks themselves, which is why it is a bar here and not
only a diagnostic.
The subject truths are deviations. brms's r_ draws are
deviations from the intercept, while simulate_recovery()'s
truth$subjects are absolute values, so the generator emits each
subject's value minus that data set's population value, on the link
scale, under the label the subject has in data. The subject values
themselves are not read off the prior draw: they are drawn by
simulate_recovery() from the drawn SDs and correlations, which is
the same conditional the prior puts on them.
References
Talts, S., Betancourt, M., Simpson, D., Vehtari, A., & Gelman, A. (2018). Validating Bayesian inference algorithms with simulation-based calibration. doi:10.48550/arXiv.1804.06788
Modrák, M., Moon, A. H., Kim, S., Bürkner, P.-C., Huurre, N., Faltejsková, K., Gelman, A., & Vehtari, A. (2023). Simulation-based calibration checking for Bayesian computation: The choice of test quantities shapes sensitivity. Bayesian Analysis. doi:10.1214/23-BA1404
Examples
if (FALSE) { # \dontrun{
model <- bmm::mixture2p(resp_error = "y")
results <- sbc(
model,
recovery_formula(model),
data.frame(id = rep(1:20, each = 50), y = 0),
n_sims = 20,
chains = 2, iter = 500, backend = "cmdstanr",
seed = 1
)
SBC::plot_rank_hist(results)
attr(results, "bmmtools_sbc")$variables
# any formula, with a generator that reads the draw row itself: here a
# condition effect on kappa, so `draws` carries `b_kappa_cond` too
design <- data.frame(
id = rep(1:20, each = 50), cond = rep(c(-0.5, 0.5), 500), y = 0
)
generate <- function(draws, data) {
# one call per subject and condition: rmixture2p() takes scalars
cells <- split(seq_len(nrow(data)), list(data$id, data$cond))
for (rows in cells) {
id <- data$id[[rows[[1]]]]
cond <- data$cond[[rows[[1]]]]
kappa <- exp(
draws[["b_kappa_Intercept"]] + draws[["b_kappa_cond"]] * cond +
draws[[paste0("r_id__kappa[", id, ",Intercept]")]]
)
p_mem <- plogis(
draws[["b_thetat_Intercept"]] +
draws[[paste0("r_id__thetat[", id, ",Intercept]")]]
)
data$y[rows] <- bmm::rmixture2p(
length(rows),
kappa = kappa, p_mem = p_mem
)
}
data
}
results <- sbc(
model,
bmm::bmf(kappa ~ 1 + cond + (1 | id), thetat ~ 1 + (1 | id)),
design,
generator = generate,
n_sims = 20,
chains = 2, iter = 500, backend = "cmdstanr"
)
} # }
