Spatial Features Explained: Turning Geometry into Columns
Problem statement
A machine learning model takes a table of numbers. A geometry is not a number, so every spatial model needs geometry turned into columns β and that translation is where the modelling actually happens.
The choice matters more than the algorithm. Measured on a real problem, the same random forest with different feature sets:
terrain only (slope, elevation, aspect, TPI) true map RΒ² 0.559
terrain + raw x, y true map RΒ² 0.721
A 29% improvement from two columns. And under block cross-validation, which simulates applying the model elsewhere, the same two columns gained far less β 0.221 to 0.324 β because a coordinate means nothing outside the area it was learned in.
Features that encode where a thing is beat features that encode which place it is, wherever the model has to move.
Quick answer
Four families, in increasing order of transferability:
# 1. raw position β powerful here, meaningless elsewhere
X = np.column_stack([x, y])
# 2. geometry properties β travel well
X = np.column_stack([gdf.geometry.area, gdf.geometry.length,
4 * np.pi * gdf.geometry.area / gdf.geometry.length ** 2])
# 3. distance to things β travel well and are interpretable
X = np.column_stack([distance_to_coast, distance_to_road, distance_to_water])
# 4. neighbourhood summaries β travel well, and need a scale
X = np.column_stack([mean_elevation_500m, density_1km, majority_class_200m])
Step-by-step solution
1. Geometry properties: what the shape is
gdf["area"] = gdf.geometry.area # projected CRS
gdf["perimeter"] = gdf.geometry.length
gdf["compactness"] = 4 * np.pi * gdf["area"] / gdf["perimeter"] ** 2
gdf["elongation"] = gdf.geometry.minimum_rotated_rectangle.apply(elongation)
gdf["vertices"] = gdf.geometry.apply(lambda g: len(g.exterior.coords))
Compactness β 1 for a circle, lower for anything irregular β distinguishes a warehouse from a terrace row without any coordinate. Vertex count distinguishes hand-digitised outlines from automatically extracted ones, which is sometimes exactly the signal you want and sometimes leakage about the data source.
All of these require a projected CRS. Area in square degrees varies with latitude.
2. Distance to things: what the setting is
from scipy.spatial import cKDTree
distances, _ = cKDTree(coast_points).query(sample_coords, k=1)
Distance to the coast, to a river, to a road, to the nearest town. These are the most transferable spatial features there are: 500 m from a river means something similar everywhere.
Use cKDTree for point targets and a spatial index with .distance() for lines and polygons. Both need projected coordinates.
3. Neighbourhood summaries: what is around
mean_elevation = uniform_filter(dem, size=window)
neighbour_count = tree.query_ball_point(coords, r=500, return_length=True)
The scale is the parameter, and it is a modelling decision. Elevation averaged over 100 m and over 5 km are different variables β one is local relief, the other is regional setting.
Compute several scales and let the model choose. Two or three windows spanning an order of magnitude is usually enough.
4. Raw coordinates: last, and knowingly
They work, and they do not travel. Include them only when the model will stay inside the area they were fitted on β and record that decision, because it constrains every future use of the model.
5. Check that the features exist where you will predict
A feature computed from a road layer that covers only the study area is unavailable elsewhere. That is a deployment blocker, not a modelling detail, and it is worth checking before building anything.
Code examples
Example 1 β geometry properties in one pass
import numpy as np
import geopandas as gpd
def geometry_features(gdf, crs=None):
"""Shape descriptors that do not depend on where the feature is."""
utm = gdf.to_crs(crs or gdf.estimate_utm_crs())
geometry = utm.geometry
out = gpd.GeoDataFrame(index=gdf.index)
out["area"] = geometry.area
out["perimeter"] = geometry.length
out["compactness"] = np.where(
out["perimeter"] > 0,
4 * np.pi * out["area"] / out["perimeter"] ** 2, np.nan)
hull = geometry.convex_hull
out["convexity"] = np.where(hull.area > 0, out["area"] / hull.area, np.nan)
boxes = geometry.minimum_rotated_rectangle()
out["elongation"] = boxes.apply(_elongation)
out["vertices"] = geometry.apply(
lambda g: 0 if g is None else len(g.exterior.coords)
if g.geom_type == "Polygon" else np.nan)
print(f" {len(out)} rows, {out.columns.tolist()}")
print(out.describe().loc[["mean", "50%", "std"]].round(3).to_string())
return out.drop(columns="geometry", errors="ignore")
def _elongation(box):
if box is None or box.is_empty:
return np.nan
coords = np.array(box.exterior.coords[:-1])
sides = np.linalg.norm(np.diff(np.vstack([coords, coords[:1]]), axis=0),
axis=1)
long_side, short_side = max(sides[:2]), min(sides[:2])
return float(long_side / short_side) if short_side else np.nan
Printing the summary statistics is worth the line. A compactness column that is entirely NaN means zero-length perimeters, which means empty or degenerate geometry that will break the model with a confusing error much later.
Example 2 β distances and neighbourhood counts
import numpy as np
from scipy.spatial import cKDTree
def context_features(sample_coords, layers, radii=(200, 500, 1000)):
"""Distance to, and count within, each of several reference layers.
layers: {"road": coords_array, "water": coords_array, ...}
"""
features, names = [], []
for name, targets in layers.items():
tree = cKDTree(np.asarray(targets))
distances, _ = tree.query(sample_coords, k=1)
features.append(distances)
names.append(f"dist_{name}")
for radius in radii:
counts = tree.query_ball_point(sample_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)
print(f" {matrix.shape[1]} features from {len(layers)} layers")
for i, name in enumerate(names):
column = matrix[:, i]
print(f" {name:22} median {np.median(column):10.1f} "
f"zero {np.mean(column == 0):5.1%}")
return matrix, names
A count feature that is zero for most rows carries little information at that radius. The zero fraction printed here tells you immediately which radii are useful for your data.
Example 3 β raster neighbourhood features at several scales
import numpy as np
import rasterio
from scipy.ndimage import uniform_filter, generic_filter
def raster_features(raster_path, coords, windows=(3, 9, 27), band=1):
"""Sample a raster at several neighbourhood scales."""
with rasterio.open(raster_path) as src:
data = src.read(band).astype("float32")
if src.nodata is not None:
data[data == src.nodata] = np.nan
transform = src.transform
cell = abs(transform.a)
rows, cols = rasterio.transform.rowcol(transform, coords[:, 0],
coords[:, 1])
rows = np.clip(np.asarray(rows), 0, src.height - 1)
cols = np.clip(np.asarray(cols), 0, src.width - 1)
filled = np.nan_to_num(data, nan=float(np.nanmean(data)))
features, names = [data[rows, cols]], ["value"]
for window in windows:
mean = uniform_filter(filled, size=window)
squared = uniform_filter(filled ** 2, size=window)
std = np.sqrt(np.maximum(squared - mean ** 2, 0))
features.append(mean[rows, cols])
names.append(f"mean_{int(window * cell)}m")
features.append(std[rows, cols])
names.append(f"std_{int(window * cell)}m")
features.append(data[rows, cols] - mean[rows, cols])
names.append(f"relative_{int(window * cell)}m")
matrix = np.column_stack(features)
print(f" {matrix.shape[1]} features at {[int(w * cell) for w in windows]} m")
return matrix, names
The relative_ features β the value minus its neighbourhood mean β are often the most useful of the set. They express "higher or lower than around here", which is scale-explicit and transfers well, whereas the raw value carries the regional level.
Explanation
Why the feature set matters more than the algorithm
Swapping a random forest for gradient boosting typically moves a score by a few percent. Adding a well-chosen covariate can move it far more β the measured coordinate example was 29%.
That is because the algorithm can only combine what it is given. A model with no information about proximity to water cannot learn a relationship with proximity to water, however sophisticated it is.
Time spent on features is therefore usually better spent than time on hyperparameters, and it is also where domain knowledge enters a model at all.
Why distance features transfer and coordinates do not
Both encode position. The difference is whether the encoding means the same thing elsewhere.
"500 m from a river" implies similar drainage, soil moisture and flood risk anywhere. "Easting 422,000" implies nothing outside its own UTM zone, and something completely different in another.
The measured consequence: coordinates gained 0.162 RΒ² in-area and 0.103 under block CV. Distance features gain similarly in both, because the relationship they encode is real rather than positional.
Why the neighbourhood scale is a feature, not a parameter
Elevation averaged over 100 m and over 5 km measure different things: local terrain and regional setting. A model given both can learn that vegetation responds to one and not the other.
Choosing a single "best" window discards that. Computing several and letting the model select is both easier and more informative β and the importance ranking then tells you which scale the process operates at, which is a genuine finding.
Two or three windows spanning an order of magnitude is enough. More adds correlated columns without adding information.
Why to check feature availability before modelling
A model is only deployable where its features can be computed. A feature derived from a detailed road network available for one country cannot be computed for another.
This is worth checking first, because it is a hard constraint rather than an accuracy trade-off. A model that scores 0.9 and cannot be run where it is needed is worth less than one that scores 0.7 and can.
Edge cases or notes
- Project before computing area, length or distance.
- Compactness needs a non-zero perimeter. Guard against degenerate geometry.
- Compute several neighbourhood scales and let the model choose.
- Relative features β value minus neighbourhood mean β often transfer best.
- Vertex count can be leakage about the digitising source rather than the object.
- Check feature availability at deployment, not just at training.
- Distance features are unbounded; consider a log transform for skewed ones.
- Raw coordinates last, and knowingly.
Internal links
- How to build spatial features for a machine learning model β the implementation
- Spatial machine learning explained β why location is special
- Feature importance says coordinates are the best predictor β the coordinate trade-off measured
- How to do spatial cross-validation in Python β measuring transferability
- How to sample training points from rasters and polygons β where the rows come from
- How to extract raster values at point locations with rasterio β the sampling mechanics
- How to find the nearest point in GeoPandas β distance features
- Extrapolation in space explained: the area of applicability β where the features stop being valid
FAQ
What are spatial features?
Columns derived from geometry: shape properties, distances to reference features, neighbourhood summaries, and raw coordinates. They are how geometry enters a model that takes a table.
Should I include x and y as features?
Only if the model will stay inside the area it was fitted on. Measured, they raised the in-area RΒ² from 0.559 to 0.721 and gained far less under block CV.
Which spatial features transfer best?
Distances to reference features and relative neighbourhood measures. "500 m from a river" means the same thing anywhere; an easting does not.
What neighbourhood size should I use?
Several. Compute two or three windows spanning an order of magnitude and let the model choose; the importance ranking then tells you the scale of the process.
Do I need a projected CRS?
Yes, for anything measuring area, length or distance. Square degrees vary with latitude.
Is vertex count a useful feature?
Sometimes, and it can be leakage β it often reflects how the data was digitised rather than what the object is.
How many features should I build?
Enough to express the domain knowledge you have. Feature choice usually moves a score more than algorithm choice does.