Building Buffer Predictors for a Land-Use Regression Model
This guide, part of Land-Use Regression for Air Pollution Exposure, covers the step that consumes most of the wall-clock time and produces most of the silent errors: turning a road network and a land-cover layer into a rectangular table of predictors, first at sixty monitoring sites and then at two million prediction points.
Problem Context & Constraints
The naive implementation buffers each site, clips the road network to the buffer, and sums the segment lengths. It is correct, it is obvious, and it is unusable at prediction scale: with six buffer radii, two road classes and two land-cover classes, a metropolitan grid of two million points requires forty-eight million clip operations. A loop that takes 40 ms per site finishes the campaign in three seconds and the grid in three weeks.
The correctness traps are worse than the performance one because they are invisible. Clipping a line at a buffer edge is not the same as selecting lines that intersect the buffer. The latter counts a 5 km motorway segment in full because one end clips the 100 m buffer, inflating the predictor by an order of magnitude at exactly the sites where near-road exposure matters. A buffer built in a geographic coordinate system is an ellipse of varying size, so a 300 m buffer is 300 m of latitude and something else entirely of longitude. Land-cover polygons that overlap — common where a source layer has been edited over time — double-count area, pushing a proportion above one.
And one design constraint runs through the whole build: the predictor computed at a monitoring site and the predictor computed at a prediction point must come from the same code path. If sites are processed with an exact overlay and the grid with a raster approximation, the model is fitted on one definition and applied to another, and the mismatch appears as unexplained bias in exactly the places the campaign did not sample.
Prerequisites
python3.11geopandas0.14.4,shapely2.0.4,numpy1.26.4,pandas2.2.2rasterio1.3.10 andrasterstats0.19.0 for the raster path- A road network with a functional class attribute and no duplicate geometries
- A land-cover layer with non-overlapping polygons, or a categorical raster
- Every layer in one projected coordinate reference system in metres, selected per Coordinate Reference Systems for Public Health
Step-by-Step Solution
The build below uses a spatial index to restrict the candidate geometries per point, clips rather than selects, and processes the largest buffer first so smaller buffers reuse the already-restricted candidate set.
# Buffer predictors for land-use regression, vectorised and deterministic.
# Pinned: geopandas==0.14.4, shapely==2.0.4, numpy==1.26.4, pandas==2.2.2
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
from shapely.strtree import STRtree
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("lur.buffers")
BUFFERS_M = (25, 50, 100, 300, 1000, 3000)
def road_length_predictors(points: gpd.GeoDataFrame, roads: gpd.GeoDataFrame,
class_col: str = "fclass",
classes=("motorway", "trunk", "primary"),
buffers=BUFFERS_M) -> pd.DataFrame:
"""Clipped road length within each buffer, for every point.
Uses one STRtree over the road geometries and queries the LARGEST buffer once
per point, then clips the resulting candidate set against every smaller buffer.
"""
_require_metric(points, roads)
points = points.sort_values("point_id").reset_index(drop=True)
sel = roads[roads[class_col].isin(classes)].reset_index(drop=True)
geoms = sel.geometry.to_numpy()
tree = STRtree(geoms)
rmax = max(buffers)
out = {f"road_major_{b}": np.zeros(len(points)) for b in buffers}
for i, pt in enumerate(points.geometry):
# One index query at the widest radius; everything else is arithmetic.
cand_idx = tree.query(pt.buffer(rmax))
if len(cand_idx) == 0:
continue
cand = geoms[cand_idx]
for b in buffers:
ring = pt.buffer(b)
total = 0.0
for g in cand:
if not g.intersects(ring):
continue
total += g.intersection(ring).length # CLIP, never select whole
out[f"road_major_{b}"][i] = total
if i and i % 50_000 == 0:
log.info("road predictors: %d/%d points", i, len(points))
df = pd.DataFrame(out)
df.insert(0, "point_id", points["point_id"].to_numpy())
log.info("road predictors complete: %d points x %d buffers", len(df), len(buffers))
return df
def landcover_area_predictors(points: gpd.GeoDataFrame, landcover: gpd.GeoDataFrame,
class_col: str = "class",
classes=("industrial", "forest"),
buffers=BUFFERS_M) -> pd.DataFrame:
"""Area proportion of each land-cover class within each buffer."""
_require_metric(points, landcover)
points = points.sort_values("point_id").reset_index(drop=True)
out = {}
for cls in classes:
lc = landcover[landcover[class_col] == cls]
# Dissolve first: overlapping source polygons would double-count area.
merged = lc.geometry.union_all()
tree = STRtree(np.asarray([merged]))
for b in buffers:
col = np.zeros(len(points))
area_b = np.pi * b * b
for i, pt in enumerate(points.geometry):
ring = pt.buffer(b)
if len(tree.query(ring)) == 0:
continue
col[i] = merged.intersection(ring).area / area_b
out[f"{'green' if cls == 'forest' else cls}_{b}"] = col
df = pd.DataFrame(out)
df.insert(0, "point_id", points["point_id"].to_numpy())
return df
def _require_metric(*frames: gpd.GeoDataFrame) -> None:
crs = {f.crs for f in frames}
if len(crs) != 1 or next(iter(crs)) is None:
raise ValueError("all layers must share one CRS")
if next(iter(crs)).is_geographic:
raise ValueError("buffers must be built in a projected CRS measured in metres")
Three implementation choices carry the correctness of this build. The intersection(...).length call is the clip, and replacing it with a length of the whole candidate geometry is the error shown in the diagram above. The union_all() dissolve before the land-cover intersection removes double counting from overlapping source polygons, which otherwise pushes a proportion above 1.0 and produces a predictor the regression cannot interpret. And the coordinate reference system guard fails loudly rather than silently producing degree-based buffers.
For a prediction grid of millions of points, the pure-Python inner loop is still too slow, and the right move is to switch the road predictors to a rasterised path: burn road length per cell once at a fine resolution, then compute a moving-window sum at each buffer radius with an integral image. The result is numerically close but not identical to the exact clip, so the sites must be recomputed the same way — fit on the raster definition if you predict on the raster definition.
Validation & Edge Cases
Monotonicity across the ladder. Road length within 1000 m must be at least road length within 300 m at the same point. A violation means the clip is wrong or the candidate set was restricted per buffer instead of at the widest radius. Assert it.
Proportions inside the unit interval. Land-cover area proportion must lie between 0 and 1. Values above 1 mean overlapping source polygons survived the dissolve; values that sum above 1 across mutually exclusive classes mean the same.
Points near the study-area edge. A buffer at the boundary extends into territory with no road data, so the predictor is an undercount rather than a true low value. Pad the input layers by the widest buffer radius beyond the prediction extent, or flag the affected points.
Empty candidate sets. A point with no road within 3 km legitimately gets zero, but a systematic block of zeros usually means a coordinate reference system mismatch that the guard did not catch because both layers were wrong in the same way. Spot-check a handful of known locations against a map.
INFO lur.buffers road predictors: 500000/2118400 points
INFO lur.buffers road predictors complete: 2118400 points x 6 buffers
ERROR lur.buffers monotonicity violated at 14 points: road_major_300 > road_major_1000
Duplicate road geometries. Networks assembled from several sources routinely contain the same carriageway twice. Every duplicate doubles its contribution to every buffer containing it. Deduplicate on geometry before building anything.
Sites and grid built on different vintages. If the campaign predictors were built in March against one road extract and the grid in June against another, a new bypass appears in one table and not the other. Pin the extract, and record its identifier.
The two invariants in that list are cheap to assert and catch most build errors, because a correct predictor table has a shape you can predict in advance:
Compliance Notes
The predictor build is a deterministic transformation and must be recorded as one. Store the road and land-cover source identifiers with their extract dates, the functional classes included in each aggregate, the exact buffer list, the computation path (vector clip or raster window) with the cell size where applicable, the dissolve step for overlapping polygons, and the padding applied at the study-area edge.
Record the assertion results too, not just the table. A build that passed the monotonicity and unit-interval checks is a different artifact from one where the checks were never run, and the difference is invisible in the output file. Where the same predictor table is reused across studies, the checksum of the table plus the configuration digest is what lets a second team confirm they are working from the same inputs rather than a rebuild that quietly used a newer network.
Related Topics
- Land-Use Regression for Air Pollution Exposure — the parent guide covering selection and validation of these predictors.
- Avoiding Overfitting in Land-Use Regression Variable Selection — what to do with forty correlated candidates and sixty sites.
- Zonal Statistics for Raster Exposure at Address Points — the same windowed-summary machinery applied to an exposure raster.