--- title: "Proper scoring rules for Bayesian model comparison" format: html vignette: > %\VignetteIndexEntry{Proper scoring rules for Bayesian model comparison} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", # Evaluated wherever INLA and the packages used here are installed. eval = all(vapply( c("INLA", "scoringRules", "dplyr", "ggplot2", "broom"), requireNamespace, logical(1), quietly = TRUE )) ) ``` inlabru practitioners often evaluate models by how well their posterior predictive distribution covers held-out data, rather than by point-forecast error alone. A scoring rule is *proper* if it is minimised in expectation only when the forecaster reports their true predictive distribution — this rules out gaming by sharpening or widening predictions artificially. inlaops provides `bru_score()` to compute four scores using the full posterior predictive distribution via `inlabru::generate()` and the `scoringRules` package. CRPS and log-score are strictly proper. Squared error evaluates only the posterior mean. Dawid-Sebastiani is proper for location-scale families. ## Setup ```{r, message = FALSE} library(inlabru) library(inlaops) library(scoringRules) library(broom) library(dplyr) library(ggplot2) ``` ## The four scores | Score | Formula | Interpretation | |---|---|---| | CRPS | $\text{E}[\|X - y\|] - \frac{1}{2}\text{E}[\|X - X'\|]$ | Lower is better. Generalises MAE. Works for any distribution. | | Log-score | $-\log p(y)$ | Lower is better. Penalises overconfident wrong predictions heavily. | | SE | $(y - \hat{\mu})^2$ | Squared error of the posterior mean. | | Dawid-Sebastiani | $\frac{(y - \hat{\mu})^2}{\hat{\sigma}^2} + \log \hat{\sigma}^2$ | Penalises miscalibrated variance. | CRPS and log-score are fully proper — they evaluate the entire predictive distribution, not just the mean or variance. SE only assesses the posterior mean and ignores predictive uncertainty. Dawid-Sebastiani is a Gaussian approximation to the log-score. ## Example: comparing two models ```{r} set.seed(42) n <- 500 dat <- data.frame( x1 = rnorm(n), x2 = rnorm(n), x3 = rnorm(n) ) dat$y <- 1.5 + 0.9 * dat$x1 + 0.3 * dat$x2 + rnorm(n, sd = 0.8) # Hold out 20% for evaluation idx_holdout <- sample(n, size = 100L) train_dat <- dat[-idx_holdout, ] test_dat <- dat[ idx_holdout, ] ``` Fit two models: a correctly-specified model and a model that omits `x2`. ```{r} fit_full <- bru( y ~ Intercept(1) + x1 + x2, family = "gaussian", data = train_dat, options = bru_options(control.compute = list(config = TRUE, dic = TRUE, waic = TRUE)) ) fit_reduced <- bru( y ~ Intercept(1) + x1, family = "gaussian", data = train_dat, options = bru_options(control.compute = list(config = TRUE, dic = TRUE, waic = TRUE)) ) ``` Score both models against the holdout set. `bru_score()` calls `inlabru::generate()` to draw raw posterior samples and passes them to `scoringRules`. The prediction formula must produce samples of **Y**, including the observation-model stochasticity. The package is family-agnostic — you write the appropriate `r*()` call yourself. Inside an `inlabru::generate()` formula the predictor is evaluated once per posterior sample: latent-field symbols (`Intercept`, `field`, ...) are scalars per sample, and covariates (`x1`, `x2`) are vectors over observations. So `length()` is the per-sample observation count for the `r*()` call: For continuous families the observation noise is a hyperparameter. Hyperparameters are available inside the formula under their standardised names (see `inlabru::bru_standardise_names()`), so the Gaussian SD is sampled jointly with the latent field: ```{r} scores_full <- bru_score( fit_full, newdata = test_dat, pred_formula = ~ rnorm( length(x1), mean = Intercept + x1 + x2, sd = 1 / sqrt(Precision_for_the_Gaussian_observations) ), y_col = "y", n_samples = 2000L, seed = 1L ) scores_reduced <- bru_score( fit_reduced, newdata = test_dat, pred_formula = ~ rnorm( length(x1), mean = Intercept + x1, sd = 1 / sqrt(Precision_for_the_Gaussian_observations) ), y_col = "y", n_samples = 2000L, seed = 1L ) ``` `rownames(fit_full$summary.hyperpar)` lists the hyperparameters; pass them through `inlabru::bru_standardise_names()` to get the formula names. Dropping the `rnorm(...)` layer would make `bru_score()` compute CRPS and log-score against draws of the *posterior mean* — understating predictive variance and yielding optimistic scores. Summarise mean scores across holdout observations: ```{r} bind_rows( mutate(scores_full, model = "full"), mutate(scores_reduced, model = "reduced") ) |> group_by(model) |> summarise( mean_crps = mean(crps), mean_logs = mean(logs), mean_rmse = sqrt(mean(se)) ) ``` The full model scores better on all three metrics. ## Visualising score distributions Mean scores can hide important heterogeneity. Plot the per-observation score distributions to identify systematic patterns: ```{r} bind_rows( mutate(scores_full, model = "full"), mutate(scores_reduced, model = "reduced") ) |> ggplot(aes(x = crps, fill = model)) + geom_density(alpha = 0.5) + labs(x = "CRPS (lower is better)", y = "Density") + theme_minimal() ``` Observations where the reduced model scores much worse than the full model are precisely those with large `x2` values — confirming that omitting `x2` hurts predictions at the tails. ## Relationship to WAIC and DIC WAIC and DIC are information criteria computed on the training data. Proper scoring rules are computed against genuinely held-out observations. | Metric | Computed on | Accounts for overfitting? | Available at serving time? | |---|---|---|---| | DIC | Training | Via effective parameters | No — pin metadata only | | WAIC | Training | More reliably than DIC | No — pin metadata only | | CRPS / log-score | Holdout | Yes | Via `bru_score()` | Use WAIC/DIC for fast model selection during development (no holdout needed). Use CRPS/log-score for final model comparison and reporting. WAIC and DIC come from `glance()`, provided by inlabru: ```{r} glance(fit_full) ``` ## Scoring spatial and non-Gaussian models The contract is the same for spatial and non-spatial fits: `pred_formula` must produce samples of `Y`. The package does not inject family-aware observation noise — write the appropriate `r*()` call. The blocks below are **illustrative API sketches**, not runnable end-to-end (they reference `spatial_fit`, `poisson_fit`, etc. — substitute your own fitted model). They show the formula shape per family: ```r # Spatial Gaussian model with SPDE field spatial_scores <- bru_score( spatial_fit, newdata = holdout_sp, # SpatialPointsDataFrame or sf pred_formula = ~ rnorm( length(x1), mean = Intercept + field, sd = 1 / sqrt(Precision_for_the_Gaussian_observations) ), y_col = "y", n_samples = 1000L ) # Poisson — write the rpois yourself. poisson_scores <- bru_score( poisson_fit, newdata = holdout_sp, pred_formula = ~ rpois(length(x1), exp(Intercept + field)), y_col = "counts", n_samples = 1000L ) # Binomial — pass the trial count via newdata column (e.g. `trials`). # Inside the formula, covariate columns are vectors so `trials` and # `length(trials)` both work. binomial_scores <- bru_score( binomial_fit, newdata = holdout_sp, pred_formula = ~ rbinom( length(trials), size = trials, prob = plogis(Intercept + field) ), y_col = "successes", n_samples = 1000L ) # Negative binomial — INLA's nbinomial parameterisation uses size on the # precision scale; check your fit's hyperparameter names. A simpler path # when you don't want to bake INLA's hyperpar names into the formula is # to score on a known approximate dispersion: nb_scores <- bru_score( nb_fit, newdata = holdout_sp, pred_formula = ~ rnbinom( length(x1), mu = exp(Intercept + field), size = known_size ), y_col = "counts", n_samples = 1000L ) ``` For zero-inflated families, sample the mixture explicitly: `ifelse(runif(length(x), ...) < pi, 0, rpois(length(x), lambda))`. ## Notes on `n_samples` `bru_score()` uses `inlabru::generate()` which draws from the full joint posterior via `inla.posterior.sample()`. Increasing `n_samples` reduces Monte Carlo variance in the scores at the cost of computation time. - For development and model comparison: 500–1000 samples is usually sufficient. - For final evaluation and reporting: 2000–5000 samples. - CRPS converges faster than log-score, which requires accurate tail probability estimates.