Skip to main content
← All writingKylan Thomson

Geospatial ML · Evaluation

Why Random Cross-Validation Lies in Geospatial ML

A held-out point a few hundred metres from its training neighbour is not a test. Spatial autocorrelation turns random k-fold into a memory exam, and the fix is to hold out places rather than rows.

Published
Reading time
10 minutes
Project
SporeCast case study

SporeCast, my mushroom-forecasting model for Washington State, reports an AUC of 0.94 for western matsutake. Measured the way most tutorials would have you measure it, the number is 0.99. Measured the way a forager would, it is 0.60. All three numbers come from the same model and the same data. Only the question changed.

This piece is about why the first two numbers are lies, what the honest number costs to compute, and what the gap looks like across twenty species when you actually go and measure it. The short version: on spatial data, random k-fold cross-validation does not test prediction. It tests memory.

What a random fold actually measures

The training set is 24,266 presence records pooled from iNaturalist, GBIF, Mushroom Observer, and iDigBio. Before any modelling, I measured how close those records sit to each other. The median distance from an observation to its nearest neighbour is 137 metres. Eighty-one percent of records have another record within one kilometre, and ninety-seven percent have one within five. Six percent share their coordinates exactly with another record.

Now picture a random 80/20 split. For almost every held-out point there is a training point a few hundred metres away, drawn from the same slope, the same soil, the same 10-metre satellite embedding, very likely the same afternoon and the same person. A model does not need to have learned anything about mushroom habitat to score that point correctly. It needs to have memorised its neighbour, and gradient-boosted trees are extremely good at memorising neighbours.

Spatial autocorrelation is the technical name for this, and it is not a subtle effect at the tails. It is the main effect. The environmental features that make the model useful (elevation, soil moisture, distance to water, satellite texture) are precisely the ones that vary smoothly across space, so any two nearby points share them. Random folds put those pairs on opposite sides of the split and call the resulting score generalisation.

Holding out places instead of rows

The fix is old and simple: partition space, not rows. Every record is assigned to a block on a latitude-longitude lattice, and folds are built from whole blocks, so a test point's neighbours are in the test fold with it. In scikit-learn terms it is GroupKFold with the block id as the group. The whole blocking function is four lines.

def _spatial_blocks(coords, block_deg):
    b = np.floor(coords / block_deg).astype(int)
    return np.array([f"{r}_{c}" for r, c in b])
model.py: blocks are a lattice, and the block id is the group key for GroupKFold.

The interesting question is how big the blocks should be. Too small and you are back to random folds with extra steps. Too big and a four-fold split leaves the model training on three quarters of a state and testing on the other quarter, which is a different and harsher question than the one the map will be asked.

Deriving block size from the data

Rather than pick a number, SporeCast derives one from the empirical variogram of the predictors that carry the most spatial structure: elevation, soil moisture, distance to water, and distance to the two host-tree classes. For each field it standardises the values, bins pairwise distances, computes the semivariance in each bin, and reads off the range, the distance at which semivariance reaches 95 percent of its sill. The block size is the median range across those fields, clamped to a floor and a ceiling of ▓▓▓ so a degenerate variogram cannot produce a block the size of a county or a continent.

Two honest notes on that machinery. First, broad climate fields (annual mean temperature, autumn precipitation) were deliberately removed from the set, because their ranges are so long that they pinned every species to the ceiling and erased the per-species adaptivity the method was meant to add. Second, in the current build every one of the twenty species still lands on the ceiling. The adaptive block size has, for now, degenerated to a constant. It is still the right constant, and the code's own comment on the matter is the one I would write today: the gain is a more conservative, less leakage-inflated estimate, not a measured accuracy jump.

What the gap looks like

There was no random-fold baseline in the project, because I never trusted one enough to run it. For this article I ran it anyway: LightGBM, four folds, the same sixty features and the same class-balanced weighting as the production ensemble, over the cached training frames, for six species spanning the range of sample sizes. The background here is the forested serving grid rather than the regional background, for reasons the next section explains.

  • Random k-fold
  • Blocked, 0.25°
  • Blocked, 0.75°
  • Blocked, 1.5°
0.850.900.951.00AUC, WITHIN FORESTED HABITATGolden chanterellen = 2,771Golden chanterelle, random k-fold: AUC 0.983Golden chanterelle, 0.25° blocks: AUC 0.974Golden chanterelle, 0.75° blocks: AUC 0.966Golden chanterelle, 1.5° blocks: AUC 0.950−0.033King boleten = 2,091King bolete, random k-fold: AUC 0.979King bolete, 0.25° blocks: AUC 0.967King bolete, 0.75° blocks: AUC 0.953King bolete, 1.5° blocks: AUC 0.950−0.029Winter chanterellen = 2,484Winter chanterelle, random k-fold: AUC 0.984Winter chanterelle, 0.25° blocks: AUC 0.969Winter chanterelle, 0.75° blocks: AUC 0.951Winter chanterelle, 1.5° blocks: AUC 0.958−0.026Mountain moreln = 439Mountain morel, random k-fold: AUC 0.982Mountain morel, 0.25° blocks: AUC 0.974Mountain morel, 0.75° blocks: AUC 0.958Mountain morel, 1.5° blocks: AUC 0.954−0.028Western matsutaken = 449Western matsutake, random k-fold: AUC 0.919Western matsutake, 0.25° blocks: AUC 0.917Western matsutake, 0.75° blocks: AUC 0.898Western matsutake, 1.5° blocks: AUC 0.865−0.053Bellybutton hedgehogn = 195Bellybutton hedgehog, random k-fold: AUC 0.967Bellybutton hedgehog, 0.25° blocks: AUC 0.945Bellybutton hedgehog, 0.75° blocks: AUC 0.928Bellybutton hedgehog, 1.5° blocks: AUC 0.865−0.102
View as table
AUC by validation scheme, within-habitat background
SpeciesPresencesRandom0.25°0.75°1.5°Gap
Golden chanterelle2,7710.9830.9740.9660.9500.033
King bolete2,0910.9790.9670.9530.9500.029
Winter chanterelle2,4840.9840.9690.9510.9580.026
Mountain morel4390.9820.9740.9580.9540.028
Western matsutake4490.9190.9170.8980.8650.053
Bellybutton hedgehog1950.9670.9450.9280.8650.102
Out-of-fold AUC for six species under random k-fold and three spatial block sizes, scored within forested habitat. Every species scores lower as the blocks grow, and the two rarest species lose the most: the bellybutton hedgehog drops a full tenth of an AUC point. Re-run for this article over the July 2026 training frames.

The mean gap between random folds and 1.5° blocks is 0.045 AUC, and the pattern underneath it is the important part. The species with thousands of records lose about three hundredths. The two species with a few hundred records lose five to ten. That is exactly what leakage predicts: the thinner the data, the larger the share of a random fold's score that is memorised neighbours rather than learned habitat, so the more the honest number falls when the neighbours are taken away.

Put differently, random cross-validation is most flattering precisely where you most need it to be strict. A rare species is the one whose map you should trust least, and it is the one whose random-fold score looks best relative to reality.

The metric lies too

Blocking the folds fixes half the problem. The other half is what the model is being asked to separate presences from. A species distribution model trains presences against background points drawn from the region, and in the Pacific Northwest the region is mostly places a forager would never look: sagebrush steppe, alpine rock, open water. Against that background, every species scores about 0.97, because the model has learned that mushrooms grow in forests. That is true, and useless. The person opening the map is already standing in a forest.

So the score that ships is computed against the forested part of the serving grid, within reach of known finds, with any cell that holds a find removed. It asks the question the user asks: given that I am in plausible habitat, does this model know where within it to send me? The presences are scored out of fold and the landscape is scored by the final blend, which is the conservative choice.

  • Full background (mostly desert vs. forest)
  • Within forested habitat
0.60.70.80.91.0AUC (SPATIALLY BLOCKED, OUT OF FOLD)Black morelBlack morel, full background: 0.990Black morel, within habitat: 0.977Black trumpetBlack trumpet, full background: 0.985Black trumpet, within habitat: 0.921Lion's maneLion's mane, full background: 0.973Lion's mane, within habitat: 0.915Oyster mushroomOyster mushroom, full background: 0.981Oyster mushroom, within habitat: 0.870Lobster mushroomLobster mushroom, full background: 0.982Lobster mushroom, within habitat: 0.867Candy capCandy cap, full background: 0.983Candy cap, within habitat: 0.866Shrimp russulaShrimp russula, full background: 0.975Shrimp russula, within habitat: 0.850Spring king boleteSpring king bolete, full background: 0.971Spring king bolete, within habitat: 0.847Mountain morelMountain morel, full background: 0.963Mountain morel, within habitat: 0.833Cascade chanterelleCascade chanterelle, full background: 0.969Cascade chanterelle, within habitat: 0.831Golden chanterelleGolden chanterelle, full background: 0.977Golden chanterelle, within habitat: 0.805Cauliflower mushroomCauliflower mushroom, full background: 0.975Cauliflower mushroom, within habitat: 0.800Bellybutton hedgehogBellybutton hedgehog, full background: 0.960Bellybutton hedgehog, within habitat: 0.783White chanterelleWhite chanterelle, full background: 0.966White chanterelle, within habitat: 0.781Winter chanterelleWinter chanterelle, full background: 0.976Winter chanterelle, within habitat: 0.772King boleteKing bolete, full background: 0.966King bolete, within habitat: 0.758Chicken of the woodsChicken of the woods, full background: 0.966Chicken of the woods, within habitat: 0.703Conifer coral toothConifer coral tooth, full background: 0.964Conifer coral tooth, within habitat: 0.688HedgehogHedgehog, full background: 0.948Hedgehog, within habitat: 0.668Western matsutakeWestern matsutake, full background: 0.940Western matsutake, within habitat: 0.596
View as table
Headline AUC vs within-habitat AUC by species
SpeciesFull backgroundWithin habitatGap
Black morel0.9900.9770.013
Black trumpet0.9850.9210.064
Lion's mane0.9730.9150.058
Oyster mushroom0.9810.8700.111
Lobster mushroom0.9820.8670.115
Candy cap0.9830.8660.117
Shrimp russula0.9750.8500.125
Spring king bolete0.9710.8470.124
Mountain morel0.9630.8330.130
Cascade chanterelle0.9690.8310.138
Golden chanterelle0.9770.8050.172
Cauliflower mushroom0.9750.8000.175
Bellybutton hedgehog0.9600.7830.177
White chanterelle0.9660.7810.185
Winter chanterelle0.9760.7720.204
King bolete0.9660.7580.208
Chicken of the woods0.9660.7030.263
Conifer coral tooth0.9640.6880.276
Hedgehog0.9480.6680.280
Western matsutake0.9400.5960.344
Headline AUC against the full regional background versus AUC within forested habitat, for all twenty served species, from the model's baked metrics (July 2026 build). The headline numbers cluster near 0.97 regardless of species; the within-habitat numbers spread from 0.60 to 0.98 and are the ones that describe the map.

The median species goes from 0.972 to 0.818. Black morel barely moves, because its habitat signal (recent burns, specific soils) is sharp enough to survive the harder question. Western matsutake goes from 0.940 to 0.596, which is nearly a coin flip inside its own habitat, and it is the species I would most want the map to be good at. That row is why this evaluation exists. Reporting 0.94 for matsutake would not have been wrong on any technicality. It would just have described a model that does not exist.

Two more ways to hold out

Blocked folds test whether the model transfers across space. They do not test whether it transfers across time, or across the quirks of whoever collected the data. SporeCast runs two further holdouts for that.

  • Held out by time. Train on the oldest three quarters of dated records, test on the most recent quarter, with the background split disjointly between the two. There is a trap here: foragers revisit patches, so a recent find usually sits within a kilometre of an older find of the same species. The measured median is 1.0 to 1.2 kilometres. Without a spatial exclusion the temporal holdout is just a random fold with a date on it, so test finds within five kilometres of any training find are dropped. At the full block scale nothing survives for any species, which is itself a finding: the data cannot support a fully independent temporal test.
  • Held out by platform. Train on iNaturalist alone, test on the other three archives. iNaturalist is 90 percent of the records, so this asks whether the model learned mushrooms or learned iNaturalist users. For one species there are not enough non-iNaturalist records to run it at all, and the model reports that rather than a number.

A single split cannot produce out-of-fold predictions for a stacked meta-learner, so both holdouts score a plain mean of the seven ensemble members rather than the stacked blend that ships. That understates the production model slightly, which is the right direction to be wrong in.

An evaluation that holds out places An architecture diagram generated by Archify. Presences + background · thinned · effort-weighted · Architecture component Presences + background thinned · effort-weighted Spatial blocks · lattice · size from variogram · Architecture component · five predictors, median range Spatial blocks lattice · size from variogram five predictors, median range GroupKFold · 4 folds of whole blocks · Architecture component GroupKFold 4 folds of whole blocks Seven learners · out-of-fold predictions · Architecture component Seven learners out-of-fold predictions Stacked meta-learner · logistic on OOF · isotonic · Architecture component Stacked meta-learner logistic on OOF · isotonic Within-habitat scoring · forested serve grid only · Architecture component · the number that ships Within-habitat scoring forested serve grid only the number that ships Time & source holdouts · 5 km gap · train on iNat · Architecture component Time & source holdouts 5 km gap · train on iNat Block bootstrap · 200 resamples · 95% CI on AUC · Architecture component Block bootstrap 200 resamples · 95% CI on AUC coordinates group ids rows blocks held out OOF scores OOF vs blended landscape member mean whole blocks resampled Legend Backend Database
The evaluation harness as it runs per species: whole spatial blocks are held out, seven learners produce out-of-fold scores, a stacked meta-learner blends them, and three separate reports come off the blend: the within-habitat score, the time and source holdouts, and a block bootstrap for the interval.

Confidence intervals that respect geography

If the folds are spatial, the uncertainty should be too. A bootstrap that resamples individual records assumes they are independent, which is the exact assumption this whole article is about violating. SporeCast resamples whole blocks with replacement, two hundred times, and reports the 2.5th and 97.5th percentiles of AUC and of the Boyce index. Golden chanterelle, the best-sampled species, comes out at 0.966 to 0.987. The rarer species get intervals wide enough to be embarrassing, which is the point of computing them.

The same logic applies to feature importance. Permutation importance computed in-sample on a tree ensemble is close to meaningless, because a tree that has memorised its training points will report that whatever feature lets it find them again is important. It is computed on held-out folds instead.

The recipe, if you only remember one thing

  1. Before you fit anything, compute nearest-neighbour distances between your records. If most of them are small relative to the scale at which your features vary, random folds will lie to you.
  2. Block your folds on a spatial lattice, and size the blocks from a variogram of your most spatially structured predictors rather than from habit. Clamp the result, and say so when it hits the clamp.
  3. Score against the background your users actually stand in. A high AUC against an easy background is a description of the background.
  4. Add a temporal and a source holdout, and expect the temporal one to need a spatial exclusion of its own.
  5. Bootstrap blocks, not rows, and publish the interval next to the point.

None of this makes the model better. All of it makes the number honest, and an honest 0.82 has done more for this project than a flattering 0.97 ever could, because it told me which species to keep working on. The full system, including the seven-learner ensemble, the effort correction, and the phenology model, is written up in the SporeCast case study. The observer-bias half of the problem, which is arguably the larger one, gets its own article.

Book a 30-min callEmail about this work