Nothing
# sample_glmm.R
# ------------------------------------------------------------------------------
# Generic R front-door fitter for the model-agnostic sampler kernels that drive
# a tulpa ModelData: NUTS and ESS / SGHMC / SGLD / MCLMC / SMC
# / VI. All seven share one C++ entry (cpp_tulpa_sample_glmm),
# which builds the ModelData once through the built-in-family spec scaffold and
# dispatches to the requested kernel. This R wrapper assembles that call from a
# design-matrix bundle and returns a `tulpa_fit` carrying the backend's draws.
#
# Scope: fixed-effect GLMs over the built-in family set (binomial / poisson /
# gaussian / neg_binomial_2 / ...). Random-effect terms route to the conditional
# R-closure logpost backends (mode = "mala" / "pathfinder" / "imh_laplace"),
# which condition on sigma_re; see tulpa() and `build_glmm_logpost()`.
# ------------------------------------------------------------------------------
#' Fit a fixed-effect GLM with a model-agnostic sampler kernel
#'
#' @description
#' Drives one of tulpa's ModelData sampler kernels -- NUTS (`"hmc"`), elliptical
#' slice sampling (`"ess"`), SGHMC (`"sghmc"`), SGLD (`"sgld"`), MCLMC
#' (`"mclmc"`), sequential Monte Carlo (`"smc"`), or variational inference
#' (`"vi"`) -- on a fixed-effect GLM. The model (design + per-observation
#' likelihood) is built once through the same built-in-family scaffold the
#' single-point Laplace fit uses, so no likelihood / link logic is duplicated.
#'
#' @param y Response vector.
#' @param n_trials Binomial denominators (or `NULL` -> all 1).
#' @param X Fixed-effect design matrix (`nrow(X) == length(y)`).
#' @param family Character family name (see [family_names()]).
#' @param backend One of `"hmc"`, `"ess"`, `"sghmc"`, `"sgld"`, `"mclmc"`,
#' `"smc"`, `"vi"`.
#' @template phi
#' @param phi2 Optional second dispersion: the Student-t degrees of freedom
#' (`family = "t"`; default 4 when `NULL`).
#' @param offset Optional fixed additive term on the linear predictor
#' (`eta = offset + X beta`), length `length(y)`; `NULL` -> no offset.
#' @param fixed_names Optional fixed-effect names for the draw columns.
#' @param re_spec Optional random-effect spec: a list with `idx` (list of
#' per-term 1-based group-index vectors), `ngroups`, `ncoefs`, `correlated`
#' (per-term), and `Z` (per-term RE design or `NULL`). `NULL` -> no RE.
#' @param spatial_spec Optional areal spatial spec: a list with `type`
#' (`"icar"`/`"bym2"`), `spatial_idx`, `n_spatial_units`, `adj_row_ptr`,
#' `adj_col_idx`, `n_neighbors`, and `scale_factor` (BYM2). `NULL` -> none.
#' @param temporal_spec Optional temporal spec: a list with `type`
#' (`"rw1"`/`"rw2"`/`"ar1"`), `time_idx`, `n_times`, `n_groups`, `group_idx`,
#' and `cyclic`. `NULL` -> none.
#' @param sigma_re_scale Half-Cauchy scale for the RE / BYM2 standard-deviation
#' hyperprior (sampled jointly with the latent effects).
#' @param warm_start Optional `list(init, inv_metric_diag)` seeding the NUTS
#' kernel: `init` an `n_chains x total_params` matrix of initial positions,
#' one row per chain, and `inv_metric_diag` a positive vector of length
#' `total_params` used as the starting inverse mass (warmup adaptation still
#' runs from it). Build it with `.build_warm_start()` against
#' `cpp_tulpa_glmm_layout()` rather than by hand -- the entries are positional
#' and the layout owns the positions. Only the NUTS/HMC kernel takes one;
#' any other backend errors rather than sampling from the default start.
#' @param control List of kernel tuning knobs (`n_iter`, `warmup`, `seed`,
#' `sigma_beta`, `n_chains`, `max_treedepth`, `adapt_delta`, `epsilon`, `L`,
#' `batch_size`, `alpha`, `n_particles`, `n_mcmc_steps`, `ess_threshold`,
#' `vi_variant`, `vi_mc_samples`, `vi_max_iter`, `vi_max_grad_norm`,
#' `vi_tol_grad`, `vi_tol_rel_elbo`, `vi_patience`,
#' `n_draws`, `verbose`, `mass_matrix`, `checkpoint`, `wbic`).
#'
#' `wbic = TRUE` (SMC only) routes the tempering path through the posterior
#' tempered at `1 / log(n)`, `p(theta) L(theta)^(1 / log n)`, and stores the
#' equally weighted population there as `$tempered_draws` (with
#' `$tempered_beta`), which [wbic()] reads. The run then continues to the
#' posterior, so `$draws` are posterior draws as without it.
#'
#' `checkpoint = list(path =, resume =)` is per-chain checkpoint/resume on
#' the NUTS/HMC kernel only (a chain is the checkpoint unit, deterministic
#' in seed + chain id + data + settings, so a resumed chain is bit-for-bit
#' identical to the uninterrupted one): every chain appends its finished
#' result to `path`, and `resume = TRUE` (the default) loads any finished
#' chains from a prior run and fits only the rest; `resume = FALSE` starts
#' over, removing any stale file first. Other backends refuse this key
#' rather than silently drop it.
#'
#' `mass_matrix` selects the NUTS/HMC metric: `"diag"` (the default),
#' `"dense"`, `"block_diag"`, or `"auto"`. Under `"auto"` the kernel reads the
#' parameter layout and gives each correlated hyperparameter group its own
#' small dense block -- the BYM2 and GP `(log sigma, phi)` pairs, the
#' multiscale-temporal variances, a correlated random-slope term's Cholesky
#' coordinates -- while an ICAR or latent-factor model small enough for the
#' `O(p^2)` per-step cost takes a full dense metric. `"block_diag"`
#' additionally blocks the temporal-GP and HSGP hyperparameter pairs, which
#' `"auto"` leaves to the diagonal. Backends other than `"hmc"` carry no
#' metric and reject a non-default value.
#'
#' `vi_max_iter` and `vi_mc_samples` both bound a loop that has to run at
#' least once -- the optimisation loop whose ELBO history is the fit's only
#' record, and the reparameterisation average every gradient divides by -- so
#' values below 1 are rejected. `vi_max_grad_norm` (default 10) is the
#' gradient-norm clip applied before every Adam step.
#'
#' `vi_max_iter` is a ceiling; what ends a VI run is usually the stopping
#' rule, which `vi_patience` (default 50), `vi_tol_rel_elbo` (default 0.01)
#' and `vi_tol_grad` (default 1e-4) control. The loop stops when the ELBO
#' gain across the last `vi_patience` iterations -- the mean of the window's
#' second half minus the mean of its first -- falls to `vi_tol_rel_elbo`
#' times the ELBO span the run has covered, or when the gradient norm falls
#' below `vi_tol_grad`. Both the gain and its threshold are ELBO
#' DIFFERENCES, so the arbitrary additive constant in an ELBO cancels.
#' Setting `vi_tol_rel_elbo = 0` stops only on a window that is flat or
#' falling. The fit reports `vi_iterations` and `converged_reason`
#' (`"patience"`, `"gradient_norm"` or `"max_iter"`) so a run that stopped
#' short of its budget can be told from one that used it.
#'
#' `epsilon` pins the step size on the stochastic-gradient backends: `"sghmc"`
#' runs its warmup step-size adapter only when no `epsilon` is supplied, and
#' `"sgld"` runs its polynomial decay `a * (b + t)^-gamma` only then, so a
#' supplied value is what the whole run samples at. On `"mclmc"` a
#' non-positive `epsilon` selects the kernel's own adaptation. `alpha` is the
#' SGHMC friction and `L` its leapfrog count; SGLD carries neither.
#'
#' The SGHMC discretisation is calibrated for small `epsilon^2 * lambda_max`,
#' and inflates every posterior SD above that; the acceptance statistic its
#' adapter targets is computed from a log-posterior ratio the sampler never
#' accepts or rejects on, so it does not measure that error. A sharply
#' informative design wants an `epsilon` chosen by hand.
#'
#' The elliptical-slice kernel takes four more, all prefixed `ess_` and all
#' inert on other backends. Note that `ess_threshold` above is SMC's
#' resampling threshold and not one of them -- the two unrelated senses of
#' "ESS" are why these carry the prefix.
#' `ess_adapt_during_warmup` (default `TRUE`) adapts during warmup: the
#' random-walk proposal SDs on the non-Gaussian parameters, the widths of the
#' one-dimensional slice moves, and each Gaussian block's ellipse, which is
#' refitted to the block's own warmup draws (mean and SD per coordinate) in
#' place of its prior. Every adaptation stops at the end of warmup.
#' `ess_adapt_interval` (default 50) is how many sweeps sit between the
#' proposal-SD and width updates, so it **acts only while adapting**.
#' `ess_joint_sigma_re` toggles the slice updates of each random-effect
#' log-SD, one holding the latent block fixed and one holding the effects
#' themselves fixed (the other parameterization's update), which default to
#' on whenever a random-effect term is present: a scale and its effects are
#' strongly dependent under either parameterization alone, and forcing them
#' off is how one demonstrates that. `ess_joint_proposal_sd` (default 0.1) is
#' those moves' initial slice width.
#'
#' Two further moves run whenever the model has a random-effect term and a
#' single linear predictor, with no knob: an exact draw of the level a fixed
#' effect shares with a random-effect coefficient whose design column repeats
#' it (the intercept against `(1 | g)`), and a slice update moving every other
#' fixed effect together with the between-group part of it the group effects
#' carry (gcol33/tulpa#877).
#'
#' The elliptical-slice kernel draws from R's own RNG, so `control$seed` does
#' not reach it: `set.seed()` before the call is what reproduces an ESS run.
#' Every other backend carries `control$seed` into its own generator.
#'
#' @return A `tulpa_fit` with `draws`, `means`, `param_names`, the kernel's
#' diagnostics, and (for `"hmc"`) `chain_id` / `n_chains` so chain diagnostics
#' apply.
#' @noRd
tulpa_sample_glmm <- function(y, n_trials, X, family, backend, phi = 1.0,
phi2 = NULL,
offset = NULL, fixed_names = NULL,
re_spec = NULL, spatial_spec = NULL,
temporal_spec = NULL, svc_spec = NULL,
tvc_spec = NULL, zi_spec = NULL,
sigma_re_scale = 2.5,
sigma_beta = .tulpa_prior_sd("sample_glmm"),
warm_start = NULL,
control = list()) {
tulpa_check_control(control, .CONTROL_KEYS$sample_glmm, "tulpa_sample_glmm")
.family_or_stop(family)
if (!is.null(phi2)) .phi2_or_stop(family, phi2)
X <- as.matrix(X)
N <- length(y)
if (!is.null(offset)) {
offset <- as.numeric(offset)
if (length(offset) != N) {
stop(sprintf("length(offset) (%d) must equal length(y) (%d).",
length(offset), N), call. = FALSE)
}
}
# Only the NUTS branch reads n_chains; every other kernel returns one set of
# draws with no chain_id. The same function already hard-refuses mass_matrix
# and a warm start on those backends, so silence on n_chains was an omission
# (gcol33/tulpa#704): a caller asking for four chains got one particle set and
# no signal. Refuse it here, where a default is still distinguishable from a
# request.
if (!is.null(control$n_chains) && !backend %in% c("nuts", "hmc")) {
stop("`control$n_chains` is only read by the NUTS/HMC kernel; backend '",
backend, "' returns a single set of draws. Drop it, or use ",
"backend = 'hmc'.", call. = FALSE)
}
# Per-chain checkpoint/resume: NUTS/HMC only (a chain is the checkpoint unit),
# same shape and helper the nested-Laplace fitters use (gcol33/tulpa#808). A
# fresh (resume = FALSE) run removes any stale file once here, before the
# C++ kernel opens it.
if (!is.null(control$checkpoint) && !backend %in% c("nuts", "hmc")) {
stop("`control$checkpoint` is only read by the NUTS/HMC kernel; backend '",
backend, "' has no per-chain state to checkpoint. Drop it, or use ",
"backend = 'hmc'.", call. = FALSE)
}
# WBIC (Watanabe 2013) averages the log-likelihood over the posterior
# tempered at 1 / log(n). The SMC path passes through that distribution on
# its way to the posterior and records the population there; no other kernel
# visits it.
smc_bridge_end <- 1.0
if (!is.null(control$wbic)) {
if (!is.logical(control$wbic) || length(control$wbic) != 1L ||
is.na(control$wbic)) {
stop("`control$wbic` must be TRUE or FALSE.", call. = FALSE)
}
if (isTRUE(control$wbic)) {
if (!identical(backend, "smc")) {
stop("`control$wbic` is only read by the SMC kernel; backend '",
backend, "' does not visit the tempered posterior. Use ",
"backend = 'smc'.", call. = FALSE)
}
smc_bridge_end <- .wbic_temperature(N)
}
}
.ckpt <- .nl_checkpoint_args(control, use_option = FALSE)
if (nzchar(.ckpt$path) && !isTRUE(.ckpt$resume) && file.exists(.ckpt$path)) {
file.remove(.ckpt$path)
}
n_iter <- control$n_iter %||% 2000L
warmup <- control$warmup %||% (n_iter %/% 2L)
# SMC and VI read neither knob (particles / optimizer iterations instead);
# MCLMC runs its warmup on top of `n_iter` kept draws, every other kernel
# counts it inside (gcol33/tulpa#872).
if (!backend %in% c("smc", "vi")) {
.check_run_length(n_iter, warmup,
sprintf("tulpa_sample_glmm[backend = '%s']", backend),
counts = if (identical(backend, "mclmc")) "post" else "total")
}
vi_max_iter <- as.integer(control$vi_max_iter %||% 10000L)
vi_mc_samples <- as.integer(control$vi_mc_samples %||% 10L)
if (is.na(vi_max_iter) || vi_max_iter < 1L) {
stop("`control$vi_max_iter` must be at least 1; got ",
format(control$vi_max_iter), ".", call. = FALSE)
}
if (is.na(vi_mc_samples) || vi_mc_samples < 1L) {
stop("`control$vi_mc_samples` must be at least 1; got ",
format(control$vi_mc_samples), ".", call. = FALSE)
}
# The stopping rule, not the budget, is what usually ends a VI run, so its
# three knobs are reachable from `control` (gcol33/tulpa#821). `vi_patience`
# is the width of the window the ELBO gain is measured over and needs two
# halves to compare; `vi_tol_rel_elbo` is that gain as a fraction of the
# run's own ELBO span, so 0 stops only on a flat or falling window.
vi_patience <- as.integer(control$vi_patience %||% 50L)
vi_tol_rel_elbo <- as.numeric(control$vi_tol_rel_elbo %||% 0.01)
vi_tol_grad <- as.numeric(control$vi_tol_grad %||% 1e-4)
if (is.na(vi_patience) || vi_patience < 2L) {
stop("`control$vi_patience` must be at least 2; got ",
format(control$vi_patience), ".", call. = FALSE)
}
if (is.na(vi_tol_rel_elbo) || vi_tol_rel_elbo < 0) {
stop("`control$vi_tol_rel_elbo` must be non-negative; got ",
format(control$vi_tol_rel_elbo), ".", call. = FALSE)
}
if (is.na(vi_tol_grad) || vi_tol_grad < 0) {
stop("`control$vi_tol_grad` must be non-negative; got ",
format(control$vi_tol_grad), ".", call. = FALSE)
}
res <- cpp_tulpa_sample_glmm(
y = as.numeric(y),
n_trials = if (is.null(n_trials)) rep(1L, N) else as.integer(n_trials),
X = X,
family = family,
backend = backend,
phi = .phi_to_kernel(family, as.numeric(phi)),
sigma_beta = as.numeric(sigma_beta),
n_iter = as.integer(n_iter),
n_warmup = as.integer(warmup),
seed = as.integer(control$seed %||% sample.int(.Machine$integer.max, 1L)),
verbose = isTRUE(control$verbose),
n_chains = as.integer(control$n_chains %||% 4L),
max_treedepth = as.integer(control$max_treedepth %||% 10L),
adapt_delta = as.numeric(control$adapt_delta %||% 0.8),
epsilon = as.numeric(control$epsilon %||% 0.0),
L = as.integer(control$L %||% 10L),
batch_size = as.integer(control$batch_size %||% 0L),
alpha = as.numeric(control$alpha %||% 0.1),
mclmc_adjusted = as.integer(control$mclmc_adjusted %||% 0L),
n_particles = as.integer(control$n_particles %||% 1000L),
n_mcmc_steps = as.integer(control$n_mcmc_steps %||% 5L),
ess_threshold = as.numeric(control$ess_threshold %||% 0.5),
vi_variant = as.integer(control$vi_variant %||% 3L),
vi_mc_samples = vi_mc_samples,
vi_max_iter = vi_max_iter,
vi_n_draws = as.integer(control$n_draws %||% 2000L),
vi_max_grad_norm = as.numeric(control$vi_max_grad_norm %||% 10.0),
vi_tol_grad = vi_tol_grad,
vi_tol_rel_elbo = vi_tol_rel_elbo,
vi_patience = vi_patience,
offset_nullable = offset,
re_spec = re_spec,
spatial_spec = spatial_spec,
temporal_spec = temporal_spec,
sigma_re_scale = as.numeric(sigma_re_scale),
fixed_names = fixed_names %||% colnames(X),
phi2 = phi2 %||% NA_real_,
svc_spec = svc_spec,
tvc_spec = tvc_spec,
zi_spec = zi_spec,
init_nullable = warm_start$init,
inv_metric_diag_nullable = warm_start$inv_metric_diag,
# NUTS/HMC metric. The default stays "diag" -- the metric a fit gets when
# nothing asks for another one -- so a fit that does not set this samples
# exactly as before. "auto" turns on the structural block detection
# (correlated hyperparameter pairs get small dense blocks, an ICAR or
# latent-factor model goes to full DENSE); any other backend rejects a
# non-default value rather than ignoring it.
mass_matrix = match.arg(control$mass_matrix %||% "diag",
c("diag", "dense", "block_diag", "auto")),
# ESS kernel knobs; inert on every other backend.
ess_adapt_during_warmup = isTRUE(control$ess_adapt_during_warmup %||% TRUE),
ess_adapt_interval = as.integer(control$ess_adapt_interval %||% 50L),
# -1 keeps the layout-driven rule (on whenever an RE term is present).
ess_joint_sigma_re = if (is.null(control$ess_joint_sigma_re)) -1L
else as.integer(isTRUE(control$ess_joint_sigma_re)),
ess_joint_proposal_sd = as.numeric(control$ess_joint_proposal_sd %||% 0.1),
checkpoint_path = .ckpt$path,
smc_bridge_end = smc_bridge_end
)
if (is.null(res$tempered_draws)) res$tempered_beta <- NULL
# The C++ kernel names every column of the full parameter vector (fixed effects
# + latent effects + variance-component hyperparameters) via the ParamLayout,
# so the draws / means carry their own names. Fall back to fixed-effect names
# only when the draw matrix is empty (no columns named).
nm <- colnames(res$draws)
if (is.null(nm)) {
nm <- fixed_names %||% colnames(X) %||% paste0("beta", seq_len(ncol(X)))
}
res$param_names <- nm
# The model the draws were laid out on, as the kernel received it. A draw row
# is the full parameter vector of that model, so the in-sample linear
# predictor at a draw is read back through the same ModelData assembly
# (`.tulpa_eta_draws()` -> `cpp_tulpa_glmm_eta_draws()`), fields included.
res$model_inputs <- list(
y = as.numeric(y),
n_trials = if (is.null(n_trials)) rep(1L, N) else as.integer(n_trials),
X = X, family = family,
phi = .phi_to_kernel(family, as.numeric(phi)),
phi2 = phi2 %||% NA_real_,
sigma_beta = as.numeric(sigma_beta),
offset = offset, re_spec = re_spec, spatial_spec = spatial_spec,
temporal_spec = temporal_spec, sigma_re_scale = as.numeric(sigma_re_scale),
svc_spec = svc_spec, tvc_spec = tvc_spec, zi_spec = zi_spec)
# The draws-provenance gate reads fit$backend to learn whether these draws are
# an MCMC chain, and treats an untagged fit as one. Closing without the stamp
# had diagnostics() computing Rhat and ESS on SMC particles and VI draws
# (gcol33/tulpa#693) -- the quantities the gate exists to withhold.
.finalize_fit(res, backend = backend,
param_names = nm)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.