How to Do Spatial Cross-Validation in Python

On this page

Problem statement

Spatial block cross-validation is the standard recommendation for spatial machine learning. Applied without thinking about the deployment scenario, it gives a worse answer than the random k-fold it replaced.

Measured on a real problem — predicting Sentinel-2 NDVI from terrain on Snowdon, 3,000 training cells, repeated over ten random samples (Example 4):

                        mean R²    sd
random 5-fold CV         +0.339   0.024
spatial block CV         +0.102   0.106
true error over the
whole unsampled map      +0.344   0.008

Against the truth, random CV missed by 0.005 and block CV by 0.24, with four times the spread from sample to sample. The scheme to use is the one whose question matches the deployment, and the comparison above is how to check it.

Change the task to genuine extrapolation — train on the western half, predict the east — and both lie:

random CV      +0.338
block CV       +0.033
actual         -0.409

Quick answer

Match the validation to the deployment:

from sklearn.cluster import KMeans
from sklearn.model_selection import GroupKFold, KFold

# predicting inside the sampled area
folds = KFold(n_splits=5, shuffle=True, random_state=0).split(X)

# predicting in unsampled areas within the same region
blocks = KMeans(n_clusters=10, random_state=0, n_init=10).fit_predict(coords)
folds = GroupKFold(n_splits=5).split(X, y, blocks)

# predicting in a genuinely new region: hold one out for real
region = np.where(coords[:, 0] < np.median(coords[:, 0]), "west", "east")
train = region != "east"
Three validation schemes matched to three deployment scenarios: interpolation, unsampled areas, and a new region. match the scheme to the deploymentwhere will the model actually predict?inside the samplerandom k-foldaccurate to 0.005unsampled, same regionblock CVexpect pessimisma new regionhold out a real regionCV cannot estimate itThere is no universally honest scheme; each estimates a different quantity spatialworkflow.io
There is no universally honest scheme. Each estimates a different quantity.

What you get — from 4. Keep everything inside the fold

[0.429 0.403 0.327 0.312 0.377]

The deployment task, block size, repeats, the fold boundary

1. Decide what the model will be asked to do

  • Fill gaps inside a surveyed area. Random k-fold matches this and was accurate to 0.005 R².
  • Predict unsampled ground in the same region. Block CV is the right shape, and expect it to be pessimistic.
  • Transfer to a new region. Neither works. Hold out a real region.

Writing this down before choosing a splitter is the whole method.

2. Build blocks larger than the autocorrelation range

Blocks smaller than the range still leak: a held-out point has training points within its correlation distance, just in a different fold.

Estimate the range from a variogram of the target, and make blocks at least that size. On the measured data the semivariance of NDVI reached 95% of its variance at 2.5 km (Example 4). Ten KMeans blocks over the 10 km window spanned a median 3.65 km, just clear of it; five blocks are larger still.

3. Use enough blocks, and repeat

Five blocks gave a standard deviation of 0.106 across ten samples of 3,000 cells, ranging from −0.057 to +0.258. A single number from a single sample is false precision. On one sample, five different ten-block layouts varied much less (+0.234 to +0.258, Example 1): the blocks follow the sample, so repeat over samples, not just seeds.

Ten to twenty blocks with a 30% hold-out is a better default, and repeating over several random block layouts and reporting the spread is better still.

4. Keep everything inside the fold

Feature scaling, imputation, feature selection and hyperparameter tuning all leak if fitted on the full dataset before splitting.

from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import KFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

sample = np.random.default_rng(0).choice(len(y), 3000, replace=False)
Xs, ys, cs = X[sample], y[sample], coords[sample]
folds = KFold(n_splits=5, shuffle=True, random_state=0)
model = make_pipeline(StandardScaler(), RandomForestRegressor(n_estimators=200, n_jobs=-1, random_state=0))
print(cross_val_score(model, Xs, ys, cv=folds).round(3))
[0.429 0.403 0.327 0.312 0.377]

A Pipeline refits every step per fold. Scaling outside it uses the test fold's statistics — an ordinary, non-spatial leak that spatial CV does nothing about.

5. Report the scheme with the score

R² = 0.34 is not a result. R² = 0.34, random 5-fold, for prediction within the sampled extent is.

Random CV, block CV and the true map R² over ten random samples of 3,000 cells: block CV ranged from −0.057 to +0.258 while the truth stayed near 0.344. ten samples of 3,000 cells, Snowdon NDVI from terrainmean R²sdrangerandom 5-fold CV+0.3390.024+0.303 to +0.3755-block CV+0.1020.106−0.057 to +0.258true map R²+0.3440.008+0.335 to +0.360More than four times the spread of random CV — one sample is not a measurement spatialworkflow.io
Block CV has five times the variance of random CV. One layout is not a measurement.

Buffered blocks and an honest transfer test

The data used on this page

A 10 km window on Snowdon, 4.16–4.00° W, 53.03–53.12° N. The target is NDVI from the least cloudy Sentinel-2 L2A scene of June–September 2025 on Earth Search, on the scene's 20 m grid; the features are terrain from the Copernicus DEM GLO-30 resampled to the same grid — elevation, slope, northness, eastness and a topographic position index over 300 m. Cells the scene classifies as water, cloud, cloud shadow, snow or no data are dropped. X, y and coords hold every remaining cell; the examples sample from them.

import numpy as np
import rasterio
import rasterio.merge
from pystac_client import Client
from rasterio.enums import Resampling
from rasterio.warp import reproject, transform_bounds
from scipy import ndimage

AOI = (-4.16, 53.03, -4.00, 53.12)
items = Client.open("https://earth-search.aws.element84.com/v1").search(
    collections=["sentinel-2-l2a"], bbox=list(AOI), datetime="2025-06-01/2025-10-01",
    query={"eo:cloud_cover": {"lt": 60}}).items()
item = min(items, key=lambda i: i.properties["eo:cloud_cover"])

with rasterio.open(item.assets["scl"].href) as ds:                 # the 20 m grid
    win = rasterio.windows.from_bounds(*transform_bounds("EPSG:4326", ds.crs, *AOI),
                                       transform=ds.transform).round_offsets().round_lengths()
    scl, transform, crs = ds.read(1, window=win), ds.window_transform(win), ds.crs
    bounds = rasterio.windows.bounds(win, ds.transform)
shape = scl.shape
band = {}
for name in ("red", "nir"):                                         # 10 m bands averaged onto it
    with rasterio.open(item.assets[name].href) as ds:
        band[name] = ds.read(1, window=rasterio.windows.from_bounds(*bounds, transform=ds.transform),
                             out_shape=shape, resampling=Resampling.average).astype("float32") / 1e4
ndvi = (band["nir"] - band["red"]) / (band["nir"] + band["red"])

tiles = [rasterio.open(f"https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N53_00_{t}_00_DEM/"
                       f"Copernicus_DSM_COG_10_N53_00_{t}_00_DEM.tif") for t in ("W005", "W004")]
mosaic, mosaic_transform = rasterio.merge.merge(tiles, bounds=(-4.18, 53.02, -3.98, 53.13))
elev = np.full(shape, np.nan, dtype="float32")
reproject(mosaic[0], elev, src_transform=mosaic_transform, src_crs=tiles[0].crs,
          dst_transform=transform, dst_crs=crs, resampling=Resampling.bilinear)
dz_dy, dz_dx = np.gradient(elev, -20, 20)                           # rows run south
slope = np.degrees(np.arctan(np.hypot(dz_dx, dz_dy)))
aspect = np.arctan2(-dz_dx, -dz_dy)                                 # direction the slope faces, from north
tpi = elev - ndimage.uniform_filter(elev, size=15)                  # against the 300 m neighbourhood

valid = np.isin(scl, (2, 4, 5, 7)) & np.isfinite(ndvi) & np.isfinite(elev)
rows, cols = np.nonzero(valid)
xs, ys = rasterio.transform.xy(transform, rows, cols)
coords = np.column_stack([xs, ys])
X = np.column_stack([elev[valid], slope[valid], np.cos(aspect[valid]), np.sin(aspect[valid]), tpi[valid]])
y = ndvi[valid]
FEATURES = ["elevation", "slope", "northness", "eastness", "tpi"]
print(f"{item.id}: {shape[1]} x {shape[0]} cells of 20 m, {valid.sum():,} usable "
      f"({valid.mean():.1%}); NDVI mean {y.mean():.3f}, sd {y.std():.3f}")
S2C_30UVD_20250712_0_L2A: 543 x 509 cells of 20 m, 270,536 usable (97.9%); NDVI mean 0.730, sd 0.145

Example 1 — spatial blocks with the spread reported

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupKFold


def spatial_cv(model_factory, X, y, coords, n_blocks=10, n_splits=5,
               repeats=5):
    """Block CV repeated over several block layouts."""
    scores = []
    for seed in range(repeats):
        blocks = KMeans(n_clusters=n_blocks, random_state=seed,
                        n_init=10).fit_predict(coords)
        predicted = np.empty(len(y))
        for train, test in GroupKFold(n_splits=n_splits).split(X, y, blocks):
            predicted[test] = model_factory().fit(X[train], y[train]) \
                                             .predict(X[test])
        scores.append(r2_score(y, predicted))

    scores = np.array(scores)
    print(f"  {n_blocks} blocks, {n_splits} folds, {repeats} layouts")
    print(f"  R2 {scores.mean():+.3f} ± {scores.std():.3f} "
          f"(range {scores.min():+.3f} to {scores.max():+.3f})")
    if scores.std() > 0.05:
        print("  ! unstable — report the spread, not a single number")
    return scores


forest = lambda: RandomForestRegressor(n_estimators=200, min_samples_leaf=2, n_jobs=-1, random_state=0)
spatial_cv(forest, Xs, ys, cs)
  10 blocks, 5 folds, 5 layouts
  R2 +0.245 ± 0.008 (range +0.234 to +0.258)
Semivariance of Snowdon NDVI at four separations, as a share of its variance: the autocorrelation range is about 2.5 km. how alike two cells are, by distance apart250 m apart0.0092 — still strongly alike1 km apart0.01603 km apart0.0213 — as unlike as any two6 km apart0.0227050%100%% of varianceSemivariance reached 95% of the variance (0.0210) at 2.5 km: blocks need to be larger than that spatialworkflow.io
Measured in Example 4 from 5,000 cells. Ten KMeans blocks spanned a median 3.65 km, just beyond the range.

Example 2 — blocks with a buffer, so folds do not touch

import numpy as np
from scipy.spatial import cKDTree


def buffered_blocks(coords, n_blocks=10, buffer_m=500, seed=0):
    """Assign blocks, then drop training points within a buffer of the test fold."""
    from sklearn.cluster import KMeans
    blocks = KMeans(n_clusters=n_blocks, random_state=seed,
                    n_init=10).fit_predict(coords)
    tree = cKDTree(coords)

    for block in np.unique(blocks):
        test = blocks == block
        if not test.any():
            continue
        near = set()
        for idx in tree.query_ball_point(coords[test], r=buffer_m):
            near.update(idx)
        train = np.ones(len(coords), bool)
        train[test] = False
        excluded = np.zeros(len(coords), bool)
        excluded[list(near)] = True
        train &= ~excluded

        print(f"  block {block}: {int(test.sum()):5,} test, "
              f"{int(train.sum()):5,} train, "
              f"{int((~test & excluded).sum()):5,} buffered out")
        yield train, test


predicted = np.empty(len(ys))
for train, test in buffered_blocks(cs, n_blocks=10, buffer_m=500):
    predicted[test] = forest().fit(Xs[train], ys[train]).predict(Xs[test])
print(f"  10 blocks with a 500 m buffer: R2 {r2_score(ys, predicted):+.3f}")
  block 0:   349 test, 2,567 train,    84 buffered out
  block 1:   333 test, 2,499 train,   168 buffered out
  block 2:   299 test, 2,627 train,    74 buffered out
  block 3:   244 test, 2,696 train,    60 buffered out
  block 4:   349 test, 2,538 train,   113 buffered out
  block 5:   258 test, 2,632 train,   110 buffered out
  block 6:   257 test, 2,636 train,   107 buffered out
  block 7:   267 test, 2,668 train,    65 buffered out
  block 8:   343 test, 2,534 train,   123 buffered out
  block 9:   301 test, 2,595 train,   104 buffered out
  10 blocks with a 500 m buffer: R2 +0.203

The buffer is what makes block CV do what it claims. Without it, a training point one metre outside a block boundary is a near neighbour of a test point one metre inside — exactly the leak blocks were meant to prevent.

The cost is real. Here a 500 m buffer around ten blocks removed 60 to 168 training cells per fold, 2–6% of the training data, and the score fell to +0.203, against +0.234 to +0.258 for unbuffered ten-block layouts of the same sample.

Example 3 — the honest transfer test

import numpy as np
from sklearn.metrics import r2_score, mean_absolute_error


def transfer_test(model_factory, X, y, coords, axis=0):
    """Train on one half, predict the other. Both directions."""
    threshold = np.median(coords[:, axis])
    left = coords[:, axis] < threshold

    results = {}
    for label, train, test in (("left -> right", left, ~left),
                               ("right -> left", ~left, left)):
        model = model_factory().fit(X[train], y[train])
        predicted = model.predict(X[test])
        results[label] = {
            "r2": r2_score(y[test], predicted),
            "mae": mean_absolute_error(y[test], predicted),
            "n_train": int(train.sum()), "n_test": int(test.sum()),
        }
        print(f"  {label}: R2 {results[label]['r2']:+.3f}, "
              f"MAE {results[label]['mae']:.4f} "
              f"({int(train.sum()):,} train, {int(test.sum()):,} test)")

    worst = min(r["r2"] for r in results.values())
    if worst < 0:
        print("  ! the model does not transfer — worse than predicting the "
              "training mean in at least one direction")
    return results


six = np.random.default_rng(1).choice(len(y), 6000, replace=False)     # 3,000 cells either side
transfer_test(forest, X[six], y[six], coords[six])
  left -> right: R2 +0.083, MAE 0.1025 (2,997 train, 3,003 test)
  right -> left: R2 +0.086, MAE 0.0838 (3,003 train, 2,997 test)

On this sample of 6,000 cells both directions transferred, barely: +0.083 and +0.086. Trained on a different 3,000 western cells and applied to every eastern cell (Example 4), the same model scored −0.409. A transfer score is itself unstable, which is one more reason to run it on several samples. A negative R² means the model is worse than predicting the training mean. It is common in genuine transfer tests and almost never reported, because most pipelines never run one.

Example 4 — the tables at the top of this page, and the range

from sklearn.model_selection import GroupKFold


def scheme_scores(Xs, ys, cs, seed):
    model = lambda: RandomForestRegressor(n_estimators=200, min_samples_leaf=2, n_jobs=-1, random_state=seed)
    def cv(splitter):
        p = np.empty(len(ys))
        for train, test in splitter:
            p[test] = model().fit(Xs[train], ys[train]).predict(Xs[test])
        return r2_score(ys, p)
    blocks = KMeans(n_clusters=5, random_state=seed, n_init=10).fit_predict(cs)
    return (cv(KFold(n_splits=5, shuffle=True, random_state=seed).split(Xs)),
            cv(GroupKFold(n_splits=5).split(Xs, ys, blocks)), model)


runs = []
for seed in range(10):
    pick = np.random.default_rng(seed).choice(len(y), 3000, replace=False)
    others = np.setdiff1d(np.arange(len(y)), pick)
    random_r2, block_r2, model = scheme_scores(X[pick], y[pick], coords[pick], seed)
    runs.append((random_r2, block_r2, r2_score(y[others], model().fit(X[pick], y[pick]).predict(X[others]))))
for name, column in zip(("random 5-fold CV", "5-block spatial CV", "true map R²"), np.array(runs).T):
    print(f"{name:20} mean {column.mean():+.3f}  sd {column.std():.3f}  range {column.min():+.3f} to {column.max():+.3f}")

west = coords[:, 0] < np.median(coords[:, 0])
pick = np.random.default_rng(0).choice(np.nonzero(west)[0], 3000, replace=False)
random_r2, block_r2, model = scheme_scores(X[pick], y[pick], coords[pick], 0)
print(f"trained in the west: random CV {random_r2:+.3f}, block CV {block_r2:+.3f}, actual in the east "
      f"{r2_score(y[~west], model().fit(X[pick], y[pick]).predict(X[~west])):+.3f}")

# Empirical variogram of the target, 5,000 cells, 250 m lags
from scipy.spatial.distance import pdist
v = np.random.default_rng(2).choice(len(y), 5000, replace=False)
d, g = pdist(coords[v]), 0.5 * pdist(y[v, None], "sqeuclidean")
edges = np.arange(0, 6001, 250)
gamma = np.array([g[(d >= a) & (d < b)].mean() for a, b in zip(edges[:-1], edges[1:])])
sill = y.var()
reach = edges[1:][np.argmax(gamma >= 0.95 * sill)] if (gamma >= 0.95 * sill).any() else None
print(f"variance {sill:.4f}; semivariance at 250 m {gamma[0]:.4f}, 1 km {gamma[3]:.4f}, 3 km {gamma[11]:.4f}, "
      f"6 km {gamma[-1]:.4f}; first reaches 95% of the variance at {reach} m")
blocks10 = KMeans(n_clusters=10, random_state=0, n_init=10).fit_predict(cs)
print(f"10 KMeans blocks on the sample: median block span "
      f"{np.median([np.ptp(cs[blocks10 == k], axis=0).max() for k in range(10)]):,.0f} m")
random 5-fold CV     mean +0.339  sd 0.024  range +0.303 to +0.375
5-block spatial CV   mean +0.102  sd 0.106  range -0.057 to +0.258
true map R²          mean +0.344  sd 0.008  range +0.335 to +0.360
trained in the west: random CV +0.338, block CV +0.033, actual in the east -0.409
variance 0.0210; semivariance at 250 m 0.0092, 1 km 0.0160, 3 km 0.0213, 6 km 0.0227; first reaches 95% of the variance at 2500 m
10 KMeans blocks on the sample: median block span 3,650 m

Why a block has to outrun the autocorrelation

Why block CV was pessimistic here, and why no scheme can score a new region, is the subject of spatial leakage explained. What matters when building the folds is their size.

Why blocks need to exceed the autocorrelation range

The point of a block is that test points have no training points within their correlation distance.

If blocks are smaller than the range, a test point near a block edge has training points from the adjacent block within range, and the fold leaks. The score then sits between random CV and true block CV, and nothing indicates which.

Estimating the range from a variogram of the target — not of the residuals, not of a covariate — is the defensible way to size them.

Why to repeat over layouts

The score depends on which regions each fold happens to hold out, and the blocks are cut from wherever the sample fell. Re-seeding KMeans on one sample moves them little; drawing a new sample moves them a lot.

Measured: ten samples of 3,000 cells gave block-CV scores from −0.057 to +0.258, a range of 0.32 R² from the same landscape and an identical model.

Reporting one of those numbers to three decimal places is false precision by a wide margin. Repeat, and report the mean and spread.

Blocks smaller than the autocorrelation range still leaking across fold boundaries, against blocks larger than it. blocks must exceed the autocorrelation rangeblocks smaller than the range✕test points near a boundary✕training points within range✕the fold still leaks✕score sits between the two schemesblocks larger, with a buffer✓no training point within range✓a genuine gap to cross✓more pessimistic, correctly✓costs training dataEstimate the range from a variogram of the target rather than guessing spatialworkflow.io
A block smaller than the range produces a score between random and true block CV, with nothing to say which.

Pipelines, tuning folds and grouping by cluster

  • Match the scheme to the deployment, not to a rule of thumb.
  • Blocks must exceed the autocorrelation range, or folds still leak.
  • Buffer between folds to remove edge leakage, and accept the extra pessimism.
  • Repeat over samples and block layouts. Block CV had a standard deviation of 0.106 across samples here.
  • Use a Pipeline so scaling and imputation are refit per fold.
  • Do not tune on the folds you report.
  • A negative R² is meaningful — worse than the mean.
  • Group by cluster, not just by location, when samples come in plots or transects.

FAQ

How do I do spatial cross-validation in Python?

Cluster the coordinates into blocks with KMeans, use GroupKFold with those blocks, and repeat over several block layouts reporting the spread.

Is spatial CV always better than random CV?

No. For predicting inside a sampled area, random CV matched the truth to 0.005 R² while block CV was pessimistic by 0.24.

How big should the blocks be?

Larger than the target's autocorrelation range, estimated from a variogram. Smaller blocks still leak across fold boundaries.

How many blocks should I use?

Enough that each block is larger than the autocorrelation range — about 2.5 km here — with several repeats. Five blocks gave a standard deviation of 0.106 across samples here.

Should I buffer between folds?

Yes if you want block CV to mean what it claims. It costs training data and makes the estimate more pessimistic, which is the point.

Can spatial CV tell me if my model works in a new region?

No. It promised +0.033 where a real west-to-east transfer gave −0.409. Hold out a real region instead.

Where do scaling and imputation go?

Inside a Pipeline, so they are refit per fold. Fitting them on the full dataset leaks in the ordinary, non-spatial way.