How to Cross-Validate an Interpolated Surface

On this page

Problem statement

You interpolate a surface and want a number for how good it is. The standard answer is leave-one-out cross-validation: remove each sample, predict it from the rest, and take the RMSE of the residuals.

That number is not the error of your surface. It is the error of your surface at the sample locations, and the sample locations are the best-supported places on the map.

Measured against a real elevation model, where the true error over every cell is known:

                          random, 500 samples   clustered, 377 samples
leave-one-out CV RMSE           55.8 m                 37.0 m
true RMSE, every cell           56.0 m                185.3 m
5 spatial blocks CV            188.3 m                196.4 m

On a well-spread sample LOO was right to within half a percent. On a clustered sample of the same terrain it was five times too optimistic. Block cross-validation came close on the clustered sample and was three times too pessimistic on the random one (Example 1). No single scheme is safe to assume.

Quick answer

import numpy as np
from scipy.spatial import cKDTree


def loo_cv(points, values, predict, **kwargs):
    """Leave-one-out cross-validation for any interpolator."""
    predicted = np.empty(len(points))
    for i in range(len(points)):
        keep = np.ones(len(points), bool)
        keep[i] = False
        predicted[i] = predict(points[keep], values[keep],
                               points[i:i + 1], **kwargs)[0]

    residual = values - predicted
    return {
        "rmse": float(np.sqrt(np.mean(residual ** 2))),
        "mae": float(np.mean(np.abs(residual))),
        "bias": float(np.mean(residual)),
        "r2": float(1 - residual.var() / values.var()),
    }

Then report it honestly: "leave-one-out RMSE at the sample locations", not "the accuracy of the map".

Leave-one-out cross-validation on a clustered sample reporting 37.0 metres against a true surface error of 185.3 metres over every cell. a clustered sample, scored three waysleave-one-out37.0 m5 spatial blocks196.4 mtrue error, every cell185.3 m0200LOO kept each held-out point next to its cluster-mates; the blocks did not spatialworkflow.io
Cross-validation measures the surface where the samples are. That is not where the error is.

What you get β€” from The data used on this page

DEM 52 to 1072 m; 500 samples, 12,000 held out

What LOO measures, and block CV for clustered samples

1. Understand what LOO actually measures

Removing one point from a well-spread sample leaves a hole roughly one sample-spacing wide, and the prediction has to bridge it β€” much the problem the average cell of the map faces. That is why LOO works on a well-spread design: 55.8 m against a true 56.0 m here.

2. Understand why clustering flips the sign

In a clustered design, removing one point from a cluster leaves its neighbours a few metres away. The prediction is then almost exact, and LOO reports an error close to the measurement noise.

Meanwhile the surface between clusters is terrible. Measured on the clustered design here, the true RMSE was 185.3 m against 56.0 m for the random one β€” and the clustered design's LOO reported 37.0 m, a better score than the random design's.

Cross-validation on clustered data is worse than no validation, because it is confidently wrong in the reassuring direction.

3. Use spatial (block) cross-validation instead

Hold out whole regions, not individual points, so the held-out points do not have their own neighbours to lean on:

def block_folds(points, n_blocks=5, seed=0):
    """Assign points to spatial blocks by k-means on their coordinates."""
    from sklearn.cluster import KMeans
    return KMeans(n_clusters=n_blocks, random_state=seed,
                  n_init=10).fit_predict(points)

Block CV answers "how well does this surface do somewhere I have not sampled". On the clustered design that is the right question β€” five blocks gave 196.4 m against a true 185.3 m. On the random design it is the wrong one: holding out a fifth of the map makes every prediction bridge a hole a kilometre or more wide, and five blocks gave 188.3 m against a true 56.0 m. Match the scheme to how the map will be used.

4. Report the error as a function of distance, not as one number

A single RMSE hides the thing that matters most. Measured error by distance to the nearest sample, on the clustered design (Example 2):

   0 -   100 m     9,680 cells    21.2 m
 100 -   250 m    14,020 cells    50.3 m
 250 -   500 m    15,753 cells   101.6 m
 500 - 1,000 m    25,193 cells   142.2 m
over 1,000 m      46,243 cells   258.7 m

One number would say "about 185 m". The table says the map is excellent in some places and useless in others, and exactly where.

5. Check the residuals for structure

residual = values - predicted

Residuals should look like noise. If they are spatially clustered β€” all positive in one region, all negative in another β€” there is a trend the interpolator is not capturing, and the fix is to model the trend rather than to tune the interpolator.

Leave-one-out holding out a single point that still has near neighbours, against block cross-validation holding out a whole region. what each scheme actually asksleave-one-outβœ“"predict this point from its neighbours"βœ“neighbours remain, metres awayβœ“answers: is the local model right?βœ“meaningless under clusteringblock CVβœ“"predict this region from elsewhere"βœ“the whole neighbourhood is goneβœ“answers: does it fill gaps?βœ“block size sets the questionMatch the scheme to the gap the surface must fill spatialworkflow.io
Under clustered sampling, a held-out point still has neighbours metres away. A held-out block does not.

LOO, k-fold and block CV side by side

The data used on this page

The surface is real: the Copernicus DEM GLO-30 over a 10 Γ— 10 km window centred on the summit of Snowdon (Yr Wyddfa), warped to a 30 m grid in EPSG:32630 (x 422,880–432,870, y 5,875,440–5,885,430; 333 Γ— 333 cells, 99.8 kmΒ²). The 500 samples are cell centres drawn at random with a fixed seed; 12,000 further cells, none of them samples, are held out as test_points/test_values. grid_points/truth are every cell centre and its elevation, and truth_fn reads the DEM bilinearly at any location, which the error tables score against. idw is the function from Spatial interpolation explained, and surface is its power-2, 12-neighbour surface on that 30 m grid. This page validates both the random sample and a clustered design: 8 centres placed at random, 50 samples round each with a 400 m standard deviation (seed 1), snapped to cells β€” 377 samples once those outside the window are dropped β€” as cpoints/cvalues.

import os

import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.warp import reproject
from scipy.interpolate import RegularGridInterpolator

bounds = (422_880.0, 5_875_440.0, 432_870.0, 5_885_430.0)
transform = from_origin(bounds[0], bounds[3], 30, 30)
shape = (333, 333)
if not os.path.exists("snowdon_dem_utm30.tif"):
    COP = ("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N53_00_{0}_00_DEM/"
           "Copernicus_DSM_COG_10_N53_00_{0}_00_DEM.tif")
    dem = np.full(shape, np.nan, "float32")
    for tile in ("W005", "W004"):
        part = np.full(shape, np.nan, "float32")
        with rasterio.open(COP.format(tile)) as src:
            reproject(rasterio.band(src, 1), part, dst_transform=transform, dst_crs="EPSG:32630",
                      dst_nodata=np.nan, resampling=Resampling.bilinear)
        dem = np.where(np.isnan(dem), part, dem)
    with rasterio.open("snowdon_dem_utm30.tif", "w", driver="GTiff", width=shape[1], height=shape[0], count=1,
                       dtype="float32", crs="EPSG:32630", transform=transform, nodata=np.nan) as dst:
        dst.write(dem, 1)
dem = rasterio.open("snowdon_dem_utm30.tif").read(1)

xs = bounds[0] + (np.arange(shape[1]) + 0.5) * 30
ys = bounds[3] - (np.arange(shape[0]) + 0.5) * 30
gx, gy = np.meshgrid(xs, ys)
grid_points = np.column_stack([gx.ravel(), gy.ravel()])
truth = dem.ravel().astype("float64")

rng = np.random.default_rng(0)
order = rng.permutation(truth.size)
sample_idx, test_idx = order[:500], order[500:12_500]
points, values = grid_points[sample_idx], truth[sample_idx]
test_points, test_values = grid_points[test_idx], truth[test_idx]
_bilinear = RegularGridInterpolator((ys[::-1], xs), dem[::-1], bounds_error=False, fill_value=None)
truth_fn = lambda xy: _bilinear(np.column_stack([xy[:, 1], xy[:, 0]]))
from scipy.spatial import cKDTree


def idw(points, values, targets, power=2, k=12):
    tree = cKDTree(points)
    distance, index = tree.query(targets, k=min(k, len(points)))
    distance, index = distance.reshape(len(targets), -1), index.reshape(len(targets), -1)
    weights = 1.0 / np.maximum(distance, 1e-9) ** power
    predicted = (weights * values[index]).sum(1) / weights.sum(1)
    exact = distance[:, 0] < 1e-9
    predicted[exact] = values[index[exact, 0]]
    return predicted, distance[:, 0]


surface = idw(points, values, grid_points)[0].reshape(shape)

crng = np.random.default_rng(1)
centres = np.column_stack([crng.uniform(bounds[0], bounds[2], 8), crng.uniform(bounds[1], bounds[3], 8)])
raw = (centres[:, None, :] + crng.normal(0, 400, (8, 50, 2))).reshape(-1, 2)
raw = raw[(raw[:, 0] > bounds[0]) & (raw[:, 0] < bounds[2]) & (raw[:, 1] > bounds[1]) & (raw[:, 1] < bounds[3])]
cidx = np.unique(((bounds[3] - raw[:, 1]) // 30).astype(int) * shape[1] + ((raw[:, 0] - bounds[0]) // 30).astype(int))
cpoints, cvalues = grid_points[cidx], truth[cidx]
print(f"DEM {np.nanmin(dem):.0f} to {np.nanmax(dem):.0f} m; {len(points)} samples, {len(test_points):,} held out")
DEM 52 to 1072 m; 500 samples, 12,000 held out

Example 1 β€” LOO, k-fold and block CV side by side

import numpy as np
from scipy.spatial import cKDTree


def compare_validation(points, values, predict, n_blocks=5, k_folds=5, seed=0):
    """The same interpolator scored three ways, so the gap is visible."""
    rng = np.random.default_rng(seed)
    n = len(points)

    def score(fold_ids):
        predicted = np.empty(n)
        for fold in np.unique(fold_ids):
            test = fold_ids == fold
            predicted[test] = predict(points[~test], values[~test], points[test])
        return float(np.sqrt(np.mean((values - predicted) ** 2)))

    loo = score(np.arange(n))
    kfold = score(rng.integers(0, k_folds, n))

    from sklearn.cluster import KMeans
    blocks = KMeans(n_clusters=n_blocks, random_state=seed,
                    n_init=10).fit_predict(points)
    block = score(blocks)

    print(f"  leave-one-out      {loo:7.2f}")
    print(f"  random {k_folds}-fold      {kfold:7.2f}")
    print(f"  {n_blocks} spatial blocks  {block:7.2f}")
    print(f"  block / LOO ratio  {block / loo:7.2f}")
    return {"loo": loo, "kfold": kfold, "block": block}


predict = lambda p, v, t: idw(p, v, t)[0]
true_error = lambda p, v: float(np.sqrt(np.mean((predict(p, v, grid_points) - truth) ** 2)))
for name, p, v in (("random", points, values), ("clustered", cpoints, cvalues)):
    print(f"{name} ({len(p)} samples), true error over every cell {true_error(p, v):.2f}")
    _ = compare_validation(p, v, predict)
print("loo_cv on the random sample:", {k: round(x, 3) for k, x in loo_cv(points, values, predict).items()})
random (500 samples), true error over every cell 55.99
  leave-one-out        55.82
  random 5-fold        62.90
  5 spatial blocks   188.25
  block / LOO ratio     3.37
clustered (377 samples), true error over every cell 185.27
  leave-one-out        37.04
  random 5-fold        40.82
  5 spatial blocks   196.40
  block / LOO ratio     5.30
loo_cv on the random sample: {'rmse': 55.824, 'mae': 38.26, 'bias': 2.644, 'r2': 0.92}

The ratio on its own does not diagnose the design: it was 3.4 on the random sample and 5.3 on the clustered one. What differs is which score matches the truth β€” LOO on the random design, the blocks on the clustered one. Read the coverage statistics in Example 3 to know which situation you are in.

Leave-one-out, random five-fold and five-block cross-validation against the true error, for a random and a clustered sample of the Snowdon DEM. which score matched the truthscorerandom, 500clustered, 377leave-one-out55.8 m37.0 mrandom 5-fold62.9 m40.8 m5 spatial blocks188.3 m196.4 mtrue, every cell56.0 m185.3 mEach scheme is right for one design and badly wrong for the other spatialworkflow.io
Read the coverage statistics first; they tell you which column you are in.

Example 2 β€” error as a function of support distance

import numpy as np
from scipy.spatial import cKDTree


def error_by_distance(points, values, targets, truth, predict,
                      bands=((0, 100), (100, 250), (250, 500),
                             (500, 1000), (1000, np.inf))):
    """The table that a single RMSE hides."""
    predicted = predict(points, values, targets)
    nearest, _ = cKDTree(points).query(targets, k=1)

    rows = []
    for lo, hi in bands:
        band = (nearest >= lo) & (nearest < hi)
        if band.sum() < 20:
            continue
        rmse = float(np.sqrt(np.mean((predicted[band] - truth[band]) ** 2)))
        label = f"{lo:>5.0f}-{'inf' if np.isinf(hi) else int(hi):>5}"
        print(f"  {label} m  {int(band.sum()):7,} cells  RMSE {rmse:7.2f}")
        rows.append({"lo": lo, "hi": None if np.isinf(hi) else hi,
                     "cells": int(band.sum()), "rmse": rmse})
    return rows


_ = error_by_distance(cpoints, cvalues, grid_points, truth, predict)
      0-  100 m    9,680 cells  RMSE   21.20
    100-  250 m   14,020 cells  RMSE   50.34
    250-  500 m   15,753 cells  RMSE  101.63
    500- 1000 m   25,193 cells  RMSE  142.21
   1000-  inf m   46,243 cells  RMSE  258.68

This needs ground truth over the whole area, which you usually do not have β€” but you can get it for a pilot: take a dense dataset, thin it to a realistic survey, interpolate, and compare. Doing that once for your variable and landscape calibrates your intuition permanently.

Example 3 β€” a validation report that will not mislead the reader

import numpy as np
from scipy.spatial import cKDTree


def validation_report(points, values, predict, bounds, n_blocks=5):
    """Everything a reader needs to judge the surface, in one dict."""
    from sklearn.cluster import KMeans

    n = len(points)
    left, bottom, right, top = bounds
    area_km2 = (right - left) * (top - bottom) / 1e6

    # coverage first: it determines everything else
    rng = np.random.default_rng(0)
    probes = np.column_stack([rng.uniform(left, right, 20000),
                              rng.uniform(bottom, top, 20000)])
    gap, _ = cKDTree(points).query(probes, k=1)

    blocks = KMeans(n_clusters=n_blocks, random_state=0, n_init=10).fit_predict(points)
    predicted = np.empty(n)
    for fold in np.unique(blocks):
        test = blocks == fold
        predicted[test] = predict(points[~test], values[~test], points[test])
    residual = values - predicted

    report = {
        "n_samples": n,
        "area_km2": round(area_km2, 1),
        "ideal_spacing_m": round(float(np.sqrt(area_km2 * 1e6 / n))),
        "mean_gap_m": round(float(gap.mean())),
        "p95_gap_m": round(float(np.percentile(gap, 95))),
        "max_gap_m": round(float(gap.max())),
        "block_cv_rmse": round(float(np.sqrt(np.mean(residual ** 2))), 2),
        "block_cv_bias": round(float(residual.mean()), 2),
        "n_blocks": n_blocks,
        "caveat": "block CV RMSE is measured at sample locations; error grows "
                  "with distance from the nearest sample",
    }
    for key, value in report.items():
        print(f"  {key:18} {value}")
    return report


_ = validation_report(points, values, predict, bounds)
  n_samples          500
  area_km2           99.8
  ideal_spacing_m    447
  mean_gap_m         228
  p95_gap_m          449
  max_gap_m          757
  block_cv_rmse      188.25
  block_cv_bias      34.59
  n_blocks           5
  caveat             block CV RMSE is measured at sample locations; error grows with distance from the nearest sample

Shipping the gap percentiles beside the RMSE is what stops the RMSE being quoted alone. The caveat string is not padding β€” it is the sentence that will otherwise be omitted from every summary of your work.

When LOO is right and when it misleads

Why LOO was right on one design and wrong on the other

The removed point's prediction quality depends on what is left nearby.

Under even sampling, removing a point leaves a gap about one spacing wide, which is close to the problem the average cell faces β€” hence 55.8 m against a true 56.0 m.

Under clustered sampling, removing a point leaves its cluster-mates metres away, which is a far easier problem than the average cell faces. LOO then reported 37.0 m for a surface whose RMSE was 185.3 m.

Both are the same mechanism. The bias direction is set by how the sample spacing at the held-out point compares with the typical distance from a map cell to its nearest sample.

Why block CV is the right default for clustered data

Holding out a spatial block forces predictions across a real gap. The remaining points are not neighbours of the held-out ones, so the score reflects what a map user experiences: standing somewhere nobody sampled.

The usual objection is that block CV is pessimistic because each fold trains on less area. On a well-spread sample that is true and large β€” 188.3 m against a true 56.0 m here β€” so use LOO there. On clustered data it is the only score that came close, and a surface whose block CV is acceptable is a surface you can defend.

Why RΒ² is a poor choice here

R2R^2 compares your errors against predicting the mean everywhere. On a variable with strong spatial structure, predicting the mean is a very weak baseline, so RΒ² comes out high even for a mediocre surface.

RMSE in the variable's own units is more informative β€” "Β±56 m of elevation" is a statement anyone can act on, and "RΒ² = 0.92" (the LOO value here) is not.

Why validation cannot rescue a bad design

Cross-validation scores the interpolator. It cannot score the survey, because it only ever asks questions at locations the survey chose.

If the samples cover 60% of the area and cluster in the accessible parts, no validation scheme computed from those samples alone will reveal the state of the other 40%. The only instruments that will are the coverage statistics β€” mean, 95th-percentile and maximum gap β€” which need no ground truth at all.

Choosing a cross-validation scheme from what the surface will be used for. the scheme encodes the questionwhat you will do with the surfacethe scheme that scores itread values near the samplesleave-one-outfill gaps between survey linesblock CV at the line spacingmap a new, unsurveyed areahold out a whole regioncompare two interpolatorsany scheme β€” but the same onequote an absolute accuracyan independent check datasetOnly the last one gives a number you can put in a report spatialworkflow.io
Cross-validation compares methods well and quotes absolute accuracy badly.

Self-prediction, fold variograms and area weighting

  • Never let a point predict itself. With a k-d tree, the nearest neighbour of a sample is itself; drop the first column.
  • LOO with n samples costs n fits. For kriging, refit the weights, not the variogram, or it becomes very slow.
  • Refit the variogram inside the fold if you want a strictly honest score; fitting it on all the data leaks information.
  • Random k-fold behaves like LOO for this purpose β€” both leave the held-out points with nearby neighbours.
  • Report bias as well as RMSE. A surface can have small RMSE and a systematic offset.
  • Check residual autocorrelation. Spatially structured residuals mean an unmodelled trend.
  • Weight the score by area, not by sample, if the design is clustered β€” otherwise the dense regions dominate.
  • Report the coverage statistics alongside, always.

FAQ

How do I validate an interpolated surface?

Hold out spatial blocks rather than individual points when the samples are clustered (on a well-spread sample, leave-one-out is closer), report RMSE in the variable's units, and publish the coverage statistics β€” mean and 95th-percentile distance to the nearest sample β€” alongside.

Is leave-one-out cross-validation reliable?

Only when the samples are well spread. It matched the true error within half a percent on a random sample here, and was five times too optimistic on a clustered one, because held-out points keep their nearby neighbours.

What is block cross-validation?

Splitting the samples into spatial groups and holding out whole groups, so predictions must cross a real gap rather than borrow from a neighbour a few metres away.

Why is my cross-validation score better than my map looks?

Almost certainly clustered sampling. The score is measured at sample locations, which are the best-supported places, and clusters make them better supported still.

Should I use RΒ² or RMSE?

RMSE, in the variable's own units. RΒ² compares against predicting the mean, which is a weak baseline for a spatially structured variable.

How do I know my error where I have no data?

You cannot measure it, but you can bound it by the distance to the nearest sample. Error grew from 21.2 m within 100 m of a sample to 258.7 m beyond a kilometre in the measurement here.

Should I refit the variogram inside each fold?

For a strictly honest score, yes. Fitting it once on all the data leaks information from the held-out fold into the model.