How to Build Spatial Features for a Machine Learning Model
Problem statement
Feature construction decides more about a spatial model than the algorithm does. Measured on a real problem, the same random forest with two extra columns:
terrain only (slope, elevation, northness, eastness, TPI) RΒ² 0.559
the same plus raw x and y RΒ² 0.721
Two columns, a 29% improvement. And the same two columns gained far less under block cross-validation β 0.221 to 0.324 β because a coordinate does not travel.
The job is to build features that carry the same information and travel: distances, neighbourhood summaries, shape descriptors and terrain derivatives.
Quick answer
import numpy as np
from scipy.spatial import cKDTree
from scipy.ndimage import uniform_filter
def spatial_features(coords, dem, transform, cell, reference_layers,
windows=(3, 9, 27)):
"""Terrain derivatives, neighbourhood summaries and distances."""
features, names = [], []
gy, gx = np.gradient(dem, cell)
for name, array in (("elevation", dem),
("slope", np.degrees(np.arctan(np.hypot(gx, gy)))),
("northness", np.cos(np.arctan2(-gx, gy))),
("eastness", np.sin(np.arctan2(-gx, gy)))):
features.append(sample(array, coords, transform))
names.append(name)
for window in windows:
smoothed = uniform_filter(np.nan_to_num(dem), size=window)
features.append(sample(dem - smoothed, coords, transform))
names.append(f"tpi_{int(window * cell)}m")
for name, targets in reference_layers.items():
distances, _ = cKDTree(targets).query(coords, k=1)
features.append(distances)
names.append(f"dist_{name}")
return np.column_stack(features), names
Step-by-step solution
1. Sample rasters correctly
rows, cols = rasterio.transform.rowcol(transform, coords[:, 0], coords[:, 1])
values = array[np.clip(rows, 0, h - 1), np.clip(cols, 0, w - 1)]
Two details matter. rowcol expects the raster's own CRS, so the coordinates must already be in it. And clipping the indices handles points exactly on the far edge, which would otherwise index one past the end.
Where a point falls on nodata, the feature should be NaN rather than the fill value β a fill of β9999 sampled as an elevation will dominate every split in the model.
2. Compute terrain derivatives in a metric CRS
Slope is a ratio of vertical to horizontal distance. In a geographic CRS the horizontal distance is in degrees, so the slope is wrong by roughly the number of metres in a degree.
The same applies to any Web Mercator raster: distances there are inflated by 1/cos(latitude), so a slope computed from one is understated by that factor.
3. Build neighbourhood features at several scales
for window in (3, 9, 27):
smoothed = uniform_filter(data, size=window)
At 20 m cells those windows are 60 m, 180 m and 540 m β local, hillslope and catchment scale. They measure different things, and which one matters is usually the question the model can answer.
Two or three scales spanning an order of magnitude is enough. Beyond that the columns are strongly correlated and add little.
4. Prefer relative features
tpi = elevation - neighbourhood_mean
"Higher or lower than around here" transfers between regions; "742 metres" does not. Topographic position index, local relative slope and spectral values relative to a local mean are all more transferable than their absolute versions.
5. Handle NaN deliberately
Neighbourhood filters propagate NaN across the whole window, so one nodata cell can void a 27-cell neighbourhood. Filling before filtering avoids that and biases the result towards the fill value.
The defensible approach is to fill with the local mean, compute, and then mask the result back to the original valid area.
Code examples
Example 1 β terrain derivatives with the CRS checked
import numpy as np
import rasterio
from scipy.ndimage import uniform_filter
def terrain_features(dem_path, windows=(3, 9, 27)):
"""Slope, aspect components and multi-scale TPI, as aligned arrays."""
with rasterio.open(dem_path) as src:
if not src.crs.is_projected:
raise ValueError(f"{dem_path} is in {src.crs}; slope needs a "
"projected CRS")
if src.crs.to_epsg() == 3857:
print(" ! Web Mercator: slope will be understated by "
"1/cos(latitude)")
dem = src.read(1).astype("float32")
if src.nodata is not None:
dem[dem == src.nodata] = np.nan
cell = abs(src.transform.a)
transform, crs = src.transform, src.crs
gy, gx = np.gradient(dem, cell)
aspect = np.arctan2(-gx, gy)
layers = {
"elevation": dem,
"slope": np.degrees(np.arctan(np.hypot(gx, gy))),
"northness": np.cos(aspect),
"eastness": np.sin(aspect),
}
valid = np.isfinite(dem)
filled = np.where(valid, dem, np.nanmean(dem))
for window in windows:
mean = uniform_filter(filled, size=window)
squared = uniform_filter(filled ** 2, size=window)
layers[f"tpi_{int(window * cell)}m"] = np.where(valid, dem - mean, np.nan)
layers[f"roughness_{int(window * cell)}m"] = np.where(
valid, np.sqrt(np.maximum(squared - mean ** 2, 0)), np.nan)
for name, array in layers.items():
print(f" {name:18} {np.nanmin(array):9.2f} .. {np.nanmax(array):9.2f} "
f"{np.isnan(array).mean():5.2%} nan")
return layers, transform, crs, cell
Raising on a geographic CRS and warning on Web Mercator catches the two errors that make every terrain derivative wrong, and neither raises an exception on its own.
Example 2 β sampling all the layers at once
import numpy as np
import rasterio
def sample_layers(layers, coords, transform, mask_nodata=True):
"""One row per coordinate, one column per layer."""
height, width = next(iter(layers.values())).shape
rows, cols = rasterio.transform.rowcol(transform, coords[:, 0], coords[:, 1])
rows = np.asarray(rows)
cols = np.asarray(cols)
inside = (rows >= 0) & (rows < height) & (cols >= 0) & (cols < width)
if not inside.all():
print(f" ! {int((~inside).sum()):,} of {len(coords):,} points fall "
"outside the raster")
rows = np.clip(rows, 0, height - 1)
cols = np.clip(cols, 0, width - 1)
names = list(layers)
matrix = np.column_stack([layers[name][rows, cols] for name in names])
matrix[~inside] = np.nan
complete = np.isfinite(matrix).all(axis=1)
print(f" {matrix.shape[0]:,} rows x {matrix.shape[1]} features, "
f"{complete.mean():.1%} complete")
for i, name in enumerate(names):
missing = float(np.isnan(matrix[:, i]).mean())
if missing > 0.01:
print(f" {name:18} {missing:5.1%} missing")
return matrix, names, complete
Reporting the complete-row fraction matters because most models drop incomplete rows. A feature that is 20% missing silently removes a fifth of the training data, and the removal is not random β it follows wherever that layer has gaps.
Example 3 β distance and density features from vector layers
import numpy as np
import geopandas as gpd
from scipy.spatial import cKDTree
def vector_features(coords, layers, radii=(250, 1000), log_distance=True):
"""Distance to, and count within, each reference layer."""
features, names = [], []
for name, gdf in layers.items():
if gdf.geom_type.isin(["Point", "MultiPoint"]).all():
targets = np.column_stack([gdf.geometry.x, gdf.geometry.y])
else:
points = gdf.geometry.representative_point()
targets = np.column_stack([points.x, points.y])
tree = cKDTree(targets)
distances, _ = tree.query(coords, k=1)
features.append(np.log1p(distances) if log_distance else distances)
names.append(f"logdist_{name}" if log_distance else f"dist_{name}")
for radius in radii:
counts = tree.query_ball_point(coords, r=radius, return_length=True)
features.append(np.asarray(counts, dtype=float))
names.append(f"{name}_within_{radius}m")
matrix = np.column_stack(features)
for i, name in enumerate(names):
column = matrix[:, i]
print(f" {name:24} median {np.median(column):9.2f} "
f"zero {np.mean(column == 0):5.1%}")
return matrix, names
Log-transforming distances is usually right: the difference between 10 m and 100 m from a river matters far more than between 10 km and 10.1 km, and a raw distance column is heavily skewed.
For line and polygon layers, using representative points is an approximation. Where exact distance to a boundary matters, use the geometry's .distance() with a spatial index instead β slower, and correct.
Explanation
Why the scale of a neighbourhood feature is the feature
Elevation minus a 60 m mean measures local roughness. Elevation minus a 540 m mean measures hillslope position. They are different variables that happen to share a formula.
Computing several and letting the model choose means the importance ranking tells you which scale the process operates at β a genuine finding, and one you cannot get by picking a single window in advance.
The cost is correlated columns. Tree models handle that adequately; linear models do not, and need either regularisation or a single scale.
Why relative features transfer better
A model fitted on elevations from 50 to 1,000 m has never seen 2,000 m. Applied to a higher range, a tree saturates and a linear model extrapolates.
Topographic position β the difference from the local mean β is centred near zero everywhere. A value of +30 m means the same thing in the Alps and the Pennines, so a model using it can transfer where one using raw elevation cannot.
The same reasoning applies to reflectance relative to a scene median, and to any variable with a regional level plus local variation.
Why nodata handling is not a detail
Two failure modes, both silent.
A nodata fill value sampled as a real value β an elevation of β9999 β becomes the most extreme value in the dataset and dominates every split.
A neighbourhood filter propagates NaN across its whole window, so one bad cell voids a 27 Γ 27 block. With several scales, the union of those voids can remove a large fraction of the training rows.
Filling before filtering and masking afterwards is the standard compromise, and it should be recorded because it introduces a small bias towards the fill value near gaps.
Why to build features once and reuse them
Feature construction over a large raster is expensive and deterministic. Computing it inside a cross-validation loop repeats the work per fold, and computing it separately for training and prediction risks the two diverging.
Build the feature stack once, as a multi-band raster with the band names recorded. Sample it for training, and read it whole for prediction. Then the training and prediction features are the same by construction.
Edge cases or notes
- Terrain derivatives need a projected CRS. Web Mercator understates slope by
1/cos(latitude). - Convert nodata to NaN before sampling, or the fill value becomes a feature value.
- Neighbourhood filters propagate NaN. Fill, filter, then mask back.
- Log-transform distance features; they are heavily skewed.
- Report the complete-row fraction β models drop incomplete rows non-randomly.
- Two or three neighbourhood scales is enough; more adds correlated columns.
- Representative points approximate distance to polygons. Use
.distance()where it matters. - Build the feature stack once and reuse it for training and prediction.
Internal links
- Spatial features explained: turning geometry into columns β which features and why
- How to sample training points from rasters and polygons β where the rows come from
- Spatial machine learning explained β the wider context
- Feature importance says coordinates are the best predictor β the coordinate trade-off
- How to calculate slope and aspect from a DEM in Python β the terrain derivatives
- How to extract raster values at point locations with rasterio β the sampling mechanics
- How to turn model predictions back into a raster β using the same stack for prediction
- Extrapolation in space explained: the area of applicability β where the features stop being valid
FAQ
What spatial features should I build?
Terrain derivatives, multi-scale neighbourhood summaries, distances to reference features, and shape descriptors. Prefer relative measures over absolute ones.
What neighbourhood sizes should I use?
Two or three spanning an order of magnitude β at 20 m cells, something like 60 m, 180 m and 540 m. The importance ranking then reveals the scale of the process.
Why does my slope look wrong?
Almost certainly a geographic or Web Mercator CRS. Slope is vertical over horizontal distance, and both must be in metres.
How do I handle nodata in neighbourhood features?
Fill before filtering and mask afterwards. Filters propagate NaN across the whole window, so one bad cell voids a large neighbourhood.
Should I log-transform distance features?
Usually. Raw distances are heavily skewed, and the difference between 10 m and 100 m matters far more than between 10 km and 10.1 km.
Why report the complete-row fraction?
Because models drop incomplete rows, and the dropped rows follow wherever a layer has gaps β so the removal is systematic, not random.
Should I compute features inside the cross-validation loop?
No. Build the stack once and sample it. Recomputing per fold repeats deterministic work and risks training and prediction features diverging.