Nested Laplace approximation for latent Gaussian models
Source:R/nested_laplace.R
tulpa_nested_laplace.RdGeneric outer-grid nested Laplace driver. Builds a grid over the hyperparameters of a single latent prior block (spatial or temporal), runs an inner Laplace at each grid point with warm-starting, and integrates over the grid to give proper hyperparameter marginals.
Supported priors:
Spatial (areal):
"icar"(1D grid on tau),"bym2"(2D on (sigma, rho)),"car_proper"(2D on (tau, rho); rho lives in the eigenvalue interval (1/lambda_min, 1/lambda_max) ofD^{-1} W).Spatial (continuous):
"nngp"(2D on (sigma2, phi_gp)),"hsgp"(2D on (sigma2, lengthscale)).Temporal:
"rw1","rw2"(1D grid on tau),"ar1"(2D on (tau, rho))SPDE continuous spatial: see
cpp_nested_laplace_spde()(separate entry, rebuilds Q via SPDE Q-builder).
Usage
tulpa_nested_laplace(
y,
n_trials,
X,
prior = NULL,
spec = NULL,
data = NULL,
re_idx = NULL,
n_re_groups = 0L,
sigma_re = 1,
family = "binomial",
phi = 1,
likelihood = NULL,
control = list()
)Arguments
- y
Response vector.
- n_trials
Trial sizes (binomial). Pass
1L-vector otherwise.- X
Fixed-effects design matrix.
- prior
A list describing the latent prior block. Required field
typeone of {"icar", "bym2", "car_proper", "rw1", "rw2", "ar1"}. Type-specific fields:icar:
spatial_idx,n_spatial_units,adj_row_ptr,adj_col_idx,n_neighbors(CSR adjacency, 0-based); optionaltau_grid.
Any block accepts an optional
svc_weight, one weight per observation, which makes it a varying coefficient: observationicontributessvc_weight[i] * f_iinstead off_i, wheref_iis the block's field at that row (z[spatial_idx[i]]for an areal block,(A u)_ifor an SPDE one). Absent, the field enters unweighted.bym2: same adjacency;
scale_factor; optionalsigma_grid,rho_grid.car_proper: same adjacency; optional
tau_grid,rho_grid,rho_bounds = c(lower, upper)(defaults to (0, 1)).rw1/rw2:
temporal_idx(1-based),n_times; optionaltau_grid,cyclic(default FALSE).ar1:
temporal_idx,n_times; optionaltau_grid,rho_grid.
A default grid axis is a starting axis, not a hard ceiling: for
icar(tau_grid) andbym2(sigma_grid) a posterior mode that rails a boundary node (pareto_k_regime = "collapsed_edge") triggers one mode-Hessian recenter-and-refit, reported throughouter_grid_placement/outer_grid_recenter_declined– seetulpa_nested_laplace_joint()'s return docs. An axis the caller pinned is never moved; mark a grid your own code defaulted withauto_grid()to keep the recenter live on it.- spec
Optional
tulpa_temporalortulpa_spatialspec object (output oftemporal_rw1(),temporal_rw2(),temporal_ar1(),spatial_car(),spatial_bym2(), etc.). When supplied alongsidedata, thepriorlist is built automatically viaprior_from_spec()– pass eitherpriororspec, not both.- data
Data frame used to validate
specand resolve time/group/site indices. Required whenspecis supplied.- re_idx
Optional 1-based RE group index per obs (defaults to no RE).
- n_re_groups
RE group count (default 0).
- sigma_re
RE standard deviation (default 1).
- family
"binomial","poisson","neg_binomial_2", etc.- phi
Dispersion passed to the family, held fixed. One convention at every door: for
gaussian/lognormalthis is the residual VARIANCE (the SD issqrt(phi)), forneg_binomial_2the size,gammathe shape,betathe precision,tthe scale;binomialandpoissonignore it. The compiled kernels parameterize the two variance families by the residual SD and are handedsqrt(phi)at the boundary.- likelihood
Optional model-supplied likelihood, replacing the built-in
family. Pass an external pointer to atulpa::NestedLikelihood(built in a model package's own C++ from aLikelihoodSpec); the inner Laplace solve then reads the per-observation score, Fisher weight, and log-likelihood from that spec instead offamily, sofamily/phiare ignored. Used by model packages to fit a custom response without adding a family to tulpa – for example tulpaObs threads its marginalized single-season occupancy likelihood (a scaled Bernoulli, with the latent occupancy state integrated out) through this. Multi-blockprioronly. DefaultNULL(usefamily).- control
Optional list of perf/numerical tuning knobs (statistical arguments stay top-level), following the
controlconvention oftulpa(). Recognised elements (defaults in parentheses):max_iter(50L),tol(1e-6) – inner Newton iteration budget and tolerance.n_threads(1L) – inner-loop OpenMP threads.x_init(NULL) – warm-start for the first grid point's inner solve.keep_grid_hessians(FALSE) – whenTRUE, retain per-grid-point fixed-effects marginal Hessian \(H_\beta\) and mode \(\hat{\beta}\) on the return list as$grid_hessians(list of dense \(p\times p\) matrices) and$grid_modes(list of length-\(p\) vectors). Used downstream by simplified-Laplace (SLA) callers to assemble skew-aware marginals – see the cumulant pooling inrubins_pool().diagnose_k(TRUE),k_samples(500L) – compute the outer Pareto-\(\hat{k}\) accuracy diagnostic ($pareto_k) by importance sampling the hyperparameter posterior against the Gaussian proposal fitted to the grid, drawingk_samplesextra inner-marginal evaluations.k_samplesis a precision knob: the GPD tail size is held at the fraction the default budget implies, so a larger budget sharpens the same k-hat instead of moving it to a deeper quantile of the weight distribution (gcol33/tulpa#631).k_tail_pointsoverrides that tail size directly (capped at 20% of the draws). Computed for a single-block, single positive-scale-axis grid; leftNA(with the grid's quadrature ESS as the fallback diagnostic) for multi-block, multi-axis, or bounded-parameter grids. Seetulpa_psis().diagnose_skew(TRUE),skew_idx(NULL) – compute the inner-Laplace skewness diagnostic ($inner_skew, gamma_3, Rue Martino & Chopin 2009 Sec 3.2.3) at the fitted MAP grid cell: one extra Newton solve, scoring thepfixed-effects latent indices by default (passskew_idx, 1-based latent indices, to score additional ones, e.g. specific spatial units – the full latent field is not scored by default since it costs one linear solve per index). This is the complementary layer todiagnose_k: the outer diagnostic scores the hyperparameter-grid integration around a FIXED inner Laplace, this scores whether that inner Gaussian approximation is itself a good fit to the latent-field conditional posterior. Seediagnostics()for the combined whole-fit verdict.within_cell("box_uniform") – the WITHIN-CELL construction the reported per-axis hyperparameter intervals are read with. The outer grid's weights say how much mass each cell holds; they do not say how it is spread inside the cell, and a quantile needs both."box_uniform"puts the cumulative FULL mass at each cell EDGE and interpolates between edges;"chord"puts the cumulative MID-mass at each cell coordinate and interpolates between coordinates – the same masses over the same boxes with the knots moved half a cell, which measures as a whole order of convergence (2.00 against 1.04 on a fixture with a closed-form posterior). THE DEFAULT IS"box_uniform"since 0.0.188, decided on FIXED-TRUTH coverage at the placement the engine ships, withauto_recenter = "resolve"as the default. Summed |coverage - nominal| over nominal 0.95 / 0.80 / 0.50, chord against box-uniform: 0.2900 / 0.1233 on the pre-registered fixed-truth instrument, 0.2004 / 0.0361 over 4680 truth-swept fits of the same fixture, and 0.2467 / 0.1572 over nine (config, axis) rows spanning seven families, at 0.69 to 1.08x the width. The conditional-coverage swing that held the default back reads 0.110 at the shipped placement against 0.415 on the coarse pinned grid it was measured on, and at nominal 0.50 it is the same on both reads.outer_grid_h_over_sdis how wide a cell is on each axis (withouter_grid_resolution_declinednaming why an axis carries no ratio, andouter_grid_railed_axesnaming any axis whose nodes do not contain its own posterior mode), andtheta_within_cellis what each axis was actually read with. Only a"density"support admits it – a CCD design, a locally refined grid and a posterior sample are not cell partitions that tile – and an axis it declines on reports"chord"with a reason rather than erroring. Nothing else moves: point estimates, moments, draws and weights are untouched, and"chord"restores the previous report exactly.skew_correct(TRUE) – consume the inner-Laplace expansion instead of only grading it: report Cornish-Fisher marginal quantiles at each coefficient's owngamma_3, about the centregamma_1 + gamma_3 / 2that Rue, Martino & Chopin (2009) eq. (22) implies, fromsummary()/confint()wherever the combined inner band says the leading-order expansion is in its regime, and the Gaussian quantiles everywhere else. It is post-processing on the reported quantiles: draws, modes and weights are untouched, so a fit run with it off is bit for bit the fit it was before. A coefficient whose location term could not be formed declines rather than reading it as zero. The band that bounded the relocation itself (centre_unreliable) is off (Inf): scored over seven fixtures with an exact reference, every finite cutoff declines the coefficients the correction helps most, because a large centre carrying a smallgamma_3is uniformly weak correlation rather than an expansion out of its regime.$skew_correctionrecords the per-coefficientgamma_3,gamma_1and the centre they form, the band, the inner importance k-hat, the combined band, the eligibility and the reason behind it; theskew_appliedattribute onsummary()/confint()records what was actually used at the requested level. RMC fit a skew normal here instead; the series correction is the same-order alternative, and unlike a skew normal its skewness does not saturate inside the band it is applied on.MEASURED. Against exact quadrature quantiles of rare-event binomial-logit posteriors it cuts total absolute endpoint error 69.2%, improving both endpoints in every case. Scored over the WHOLE marginal – paired CRPS against the exact posterior in a 400-replicate prior-predictive experiment – it reads -0.01643 against the uncorrected Laplace at t = -1.89, essentially all of the -0.01662 the exact posterior itself achieves, and its PIT re-enters the simultaneous SBC band. Applied about the Laplace mode instead of about
gamma_1 + gamma_3 / 2the same reshaping scored +0.00775 at t = +3.54, a net loss; the location term is what supplies that centre.IT IS ON BY DEFAULT, so
summary()/confint()on a nested-Laplace fit report the corrected quantiles wherever the combined inner band admits the coefficient;skew_correct = FALSErestores the uncorrected report exactly. Scored against the mixture read a correction-off fit gives, the flip is t = -1.895 on the rare-event intercept and -3.765 / -3.201 on the small-group Bernoulli design. Across twelve model classes read off one solve per seed, pooled 95% coverage moves 0.9510 -> 0.9542 at a standard error of 0.0070, with every class inside the acceptance the shipped gates use. A fit the correction cannot help – a coupled one, whose location term is unreachable – reports what it reported before, to the bit.subspace_debias(FALSE) – correct only the latent directions the inner-layer diagnostics flagged, by exact Metropolis, and leave the rest at their Gaussian conditional.TRUEtakes every default; a list overridesband(the inner-reliability floor a coordinate is selected at, default"ok"),idx(pin the corrected set explicitly, skipping the selector),closure/closure_max(grow the set by strongly coupled precision-graph neighbours – declined on this backend, which retains no joint precision), the sampler budgetn_iter/warmup/thin, andn_draws. The selector reads the per-index bandsdiagnose_skewalready attached, so it costs no extra solve; the correction itself re-runs the settled grid once with the sampler on, and the fit then reports$draws– the per-cell Metropolis sample for the selected coordinates, the rest from the Gaussian conditional given them – instead of the Gaussian-mixture moments. An EMPTY selection leaves the fit bit-for-bit identical to the plain path.$subspace_debiasrecords what was selected, the bands it was read from, and the per-cell acceptance rate. Requesting it turnskeep_grid_hessianson, since the recombination reads exactly those per-cell pieces.cila(FALSE) – corrected integrated Laplace, the second inner-layer debias (after Lai, Margossian and Sheldon, arXiv:2605.20345). Wheresubspace_debiasselects coordinates and runs exact Metropolis on them, this selects nothing: at every outer cell it drawsn_pointspoints from the whole inner Gaussian, weights each by the exact joint density it came from, and reports the weighted particles.TRUEtakes the defaults; a list overridesn_points(1024L),variant("qmc", a Sobol net;"is"for iid draws,"rqmc"for the net undern_shiftrandom shifts),n_shift(8L),n_drawsandseed. Below 512 points a cell's particle set is too coarse to be a marginal at all and the request is refused. The corrected per-cell masses become the fit's ownweights/log_marginalandweights_sourcereports"cila"; the pre-correction pair is kept as$cila$laplace. A cell whose inner solve factorized sparsely draws through the CHOLMOD factor's own triangular and permutation solves; an LDL' factor has no square root to draw with and is declined with"sparse_factor_not_ll".auto_recenter(TRUE) – outer-grid placement policy.TRUEre-centres the movable default axes on the posterior mode and refits when the grid either RAILS (an axis's own marginal is maximal at one of its own endpoints) or does not RESOLVE its posterior (an axis's node spacing exceeds 2 posterior SDs in its own coordinate). Both tests read the weights the fit already stored, so a grid that brackets and resolves its mode costs nothing beyond them; when the pass does fire it is a second full grid solve plus a finite-difference mode/Hessian stencil. A recentred axis ismode +/- 2.5 sdover 5 nodes, a cell width of 1.25 posterior SDs by construction, against a census median of 3.9 on the fixed spans.Measured over 200 fixed-truth seeds on each of six configurations (icar chain / icar lattice / rw1 / bym2 / iid / nngp), the default moves mean |coverage - nominal| from 0.043 to 0.030 at the 95% level, 0.171 to 0.084 at 80% and 0.243 to 0.129 at 50% against the rail-only policy, at 0.63 times the 95% interval width and 0.76 times the median bias, for 1.71 times the wall clock.
Three other values.
"rail"is the rail test alone, which is whatTRUEmeant before the sizing measurement settled the default.FALSEintegrates over the grid exactly as given, whatever it is, and recordsouter_grid_recenter_declined = "auto_recenter_disabled"."always"re-centres every movable default axis whatever the fit did, at 2.04 times the wall clock; it agrees with the default seed for seed on five of the six measured configurations and differs on the one whose default axes already resolve their posterior, where it takes 50% coverage to 0.135 against a nominal 0.5.Which families carry movable axes is
.NL_REGISTRY_AXIS_FIELD: icar, rw1, rw2, iid, bym2, nngp, hsgp and spde. car_proper, ar1 and hsgp_mo each carry a correlation axis with no guessable coordinate, and mcar / miid / tgmrf hold their axes in one matrix field; a fit of any of them records which throughouter_grid_recenter_declinedrather than passing in silence. The per-axis policy names are the standalone registry path only –tulpa_nested_laplace_joint()andfit_st_nested()refuse them rather than accept them and ignore them.max_grid_cells(2048L) – cell-count ceiling on a multi-block outer grid, refused with an error above it. Each cell is one inner Newton solve, so the default catches a per-block grid that multiplied out to a run nobody asked for; a deliberate converged tensor reference grid (4 axes at 7 levels is 2401 cells) raises it here.prune(FALSE),prune_tol(1e-3),screen_iters(5L) – opt-in cheap-pass screening of the outer grid. Whenprune = TRUE, the driver first sweeps the lattice running ascreen_iters-step inner Newton per cell, each warm-started from the previous screened cell's quasi-mode, computes a screening Laplace log-marginal, softmax-normalises it, and skips the full inner Newton on every cell whose screened weight is belowprune_tol. The neighbour warm-start keeps each cheap mode near its cell's own mode, so the cheap ranking tracks the full-solve ranking even where the latent mode moves across the grid. Pruned cells getlog_marginal = -Inf,n_iter = 0and inherit the pilot mode; the pilot cell is never pruned, and at leastprune_min_keepcells (5) are solved in full whatever the tolerance says – the highest-ranked dropped cells are restored up to that floor, because the outer grid placement pass reads a finite-difference curvature off the cells that were SOLVED and a kept set of one carries none. A safety gate falls back to the full grid (with a warning) whenever the cheap-screen argmax disagrees with the full-solve argmax, or the kept posterior collapses onto a cell the screen mis-estimated by more than the margin it discarded cells by. The gate bounds a MIS-RANKING, not the whole error: it compares the screen against the cells that were solved, so a cell it discarded and never solved is outside what it can see. The defaultprune = FALSEis the setting under which no such question arises.prune_tolmust be a finite numeric in[0, 1)andscreen_itersa single integer>= 1; both are validated whateverprunesays. Pruning is OPT-IN: the defaultFALSEsolves every cell, which is correct whatever the grid looks like.prune/prune_tolreach both the single-block and the multi-block dispatch; the screening DEPTH is declared by the single-block kernels only, so a multi-block prior refuses a pinnedscreen_itersrather than accepting it and ignoring it.prune_log_gap(unset) – the screening cut stated in nats instead of as a normalised weight: keep every cell within this many nats of the best screened cell.prune_tolis a weight, so what it cuts at is the gap-(log(prune_tol) + log(Z)), and the whole range a caller would type (1e-3to1e-12) is 7 to 28 nats of it – on an outer surface whose log-marginal spans thousands of nats every setting returns the same kept set. Stating the gap directly keeps the knob's resolution wherever the surface is steep. It reaches the kernel asprune_tol = exp(-gap), so the realised cut is the requested gap lesslog(Z), at mostlog(n_grid)nats narrower; the fit reports the realised value. Setting both this andprune_tolis an error – they state one cut in two units.fitted_var(TRUE) – fill the per-row predictive variancefitted_eta_var, the companion offitted_etaa caller marginalises the per-row linear predictor with. It costs one back-solve per distinct loading vector per cell, which on a design with few repeated rows is the dominant cost of a cell, so a caller reading onlyfitted_etaor the coefficient summaries setsFALSEand the fit then carries nofitted_eta_var. Declared by the single-block kernels only; a multi-block prior refusesFALSErather than accepting it and ignoring it.
Value
A list with:
theta_grid: matrix or vector of grid hyperparameter values.log_marginal: log p(y, mode | theta_k) at each grid point.weights: integration weights normalising to sum 1.theta_mean,theta_sd: posterior moments per hyperparameter.n_iter: inner Newton iterations per grid point.modes: matrix[n_grid x n_x]of inner modes, when stored.pareto_k,pareto_k_is_ess: outer Pareto-\(\hat{k}\) and its importance-sampling ESS (NAwhen not computed for the grid; seecontrol$diagnose_k).pareto_k_proposal_source,pareto_k_first_pass: which proposal family the reported k-hat came from (the outer k-hat is scored against several candidates and the best is kept), and the k-hat of the FIRST pass – the proposal exactly as this backend placed it, before any candidate refined or replaced it. A large gap between the two says the nodes are badly scaled around the hyperparameter posterior even though the verdict is fine; no gap says the placement was already right.inner_skew,inner_skew_idx,inner_skew_dropped: the inner-Laplace skewness diagnostic (gamma_3) at each scored latent index and its 1-based index, plus a count of (index, observation) contributions dropped for a non-finite third derivative (seecontrol$diagnose_skew).timing: named numeric of wall-clock seconds (total,setup,grid,postproc,diagnostics); thegridphase is the inner Laplace pass that scales with grid size. Surfaced one-line inprint.prune_cheap_log_marginal,prune_mask,prune_n_pruned,prune_tol: present only whencontrol$prune = TRUEand the safety gate did not trip – the cheap-screen log-marginal per cell, the logical mask of pruned cells, the pruned-cell count, and the threshold actually applied.prune_log_gap_cut,prune_cheap_lm_spread,prune_min_keep,prune_n_floor_restored: the same screen read on the scale it operates on – how many nats below the best screened cell the tolerance cut at, how many nats the screened surface spans, the kept-cell floor applied, and how many cells that floor put back. A cut that is a sliver of the spread is a tolerance with no resolution on this grid.prune_fallback_triggered,prune_fallback_reason: present only when the gate did trip; the rest of the fit is then the full-grid result and the reason string records which check fired.prior: echoed input.
References
Rue, Martino & Chopin (2009). Approximate Bayesian inference for latent Gaussian models by using integrated nested Laplace approximations. JRSS-B 71(2):319-392.
Examples
# \donttest{
set.seed(1)
S <- 30L # spatial units arranged in a chain
nb <- lapply(seq_len(S), function(s) setdiff(c(s - 1L, s + 1L), c(0L, S + 1L)))
nn <- lengths(nb)
field <- as.numeric(scale(cumsum(rnorm(S, 0, 0.4)))) # smooth spatial field
idx <- rep(seq_len(S), each = 6L); n <- length(idx); x <- rnorm(n)
y <- rbinom(n, 1L, plogis(-0.2 + 0.6 * x + field[idx]))
prior <- list(type = "icar", n_spatial_units = S, spatial_idx = idx,
adj_row_ptr = c(0L, cumsum(nn)), adj_col_idx = unlist(nb) - 1L,
n_neighbors = nn, tau_grid = c(0.5, 1, 2, 4, 8))
fit <- tulpa_nested_laplace(y, rep(1L, n), cbind(1, x), prior = prior,
family = "binomial")
fit$theta_mean # marginalized ICAR precision
# }