Nested Laplace approximation for latent Gaussian models
Source:R/fit_timing.R, 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
# S3 method for class 'tulpa_nested_laplace'
print(x, ...)
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,
offset = NULL,
likelihood = NULL,
hyperprior = c("proper", "flat"),
control = list()
)Arguments
- x
A nested-Laplace fit.
- ...
Ignored.
- 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(eachrho_gridvalue strictly inside (-1, 1)).
An
icar,bym2,car_properornngpblock may carryprojector, anN x n_spatial_units(N x n_spatialfornngp) matrix (dense or a Matrix sparse matrix), in place ofspatial_idx: observationithen receives(A z)_i, so restricted spatial regression isA = S P_perpwithSthe observation-to-unit incidence. The block is fitted by the multi-block driver, alone or in a list of blocks. An intrinsic field's level is placed fromA 1: removed with no fold where it is zero, folded into the intercept where it is the intercept column, otherwise left to the augmentation. Thenngpandcar_properpriors are proper and carry their level themselves.Grids of a two-axis type (
bym2,car_proper,ar1) are PAIRED when both are supplied: the i-th entries oftau_grid/sigma_gridandrho_gridform the i-th outer-grid cell, so the two must have equal length, and a tensor grid is written asexpand.grid()columns. A single supplied axis is kept as an axis (its distinct values) and crossed with the default of the other.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.Defaulted, it conditions at 1 and says so with a warning, since for a family that reads a dispersion that is a modelling choice rather than a neutral value.
fit$phi_estimatedrecords whether the value on the fit was estimated or conditioned on.- offset
Optional per-observation offset added to the linear predictor (length
length(y)), as anoffset()term in a model formula. Every single- and multi-block kernel carries it, andfitted_etaincludes it.- 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).- hyperprior
The prior an outer hyperparameter axis carries when the call states no density for it,
"proper"(default) or"flat"."proper"folds a normalised density on the axis's integration coordinate intolog_marginal: the PC priorP(sigma > 3) = 0.01on a standard deviation, variance or precision axis, the PC range prior at 0.2 times the coordinates' bounding-box diagonal on a range or lengthscale axis, a uniform on a bounded axis, and the PC + LKJ prior over a free-covariance block. An axis with no sourced density is named inlog_hyperprior_declined."flat"folds no density of the engine's own, so such an axis is integrated under its cell measure alone, is named inlog_hyperprior_declinedas"flat_hyperprior", and the fit'slog_evidencedeclines with"improper_hyperprior". A density the call states – aprior_*argument, a block'srho_prior,prior_rangeorprior_sigma, the copy coefficient's slab, atgmrf()block's own prior – applies under either choice. The same two choices, under the same names, are offered bytulpa(),tulpa_eb(),tulpa_re_cov_nested()andfit_spde().- 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(TRUE) – 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("log_quadratic") – 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."log_quadratic"joins each row's node log densities by overlapping quadratics, exact for a Gaussian at any spacing, and continues the end quadratic past the outer node instead of stopping at the box edge; it runs on the rows that resolve their own conditional (h / sd <= 2) and reads every other row as boxes, declining to"box_uniform"where no row qualifies."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 default moved from"box_uniform"at gcol33/tulpa#932: a placed axis sits at 1.25 posterior SDs per cell, where the box read is narrow by construction, and against dense references of a two-arm ICAR fixture the log-quadratic read's summed error is 0.038 against the box read's 0.110 on the same grids.outer_grid_h_over_sdis how wide a cell is on each axis, andtheta_within_cellis what each axis was actually read with. Only a"density"support admits the box and log-quadratic reads – a CCD design, a locally refined grid and a posterior sample are not cell partitions that tile – and an axis they decline on reports the read that ran with a reason rather than erroring. Nothing else moves: point estimates, moments, draws and weights are untouched.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: a coordinate strongly coupled to a member of the corrected set and left out of it is carried linearly, which is the error the correction removes, so requesting the closure also retains the modal cell's joint precision for the selector to read), 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(NULL, no ceiling) – a ceiling on a multi-block outer grid's cell count, one inner Newton solve per cell; a grid past it is refused before any solve, naming the axes that produced the count. Unset, a grid of any size is solved, and one past 2048 cells is announced with a warning first.checkpoint(list(path =, resume =)) – grid-cell checkpoint / resume. Each solved outer cell is appended topath, keyed by its hyperparameter coordinate;resume = TRUEloads the finished cells and solves only the rest, and a fingerprint mismatch (different data, settings or grid) errors rather than resuming onto a stale result. See?tulpa_nested_laplace"Checkpoint / resume".progress,progress.every,progress.file,progress.throttle– the outer-grid progress reporter: whether to print, how often (in cells), where to, and the minimum seconds between lines.prune(TRUE),prune_tol(1e-3),screen_iters(.NL_SCREEN$iters, 2 – the depth gcol33/tulpa#640 measured the screen down to) – cheap-pass screening of the outer grid. 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. After the full pass the posterior mass the dropped cells may carry is bounded by lifting each by the worst screening error measured on a kept cell (prune_dropped_mass_bound); past 1% the driver solves the dropped cells that contribute most to it until it is under 1% (prune_n_repaired), and a safety gate falls back to the full grid (with a warning) if the bound still fails. The bound assumes a dropped cell is screened no worse than the worst kept one. On by default (gcol33/tulpa#925);FALSEsolves every cell.prune_tolmust be a finite numeric in[0, 1)andscreen_itersa single integer>= 1; both are validated whateverprunesays.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. Every single-block field reports it, the NNGP, HSGP, SPDE and spatiotemporal fields included, each read off its cell's own precision at the mode; a multi-block prior refusesFALSErather than accepting it and ignoring it.var_of_means_consistency(TRUE) – the consistency pass oftulpa_nested_laplace_joint(): a field SD (tauof anicar,rw1orrw2block,sigmaof aniid,bym2orspdeblock) whose marginal has collapsed onto too few nodes to carry a spread has levels added in every row of the other axes that holds the posterior until it resolves, and the new cells are solved and carried like the rest (var_of_means_consistency_inforecords the pass). Other axes keep the nodes they were laid on.adaptive_grid(FALSE),adaptive_grid_edge_thresh(0.02),adaptive_grid_max_passes(1L) – the opt-in boundary / interior pass, on the same axes.axis_refine(NULL) – per-axis override of how far those axes may be moved, named by the grid's axes ("tau";"b1.tau"on a multi-block prior):"none","densify"(inside the declared span) or"extend". Nodes the caller wrote down densify by default and an engine-placed axis extends, as intulpa_nested_laplace_joint().
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 when the grid was screened and the safety gate did not trip – the cheap-screen log-marginal per cell (the truncated solve's value plus half its Newton decrement, the second-order estimate of the converged value the cells are ranked by; the decrement itself isprune_screen_decrement), 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_dropped_mass_bound,prune_n_repaired,prune_gate_mass: the posterior mass the dropped cells may carry, with each lifted by the worst screening error measured on a kept cell; how many dropped cells the driver solved in full to bring that under the gate; and the gate.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.
Details
print() shows the integrated hyperparameters, the outer-grid
size, the outer Pareto-\(\hat{k}\) diagnostic when present and the
wall-clock timing line ("fit in 5h 25m (grid 2h 09m)") when
fit$timing is attached, then the fixed effects.
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
# }