Spatial K-Fold Cross-Validation Setup in Python
TL;DR: Call KMeans(n_clusters=n_splits).fit_predict(coords) on your projected X/Y coordinates to create spatial groups, then pass those groups to sklearn.model_selection.GroupKFold. That single pattern replaces random KFold and eliminates the geographic information leakage that causes inflated R² scores on spatial datasets.
Why This Matters
Standard random K-fold violates the independence assumption the moment points are spatially autocorrelated — and almost every environmental, urban, or earth-science dataset is. When a random split scatters nearby points across train and test folds, the model exploits local spatial patterns it should never have seen, routinely inflating R² by 15–40% and underreporting RMSE by a comparable margin.
Spatial K-fold is the practical fix. It groups geographically proximate points together, then assigns entire groups to either the training or the test partition, maintaining a spatial gap between the two. This page shows the full setup in Python, including fold-distance validation so you can confirm separation before trusting a single metric.
This task sits within the cross-validation strategies workflow, which in turn is part of the broader Python Workflows for Spatial Modeling & Regression pipeline.
Environment and Version Pinning
pip install scikit-learn>=1.4 geopandas>=0.14 numpy>=1.26 scipy>=1.12
import numpy as np # 1.26+
import geopandas as gpd # 0.14+
from sklearn.cluster import KMeans # scikit-learn 1.4+
from sklearn.model_selection import GroupKFold # scikit-learn 1.4+
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, r2_score
from scipy.spatial.distance import cdist # scipy 1.12+
All coordinate operations require a projected metric CRS (e.g., UTM or a local equal-area projection). Do not pass geographic lat/lon — Euclidean distances in degree units are geometrically meaningless for clustering. Reproject with GeoDataFrame.to_crs() before extracting coordinates.
Step-by-Step Implementation
Step 1 — Extract projected coordinates
def extract_coords(data):
"""
Return an (n, 2) float array of projected X/Y coordinates.
Parameters
----------
data : np.ndarray or gpd.GeoDataFrame
Either a shape-(n, 2) array of [X, Y] columns, or a GeoDataFrame
whose geometry is already in a metric CRS.
"""
if isinstance(data, gpd.GeoDataFrame):
return np.column_stack([data.geometry.x.values,
data.geometry.y.values])
coords = np.asarray(data)
if coords.ndim != 2 or coords.shape[1] != 2:
raise ValueError("Expected shape (n, 2) for coordinate array.")
return coords
Step 2 — Cluster coordinates into spatial groups
KMeans assigns every point to one of n_splits compact geographic clusters. Every resulting group becomes a fold, so points within the same spatial group always stay on the same side of the train/test boundary.
def make_spatial_groups(coords, n_splits=5, random_state=42):
"""
Cluster projected coordinates into n_splits spatially contiguous groups.
Returns
-------
groups : np.ndarray, shape (n,)
Integer cluster label for each point (0 … n_splits-1).
"""
if len(coords) < n_splits:
raise ValueError(
f"Need at least {n_splits} samples for {n_splits} folds, "
f"got {len(coords)}."
)
km = KMeans(n_clusters=n_splits, random_state=random_state, n_init="auto")
return km.fit_predict(coords)
Step 3 — Generate fold indices with GroupKFold
GroupKFold guarantees that no group appears in both train and test within a single fold. Combined with spatial groups, this enforces geographic separation automatically.
def spatial_kfold_split(coords_or_gdf, n_splits=5, random_state=42):
"""
Build spatially separated K-fold indices.
Parameters
----------
coords_or_gdf : np.ndarray or gpd.GeoDataFrame
Projected coordinates, shape (n, 2), or a GeoDataFrame.
n_splits : int
Number of spatial folds.
random_state : int
Seed for KMeans reproducibility.
Returns
-------
folds : list of (train_idx, test_idx) tuples
groups : np.ndarray
Cluster labels — store these alongside your data for
reproducible reporting.
"""
coords = extract_coords(coords_or_gdf)
groups = make_spatial_groups(coords, n_splits=n_splits,
random_state=random_state)
gkf = GroupKFold(n_splits=n_splits)
dummy_y = np.zeros(len(coords))
folds = list(gkf.split(X=coords, y=dummy_y, groups=groups))
return folds, groups
Step 4 — Validate inter-fold distances
Generating folds is not enough — you need to confirm that the spatial gap between training and test points is at least as wide as the autocorrelation range of your target variable. Estimate that range from a variogram before choosing a threshold.
def validate_fold_separation(coords, folds, min_distance=1000.0):
"""
Print a pass/fail summary for each fold's minimum train-test distance.
Parameters
----------
coords : np.ndarray, shape (n, 2)
Projected coordinates (same units as min_distance).
folds : list of (train_idx, test_idx)
min_distance : float
Minimum acceptable separation in CRS units (metres for UTM).
"""
all_ok = True
for i, (train_idx, test_idx) in enumerate(folds):
dm = cdist(coords[test_idx], coords[train_idx], metric="euclidean")
min_d = dm.min()
status = "OK" if min_d >= min_distance else "WARNING"
if status == "WARNING":
all_ok = False
print(f" Fold {i+1}: min train-test distance = {min_d:,.1f} [{status}]")
if not all_ok:
print("\n At least one fold is below the distance threshold. "
"Increase n_splits or switch to grid-based blocking.")
Step 5 — Fit, score, and collect per-fold metrics
if __name__ == "__main__":
# --- Synthetic demo: 600 points in a 100 km × 100 km box (metres) ---
rng = np.random.default_rng(42)
n = 600
coords = rng.uniform(0, 100_000, (n, 2)) # metres
# Target: a gentle spatial trend plus noise
y = 0.0003 * coords[:, 0] + 0.0005 * coords[:, 1] + rng.normal(0, 5, n)
folds, groups = spatial_kfold_split(coords, n_splits=5)
print("Fold separation audit (threshold = 1 000 m):")
validate_fold_separation(coords, folds, min_distance=1_000.0)
print()
results = []
for i, (train_idx, test_idx) in enumerate(folds):
model = RandomForestRegressor(n_estimators=200, random_state=42)
model.fit(coords[train_idx], y[train_idx])
preds = model.predict(coords[test_idx])
mae = mean_absolute_error(y[test_idx], preds)
r2 = r2_score(y[test_idx], preds)
results.append({"fold": i + 1, "n_test": len(test_idx),
"MAE": mae, "R2": r2})
print(f" Fold {i+1} | n_test={len(test_idx):4d} | "
f"MAE={mae:.3f} | R²={r2:.3f}")
maes = [r["MAE"] for r in results]
r2s = [r["R2"] for r in results]
print(f"\n Mean MAE = {np.mean(maes):.3f} ± {np.std(maes):.3f}")
print(f" Mean R² = {np.mean(r2s):.3f} ± {np.std(r2s):.3f}")
Interpreting the Output
Per-fold MAE and R² are your primary diagnostic. Look for two things:
- High variance across folds (e.g., R² ranging from 0.4 to 0.9) signals that cluster sizes are uneven or that the spatial process has strong local anisotropy. Try increasing
n_splitsor switching to a grid-based blocking strategy. - R² near 1.0 on every fold when the underlying process should not be that predictable is a residual-leakage warning. Re-run
validate_fold_separationwith a tighter threshold, or compare against a random-KFold baseline — if spatial CV gives dramatically lower R², the random CV was leaking.
The groups array returned alongside folds is worth storing as a column in your GeoDataFrame. It enables spatial error mapping (e.g., plotting residuals coloured by fold assignment) and makes the validation fully reproducible.
Fold size imbalance is normal with KMeans because clusters adapt to point density. Imbalance above roughly 3:1 between the largest and smallest test set is worth investigating — large test folds deflate per-fold variance estimates, while tiny test folds inflate it.
Critical Best Practices
Match blocking scale to the autocorrelation range
The spatial scale of your folds must exceed the autocorrelation range of the target variable. Use spatial autocorrelation metrics or fit a variogram to estimate the range before choosing n_splits. If the range is 5 km and your folds are only 2 km wide, leakage persists despite spatial grouping.
Always use a projected CRS before clustering
KMeans computes Euclidean distances. Feeding it WGS84 latitude/longitude produces clusters distorted by the non-linear relationship between degrees and metres — a 1° longitude near the equator is ~111 km but only ~55 km at 60° N. Reproject to a locally appropriate metric CRS (e.g., EPSG:32633 for UTM zone 33N) before calling extract_coords.
Handle cluster imbalance proactively
If the largest fold contains more than three times as many test points as the smallest, consider grid-based blocking instead. Construct a regular grid over the bounding box, assign each point to the grid cell it falls in, then use cell IDs as GroupKFold groups. This guarantees roughly uniform geographic coverage at the cost of potentially unequal sample counts per cell.
Preserve temporal ordering in spatiotemporal data
For datasets with both spatial and temporal dimensions, apply spatial blocking first and then sort folds chronologically within each spatial block. Mixing temporal and spatial dimensions in a single clustering step confounds two orthogonal sources of leakage and makes the evaluation uninterpretable.
Store and version fold assignments
Assign the groups array as a column (gdf["spatial_fold"] = groups) and commit it to your data version-control system. Fold geometry changes between runs if random_state or n_splits changes, silently invalidating comparisons. Pinning the fold assignment ensures that model A and model B are always evaluated on exactly the same spatial partitions.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ValueError: n_samples must be >= n_splits |
Fewer data points than folds | Reduce n_splits or increase the dataset size |
| Min train-test distance = 0.0 | Points at identical coordinates (duplicates) | Deduplicate or apply a small spatial jitter before clustering |
| R² identical between random and spatial CV | Autocorrelation range shorter than cluster diameter | Use a finer variogram to re-estimate range; reduce n_splits |
| KMeans fails to converge | Coordinates not normalised, very large CRS units | Standardise coordinates with StandardScaler before clustering, then discard the scaler |
| Highly unequal fold sizes (> 5:1 ratio) | Point clustering creates one dominant dense group | Switch to grid-based blocking; choose cell size ≈ autocorrelation range |
| GroupKFold returns fewer splits than expected | One or more clusters has zero points | Verify that n_clusters == n_splits and no cluster is empty after fitting |
Next Steps
For the broader context — spatial block CV, leave-one-out variants, and how to choose between them — see the cross-validation strategies guide. If your model outputs include kriging variance surfaces rather than point predictions, the uncertainty and variance mapping workflow explains how to interpret and validate those outputs alongside fold-based metrics.
Related
- Cross-Validation Strategies for Spatial Modeling in Python — parent guide covering block CV, leave-one-out, and methodology selection
- Spatial Autocorrelation Metrics — estimate the autocorrelation range needed to size your folds correctly
- Spatial Regression Models — pair spatial CV with the right model family
← Back to Cross-Validation Strategies for Spatial Modeling in Python