Spatial Leakage Explained: Why Random Cross-Validation Lies
Problem statement
The standard advice about machine learning on spatial data is: random cross-validation is optimistic because nearby points are correlated, so use spatial block cross-validation instead.
That advice is half right, and the missing half causes as much damage as the problem it solves.
Measured on a real modelling problem β predicting vegetation index from terrain over a 10 km window, 3,000 training cells, repeated over ten random samples:
mean RΒ² sd
random 5-fold CV +0.555 0.019
spatial block CV +0.213 0.106
true error over the
whole unsampled map +0.557 0.014
Random CV was accurate to within 0.002. Block CV understated the model by 0.34 RΒ² and was five times more variable. Following the standard advice here would have led you to reject a model that works.
Change the deployment scenario, though, and the picture inverts completely β see below. Neither scheme is "the honest one". The scheme has to match what you will actually do with the model.
Quick answer
Ask what the model will be asked to predict, and validate that:
| you will predict⦠| validate with | why |
|---|---|---|
| unsampled locations inside the sampled area | random k-fold | matches deployment; block CV is pessimistic |
| a new region you have not sampled | leave-one-region-out, held out for real | both random and block CV are optimistic |
| a future time | forward-chaining split by date | space is not the leaking dimension |
from sklearn.model_selection import KFold, GroupKFold
from sklearn.cluster import KMeans
# interpolation within the sampled area
folds = KFold(n_splits=5, shuffle=True, random_state=0)
# extrapolation to unsampled ground
blocks = KMeans(n_clusters=5, random_state=0, n_init=10).fit_predict(coords)
folds = GroupKFold(n_splits=5) # groups=blocks
Step-by-step solution
1. Recognise what leakage actually is
Leakage is information reaching the test set that will not be available at prediction time.
With spatially autocorrelated data and a random split, a test point often has a training point twenty metres away. The model can effectively memorise the neighbourhood.
That is only leakage if your real predictions will be far from any training point. If you will predict at locations surrounded by training data β filling gaps in a survey, densifying a monitoring network β then having a near neighbour is not leakage. It is the deployment condition.
2. Measure the interpolation case
Training on 3,000 randomly placed cells and predicting the remaining 256,776:
random 5-fold CV +0.555 (sd 0.019 over 10 samples)
block CV +0.213 (sd 0.106)
true map RΒ² +0.557 (sd 0.014)
Random CV reproduced the truth almost exactly. Block CV was pessimistic by 0.34 and unstable: across ten runs it ranged from +0.061 to +0.429, while the truth barely moved.
The instability has a mechanical cause. With five blocks, each fold trains on 80% of the area and predicts a large contiguous region, and how hard that is depends heavily on which region the clustering happened to carve out.
3. Measure the extrapolation case
Now train on the west half only and predict the east half β a genuinely unsampled region:
random 5-fold CV on the training half +0.649
block CV on the training half +0.385
actual RΒ² on the eastern half -0.771
Both lie, in the same direction. Random CV promised +0.649; block CV, the supposedly honest scheme, still promised +0.385. Reality was β0.771.
A negative RΒ² means the model is worse than predicting the training mean everywhere. The terrain-to-vegetation relationship in the western, coastal half simply does not hold in the eastern, mountainous half β and no resampling of the western data could reveal that.
4. Draw the actual conclusion
Block CV is not a truth serum. It is a different estimator with a different bias:
- For interpolation, it is strongly pessimistic (0.213 against 0.557) and noisy.
- For extrapolation, it is less optimistic than random CV but still badly optimistic (0.385 against β0.771).
The only way to estimate transfer to a new region is to hold out a region of the kind you will actually predict, and accept that you cannot do this at all if you have only sampled one region.
5. Report the scheme with the number
RΒ² = 0.55 is not a result. RΒ² = 0.55 under random 5-fold CV, for prediction within the sampled extent is.
Code examples
Example 1 β score the model both ways and against the truth
import numpy as np
from sklearn.cluster import KMeans
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupKFold, KFold
def compare_schemes(X, y, coords, X_holdout=None, y_holdout=None,
n_blocks=5, seed=0):
"""Random CV, block CV, and β if you have it β the truth."""
def model():
return RandomForestRegressor(n_estimators=200, min_samples_leaf=2,
n_jobs=-1, random_state=seed)
def cv(splitter):
predicted = np.empty(len(y))
for train, test in splitter:
predicted[test] = model().fit(X[train], y[train]).predict(X[test])
return r2_score(y, predicted)
random_r2 = cv(KFold(n_splits=5, shuffle=True, random_state=seed).split(X))
blocks = KMeans(n_clusters=n_blocks, random_state=seed,
n_init=10).fit_predict(coords)
block_r2 = cv(GroupKFold(n_splits=n_blocks).split(X, y, blocks))
print(f" random 5-fold CV R2 {random_r2:+.3f}")
print(f" {n_blocks}-block spatial CV R2 {block_r2:+.3f}")
result = {"random": random_r2, "block": block_r2}
if X_holdout is not None:
true_r2 = r2_score(y_holdout, model().fit(X, y).predict(X_holdout))
print(f" true held-out R2 {true_r2:+.3f}")
result["true"] = true_r2
return result
Where you can construct a genuine hold-out β a dense reference dataset, a pilot area, a later survey β do it once. It calibrates how much to trust each CV scheme for your variable and landscape, and that calibration transfers to future projects far better than any rule of thumb.
Example 2 β check how stable your block CV is
import numpy as np
from sklearn.cluster import KMeans
def block_cv_stability(X, y, coords, n_repeats=10, n_blocks=5):
"""Block CV depends on which blocks the clustering happened to make."""
scores = []
for seed in range(n_repeats):
blocks = KMeans(n_clusters=n_blocks, random_state=seed,
n_init=10).fit_predict(coords)
scores.append(cv_with_groups(X, y, blocks))
scores = np.array(scores)
print(f" block CV over {n_repeats} block layouts: "
f"mean {scores.mean():+.3f}, sd {scores.std():.3f}, "
f"range {scores.min():+.3f} to {scores.max():+.3f}")
if scores.std() > 0.05:
print(" ! this estimate is unstable β report the spread, not one number")
return scores
block CV over 10 block layouts: mean +0.213, sd 0.106, range +0.061 to +0.429
! this estimate is unstable β report the spread, not one number
A single block-CV number carries a standard deviation of 0.106 here. Quoting it to three decimal places, as everyone does, is false precision by a wide margin.
Example 3 β the only honest extrapolation test
import numpy as np
from sklearn.metrics import r2_score
def region_transfer_test(X, y, coords, split_axis=0):
"""Train on one half of the study area, predict the other. No resampling."""
threshold = np.median(coords[:, split_axis])
left = coords[:, split_axis] < threshold
model = RandomForestRegressor(n_estimators=200, min_samples_leaf=2,
n_jobs=-1, random_state=0)
forward = r2_score(y[~left], model.fit(X[left], y[left]).predict(X[~left]))
backward = r2_score(y[left], model.fit(X[~left], y[~left]).predict(X[left]))
print(f" train left -> predict right: R2 {forward:+.3f}")
print(f" train right -> predict left : R2 {backward:+.3f}")
if min(forward, backward) < 0:
print(" ! the model does not transfer β it is worse than the mean "
"in at least one direction")
return {"forward": forward, "backward": backward}
Run both directions. A model that transfers one way and not the other tells you the two halves differ, which is more useful than a single number and is often the actual scientific finding.
Explanation
Why random CV was accurate here
The 3,000 training cells were spread randomly over the whole window, and the model was then asked to predict the other 256,776 cells in the same window.
A randomly held-out cell is, in expectation, exactly as far from the training set as a randomly chosen unpredicted cell. The CV task and the deployment task are the same task, so the CV estimate is unbiased β which is precisely what the measurement shows: +0.555 against +0.557.
Why block CV was pessimistic
Block CV asks a harder question than deployment does. Each fold trains on 80% of the area and predicts a large contiguous region with no nearby training data.
If you will never do that, the number is answering a question you did not ask. Here it understated the model by 0.34 RΒ² β enough to make a working model look like a failure.
It is also noisy, for a structural reason: with five folds, each test set is one large region, so the score is an average of five hard, correlated problems rather than of many independent ones. Hence sd 0.106 against random CV's 0.019.
Why both schemes failed on the new region
The west-to-east test is the case everyone is worried about, and it is worth noting that block CV did not save it. It promised +0.385 where reality was β0.771.
The reason is that block CV resamples within the training region. If the relationship between terrain and vegetation is different in the east β different geology, different land use, different rainfall β nothing inside the western data contains that information. No cross-validation scheme can estimate an error it has no evidence about.
This is the practical limit of validation. Cross-validation measures how well a model generalises to data drawn from the same distribution as the training data. A new region is a different distribution, and the honest answer is "we do not know", supported by an area-of-applicability mask rather than a number.
Why a negative RΒ² is worth stating plainly
RΒ² = β0.771 means the model's squared error is 1.77 times the variance of the target in the new region β it would have been better to predict the training mean everywhere and not build a model at all.
Negative RΒ² is common in genuine transfer tests and almost never reported, because most pipelines never run one. When you see it, the model has not merely degraded; it has learned a relationship that is actively wrong somewhere else.
Edge cases or notes
- State the scheme with every score. A bare RΒ² is not interpretable.
- Block CV has a large variance. Repeat over several block layouts and report the spread.
- The block size matters as much as the scheme. Blocks should be at least the autocorrelation range, or the folds still leak.
- Block CV scores are not comparable across sample sizes, because the block geometry changes with the sample.
- Autocorrelation is not the only leaking dimension. Time, sensor, operator and processing batch all leak.
- Duplicate or near-duplicate rows β two samples from one field β leak under any scheme.
- A negative RΒ² is meaningful, not a bug. It means the model is worse than the mean.
- Do not tune hyperparameters on the same folds you report. That leaks in the ordinary, non-spatial way.
Internal links
- Spatial machine learning explained β why location breaks the usual assumptions
- How to do spatial cross-validation in Python β the implementation
- Extrapolation in space explained: the area of applicability β what to do when you cannot validate transfer
- My model scores 0.95 in testing and fails in the field β the symptom
- How to cross-validate an interpolated surface β the same problem in interpolation
- Spatial autocorrelation explained β the structure that causes it
- Feature importance says coordinates are the best predictor β the related coordinate trap
- How to evaluate a spatial model honestly β reporting beyond one number
FAQ
Is random cross-validation always wrong for spatial data?
No. When the model will predict at unsampled locations inside a well-sampled area, random CV matched the truth almost exactly here β +0.555 against +0.557.
When should I use spatial block cross-validation?
When you will predict in areas with no nearby training data. For interpolation within a sampled area it was pessimistic by 0.34 RΒ² and five times noisier.
Does block cross-validation give the true error?
No. On a genuine new-region test it promised +0.385 where the actual result was β0.771. It is less optimistic than random CV, not honest.
How do I estimate error in a region I have not sampled?
Hold out a real region of the same kind and test on it. If you have only sampled one region, you cannot estimate it β use an area-of-applicability mask and say so.
Why is my block CV score so variable?
Because five folds means five large, correlated test regions, and the score depends on which regions the clustering produced. It had a standard deviation of 0.106 across ten layouts here.
What does a negative RΒ² mean?
The model is worse than predicting the mean. It happens routinely in genuine transfer tests and is worth reporting rather than hiding.
How big should the blocks be?
At least the autocorrelation range of the target, or neighbouring folds still share information. Estimate it from a variogram rather than guessing.