Predicting Spatial Classes with Random Forest

tool
geoml
bilingual
An interactive lab with Python, R and MapLibre: turn labeled points into a full classification surface with a Random Forest, see why a random train/test split flatters a map and what spatial block cross-validation reports instead, and a real gotcha where R silently swaps classification for regression.
Published

September 11, 2026

A few labeled points on their own only answer questions about themselves: “what class is point #7?” is a question you can already answer by looking at the file. The useful question is the one you can’t already answer: “what’s the likely class everywhere else, including where I have no data at all?” A Random Forest trained on labeled points, then applied to a regular grid across the whole area, turns a sparse set of samples into a full classification surface, the same shift in usefulness a sparse rain-gauge network gets from interpolating a rainfall map between the gauges.

Whether that surface is worth trusting is a separate question, and one this article measures rather than assumes, two ways: holding out points at random, the textbook default, and holding out whole blocks of the map. On spatial data the two give very different answers, and section 4 shows why the second is the one to believe.

A .geojson, or a shapefile (either as a single .zip, or as .shp/.dbf/.shx selected together): see the upload section below.

Just want to classify a file with adjustable settings, any class column, and a download button, without the rest of the reading? Use the standalone tool — same engine, less prose.

1. Why the coordinate system matters here

A Random Forest trained directly on longitude/latitude would treat 1 degree of separation as the same “distance” everywhere on Earth, which it isn’t. Both languages below pick a working UTM zone from the data’s own bounding-box center and reproject into it first, same reasoning, same formula, as this site’s Viewshed and Spatial Clustering tools: a reasonable default for one local dataset, not a universally correct choice for points spanning multiple UTM zones or very large areas.

2. Upload your points

No file uploaded, or your file has no column literally named class? Both cells below fall back to the same built-in survey: 30 sites, 5 points recorded close together at each, across three zones (south, centre, north) whose boundaries undulate rather than running straight. One site in five sits on a local patch of a different class, the kind of small inclusion a real survey keeps running into: a wet hollow in a dry field, a stand of a different species. Both tabs build it from the same arithmetic rule instead of a list of coordinates, so the points are identical and the two languages are comparable.

Both details are there on purpose. Surveys really are collected in sites like this, a handful of readings wherever someone stopped. And the patches are what keep the scores honest: with tidy zones and nothing unexpected inside them, any classifier would score close to 100%, which teaches nothing except misplaced confidence. Number of trees and grid resolution are fixed here (100 trees, a 10-cell grid) to keep the focus off tuning; the standalone tool exposes both as adjustable controls and lets you pick any column as the class.

3. Same spatial question, two languages

The filled grid is the predicted surface; the dots on top are the labeled training points, colored the same way. Each language draws onto its own map sources, so running either one on its own is enough to see a result.

Run both and the two surfaces won’t match everywhere. They cover exactly the same ground and start from identical points (the two cells build the survey from the same arithmetic rule), but they don’t label every cell the same way. It’s tempting to read that as Python disagreeing with R. It isn’t: both cells fix a seed so their output is reproducible, and changing that number moves the surface just as much as switching language does. A forest retrained disagrees with itself about as much as the two languages disagree with each other.

4. How good is that surface?

Both cells score the model with 5-fold cross-validation: the points are split into five groups, each group is held out in turn while a forest trains on the other four, and in the end every point has been predicted once by a model that never saw it. The only difference between the two rows of each stat card is how the points are split into groups.

Random folds scatter the held-out points across the whole map. On the built-in survey that scores about 0.76–0.81, in either language, against 0.33 for guessing among three classes. It looks like a usable map.

Spatial blocks cut the area into a 4 × 4 grid and hold out whole blocks at a time. Same points, same forest, same number of trees: about 0.54–0.59. Twenty points lower, in every one of the five seeds tried per language.

Nothing about the model changed between the two rows, only the question being asked. A random fold almost never holds out a whole site: four of a test point’s five site-mates, a few metres away, are still in training, and the forest can simply copy them. Even a point on one of the patches comes out right, because its site-mates on the same patch were seen. Hold out a whole block and the patch goes out with it: now the model has to predict it from the sites around it, which say something else.

So which number describes the map? Neither exactly, and the stat cards’ last row says why. A cross-validated score only holds for predictions made about as far from the data as its test points were. Random test points sit a median 27 m from the nearest training point: a site-mate. Block test points sit about 193 m away. The map’s own grid cells, the places it is actually asked about, sit about 73 m from the nearest sample, in between. The random score answers an easier question than the map faces, the blocks score a harder one.

In a separate run (in Python, on the same points, not on this page), the block grids whose test distances came closest to the map’s, 10 × 10 and 12 × 12 (blocks about 100–120 m across instead of 300), scored 0.61–0.65. That’s roughly the honest figure for this map: the random score overstates it by 10 to 20 points, the 4 × 4 blocks understate it by up to about 10. Other grid sizes in the same run landed anywhere from 0.52 to 0.72, so a fixed grid is a crude way to match distances; dedicated methods for spatial validation (nearest-neighbour distance matching, NNDM and its variants) do it with more care. The 4 × 4 blocks are the safer number to quote, not the exact one.

The per-class numbers show where the gap comes from. Under random folds centre, the narrow middle band squeezed between the other two, looks nearly as well learned as the rest, around 0.7. Under blocks it drops to about 0.35, barely above chance: with its own neighbourhood removed, the forest can’t tell the thin middle band from the wide ones on either side. One overall number, from either scheme, would have hidden that the model has essentially learned two of the three classes.

Both numbers move from run to run, too. The block folds are fixed (the same arithmetic rule in both tabs, so they’re identical), but the forest itself draws random numbers, and the random folds are drawn by each language’s own generator, so Python and R hold out different points there. The ranges above overlap between languages, and five seeds each are nowhere near enough to rank scikit-learn against randomForest. What doesn’t move is the gap: random folds beat blocks by a wide margin every time, in both.

One more thing the folds don’t do: change the map. Every forest trained inside the cross-validation is thrown away once it has been scored. The map comes from a separate forest trained on all 150 points, the usual practice, since cross-validation scores the way the model is fitted rather than one particular fitted forest. The choice of scheme would start changing the map only if it were used to choose something, a smoothing setting say, and random folds would then reward the forest that copies its neighbours most faithfully.

5. A silent switch: classification vs. regression

The disagreements above are a model being unsure. This one is different: a model quietly solving a different problem.

scikit-learn’s RandomForestClassifier and R’s general-purpose randomForest() decide what to do very differently. scikit-learn is unambiguous: you asked for a Classifier, so it classifies, whatever the labels look like. R’s randomForest() instead looks at the type of the target column, and the two ways of getting this wrong fail very differently:

  • A plain character column ("north"/"south", never wrapped in factor()) fails loudly. R can’t compute a regression mean on text, so the fit errors out immediately with something like non-numeric argument to binary operator. Annoying, but safe: you find out right away.
  • A column of numbers standing in for classes (0/1, or a status code read straight out of a file) is the dangerous one: it fits without complaint and silently runs a regression instead of a classification. No error, no warning, just a different kind of model whose predictions no longer look like class labels at all.

The R cell above already does this correctly (factor(as.character(...)) before the fit): modelType in its stat card reads classification. Here’s the realistic failure case: the exact same points, with class read in as 0/1/2 instead of "south"/"centre"/"north" (exactly what happens if a class column arrives as a numeric status code), and that one factor() step skipped, run live:

model$type reads regression, and predict() on a point whose true class was 0 (south) returns something like 0.044, not the class 0 itself: a regression estimate, an average of the training codes, that happens to land near it. On a point near a boundary it would land somewhere in between, a 0.7 that is no class at all. Nothing in the output says a switch happened; the only way to notice is checking model$type yourself, which is exactly what production code should do.

Note

This is real, verified behavior of randomForest(), run live on this page, not an edge case invented for the article: the R cell in section 3 above does the correct factor() coercion, one line different from the cell above. scikit-learn’s separate Classifier/ Regressor classes make the same mistake structurally impossible: the class you instantiate is the decision, made once, up front.

6. Where to next

  • Just needed to classify a file, with adjustable settings and a download button? → The standalone Spatial Classifier runs the same computation on an uploaded point file (or its own example), with any column as the class, adjustable tree count and grid resolution, the same random-vs-blocks comparison with the held-out blocks drawn on the map, and a downloadable classification surface.
  • Wondering why the coordinate system needed picking at all? → Coordinate Reference Systems covers the geographic/projected distinction this article leaned on in section 1.
  • Curious about the other side of GeoML: finding groups instead of predicting labels? → Finding Spatial Clusters with DBSCAN is this family’s entry point, unsupervised rather than supervised.