How to run spatial block cross-validation

You need an honest out-of-sample comparison between spatial models, and az.loo will not give you one — leave-one-out assumes observations are exchangeable given the parameters, which spatial dependence breaks. spatial_kfold holds out spatially contiguous blocks instead, refits on the remainder, and scores the held-out fold under the full-data joint implied by the model [Roberts et al., 2017].

The scoring rule and its derivation are in Architecture.

!pip install git+https://github.com/ljwolf/geovalidate/ --no-deps
Collecting git+https://github.com/ljwolf/geovalidate/
  Cloning https://github.com/ljwolf/geovalidate/ to /tmp/pip-req-build-amecmuag
  Running command git clone --filter=blob:none --quiet https://github.com/ljwolf/geovalidate/ /tmp/pip-req-build-amecmuag
  Resolved https://github.com/ljwolf/geovalidate/ to commit ab72fa3336a6bcecefd4b69184a06307df1fe24e
  Installing build dependencies ... ?25l-
 \
 |
 done
?25h  Getting requirements to build wheel ... ?25ldone
?25h  Preparing metadata (pyproject.toml) ... ?25ldone
?25hBuilding wheels for collected packages: geovalidate
  Building wheel for geovalidate (pyproject.toml) ... ?25ldone
?25h  Created wheel for geovalidate: filename=geovalidate-0.1.0-py3-none-any.whl size=68551 sha256=bb3a33ec9bf9b56be56045d60a3297154d9059b5bda77c8c57689639fa672d91
  Stored in directory: /tmp/pip-ephem-wheel-cache-krn42y_f/wheels/07/e1/1c/505deea87953881f24f62e5ffe6dc847b905492d550844705a
Successfully built geovalidate
Installing collected packages: geovalidate
Successfully installed geovalidate-0.1.0

Set up data with a known answer

We simulate from a SAR data-generating process on a regular grid so the “correct” model is known in advance. A well-behaved CV criterion should prefer the SAR over the misspecified OLS, SLX, and SEM alternatives.

import numpy as np
import pandas as pd
from libpysal.graph import Graph

from neighbayes.dgp import simulate_sar
from neighbayes.diagnostics import spatial_kfold
from neighbayes.models import OLS, SAR, SEM, SLX

SEED = 0
GRID = 12  # 12 x 12 = 144 cells
RHO_TRUE = 0.6
BETA_TRUE = np.array([1.0, 2.0, -1.5])

gdf = simulate_sar(
    n=GRID,
    rho=RHO_TRUE,
    beta=BETA_TRUE,
    sigma=1.0,
    seed=SEED,
    create_gdf=True,
    geometry_type="polygon",
)

W = Graph.build_contiguity(gdf, rook=True).transform("r")
print(f"n = {len(gdf)}, true rho = {RHO_TRUE}, columns = {list(gdf.columns)}")
n = 144, true rho = 0.6, columns = ['y', 'X_0', 'X_1', 'X_2', 'geometry']

Fit the models you want to compare

We fit four candidates — only the SAR matches the DGP — using short MCMC runs sufficient for a pedagogical example.

FIT_KW = dict(draws=400, tune=400, chains=2, random_seed=SEED, progressbar=False)
FORMULA = "y ~ X_1 + X_2"

models = {
    "OLS": OLS(formula=FORMULA, data=gdf, W=W),
    "SLX": SLX(formula=FORMULA, data=gdf, W=W),
    "SAR": SAR(formula=FORMULA, data=gdf, W=W, logdet_method="eigenvalue"),
    "SEM": SEM(formula=FORMULA, data=gdf, W=W, logdet_method="eigenvalue"),
}
for name, m in models.items():
    m.fit(**FIT_KW)
    print(f"  fit {name}")
  fit OLS
  fit SLX
  fit SAR
  fit SEM
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 400 tune and 400 draw iterations (800 + 800 draws total) took 5 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
  W_arr = np.asarray(W, dtype=np.float64)

Score them out of sample

spatial_kfold derives 5 spatial blocks from the cell centroids via KMeans, refits each model on each train fold, and accumulates the held-out elpd. Higher elpd is better.

CV_KW = dict(draws=200, tune=200, chains=1, random_seed=SEED, progressbar=False)

results = {}
for name, m in models.items():
    results[name] = spatial_kfold(m, geometry=gdf.geometry, n_blocks=5, **CV_KW)

summary = pd.DataFrame(
    {
        "elpd": [r.elpd for r in results.values()],
        "se": [r.se for r in results.values()],
        "n_folds": [r.n_folds for r in results.values()],
    },
    index=list(results.keys()),
).sort_values("elpd", ascending=False)
summary["delta_elpd"] = summary["elpd"] - summary["elpd"].max()
summary
elpd se n_folds delta_elpd
SAR -210.455318 1.722710 5 0.000000
SLX -236.216386 2.348071 5 -25.761069
SEM -239.714462 1.364624 5 -29.259144
OLS -282.428976 2.467718 5 -71.973659

With data simulated from a SAR DGP, spatial block CV should rank SAR on top, with the misspecified alternatives trailing behind. The delta_elpd column shows the elpd difference relative to the best model — values close to zero indicate near-equivalent predictive performance, while large negative values indicate worse out-of-block prediction.

Swap in a different fold design

The built-in KMeans blocking is convenient but coarse. For a richer menu of spatial cross-validation designs — Hilbert space-filling curves, DGGS-cell stratification, clustered hold-outs, and exclusion-buffered leave-one-out — spatial_kfold accepts any sklearn-style splitter via the splitter= keyword. The geovalidate package provides a comprehensive set of these splitters.

Install with pip install geovalidate.

Below we re-run CV using HilbertKFold, which interleaves observations along a Hilbert curve so every fold covers the entire study area — typically a more balanced design than KMeans blocking.

from geovalidate import HilbertKFold

splitter = HilbertKFold(n_splits=5, random_state=SEED)

results_hilbert = {
    name: spatial_kfold(m, splitter=splitter, geometry=gdf.geometry, **CV_KW)
    for name, m in models.items()
}

summary_hilbert = pd.DataFrame(
    {
        "elpd": [r.elpd for r in results_hilbert.values()],
        "se": [r.se for r in results_hilbert.values()],
        "method": [r.method for r in results_hilbert.values()],
    },
    index=list(results_hilbert.keys()),
).sort_values("elpd", ascending=False)
summary_hilbert["delta_elpd"] = summary_hilbert["elpd"] - summary_hilbert["elpd"].max()
summary_hilbert
elpd se method delta_elpd
SAR -207.191256 0.906427 HilbertKFold 0.000000
SEM -226.684752 1.638514 HilbertKFold -19.493496
SLX -237.219069 1.038330 HilbertKFold -30.027812
OLS -280.125493 1.441062 HilbertKFold -72.934237

The method column now records the splitter class name (HilbertKFold) instead of "kmeans". Any other geovalidate splitter slots in the same way — for example LeaveClusterOut for leave-one-region-out CV, or BallKFold for spatially-exclusive folds with an explicit exclusion radius.

See also