Designing Spatial Cross-Validation for Raster Models
Size the blocks from the data, not from habit: estimate how far the target stays correlated, then make the blocks bigger than that.
import numpy as np
def empirical_variogram(x, y, values, *, bins=np.arange(0, 40_000, 2_000), n=5_000):
"""Semivariance against separation distance for a sample of point pairs."""
rng = np.random.default_rng(0)
i, j = rng.integers(0, len(values), n), rng.integers(0, len(values), n)
d = np.hypot(x[i] - x[j], y[i] - y[j])
gamma = 0.5 * (values[i] - values[j]) ** 2
idx = np.digitize(d, bins)
return bins, np.array([gamma[idx == k].mean() if (idx == k).any() else np.nan
for k in range(1, len(bins) + 1)])
The distance at which that curve flattens is the correlation range, and any block smaller than it leaks. This page belongs to validating raster predictions and accuracy assessment in Raster Machine Learning & Model Inference.
Reading the Range off a Variogram
The empirical variogram is the cheapest possible diagnostic for how much structure a target carries, and it answers the block-size question directly rather than by argument.
For a categorical target, run the same computation on an indicator variable — one for the class of interest, zero otherwise. The resulting range describes how far patches of that class extend and cluster, which is exactly the quantity a block needs to exceed.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Variogram sampling and fold arithmetic |
scikit-learn |
>=1.3 |
GroupKFold, LeaveOneGroupOut, scoring |
geopandas |
>=0.14 |
Region polygons and spatial joins |
pandas |
>=2.0 |
The sample table carrying coordinates and folds |
pip install "numpy>=1.23" "scikit-learn>=1.3" "geopandas>=0.14" "pandas>=2.0"
Complete Working Example
import numpy as np
import pandas as pd
from sklearn.base import clone
from sklearn.metrics import f1_score
from sklearn.model_selection import GroupKFold
def spatial_cv(model, X: np.ndarray, y: np.ndarray, xy: np.ndarray,
*, block_m: float = 15_000, n_folds: int = 5,
buffer_m: float = 0.0) -> pd.DataFrame:
"""Blocked cross-validation with an optional discarded buffer between folds."""
bx = np.floor(xy[:, 0] / block_m).astype(int)
by = np.floor(xy[:, 1] / block_m).astype(int)
block = bx * 1_000_003 + by
rows = []
for fold, (train, test) in enumerate(
GroupKFold(n_splits=n_folds).split(X, y, groups=block)):
if buffer_m > 0:
# Drop training samples within buffer_m of any test sample's block
test_blocks = set(block[test].tolist())
near = np.array([
(bx[i] * 1_000_003 + by[i]) in test_blocks
or any((bx[i] + dx) * 1_000_003 + (by[i] + dy) in test_blocks
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
for i in train
])
train = train[~near]
fitted = clone(model).fit(X[train], y[train])
pred = fitted.predict(X[test])
rows.append({
"fold": fold,
"n_train": len(train),
"n_test": len(test),
"macro_f1": f1_score(y[test], pred, average="macro"),
"accuracy": (pred == y[test]).mean(),
})
out = pd.DataFrame(rows)
print(out.to_string(index=False))
print(f"\nmacro F1: {out.macro_f1.mean():.3f} "
f"(range {out.macro_f1.min():.3f}-{out.macro_f1.max():.3f})")
return out
The buffer implementation deliberately drops from the training side rather than the test side. Shrinking the training set slightly is a cost paid in model quality; shrinking the test set would change what is being measured and make folds incomparable.
Interpreting the Spread
A wide spread is information, not noise. Map the fold scores back onto the study area and look at the low-scoring blocks: they usually share something identifiable — a different ecological zone, a persistent cloud regime, a season with few observations, a region where the labels were collected by a different team. Each of those has a different remedy, and none of them is visible from the mean.
This is also the check that decides whether one model or several is the right answer. If the low fold corresponds to a genuinely different landscape, a separate model for that landscape will usually beat any amount of tuning on a single global one — and the fold scores are the evidence for that decision rather than an intuition about it.
Verification
import numpy as np
# No block appears in both sides of any split
for fold, (train, test) in enumerate(GroupKFold(5).split(X, y, groups=block)):
assert not (set(block[train]) & set(block[test])), f"fold {fold} leaks blocks"
# Folds are comparable in size and class mix
sizes = [len(test) for _, test in GroupKFold(5).split(X, y, groups=block)]
assert max(sizes) / min(sizes) < 2.0, f"fold sizes very unbalanced: {sizes}"
The size check is the one that catches a design problem rather than a code problem. Blocks vary in how many samples they contain, and if one block holds a third of the data no grouping scheme will give even folds; the fix is a better sampling design, not a different splitter. The general fold-assignment mechanics are covered in splitting chips into spatially disjoint folds.
Common Errors
The variogram is flat from the first bin
Either the target has no spatial structure at the sampled distances, or the sample is too small. Reduce the bin width and resample; a genuinely flat variogram means a random split would have been fine, which is rare but not impossible.
Buffering removes most of the training data
The buffer is large relative to the block. Keep the buffer to one block ring, and enlarge the blocks rather than the buffer if leakage is still suspected.
Scores vary wildly between runs with the same settings
Blocks are being assigned to folds in geographic order, so the split depends on iteration order. Shuffle the block list with a fixed seed before assigning.
Frequently Asked Questions
Q: How do I find the autocorrelation range in practice? Compute an empirical variogram of the target variable on a sample of point pairs and find the distance at which the semivariance flattens. For categorical targets use an indicator variogram on the class of interest. Round the answer up and use it as the block edge.
Q: Is the mean of the fold scores the number to report? Report both the mean and the range. A model scoring 0.80 on every fold and one averaging 0.80 across folds of 0.62 and 0.94 are very different products, and only the second warns you that performance depends heavily on where it is applied.
Q: Does spatial cross-validation apply to time as well? The same logic applies to any dimension with autocorrelation. For a model that will be applied to a future year, hold out whole years rather than random dates, or the score measures interpolation within a season you have already seen.
Related
- Validating Raster Predictions and Accuracy Assessment — where these fold scores become a reported figure.
- Splitting Chips into Spatially Disjoint Folds — the dataset-side implementation.
- Estimating Area with Stratified Random Sampling — the complementary design question for area figures.
- Harmonising Features across Sensors for Transfer — when the fold spread points at a sensor difference rather than a landscape one.