Put every fitted step — imputation, scaling, encoding, reduction — inside a Pipeline, tune it with a group-aware splitter whose groups are spatial blocks, and serialise the whole object. The result is a single artifact that applies at inference exactly the transformations it learned during training, which removes an entire family of training-serving bugs by construction.
This page is the packaging pattern. For the estimator choices and evaluation around it, see Training with scikit-learn for Geospatial Data.
Why This Fails in Geospatial ML Pipelines
Preprocessing outside the pipeline is the classic leak, and geospatial feature sets make it worse than usual because so many steps have fitted state: a scaler, an imputer whose medians come from the data, a target encoder for administrative regions, a PCA rotation. Fit any of them on the full table and every cross-validation score is optimistic — not by a rounding error, but by however much the held-out fold contributed to the statistics.
The second failure is a split that ignores geography. GridSearchCV defaults to KFold, which shuffles rows. With spatially autocorrelated data that puts a pixel’s neighbours in the training fold while the pixel itself is validated, so hyperparameter search selects whatever memorises best — usually maximum depth and minimum regularisation. Substituting GroupKFold with spatial-block groups changes both the chosen hyperparameters and the reported score, in the direction of reality. The reasoning is set out in spatial cross-validation strategies.
Third, the serving mismatch. When preprocessing lives in a notebook cell and the model is pickled alone, inference has to re-implement the transformations, and the re-implementation drifts. A single serialised Pipeline makes that impossible: the artifact cannot be loaded without its transformers, and it applies them in the order it learned them.
Core Principles
- Everything with a
.fit()goes inside the pipeline. - Split by space, not at random.
GroupKFoldwith spatial block groups. - Use
ColumnTransformerso continuous, categorical and cyclic features get their own treatment. - Write custom transformers as
BaseEstimatorsubclasses so they clone correctly during search. - Serialise the whole pipeline, and record the library versions with it.
- Assert the feature contract at load. Column names and order are part of the artifact.
Production-Ready Code
from __future__ import annotations
import logging
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
logger = logging.getLogger(__name__)
class CyclicAspect(BaseEstimator, TransformerMixin):
"""Turn a compass bearing column into northness and eastness.
Written as a proper estimator so GridSearchCV can clone it per fold.
"""
def fit(self, X, y=None):
self.n_features_in_ = X.shape[1]
return self
def transform(self, X):
rad = np.radians(np.asarray(X, dtype="float64"))
out = np.hstack([np.cos(rad), np.sin(rad)])
return np.nan_to_num(out, nan=0.0)
def get_feature_names_out(self, input_features=None):
base = list(input_features or [f"x{i}" for i in range(self.n_features_in_)])
return np.array([f"{b}_north" for b in base] + [f"{b}_east" for b in base])
class RatioFeatures(BaseEstimator, TransformerMixin):
"""Normalised differences between band pairs, computed inside the pipeline."""
def __init__(self, pairs: list[tuple[str, str]] | None = None):
self.pairs = pairs or [("nir", "red"), ("green", "nir")]
def fit(self, X: pd.DataFrame, y=None):
missing = [c for p in self.pairs for c in p if c not in X.columns]
if missing:
raise ValueError(f"RatioFeatures needs columns {sorted(set(missing))}")
return self
def transform(self, X: pd.DataFrame) -> np.ndarray:
cols = []
for a, b in self.pairs:
denom = X[a].to_numpy("float64") + X[b].to_numpy("float64")
num = X[a].to_numpy("float64") - X[b].to_numpy("float64")
with np.errstate(invalid="ignore", divide="ignore"):
cols.append(np.where(np.abs(denom) > 1e-6, num / denom, np.nan))
return np.column_stack(cols)
def get_feature_names_out(self, input_features=None):
return np.array([f"nd_{a}_{b}" for a, b in self.pairs])
CONTINUOUS = ["blue", "green", "red", "nir", "swir1", "elevation", "slope_deg", "tpi_11"]
CATEGORICAL = ["soil_class"]
CYCLIC = ["aspect_deg"]
RATIO_SOURCE = ["nir", "red", "green"]
def build_pipeline(estimator) -> Pipeline:
"""One artifact: preprocessing plus estimator, in the order they are learned."""
pre = ColumnTransformer(
transformers=[
("cont", Pipeline([("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler())]), CONTINUOUS),
("cat", Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore",
min_frequency=20))]), CATEGORICAL),
("cyc", CyclicAspect(), CYCLIC),
("ratio", RatioFeatures([("nir", "red"), ("green", "nir")]), RATIO_SOURCE),
],
remainder="drop",
verbose_feature_names_out=False,
)
return Pipeline([("pre", pre), ("model", estimator)])Step-by-Step Walkthrough
Step 1 — Build spatial groups alongside the frame
import geopandas as gpd
import numpy as np
samples = gpd.read_file("samples.gpkg").to_crs(32633)
coords = np.c_[samples.geometry.x, samples.geometry.y]
BLOCK_M = 5_000
groups = (np.floor(coords[:, 0] / BLOCK_M).astype("int64") * 100_000
+ np.floor(coords[:, 1] / BLOCK_M).astype("int64"))
print(f"{len(np.unique(groups))} spatial blocks over {len(samples)} samples")Step 2 — Assemble and smoke-test the pipeline
from sklearn.ensemble import HistGradientBoostingClassifier
X = samples[CONTINUOUS + CATEGORICAL + CYCLIC]
y = samples["land_cover"].to_numpy()
pipe = build_pipeline(HistGradientBoostingClassifier(random_state=0))
pipe.fit(X.head(500), y[:500])
names = pipe.named_steps["pre"].get_feature_names_out()
print(f"{len(names)} engineered features: {list(names)[:8]} ...")Fitting on 500 rows first catches column-name mistakes in seconds rather than after a full search.
Step 3 — Tune with a group-aware splitter
from sklearn.model_selection import GridSearchCV, GroupKFold
grid = {
"model__max_depth": [4, 6, None],
"model__learning_rate": [0.05, 0.1],
"model__min_samples_leaf": [20, 50],
}
search = GridSearchCV(
pipe, grid, cv=GroupKFold(n_splits=5), scoring="f1_macro",
n_jobs=-1, refit=True, verbose=1,
)
search.fit(X, y, groups=groups)
print("best spatial CV f1_macro:", round(search.best_score_, 4))
print("best params:", search.best_params_)Run the same search once with plain KFold for comparison. The gap between the two best scores is the size of the leak you would have shipped.
Step 4 — Serialise the whole artifact with its versions
import json
import sklearn
import joblib
joblib.dump(search.best_estimator_, "artifacts/landcover_pipeline.joblib")
with open("artifacts/landcover_pipeline.json", "w") as fh:
json.dump({
"sklearn": sklearn.__version__,
"input_columns": list(X.columns),
"cv": "GroupKFold(5) on 5 km spatial blocks",
"best_score_f1_macro": float(search.best_score_),
"best_params": {k: str(v) for k, v in search.best_params_.items()},
}, fh, indent=2)Step 5 — Load and enforce the contract at inference
def load_pipeline(model_path: str, meta_path: str):
meta = json.load(open(meta_path))
if sklearn.__version__ != meta["sklearn"]:
logger.warning("scikit-learn %s at serving vs %s at training",
sklearn.__version__, meta["sklearn"])
pipe = joblib.load(model_path)
def predict(frame: pd.DataFrame):
missing = [c for c in meta["input_columns"] if c not in frame.columns]
if missing:
raise ValueError(f"inference frame is missing columns: {missing}")
return pipe.predict(frame[meta["input_columns"]])
return predictVerification
import numpy as np
import pandas as pd
import pytest
from sklearn.base import clone
from sklearn.model_selection import GroupKFold, cross_val_score
def test_transformers_are_clonable():
"""A transformer that does not clone silently shares state across folds."""
t = CyclicAspect()
assert clone(t).get_params() == t.get_params()
def test_cyclic_transform_is_continuous_across_north():
t = CyclicAspect().fit(np.array([[0.0]]))
a = t.transform(np.array([[359.0]]))
b = t.transform(np.array([[1.0]]))
assert np.linalg.norm(a - b) < 0.05
def test_spatial_cv_scores_below_random_cv():
"""If they match, the groups are not actually separating anything."""
pipe = build_pipeline(HistGradientBoostingClassifier(random_state=0))
spatial = cross_val_score(pipe, X, y, cv=GroupKFold(5), groups=groups,
scoring="f1_macro").mean()
random = cross_val_score(pipe, X, y, cv=5, scoring="f1_macro").mean()
assert spatial <= random + 1e-6
print(f"leak size: {random - spatial:.3f} f1_macro")
def test_unknown_category_does_not_crash_inference():
pipe = build_pipeline(HistGradientBoostingClassifier()).fit(X, y)
new = X.head(1).copy()
new["soil_class"] = "a_class_never_seen"
assert pipe.predict(new).shape == (1,)
def test_missing_column_is_reported_clearly():
predict = load_pipeline("artifacts/landcover_pipeline.joblib",
"artifacts/landcover_pipeline.json")
with pytest.raises(ValueError, match="missing columns"):
predict(X.drop(columns=["elevation"]).head(5))The third test is the one to keep visible: it prints the size of the leak, and that number is the single most useful figure to put in front of anyone asking why the offline score dropped after the pipeline was fixed.
FAQ
Why put feature engineering inside the Pipeline instead of before it?
Because anything fitted outside sees the validation fold, so the reported score describes a process you cannot deploy. Inside, cross-validation refits every transformer on each training fold alone.
Can a Pipeline handle geometry columns?
Not directly — estimators need numeric arrays. Either convert geometry to numeric columns in a transformer at the head of the pipeline, or compute spatial features beforehand and pass the block labels separately as groups, which is what most production pipelines do.
How do I use spatial cross-validation with GridSearchCV?
Pass cv=GroupKFold(n_splits=...) and supply groups of spatial block ids to fit. Whole blocks then stay together in every fold, so hyperparameters are tuned against geographically held-out data. See implementing SpatialKFold in Python for a custom splitter.
Related
- Training with scikit-learn for Geospatial Data — estimator selection and evaluation
- Implementing SpatialKFold in Python — the splitter this pipeline should use
- Running PCA on Multispectral Bands with scikit-learn — a reduction step to slot into the pipeline
- Scaling Features Consistently Between Training and Inference — the contract this pattern enforces automatically
Part of: Training with scikit-learn for Geospatial Data Part of: Training Geospatial Predictive Models in Python