Spatial models: scope and limitations

This vignette states clearly what inlaops supports for spatial inlabru models and what it does not. Understanding this boundary prevents misuse.

What works

vetiver bridge — supported with spatial metadata

The /predict endpoint serves point-geometry spatial fits — sf POINT data, sp::SpatialPointsDataFrame data, and SPDE meshes built from either. Serialising, versioning, and pinning work for any in-scope bru object. The handler reconstructs the spatial object from the JSON request body, so two extra fields in metadata$user are required:

LGCP intensity surfaces

LGCP fits (family = "cp") are served the same way as any other spatial point fit: the user passes query locations, the pred_formula evaluates the intensity surface (~ exp(Intercept + field)), and the handler returns posterior intensity samples at the query points. The output columns describe a posterior summary, not specifically Y, so the same /predict contract works — the values are just intensity rather than counts. See vignette("lgcp") for a full worked example.

Out-of-scope for the bridge (rejected at pin time)

.vetiver_prepare_model_bru() errors before pinning if the fit’s likelihood data is incompatible with the point-reconstruction handler:

If you hit the rejection and need vetiver versioning anyway, fit the model and write your own plumber handler that calls inlabru::predict() — vetiver will still pin and version the fit object. The custom-plumber pattern in vignette("lgcp") (section 3) applies just as well to areal/polygon fits today.

Areal lookup — planned

When polygon / areal serving lands, the design will be:

This pattern handles BYM2 and similar areal models without dragging polygon WKT through every request body. It is not implemented yet — see the areal serving issue. For now, the custom-plumber pattern in vignette("lgcp") covers the same use case.

library(inlabru)
library(INLA)
library(sp)
library(inlaops)
library(vetiver)
library(pins)

# Synthesise some toy point data on a 10x10 domain. In a real application
# obs_sp comes from your sampling effort.
set.seed(2026)
n_obs  <- 60
obs_df <- data.frame(
  x = runif(n_obs, 0, 10),
  y = runif(n_obs, 0, 10)
)
obs_df$value <- 1 + 0.3 * obs_df$x + rnorm(n_obs, sd = 0.5)
obs_sp <- sp::SpatialPointsDataFrame(
  coords      = obs_df[, c("x", "y")],
  # Not `y`: that name is taken by a coordinate, and requests carry both.
  data        = data.frame(value = obs_df$value),
  proj4string = sp::CRS(SRS_string = "EPSG:32632")
)

# Build mesh and SPDE component (fmesher::fm_mesh_2d_inla replaces the
# deprecated INLA::inla.mesh.2d in inlabru >= 2.12).
mesh  <- fmesher::fm_mesh_2d_inla(
  loc.domain = cbind(c(0, 10, 10, 0), c(0, 0, 10, 10)),
  max.edge   = c(1, 3)
)
spde  <- inla.spde2.pcmatern(mesh, prior.range = c(3, 0.5),
                                   prior.sigma  = c(1, 0.01))

# Fit a spatial Gaussian model. inlabru >= 2.12 uses bru_obs() in place
# of the deprecated like().
cmp <- ~ field(coordinates, model = spde) + Intercept(1)
fit <- bru(
  cmp,
  bru_obs(formula = value ~ ., family = "gaussian", data = obs_sp),
  options = bru_options(control.compute = list(config = TRUE))
)

# Wrap and pin. coord_names and crs are required for the /predict endpoint.
# Pass metadata at construction so it is in place before vetiver_api() runs.
v <- vetiver_model(
  fit, "spatial_gaussian",
  metadata = list(
    pred_formula = "~ Intercept + field",
    n_samples    = 500L,
    coord_names  = c("x", "y"),   # column names in obs_sp that hold coordinates
    crs          = 32632           # EPSG code; use NA if data has no CRS
  )
)
board <- board_temp()
vetiver_pin_write(board, v)

The /predict endpoint reconstructs an sp or sf object from the incoming JSON using coord_names and crs, then passes it to predict.bru(), which handles the SPDE mesh projection internally. The prediction formula controls what is returned — linear predictor, response scale, or any derived quantity.

If coord_names is not supplied and a spatial model is detected, the handler will error with a message explaining what to add to metadata$user.

broom tidiers — supported for the fixed part

tidy() (provided by inlabru >= 2.15.0) extracts fixed effects and hyperparameters. For spatial models, the hyperparameters include SPDE range and marginal variance:

tidy(fit, effects = "fixed")

tidy(fit, effects = "hyperpar")

glance(fit)

augment() adds fitted values to the training locations. The user must supply the prediction formula.

What does NOT work

bru_reg() parsnip constructor

bru_reg() and bru_cv_predict() do not support spatial models. The parsnip fit interface passes a standard data frame to bru(), which does not include the mesh, SPDE object, or integration stack that spatial models require. Attempting to use bru_reg() for spatial fits will either error or silently produce a non-spatial model.

Do not do this for spatial models:

# WRONG for spatial models — this drops the spatial component
bru_reg(family = "gaussian") |>
  set_engine("inlabru") |>
  fit(y ~ field + Intercept(1), data = obs_sp)

bru_cv_predict() cross-validation helper

Standard k-fold cross-validation is not appropriate for spatial models because:

  1. Training folds preserve spatial autocorrelation — holdout scores are optimistic relative to prediction at new locations.
  2. The SPDE random effect is indexed to a fixed mesh. Re-fitting on a subset of data does not change the mesh or reproject the field — it just conditions on fewer observations, which is not the same as predicting at unobserved locations.
  3. For point-process models (LGCP), the integration stack is built from the full domain, not the training fold.

For spatial cross-validation, use spatial blocking (e.g., the blockCV package) and evaluate with proper scoring rules via bru_score(). See vignette("scoring").

The block below is an illustrative skeleton — substitute your own components, likelihood, and holdout_sp to make it runnable. It is not chunk-evaluated:

library(inlabru)
library(inlaops)

# 1. Fit the full model with config = TRUE
fit <- bru(
  components,
  likelihood,
  options = bru_options(control.compute = list(config = TRUE,
                                               dic    = TRUE,
                                               waic   = TRUE))
)

# 2. Inspect with tidy/glance
tidy(fit, effects = "hyperpar")
glance(fit)

# 3. Evaluate against held-out locations with proper scoring rules
holdout_scores <- bru_score(
  fit,
  newdata      = holdout_sp,
  # Samples of Y, here for a count model; see vignette("scoring").
  pred_formula = ~ rpois(length(field), exp(Intercept + field)),
  y_col        = "counts",
  n_samples    = 1000L
)
summary(holdout_scores)

# 4. Version and serve with vetiver
library(vetiver); library(pins)
v <- vetiver_model(
  fit, "spatial_model_v1",
  metadata = list(
    pred_formula = "~ exp(Intercept + field)",
    n_samples    = 500L,
    coord_names  = c("x", "y"),  # required for /predict endpoint
    crs          = 32632
  )
)
vetiver_pin_write(board_folder("./model-store"), v)

Request body contract

JSON requests to /predict carry one row per query point with two groups of columns:

  1. Coord columns matching metadata$user$coord_names, holding numeric coordinates in the CRS declared in metadata$user$crs.
  2. Covariate columns matching the non-geometry columns of the fit’s likelihood data — e.g., temperature, elevation, treatment indicator — used inside the prediction formula.

The ptype the handler builds at pin time encodes both groups, so vetiver’s JSON validator does not strip either before reconstruction. If you need a different request schema (custom coord names, additional columns), pass a custom prototype via save_prototype to vetiver_model():

v <- vetiver_model(
  fit,
  "custom_schema",
  save_prototype = tibble::tibble(lon = 0.0, lat = 0.0, x1 = 0.0)
)
v$metadata$user$coord_names <- c("lon", "lat")  # match what you sent above

Docker image for spatial fits

inlabru imports sf, so every inlaops image needs GDAL/PROJ/GEOS at runtime, spatial model or not. vignette("docker") has a Dockerfile on rocker/geospatial that serves spatial and non-spatial fits alike.

Multi-likelihood models

inlabru supports fitting multiple likelihoods simultaneously (e.g., combining point observations with areal aggregates). The vetiver bridge handles these correctly as long as you pass a custom prototype via save_prototype to vetiver_model(), because vetiver_ptype.bru() only reads from the first likelihood:

v <- vetiver_model(
  fit,
  "multi_lhood_spatial",
  save_prototype = tibble::tibble(x = 0, y = 0)
)