Runs the POSTERIOR simulation-based calibration experiment (Talts et al.
2018; Sailynoja, Schmitt, Buerkner and Vehtari 2026, Algorithm 2) on a
fitted tobs_fit, through the engine's tulpa::sbc() front door. The truth
is drawn from the fit's own posterior at the observed data, a replicate is
simulated at that truth on FRESH cells, the model is refitted on the two data
sets together, and the truth's rank under that augmented posterior is
recorded. Under exact inference those ranks are Uniform(0, 1); the departure
from uniformity is a verdict on the approximation.
Arguments
- object
A fitted
tobs_fit. Its family must be registered; see Details for the roster.- n.sim
Number of simulations. Each costs one refit on the pooled data.
- n.draws
Posterior draws per fit backing the reported predictives.
- n.ref
Reference draws behind the
log_likrank.0drops that arm.- quantities
Optional character vector restricting what is scored.
- controls
Mis-scaled control arms to add: any of
"wide","narrow".- bad.factor
The control arms' SD multiplier.
- level
Simultaneous band level.
- seed
Seed offset. Simulation
idraws its truth atseed + i.- model.only
Return the callback list instead of running the experiment, for inspection or for passing to
tulpa::sbc()directly.- fit.control
Merged into the
controllist of every refit.- control
Passed to
tulpa::sbc()(progress,rand_seed).- ...
Unused.
Value
An object of class sbc from tulpa, carrying the per-quantity
rank ECDFs, the exact simultaneous band, and the uniformity tests. When
model.only = TRUE, the callback list instead.
Details
Registered families: occu_cover (the coupled occupancy + cover hurdle on
the joint nested-Laplace engine), and occu, abun, count, removal,
distance, fp_occu, royle_nichols, occu_ttd and double_observer,
whose site marginals multiply. For those the replicate is the family's own
simulate() kernel at the drawn theta, the rank arm is the family's exact marginal
log-likelihood, and two kinds of fit are refused rather than approximated: a
structured term (its field is a latent quantity shared across sites that
theta does not hold, which is what the coupled occu_cover route handles by
drawing on fresh cells) and a visit-level observation design (the replicate
kernel assembles the observation arm from the site-level design, so the two
sides would carry different models).
dyn_occu (constant-rate only) pools on the SITE axis and leaves the
season axis alone, since its response is 3D
[n_sites x max_visits x n_seasons]: a bespoke replicate generator
forward-simulates the colonization/extinction HMM, but the posterior
draws and the rank statistic are the same shared machinery every other
registered family uses.
int_occu (full source-site overlap only) pools on the SITE axis and
leaves the per-source axis alone, since its response is a list of one
detection matrix per source; the rank statistic is the shared machinery
unchanged. Registering it surfaced a real bug (since fixed): the detection
intercept was silently left in standardized-covariate
units on every fit with an autoscaled detection covariate, independent of
SBC.
gdistremoval pools two response matrices (yDist band counts, yRem
period counts) together on the SITE axis, reusing the same
list-of-matrices pool int_occu uses; its replicate generator is its
own simulate() handler.
dyn_abun (constant-rate only) shares dyn_occu's 3D
[n_sites x max_visits x n_seasons] response and site-axis pooling, but
unlike dyn_occu it already has a working simulate() handler (the
Dail-Madsen open N-mixture forward is not a two-state HMM that needs a
bespoke generator), so its replicate is the same family-simulate() route
as occu/abun/etc.
occu_categorical is a tobs_multiarm_fit with two independent
Laplace-Gaussian blocks (presence, class) rather than a joint MVN draw
matrix; draws() samples the two blocks independently and loglik_many()
scores the two-arm likelihood directly, since this family has no
.tobs_pointwise_loglik dispatch to reuse.
distsamp_open (constant-dynamics, Poisson only) shares dyn_abun's 3D
response and site-axis pooling, and its fit$means/fit$draws are the
standard .tobs_bfgs_marginal_fit() shape, so draws/simulate/
loglik_many are all the shared generic ones.
occu_multi's response is a list of S per-species matrices, the same
shape int_occu() pools; its simulate() is custom, since species share
one joint multi-species state rather than being independently observed.
dyn_int_occu is the product of the multi-season and multi-source shapes:
a named list of S per-source 3D arrays, pooled by a new
.tobs_sbc_pool_named_3d that composes both existing pooling rules.
t_occu is a pg_gibbs family whose fit$draws is already the real
pooled posterior sample; it has no simulate() handler and no
.tobs_pointwise_loglik dispatch, so both are custom – simulate draws
a fresh AR1 year effect at the theta's own (sigma, rho), and
loglik_many is a Laplace approximation to that year effect's marginal
(FD-validated, brute-force cross-checked at T = 2).
cover is a tobs_multiarm_fit with the same two-independent-block shape
as occu_categorical; positive = "lognormal" only for v1, with
dispersion held fixed (no SE is reported for it anywhere in the package).
occu_multiscale_cover is a standard single-block fit (unlike cover's
two-block one); the exchangeable unit is the CELL rather than the plot, so
pooling and site labeling track cell indices.
A family is registered by adding one entry to the internal registry – a replicate generator, a refit call and optionally a joint statistic; the pooling, the grouping labels, the arm construction and the controls are shared.
What is scored
The arm fixed effects, the areal field SD on each arm, the copy scale
alpha, and the dispersion when the fit estimates one. The per-cell field
itself is not scored – it is integrated out by the fit and carries no truth
theta could hold. The cover-arm field SD and alpha are read per draw off
the outer hyperparameter grid, whose cells are sampled by their own
normalized weight, so what is ranked is the grid-marginalized posterior of
each rather than a function of component modes.
A quantity whose posterior has no spread – a dispersion the joint engine
holds fixed in its cell-coupling spec, for instance – is dropped rather than
scored, and named in attr(x, "fixed").
An extra log_lik quantity ranks a joint statistic of the whole parameter
vector, which catches a posterior getting each marginal right while getting
the dependence between them wrong.
Fresh cells
The replicate is drawn on new cells whose areal graph is block-diagonal
against the observed one. occu_cover couples its occurrence and cover arms
through one shared field, and theta carries no per-cell field value, so a
replicate re-observing the observed cells would depend on the observed data
given theta and break the factorization posterior SBC rests on. group_ids
is supplied to tulpa::sbc(), which verifies the observable half of that
(disjoint group labels) rather than assuming it.
Controls
controls = c("wide", "narrow") adds arms reporting the same draws rescaled
about their mean by bad.factor and its reciprocal. They are deliberately
mis-scaled posteriors and are expected to leave the band: a calibration read
that nothing can fail is not evidence.
References
Talts, S., Betancourt, M., Simpson, D., Vehtari, A. and Gelman, A. (2018). Validating Bayesian inference algorithms with simulation-based calibration. arXiv:1804.06788.
Sailynoja, T., Schmitt, M., Buerkner, P.-C. and Vehtari, A. (2026). Posterior SBC: simulation-based calibration checking conditional on data. Statistics and Computing 36:78.
Examples
# \donttest{
N <- 20L; J <- 3L
adj <- matrix(0L, N, N)
for (s in seq_len(N)) {
if (s > 1L) adj[s, s - 1L] <- 1L
if (s < N) adj[s, s + 1L] <- 1L
}
sim <- simulate_occu_cover(N = N, J = J, positive = "lognormal",
adj = adj, sigma = 0.7, alpha = 1, seed = 1L)
long <- data.frame(site_id = rep(seq_len(N), each = J),
visit = rep(seq_len(J), times = N),
y = as.vector(t(sim$y)),
det_cov1 = sim$visit_data$det_cov1,
pos_cov1 = sim$visit_data$pos_cov1)
od <- tobs_data(long, y = "y", site = "site_id", visit = "visit",
det.covs = c("det_cov1", "pos_cov1"))
y_pos <- sim$y_pos; y_pos[is.na(y_pos)] <- 0
# The dispersion goes on the outer grid so it is estimated and scored, and
# the field-SD grid is pinned so both stages integrate the same support.
ctl <- list(engine = "joint", verbose = FALSE,
sigma.grid = exp(seq(log(0.15), log(2.0), length.out = 9)),
phi.grid.pos = exp(seq(log(0.20), log(0.90), length.out = 7)))
fit <- tobs(~ occ_cov1 + icar(graph = adj),
data = cbind(data.frame(site_id = seq_len(N)), sim$data),
family = occu_cover("lognormal"),
detection = ~ det_cov1,
positive = ~ pos_cov1 + share(spatial()),
y = od$y, y_pos = y_pos, visits = od$det.covs,
method = "nested_laplace", control = ctl)
sbc(fit, n.sim = 20L, controls = "narrow", fit.control = ctl)
# }