Spatial — Meuse zinc SPDE

The Meuse-zinc dataset (Pebesma, sp and gstat) is the canonical geostatistics benchmark: top-soil zinc concentration at 155 sample points along the Meuse river, with distance to the river as the only covariate. The model is Gaussian-on-log-zinc with an SPDE–Matérn spatial random field on a triangulated mesh:

\[\begin{aligned} \log y_i &= \alpha + \beta\,\mathrm{dist}_i + u(s_i) + \varepsilon_i, \\ u &\sim \text{SPDE-Matérn}(\rho, \sigma; \alpha = 2), \\ \varepsilon_i &\sim \mathcal{N}(0, 1/\tau_\varepsilon), \end{aligned}\]

with PC priors on (ρ, σ) (Fuglstad et al. 2019) and a PC prior on τ_ε.

Loading the fixture

The fixture ships the input data, a fmesher-generated mesh (points, triangle vertices tv), the spatial projector A_field, and the R-INLA posterior summary. Pinning the mesh isolates the oracle from the M3 mesh-parity gate so the test exercises the LGM + SPDE + INLA path only.

using JLD2, SparseArrays, LinearAlgebra
using GMRFs, LatentGaussianModels, INLASPDE

const FIXTURE = joinpath(@__DIR__, "..", "..", "..",
    "packages", "INLASPDE.jl",
    "test", "oracle", "fixtures", "meuse_spde.jld2")

fxt = load(FIXTURE)["fixture"]

y       = Float64.(fxt["input"]["y"])
dist    = Float64.(fxt["input"]["dist"])
points  = fxt["mesh"]["loc"]::Matrix{Float64}
tv      = fxt["mesh"]["tv"]::Matrix{Int}
A_field = SparseMatrixCSC{Float64, Int}(fxt["A_field"])

n_obs = length(y)
n_v   = size(points, 1)

Building the model

Latent vector layout: x = [α; β; u], where u ∈ ℝ^{n_v} is the mesh-vertex SPDE field. The projector stacks the intercept column, the covariate column, and the fmesher-built barycentric A_field:

spde = SPDE2(points, tv; α = 2,
    pc = PCMatern(
        range_U = 0.5, range_α = 0.5,
        sigma_U = 1.0, sigma_α = 0.5,
    ))

c_int  = Intercept(prec = 1.0e-3)
c_dist = FixedEffects(1; prec = 1.0e-3)

A = hcat(ones(n_obs, 1), reshape(dist, n_obs, 1), A_field)

ℓ     = GaussianLikelihood(hyperprior = PCPrecision(1.0, 0.01))
model = LatentGaussianModel(ℓ, (c_int, c_dist, spde), A)

Fitting

res = inla(model, y)

Posterior summaries

spde_user_scale maps the internal log-scale SPDE hyperparameters back to interpretable (range, σ):

fixed_effects(model, res)
2-element Vector{@NamedTuple{name::String, mean::Float64, sd::Float64, lower::Float64, upper::Float64}}:
 (name = "Intercept[1]", mean = 6.616720890179365, sd = 0.1560711371815215, lower = 6.310827082030758, upper = 6.922614698327972)
 (name = "FixedEffects[2]", mean = -2.8234421079650867, sd = 0.3996178018777923, lower = -3.6066786078580746, upper = -2.0402056080720987)
θ̂ = res.θ̂
ρ̂, σ̂ = spde_user_scale(spde, θ̂[2:3])
τ̂_noise = exp(θ̂[1])
(range = ρ̂, sigma = σ̂, tau_noise = τ̂_noise)
(range = 0.5786891628419261, sigma = 0.45266851597075974, tau_noise = 15.389168886803379)

Comparing to R-INLA

The fixture's summary_fixed, summary_hyperpar, and mlik carry R-INLA's posterior. Per plans/testing-strategy.md, the Meuse oracle tolerances are 1% on fixed-effect means relative to their posterior SD, 25% on (ρ, σ), and 30% on τ_ε (looser bands reflect mode-vs-mean and integration-scheme differences). The full assertion suite lives in test/oracle/test_meuse_spde.jl.

sf_rows = fxt["summary_fixed"]["rownames"]
sf_mean = Float64.(fxt["summary_fixed"]["mean"])
α_R = sf_mean[findfirst(==("intercept"), sf_rows)]
β_R = sf_mean[findfirst(==("dist"), sf_rows)]

fe = fixed_effects(model, res)
(α_julia = fe[1].mean, α_R = α_R, β_julia = fe[2].mean, β_R = β_R)
(α_julia = 6.616720890179365, α_R = 6.6193211692206635, β_julia = -2.8234421079650867, β_R = -2.8116029235543802)

Mesh parity (M3)

The mesh used here was generated by R-INLA's fmesher and shipped with the fixture, so this vignette is decoupled from our own mesher. For a full Julia-only run, swap points, tv, and the projector for the output of inla_mesh_2d and MeshProjector. The fmesher_parity test suite tracks vertex counts and minimum-angle bounds against R-INLA's fmesher outputs on the unit square, L-shape, and Meuse hull.

Predicting on a raster grid

The fit above lives on the mesh; users typically want the spatial field interpolated onto a regular pixel grid for visualisation, overlay against rasterised covariates, or downstream geostatistical workflows. That is exactly what INLASPDERasters.jl provides via predict_raster(model, res, template).

The raster path requires an SPDE2 component constructed from a retained INLAMesh (per ADR-036), so we rebuild the model with a Julia-native mesh and MeshProjector rather than the fixture's (points, tv) + A_field scaffolding:

using INLASPDERasters
using Rasters: Raster, X, Y
using Random: Xoshiro

locs = fxt["input"]["locations"]::Matrix{Float64}

mesh_jl = inla_mesh_2d(locs;
    max_edge = (0.4, 0.8), cutoff = 0.05, offset = (0.2, 0.5))

spde_jl = SPDE2(mesh_jl; α = 2,
    pc = PCMatern(
        range_U = 0.5, range_α = 0.5,
        sigma_U = 1.0, sigma_α = 0.5,
    ))

P_obs = MeshProjector(mesh_jl, locs)
A_field_jl = SparseMatrixCSC{Float64, Int}(P_obs.A)
A_jl = hcat(ones(n_obs, 1), reshape(dist, n_obs, 1), A_field_jl)

model_jl = LatentGaussianModel(ℓ, (c_int, c_dist, spde_jl), A_jl)
res_jl = inla(model_jl, y)

The template raster fixes the output extent, resolution, and CRS; predict_raster returns a Raster matching it cell-for-cell, with NaN outside the mesh:

xs = 178.5:0.05:181.5
ys = 329.5:0.05:333.7
template = Raster(zeros(length(xs), length(ys)),
    (X(collect(xs)), Y(collect(ys))))

r_mean = predict_raster(model_jl, res_jl, template;
    component = SPDE2, quantity = :mean)
r_lower = predict_raster(model_jl, res_jl, template;
    component = SPDE2, quantity = :lower)
r_upper = predict_raster(model_jl, res_jl, template;
    component = SPDE2, quantity = :upper)

interior(r) = filter(!isnan, parent(r))
(
    grid_size = size(r_mean),
    n_cells_inside_mesh = count(!isnan, parent(r_mean)),
    mean_extrema  = extrema(interior(r_mean)),
    lower_extrema = extrema(interior(r_lower)),
    upper_extrema = extrema(interior(r_upper)),
)
(grid_size = (61, 85), n_cells_inside_mesh = 4395, mean_extrema = (-0.9464622147131828, 0.9251520703262933), lower_extrema = (-1.4255077830479734, 0.38206214048406323), upper_extrema = (-0.4674166463783921, 1.4682420001685235))

r_mean, r_lower, r_upper are projections of the per-vertex posterior summaries reported by random_effects(model_jl, res_jl) onto the cell centres of template. They share the template's extent, resolution, and CRS — ready for direct plotting or overlay against a rasterised covariate.

For tail probabilities — for example, "the probability that the SPDE residual exceeds 0.5 log-mg/kg" — the Gaussian-approximation overload cannot help, since a per-cell P(η > c) is not a linear functional of the vertex marginals. The sample-based overload threads draws from the joint posterior through the same projector and reduces in-cell:

rng = Xoshiro(20260506)
r_exc = predict_raster(rng, model_jl, res_jl, template;
    component = SPDE2,
    quantity = Exceedance(0.5),
    n_samples = 500)
(
    n_cells_inside_mesh = count(!isnan, parent(r_exc)),
    exceedance_extrema  = extrema(interior(r_exc)),
)
(n_cells_inside_mesh = 4395, exceedance_extrema = (0.0, 0.928))

Each cell of r_exc is the empirical fraction of posterior draws in which the SPDE field at that cell exceeds 0.5. The Exceedance wrapper distinguishes "value to threshold against" from "credible-level quantile to compute" — passing a Real in [0, 1] instead computes the per-cell quantile of the sampled field.

Coming from R-INLA: the @lgm macro

The same fit expresses cleanly through LGMFormula.jl's @lgm macro. Coordinate-indexed terms f((east, north), spde) invoke the MeshProjector for you and place the resulting block in the design matrix at the position implied by formula order — the explicit constructor above and the macro both produce the same LatentGaussianModel:

using LGMFormula

df = (
    logzinc = y,
    dist    = dist,
    east    = locs[:, 1],
    north   = locs[:, 2],
)

model_macro = @lgm logzinc ~ 1 + dist + f((east, north), spde_jl) data=df family=GaussianLikelihood(hyperprior=PCPrecision(1.0, 0.01))
(
    A_size            = size(model_macro.mapping.A),
    components        = nameof.(typeof.(model_macro.components)),
    matches_explicit  = size(model_macro.mapping.A) == size(model_jl.mapping.A),
)
(A_size = (155, 251), components = (:Intercept, :FixedEffects, :SPDE2), matches_explicit = true)

The tuple-coordinate form requires INLASPDE to be loaded so the LGMFormulaINLASPDEExt weakdep extension is active; that is satisfied here by the using INLASPDE at the top of the vignette. See the migration guide for the full range of forms @lgm accepts.