Splitting Chips into Spatially Disjoint Folds
To split chips so validation is honest, derive a block id from each chip’s map coordinates and hand that to a group-aware splitter:
import numpy as np
from sklearn.model_selection import GroupKFold
BLOCK_M = 20_000 # 20 km blocks
block = (np.floor(chip_x / BLOCK_M).astype(int) * 100_000
+ np.floor(chip_y / BLOCK_M).astype(int)) # one id per block
for train_idx, val_idx in GroupKFold(n_splits=5).split(X, y, groups=block):
...
The whole point is that GroupKFold never puts the same group on both sides of a split, so a block — and everything spatially close within it — moves as a unit. This page belongs to building training datasets from satellite imagery, part of Raster Machine Learning & Model Inference.
Why Random Splits Fail on Raster Data
Spatial data violates the independence assumption that random splitting relies on. Two pixels 30 m apart are nearly the same observation: same field, same soil, same weather, same atmospheric state on the same day. A random split puts one in training and the other in validation, and the model scores well by recognising a neighbourhood it has already seen.
The curve above is worth producing for any new target variable. Sweep the block size, plot the cross-validated score, and use the size at which the score stops falling. It costs a handful of training runs and settles an argument that otherwise runs for the life of the project.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Block id arithmetic |
pandas |
>=2.0 |
The chip index table that carries coordinates and folds |
scikit-learn |
>=1.3 |
GroupKFold and StratifiedGroupKFold |
geopandas |
>=0.14 |
Region polygons for leave-one-region-out designs |
pip install "numpy>=1.23" "pandas>=2.0" "scikit-learn>=1.3" "geopandas>=0.14"
Complete Working Example
import numpy as np
import pandas as pd
from sklearn.model_selection import StratifiedGroupKFold
def assign_spatial_folds(index: pd.DataFrame, *, block_m: float = 20_000,
n_folds: int = 5, buffer_blocks: int = 0,
seed: int = 0) -> pd.DataFrame:
"""Add block_id and fold columns to a chip index table.
index must carry `x` and `y`: the chip centre in the projected CRS.
Rows with fold == -1 fall on a buffer and should be dropped.
"""
bx = np.floor(index["x"].to_numpy() / block_m).astype(int)
by = np.floor(index["y"].to_numpy() / block_m).astype(int)
index = index.assign(block_id=bx * 1_000_003 + by) # collision-free enough
# Assign whole blocks to folds, shuffled so folds are not geographic stripes
rng = np.random.default_rng(seed)
blocks = index["block_id"].unique()
rng.shuffle(blocks)
fold_of = {b: i % n_folds for i, b in enumerate(blocks)}
index = index.assign(fold=index["block_id"].map(fold_of))
if buffer_blocks:
# Drop chips in a ring of blocks whose neighbours belong to another fold
keep = np.ones(len(index), dtype=bool)
coords = {(x, y): fold_of[x * 1_000_003 + y] for x, y in zip(bx, by)}
for i, (x, y) in enumerate(zip(bx, by)):
here = coords[(x, y)]
for dx in range(-buffer_blocks, buffer_blocks + 1):
for dy in range(-buffer_blocks, buffer_blocks + 1):
other = coords.get((x + dx, y + dy))
if other is not None and other != here:
keep[i] = False
index.loc[~keep, "fold"] = -1
return index
if __name__ == "__main__":
idx = pd.read_parquet("chips_index.parquet")
idx = assign_spatial_folds(idx, block_m=20_000, n_folds=5, buffer_blocks=1)
usable = idx[idx.fold >= 0]
print(idx.groupby("fold").size())
print(f"{1 - len(usable) / len(idx):.1%} of chips dropped to the buffer")
splitter = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=0)
for train, val in splitter.split(usable, usable["class_id"], usable["block_id"]):
print(len(train), "train /", len(val), "val")
Shuffling the block list before assigning folds matters more than it looks. Assigning folds in raster order produces folds that are geographic stripes, so fold 0 is the north of the study area and fold 4 the south. Cross-validation then measures north-to-south transfer specifically, which is a real question but not the one five-fold CV is supposed to answer.
StratifiedGroupKFold adds class balance on top of the grouping, which matters when a rare class clusters — without it a fold can end up with no examples of a class at all and the per-class metrics become undefined.
Variant Patterns
1. Leave-one-region-out
When the deployment question is “will this work in the next country”, the fold unit should be the region, not an arbitrary block.
import geopandas as gpd
from sklearn.model_selection import LeaveOneGroupOut
regions = gpd.read_file("regions.gpkg").to_crs(chip_crs)
chips = gpd.sjoin(chip_points, regions[["region_id", "geometry"]], how="left")
for train, val in LeaveOneGroupOut().split(X, y, groups=chips["region_id"]):
... # five scores; report the spread, not just the mean
2. Blocks by tile rather than by distance
When the imagery arrives as tiles, the tile is often the natural block: atmospheric state, acquisition date and processing baseline are constant within it and differ across it. Using the tile id as the group is one line and catches sensor-state leakage that distance-based blocks miss.
groups = index["scene_id"].to_numpy() # e.g. S2A_36NYF_20260614
3. Nested folds for hyperparameter tuning
Selecting hyperparameters on the same fold that reports the score leaks just as surely as a random split does. Nest the search inside the outer split, with the same grouping at both levels.
from sklearn.model_selection import GridSearchCV, GroupKFold
outer, inner = GroupKFold(5), GroupKFold(4)
for train, test in outer.split(X, y, groups=block):
search = GridSearchCV(model, grid, cv=inner)
search.fit(X[train], y[train], groups=block[train])
score = search.score(X[test], y[test])
Common Errors
ValueError: n_splits cannot be greater than the number of groups
There are fewer blocks than folds, which means the block size is larger than the study area. Reduce the block edge, or accept that this dataset supports only a hold-out rather than cross-validation.
Folds have wildly different sizes
GroupKFold balances by sample count, but blocks vary in how many chips they contain. If one block holds a third of the chips, no grouping scheme will produce even folds — the collection design is the problem, not the splitter.
A class vanishes from a validation fold
Use StratifiedGroupKFold, and if it still happens, the class exists in too few blocks to validate. Report it as unmeasured rather than quietly averaging over the folds where it appeared.
Frequently Asked Questions
Q: How big should a spatial block be? Larger than the distance over which your target is correlated, which for land cover is usually a few kilometres and for soil or climate variables can be tens. Estimate it from an empirical variogram of the target rather than guessing, then round up.
Q: Does blocking waste data? Buffering does, by a few per cent. Blocking itself does not — the same chips are used, only grouped differently. What blocking costs is the illusion of a high score; what it buys is a number that predicts performance on a new area.
Q: What if one block holds all of a rare class? Then that class cannot be validated, and the honest response is to say so rather than to fall back on a random split. Collect labels for the class in at least three separated locations; until then treat its accuracy as unmeasured.
Related
- Building Training Datasets from Satellite Imagery — where the fold column is written into the chip index.
- Designing Spatial Cross-Validation for Raster Models — the assessment-side treatment of the same idea.
- Balancing Class Frequencies in Chip Datasets — interacts directly with fold assignment when classes cluster.
- Validating Raster Predictions and Accuracy Assessment — turning fold scores into a reportable figure.