Cross-Validating an Exposure Surface With Held-Out Monitors
This guide, part of Interpolating Environmental Exposure Surfaces, fixes a specific and very common overstatement: an exposure surface reported with a cross-validated fit of 0.85 that in practice predicts poorly everywhere a monitor is not.
Problem Context & Constraints
Cross-validation assumes the held-out observations are independent of the training set. Spatial data violate that assumption by construction — that is the whole premise of interpolating them. Shuffle a monitor network into five random folds and most held-out sites still have a training-set neighbour a few kilometres away, so the model is asked to interpolate a short distance, which it does well. The reported error is the error of a task the surface is never actually asked to perform.
The task it is asked to perform is prediction at residences, and residences are not distributed like monitors. Regulatory networks are concentrated in population centres and near known sources; large rural areas have no site within the correlation range. The relevant question is therefore how well the surface predicts at a location with no nearby monitor, and only a validation design that removes the neighbours can answer it.
Three constraints follow. The holdout unit must be the site, not the observation — holding out a single day from a site that contributes 365 rows measures nothing. Any tuning that looks at the data must happen inside the fold, including variable selection, variogram fitting and exponent selection; a model whose variogram was fitted on all sites has already seen the held-out one. And the error must be reported in the units of the exposure, not only as a correlation, because a correlation of 0.8 with a two-microgram bias is a different product from a correlation of 0.8 without one.
Prerequisites
python3.11numpy1.26.4,geopandas0.14.4,scipy1.13.1- A surface-fitting function that takes a training frame and target geometries and returns predictions — the kriging routine from the parent guide satisfies this
- Monitor sites in a projected coordinate reference system in metres, with a stable site identifier
- An estimate of the correlation range, used to size the blocks
Step-by-Step Solution
Implement both designs and report both. Leave-one-site-out is the right default for small networks; spatial blocking is what you report when the study area contains large unmonitored regions.
# Leave-one-site-out and spatially blocked cross-validation for an exposure surface.
# Pinned: numpy==1.26.4, geopandas==0.14.4
import logging
import numpy as np
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exposure.cv")
def _scores(obs, pred):
resid = obs - pred
ss_res = float((resid ** 2).sum())
ss_tot = float(((obs - obs.mean()) ** 2).sum())
return {"n": int(len(obs)),
"rmse": float(np.sqrt((resid ** 2).mean())),
"mae": float(np.abs(resid).mean()),
"bias": float(resid.mean()),
"r2": float(1.0 - ss_res / ss_tot) if ss_tot > 0 else float("nan")}
def loo_cv(sites: gpd.GeoDataFrame, value_col: str, fit_predict) -> dict:
"""Leave-one-site-out. fit_predict(train_gdf, value_col, target_gdf) -> array."""
sites = sites.sort_values("site_id").reset_index(drop=True)
obs = sites[value_col].to_numpy(float)
pred = np.empty(len(sites))
for i in range(len(sites)):
train = sites.drop(index=i)
# The whole model is refitted inside the fold: variogram, trend, selection.
pred[i] = fit_predict(train, value_col, sites.iloc[[i]])[0]
s = _scores(obs, pred)
log.info("loo n=%d rmse=%.4f bias=%+.4f r2=%.3f", s["n"], s["rmse"], s["bias"], s["r2"])
return s
def blocked_cv(sites: gpd.GeoDataFrame, value_col: str, fit_predict,
block_m: float, seed: int = 20260918) -> dict:
"""Spatially blocked folds: whole square blocks of the extent are held out."""
sites = sites.sort_values("site_id").reset_index(drop=True)
x = sites.geometry.x.to_numpy()
y = sites.geometry.y.to_numpy()
bx = np.floor((x - x.min()) / block_m).astype(int)
by = np.floor((y - y.min()) / block_m).astype(int)
block_id = bx * 10_000 + by
blocks = np.unique(block_id)
rng = np.random.default_rng(seed)
rng.shuffle(blocks)
obs, pred, used = [], [], 0
for b in blocks:
hold = block_id == b
if hold.sum() == 0 or (~hold).sum() < 10:
continue # too little left to fit anything
p = fit_predict(sites[~hold], value_col, sites[hold])
obs.extend(sites.loc[hold, value_col].to_numpy(float))
pred.extend(np.asarray(p, dtype=float))
used += 1
s = _scores(np.asarray(obs), np.asarray(pred))
s.update({"blocks_used": used, "block_m": block_m, "seed": seed})
log.info("blocked block=%.0fm blocks=%d n=%d rmse=%.4f bias=%+.4f r2=%.3f",
block_m, used, s["n"], s["rmse"], s["bias"], s["r2"])
return s
The block size is the parameter that decides what the blocked design measures. Blocks smaller than the correlation range leave neighbours across the block edge and the result drifts back toward the random-fold number. Blocks around one to two times the fitted range remove the local information and report the error that applies inside a network gap of that size. Choose the block size to match the size of the actual unmonitored regions in your study area, and state it — a blocked error without its block size is uninterpretable.
The seeded shuffle exists so the fold assignment is reproducible. Block cross-validation is otherwise deterministic given the block size and origin, but the shuffle order affects nothing except the log, which is exactly why it should be fixed rather than left to chance.
Validation & Edge Cases
Selection leakage. The most frequent defect in published exposure models is choosing predictors on all the data and then cross-validating the chosen model. The fix is mechanical: the fold loop must call a function that performs selection, not a pre-selected formula. If refitting inside every fold is too slow to run, that is a performance problem, not a licence to leak.
A fold that cannot be fitted. Blocking sometimes leaves too few training sites for a variogram. Skip the block, count it, and report how many were skipped — a blocked result computed over a third of the blocks is not the same statistic as one computed over all of them.
A network that changes between years. Sites open and close, so the fold structure is not stable across a multi-year surface. Validating only the years with the fullest network reports the best case and hides the worst. Run the design separately for every year the surface is applied to and keep the per-year table; a year in which four sites closed will show it, and that year’s exposure values deserve wider uncertainty in the health model.
Too few sites for blocking at all. With fewer than roughly twenty-five sites, most blocks leave a training set too small to refit, and the blocked statistic is computed from a handful of folds with wide sampling error. In that situation report leave-one-site-out only, say explicitly that blocked validation was not feasible, and treat any use of the surface in unmonitored regions as unvalidated rather than as validated by the optimistic number.
Bias hiding inside a good correlation. Report bias separately and always. A surface that tracks the spatial pattern well but sits systematically low will produce a correlation near 0.9 and an exposure-response slope that is wrong by the ratio of the scales.
Error that varies across the region. A single number hides the thing you most need to know. Map the residuals; if they are large in one part of the state, the assumption of one covariance model for the whole area has failed, and the remedy is in Handling Non-Stationary Variograms Across a State.
INFO exposure.cv loo n=48 rmse=1.1802 bias=-0.0431 r2=0.742
INFO exposure.cv blocked block=150000m blocks=9 n=48 rmse=2.0617 bias=-0.2085 r2=0.213
Those two lines describe the same surface. Reporting only the first would claim the model explains three quarters of the variation; the second says that in a region the size of a real network gap it explains about a fifth. Both are true, and a study that uses the surface in rural counties is governed by the second.
Where the residual map belongs in the workflow is easiest to see laid against the validation outputs themselves:
Compliance Notes
The validation record is part of the surface’s provenance, not a separate analysis artifact. Store, alongside the surface: the design used, the block size and seed where applicable, the number of folds attempted and completed, the site count, and root mean squared error, mean absolute error, bias and fit for each design. Where the study applies the surface across several years, store the statistics per year rather than pooled, because a surface that degrades in later years is a finding that pooling hides.
State the validation design in the methods text of any product built on the surface. “Cross-validated = 0.74” is not a sufficient statement — the same surface honestly yields 0.21 under a design that matches how it is used. The distinction is the difference between a defensible claim and one that collapses under review.
Related Topics
- Interpolating Environmental Exposure Surfaces — the parent guide covering variogram fitting and prediction.
- Choosing an IDW Power for Sparse Monitor Networks — the same holdout logic applied to selecting an exponent.
- Avoiding Overfitting in Land-Use Regression Variable Selection — where selection leakage does the most damage.