inlaops registers six S3 methods against the "bru" class so that vetiver_model() works with inlabru fits out of the box — the same way it works with lm() or mgcv::gam(). This vignette walks through the full workflow: fitting, pinning, serving over HTTP, and monitoring.
Prerequisites
1. Fit a model
Always set config = TRUE so that the fit stores the posterior configurations needed for sampling at the /predict endpoint.
set.seed(42)
n <- 200
dat <- data.frame(
x1 = rnorm(n),
x2 = rnorm(n)
)
dat$y <- 2 + 0.8 * dat$x1 - 0.4 * dat$x2 + rnorm(n, sd = 0.5)
fit <- bru(
y ~ Intercept(1) + x1 + x2,
family = "gaussian",
data = dat,
options = bru_options(
control.compute = list(config = TRUE, dic = TRUE, waic = TRUE)
)
)2. Wrap with vetiver
vetiver_model() dispatches on the "bru" class. inlaops provides the description, metadata, and input prototype (the predictor columns a request must carry) automatically.
The prediction formula goes into the pin metadata as a character string — R expressions do not survive the YAML metadata round-trip. The API parses it once when it starts. Training-time fit criteria (WAIC, DIC) can ride along for monitoring later.
v <- vetiver_model(
fit, "gaussian_glm",
metadata = list(
pred_formula = "~ Intercept + x1 + x2",
n_samples = 200L, # posterior samples per request; 200 is the default
waic = fit$waic$waic,
dic = fit$dic$dic
)
)
v
#>
#> ── gaussian_glm ─ <bru> model for deployment
#> An inlabru model (gaussian family) using 2 features
v$prototype
#> # A tibble: 0 × 2
#> # ℹ 2 variables: x1 <dbl>, x2 <dbl>Aside — parsnip-fitted models
If you fit via parsnip::fit() instead of inlabru::bru() directly, pass the inner bru fit to vetiver_model(). The bridge does not auto-unwrap parsnip’s model_fit:
fit_p <- bru_reg(family = "gaussian") |>
parsnip::set_engine("inlabru") |>
parsnip::fit(y ~ Intercept(1) + x1 + x2, data = dat)
v_parsnip <- vetiver_model(
fit_p$fit, "gaussian_glm", # note: fit_p$fit
metadata = list(pred_formula = "~ Intercept + x1 + x2")
)3. Pin to a versioned board
board <- board_temp() # swap for board_s3(), board_connect(), etc.
vetiver_pin_write(board, v)
#> Creating new version '20261016T203253Z-6ff8b'
#> Writing to pin 'gaussian_glm'
#>
#> Create a Model Card for your published model
#> • Model Cards provide a framework for transparent, responsible reporting
#> • Use the vetiver `.Rmd` template as a place to start
pin_versions(board, "gaussian_glm")
#> # A tibble: 1 × 3
#> version created hash
#> <chr> <dttm> <chr>
#> 1 20261016T203253Z-6ff8b 2026-10-16 20:32:53 6ff8b4. Serve the API and call it
In production the API runs on its own (see vignette("docker") and vignette("connect")). Locally you would run this in a separate R session; it blocks until stopped:
pr() |>
vetiver_api(v) |>
pr_run(port = 8088)To keep this vignette self-contained we start the same API in a background R process instead. It reads the model back from the pin board, as a deployed API would:
port <- httpuv::randomPort()
server <- callr::r_bg(
function(board_path, port) {
library(inlaops)
v <- vetiver::vetiver_pin_read(pins::board_folder(board_path), "gaussian_glm")
plumber::pr() |> vetiver::vetiver_api(v) |> plumber::pr_run(port = port)
},
args = list(board_path = board$path, port = port)
)
# Wait until the API answers its health check.
for (i in 1:60) {
up <- tryCatch(
{
httr2::request(sprintf("http://127.0.0.1:%d/ping", port)) |>
httr2::req_perform()
TRUE
},
error = function(e) FALSE
)
if (up || !server$is_alive()) break
Sys.sleep(1)
}
stopifnot(up)Any HTTP client can now ask for predictions. From R, vetiver’s own client sends a data frame and returns a tibble:
endpoint <- vetiver_endpoint(sprintf("http://127.0.0.1:%d/predict", port))
new_obs <- data.frame(x1 = c(0.5, -1.2), x2 = c(1.0, 0.3))
predict(endpoint, new_obs)
#> # A tibble: 2 × 4
#> .pred .pred_low .pred_high .pred_sd
#> <dbl> <dbl> <dbl> <dbl>
#> 1 1.99 1.89 2.11 0.06
#> 2 0.938 0.840 1.04 0.0586The response carries the posterior mean (.pred), the 2.5th and 97.5th posterior quantiles (.pred_low, .pred_high), and the posterior standard deviation (.pred_sd) — not just a point estimate, so downstream consumers can propagate predictive uncertainty. The true means here are 2 + 0.8 x1 − 0.4 x2: 2.0 and 0.92.
Callers outside R send the same rows as JSON:
curl -X POST http://127.0.0.1:8088/predict \
-H "Content-Type: application/json" \
-d '[{"x1": 0.5, "x2": 1.0}, {"x1": -1.2, "x2": 0.3}]'Requests are checked against the input prototype, so a body missing a predictor column is rejected:
try(predict(endpoint, data.frame(x1 = 0.5)))
#> Error in predict(endpoint, data.frame(x1 = 0.5)) : Failed to predict.
#> • Server error: (500) Internal Server Error
server$kill()
#> [1] TRUEFor packaging the model into a container, see vignette("docker"); for hosting it on Posit Connect, see vignette("connect").
5. Track model quality across versions
INLA-specific fit metrics (WAIC, DIC) cannot be recomputed from a live endpoint — they are only available at training time. They were stored as pin metadata above; read them back per version for longitudinal monitoring:
versions <- pin_versions(board, "gaussian_glm")
do.call(rbind, lapply(versions$version, function(ver) {
meta <- pin_meta(board, "gaussian_glm", version = ver)
data.frame(version = ver, waic = meta$user$waic, dic = meta$user$dic)
}))
#> version waic dic
#> 1 20261016T203253Z-6ff8b 312.5316 312.0669Standard prediction-drift monitoring uses vetiver_compute_metrics() against the .pred column, the same as any other vetiver model. Drift monitoring is time-windowed, so the input data needs a timestamp column. In a real deployment this comes from the time you collected each observation — here we synthesise one:
monitoring_data <- dat # in production: observations collected since deployment
monitoring_data$.pred <- inlabru::generate(
fit, newdata = monitoring_data,
formula = ~ Intercept + x1 + x2, n.samples = 500
) |> rowMeans()
# Synthetic timestamps for the example. Replace with real collection times.
monitoring_data$obs_date <- seq.Date(
from = as.Date("2026-01-01"),
by = "day",
length.out = nrow(monitoring_data)
)
metrics_board <- board_temp()
new_metrics <- vetiver_compute_metrics(
monitoring_data,
date_var = obs_date,
period = "week",
truth = y,
estimate = .pred
)
# vetiver_pin_metrics() appends to an existing metrics pin — the first time
# you record metrics, write the pin directly with pins::pin_write(). On
# subsequent runs (e.g. weekly cron jobs), use vetiver_pin_metrics() so each
# new window's rows are appended without overwriting history.
if ("gaussian_glm_metrics" %in% pins::pin_list(metrics_board)) {
vetiver_pin_metrics(metrics_board, new_metrics, "gaussian_glm_metrics")
} else {
pins::pin_write(metrics_board, new_metrics, "gaussian_glm_metrics")
}
#> Guessing `type = 'rds'`
#> Creating new version '20261016T203300Z-ee927'
#> Writing to pin 'gaussian_glm_metrics'
head(pins::pin_read(metrics_board, "gaussian_glm_metrics"))
#> # A tibble: 6 × 5
#> .index .n .metric .estimator .estimate
#> <date> <int> <chr> <chr> <dbl>
#> 1 2026-01-01 7 rmse standard 0.390
#> 2 2026-01-01 7 rsq standard 0.939
#> 3 2026-01-01 7 mae standard 0.283
#> 4 2026-01-08 7 rmse standard 0.611
#> 5 2026-01-08 7 rsq standard 0.914
#> 6 2026-01-08 7 mae standard 0.533period is the rolling window — "day", "week", "month", etc. Pick the cadence that matches how often new observations arrive in production. vetiver_plot_metrics() draws the pinned metrics over time.
Pins board options
| Board | Use case |
|---|---|
board_temp() / board_folder()
|
Local development and testing |
board_s3() |
AWS-hosted teams |
board_azure() |
Azure-hosted teams |
board_connect() |
Posit Connect |
Multi-likelihood models
The input prototype comes from the first likelihood’s data, and the API predicts from that likelihood. For models with multiple bru_obs() calls, supply the prototype manually via save_prototype:
v <- vetiver_model(
fit,
"multi_lhood_model",
save_prototype = tibble::tibble(x1 = 0, x2 = 0)
)