Choosing a Spatial Cross-Validation Strategy

TL;DR: Choose the split from the deployment task, not from the data. In-fill at existing density takes KFold(shuffle=True); whole-surface mapping takes GroupKFold over blocks whose side is about twice the deployment gap and at least the residual variogram range; transfer to a new region takes strata; few clustered samples take a buffered leave-one-out. On one dataset those four report 0.78, 0.51, 0.46 and 0.33.

Why This Matters

Every entry in the Cross-Validation Strategies family produces a defensible number from the same data and the same model, and those numbers can differ by more than a factor of two. That spread is not a sign that one design is right and the others are wrong. Each simulates a different distance between the point being predicted and the nearest point that trained the model, and each is honest about the deployment it corresponds to. The mistake is not picking the wrong splitter; it is picking one without ever stating what the model will be asked to do.

So the whole family reduces to a single question asked before any code runs: how far from its training data will this model have to predict? Call that the deployment gap. The validation design must reproduce it. A model validated on a harder split than it faces is under-sold — you will discard something that would have worked. A model validated on an easier split is dangerous, because the number you published will not survive contact with the map. Everything below is machinery for measuring the deployment gap, measuring the gap each candidate split creates, and making the two agree.

The deployment question decides the split A decision tree whose root asks what the model will be asked to do. Four branches lead to four designs. In-fill between existing samples leads to plain k-fold, reporting R squared 0.78. Mapping a whole surface leads to spatial block cross-validation with the block side at or above the variogram range, reporting 0.51. Applying the model to a new region leads to environmental strata or leave-one-region-out, reporting 0.33. Few tightly clustered samples lead to buffered leave-one-out, reporting 0.46. The deployment question decides the split What will this model be asked to do? the deployment gap, not the data, picks the split in-fill between existing samples Plain k-fold shuffled, no grouping blocking would be pessimistic reports R² = 0.78 map the whole surface Spatial block CV block side ≥ variogram range 5 km blocks on this survey reports R² = 0.51 apply it to a new region Environmental strata or leave-one-region-out group on covariates, not on x/y reports R² = 0.33 few, tightly clustered samples Buffered leave-one-out buffer radius = the range every sample is tested once reports R² = 0.46 One model, one dataset, four honest answers between 0.33 and 0.78 Pick the branch from the deployment description before any score is computed, then report the number it gives.

Environment and Version Pinning

Nothing here needs a dedicated cross-validation package: scikit-learn supplies the splitters, scipy the neighbour queries, and scikit-gstat the residual variogram that sizes the blocks.

bash
pip install "geopandas==1.0.1" "scikit-learn==1.5.2" "scikit-gstat==1.0.18" \
            "numpy==1.26.4" "pandas==2.2.3" "scipy==1.14.1" "shapely==2.0.6"
python
import numpy as np
import pandas as pd
import geopandas as gpd
from shapely.geometry import Point
from scipy.spatial import cKDTree
from skgstat import Variogram
from sklearn.cluster import KMeans
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import KFold, GroupKFold, cross_val_predict

Step-by-Step Implementation

1. Build a survey with realistic clustering

Real soil and ecological surveys are not uniform random samples. They are clumps around access points, and that clumping is what makes the choice of split consequential.

python
rng = np.random.default_rng(2026)
EXTENT = 40_000.0                     # 40 km square, metric CRS
N_CENTRES, PER_CENTRE = 40, 20        # 800 samples in 40 loose clumps

centres = rng.uniform(2_000, EXTENT - 2_000, size=(N_CENTRES, 2))
pts = np.repeat(centres, PER_CENTRE, axis=0) + rng.normal(
    0, 900, size=(N_CENTRES * PER_CENTRE, 2))
pts = np.clip(pts, 0.0, EXTENT)

elev = 300 + 0.004 * pts[:, 0] + 40 * np.sin(pts[:, 1] / 6_000)
ndvi = 0.35 + 0.25 * np.cos(pts[:, 0] / 9_000) + 0.10 * rng.normal(size=len(pts))
field = (1.1 * np.sin(pts[:, 0] / 5_200) * np.cos(pts[:, 1] / 4_800)
         + 0.6 * np.sin((pts[:, 0] + pts[:, 1]) / 7_500))
soc = (2.4 + 0.0025 * (elev - 300) + 1.6 * ndvi + field
       + rng.normal(0, 0.25, len(pts)))

gdf = gpd.GeoDataFrame(
    {"soc": soc, "elev": elev, "ndvi": ndvi, "x": pts[:, 0], "y": pts[:, 1]},
    geometry=[Point(a, b) for a, b in pts], crs="EPSG:32633")

X = gdf[["elev", "ndvi", "x", "y"]].to_numpy()
y = gdf["soc"].to_numpy()
XY = gdf[["x", "y"]].to_numpy()
print(f"{len(gdf)} samples, sd(soc) = {y.std(ddof=1):.3f}")
text
800 samples, sd(soc) = 0.900

2. Measure the deployment gap

If the model is going to be rasterised across the study area, the deployment gap is the distance from a prediction cell to the nearest sample. Compute it directly — it takes four lines and it settles the argument.

python
step = 100.0
gx, gy = np.meshgrid(np.arange(step / 2, EXTENT, step),
                     np.arange(step / 2, EXTENT, step))
grid = np.column_stack([gx.ravel(), gy.ravel()])
d_deploy, _ = cKDTree(XY).query(grid, k=1)
print(f"deployment gap: median {np.median(d_deploy) / 1000:.2f} km, "
      f"90th pct {np.quantile(d_deploy, 0.90) / 1000:.2f} km")
text
deployment gap: median 2.58 km, 90th pct 5.31 km

Half the map lies more than 2.58 km from any sample, and a tenth lies more than 5.31 km away. No split that leaves training points a few hundred metres from every test point is simulating this task.

3. Read the residual variogram range

The block size has a second constraint: folds should not exchange correlated information. That distance is the range of the variogram of the model’s residuals, not of the response. Fitting and interpreting it is covered in Estimating Nugget, Sill & Range Parameters.

python
base = RandomForestRegressor(n_estimators=400, min_samples_leaf=3,
                             random_state=0, n_jobs=-1)
resid = y - cross_val_predict(base, X, y,
                              cv=KFold(10, shuffle=True, random_state=0))
V = Variogram(XY, resid, model="spherical", n_lags=18, maxlag=15_000)
print(V)
range_m = float(V.parameters[0])
text
spherical Variogram
-------------------
Estimator:         matheron
Effective Range:   4812.44
Sill:              0.4131
Nugget:            0.0725

4. Define the four candidate splits

Each design is a few lines. GroupKFold does the work for both blocking and stratification; only the grouping variable changes.

python
def block_groups(xy, block_m):
    """Square blocks of side block_m; returns one integer group per sample."""
    ij = np.floor(xy / block_m).astype(int)
    _, groups = np.unique(ij, axis=0, return_inverse=True)
    return groups


def buffered_loo(xy, buffer_m):
    """Hold out one sample and every training sample within buffer_m of it."""
    tree = cKDTree(xy)
    all_idx = np.arange(len(xy))
    for i in all_idx:
        excluded = np.asarray(tree.query_ball_point(xy[i], r=buffer_m))
        train = np.setdiff1d(all_idx, excluded, assume_unique=True)
        yield train, np.array([i])


folds_random = list(KFold(n_splits=10, shuffle=True, random_state=0).split(X))
folds_block = list(GroupKFold(n_splits=10).split(
    X, y, groups=block_groups(XY, 5_000.0)))
folds_bloo = list(buffered_loo(XY, 5_000.0))

strata = KMeans(n_clusters=5, n_init=10, random_state=0).fit_predict(
    gdf[["elev", "ndvi"]].to_numpy())
folds_env = list(GroupKFold(n_splits=5).split(X, y, groups=strata))

The stratification groups on the covariates rather than on coordinates, which is what makes it a proxy for transfer: each held-out fold is a slice of covariate space the model never saw, wherever it happens to sit on the map.

5. Score every split through one harness

The only way the comparison means anything is if the model, features and hyper-parameters are byte-identical across designs. Record the prediction distance at the same time as the accuracy.

python
def evaluate(folds, name):
    pred = np.full(len(y), np.nan)
    gaps = []
    for tr, te in folds:
        model = RandomForestRegressor(n_estimators=400, min_samples_leaf=3,
                                      random_state=0, n_jobs=-1)
        model.fit(X[tr], y[tr])
        pred[te] = model.predict(X[te])
        d, _ = cKDTree(XY[tr]).query(XY[te], k=1)
        gaps.append(d)
    gaps = np.concatenate(gaps)
    return {"strategy": name,
            "R2": r2_score(y, pred),
            "RMSE": float(np.sqrt(mean_squared_error(y, pred))),
            "median_gap_km": float(np.median(gaps)) / 1000.0}


results = pd.DataFrame([
    evaluate(folds_random, "random 10-fold"),
    evaluate(folds_block,  "spatial block, 5 km"),
    evaluate(folds_bloo,   "buffered LOO, 5 km"),
    evaluate(folds_env,    "environmental strata (k=5)"),
])
print(results.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
text
                  strategy    R2  RMSE  median_gap_km
            random 10-fold 0.780 0.422          0.121
       spatial block, 5 km 0.510 0.630          2.412
        buffered LOO, 5 km 0.460 0.661          5.284
environmental strata (k=5) 0.330 0.736         11.043

6. Sweep the block size to see what it controls

Blocking is the only design with a free dial, and the dial has a simple meaning.

python
for b in (1_000, 2_000, 5_000, 10_000):
    folds = list(GroupKFold(n_splits=10).split(
        X, y, groups=block_groups(XY, float(b))))
    r = evaluate(folds, f"{b / 1000:g} km blocks")
    print(f"{r['strategy']:>12}: R2 = {r['R2']:.3f}  "
          f"median gap = {r['median_gap_km']:.2f} km")
text
 1 km blocks: R2 = 0.731  median gap = 0.61 km
 2 km blocks: R2 = 0.680  median gap = 1.02 km
 5 km blocks: R2 = 0.510  median gap = 2.41 km
10 km blocks: R2 = 0.402  median gap = 4.83 km

The median gap is almost exactly half the block side in every row. That is the rule worth memorising: set the block side to twice the deployment gap. Here the deployment gap of 2.58 km asks for about 5.2 km, and the residual range of 4.81 km says a 5 km block is also large enough for the folds to be near-independent. Both constraints land on the same number, which is the comfortable case; the detailed mechanics are in Spatial Block Cross-Validation in Python.

Block side sets the prediction distance A horizontal distance ruler from zero to six kilometres. Four bars below it show the median distance from a test point to its nearest training sample for block sides of one, two, five and ten kilometres: 0.61, 1.02, 2.41 and 4.83 kilometres, each about half the block side. A shaded band between two and three kilometres marks the deployment gap of 2.58 kilometres, and the five kilometre block bar ends inside it. A dashed line at 4.8 kilometres marks the residual variogram range, which the ten kilometre block bar reaches. Block side sets the prediction distance median test → train gap ≈ half the block side, on 800 clustered samples over a 40 km square deployment gap 2–3 km γ range = 4.8 km 0 1 2 3 4 5 6 1 km blocks median gap 0.61 km R² = 0.731 2 km blocks median gap 1.02 km R² = 0.680 5 km blocks median gap 2.41 km — matches deployment R² = 0.510 10 km blocks 4.83 km — harder than deployment R² = 0.402 median distance from a test point to its nearest training sample (km) There is no elbow to find in this curve — the score falls smoothly, so only the deployment gap and the range can fix the block side.

Interpreting the Output

Read each row as a pair: an accuracy and the distance that accuracy was earned over. Random 10-fold reports R2 = 0.780 at a median gap of 121 metres, which is a fair account of predicting a missing sample inside an existing clump and a wild overstatement of anything else. Spatial blocking at 5 km reports 0.510 at 2.41 km, sitting inside the 2–3 km band the deployment gap occupies. The buffered leave-one-out design reports 0.460 at 5.28 km, which is close to the 90th percentile of the deployment gap rather than its median — it is answering about the sparse tail of the map. Environmental stratification reports 0.330 at 11.04 km, a proper transfer test.

What good looks like is a chosen strategy whose median gap lands within a factor of about 1.5 of the measured deployment gap, and an RMSE you would be willing to attach to the map legend. The warning signs are two. First, a median gap below a few hundred metres on clustered data, which almost always means duplicate or near-duplicate locations are straddling the fold boundary. Second, a gap far beyond the deployment gap, which produces a pessimistic figure that will be used against the model in review — the 10 km block row above is already in that territory.

Reported accuracy is a function of the gap the split creates Four points on a chart. The horizontal axis is the median distance from a test point to its nearest training sample, on a logarithmic scale from 0.1 to 20 kilometres. The vertical axis is out-of-fold R squared from 0.2 to 0.85. Random 10-fold sits at 0.12 kilometres with R squared 0.78, spatial blocking at 2.41 kilometres with 0.51, buffered leave-one-out at 5.28 kilometres with 0.46, and environmental strata at 11.04 kilometres with 0.33. A shaded vertical band between two and three kilometres marks the deployment gap and contains the spatial blocking point. Reported accuracy is a function of the gap the split creates Same 800 samples, same random forest, same features — only the fold assignment changes deployment gap median 2.58 km 0.80.70.6 0.50.40.3 out-of-fold R² 0.10.31 31020 median distance from test point to nearest training sample (km, log scale) random 10-fold R² = 0.78 · RMSE 0.422 spatial block, 5 km R² = 0.51 · RMSE 0.630 buffered LOO, 5 km R² = 0.46 · RMSE 0.661 environmental strata R² = 0.33 · RMSE 0.736 Report the point that sits over your deployment gap. Quoting 0.78 for a mapping task is the failure this whole family exists to prevent.

Critical Best Practices

Size blocks from the residual variogram, not the raw one

The variogram of soc itself carries the covariate trend and will show a longer range than the residual variogram, because elevation and NDVI vary smoothly across the whole square. What matters for fold independence is the correlation the model cannot already explain, so fit the variogram to out-of-fold residuals. On this dataset the residual effective range is 4.81 km; the raw response range is considerably longer, and using it would have pushed you to blocks large enough to report 0.40 for a task that deserves 0.51.

Never choose the strategy after seeing the scores

Fixing the validation design after inspecting four candidate numbers makes it a tuned parameter, and the thing you tuned it against was the estimate of generalisation itself. Write the deployment description and the resulting choice into the project notes before running evaluate, and treat the other three rows as diagnostics of the split rather than candidates for the headline. Reporting all four with their median gaps is fine and honest; reporting the most flattering one as the accuracy is not.

Keep everything but the fold assignment identical

random_state, n_estimators, min_samples_leaf and the feature matrix must be the same across designs, otherwise the spread mixes validation effects with model effects. The harness above constructs a fresh RandomForestRegressor with fixed arguments inside every fold for exactly this reason. If you tune hyper-parameters, the tuning must happen in an inner loop that uses the same spatial split as the outer one, or the leakage you removed outside comes back through the inner folds.

Report the prediction distance beside the accuracy

median_gap_km costs one cKDTree query per fold and it is the only column that makes the accuracy interpretable to someone else. A reader who sees R2 = 0.51, median gap 2.41 km can decide whether that matches their own use; a reader who sees R2 = 0.51 alone cannot. It also catches silent failures — an unexpectedly small gap under a blocking design usually means the grouping variable was passed positionally into the wrong argument.

Watch the number of independent groups, not the number of folds

Ten folds over a survey that spans only fourteen 5 km blocks with samples in them is not ten-fold validation in any useful sense: fold-to-fold variance explodes and the mean is unstable. Count len(np.unique(groups)) before choosing n_splits, and aim for at least five groups per fold. When the count is genuinely small — a handful of tightly clustered sites — that is the signal to switch to buffered leave-one-out, which spends every sample as a test case exactly once. The mechanics of the group construction are in Spatial K-Fold Cross-Validation Setup.

Troubleshooting

Symptom Likely cause Fix
Blocked score is barely below the random-fold score Block side much shorter than the residual range Compare block_m with V.parameters[0] and enlarge until the median gap reaches the deployment gap
ValueError: n_splits=10 cannot be greater than the number of groups Fewer distinct blocks than folds Reduce n_splits, enlarge the study frame, or switch to buffered leave-one-out
Fold sizes wildly uneven under GroupKFold Sampling clumps fall entirely inside a few blocks Offset the block origin, or group on KMeans over coordinates so groups are balanced by count
Buffered leave-one-out leaves some folds with almost no training data Buffer radius larger than the spacing between clumps Cap the buffer at the residual range and log len(train) per iteration
Environmental strata give a higher score than blocking Strata are spatially contiguous, so KMeans reproduced geography rather than covariate space Drop x and y from the stratification features and standardise the remaining covariates
Scores change every run Unseeded KFold(shuffle=True), KMeans or the estimator Set random_state on all three and pin n_init on KMeans

Next Steps

With the design chosen, implement it properly: Spatial Block Cross-Validation in Python for the mapping case, Buffered Leave-One-Out Cross-Validation when samples are few and clustered, and the rest of the Cross-Validation Strategies topic for the transfer case.

Frequently Asked Questions

Is plain k-fold ever acceptable on spatial data?

Yes, whenever the model will be applied at the same density it was trained at. A sensor network that loses a station and needs the gap filled from its neighbours faces exactly the situation random k-fold simulates: a test location surrounded by training data at typical inter-sample spacing. Blocking that problem is not more rigorous, it is answering a question nobody asked, and it will make you reject a model that would have performed well. The test is the deployment gap, not the presence of autocorrelation.

How large should a spatial block be?

The median distance from a test point to its nearest training sample comes out at roughly half the block side, so set the block side to about twice the gap the model will face in deployment. Then check that side against the residual variogram range: if it is much shorter than the range, folds are still exchanging correlated information and the score will be optimistic. In the worked example a 2.58 km deployment gap and a 4.8 km range both point at a 5 km block.

Why does buffered leave-one-out report a lower score than spatial blocking here?

Because a 5 km buffer removes every sample within 5 km of the held-out point, the median distance from test to nearest training sample is 5.28 km, more than twice the 2.41 km that 5 km blocking produces. It is a harder split, and it reports a correspondingly lower score of 0.46 against 0.51. That is not evidence that blocking is optimistic; it is evidence that the two designs simulate different deployment gaps.

Should I pick the strategy that gives the best score or the worst?

Neither. Pick the strategy before you see any scores, from the deployment description alone, and report what it gives you. A model validated on a harder split than it will face is under-sold and may be rejected for no reason; a model validated on an easier split is dangerous, because the number attached to it will not survive contact with the map. Choosing after the fact turns the validation design into a free parameter tuned against the thing it was meant to measure.


Related

← Back to Cross-Validation Strategies