GIS & Geospatial Data (QGIS, GeoPandas, PostGIS) Module 10 – Spatial analysis and statistics
Machine learning with spatial data
Build land-cover features, validate classifiers with spatial blocks, not random folds, read accuracy class by class and estimate areas without pixel counts.
What you will learn
- Build features from bands, neighbourhoods and distances for a land-cover classifier
- Explain why random cross-validation overstates accuracy for spatial data, with a measured example
- Validate with spatial blocks and report a confusion matrix with producer's and user's accuracy
- Estimate class areas from a classified map with a reference sample instead of pixel counts
Before you start
What you’ll learn · Before you start
What you will learn
- Build features from bands, neighbourhoods and distances for a land-cover classifier
- Explain why random cross-validation overstates accuracy for spatial data, with a measured example
- Validate with spatial blocks and report a confusion matrix with producer's and user's accuracy
- Estimate class areas from a classified map with a reference sample instead of pixel counts
Before you start
On this page
A classifier that knows nothing about a pixel except where it is scores 0.891 in random cross-validation on this lesson’s land-cover scene, and 0.561 when whole blocks of the scene are held out, barely above the 0.494 of always answering “crop”. The first number measures memory; the second, what the map is worth on ground the model has not seen.
This lesson builds features for a land-cover classifier, shows why spatial data need spatial validation, and names what a credible map reports: accuracy class by class, and areas estimated from a reference sample.
A pixel is described by its bands, its neighbours and its place
The scene is made up so that the truth is known everywhere: 90 by 60 pixels of 20 m, patches of crop, forest and built-up land, and a river. Each pixel’s reflectance mixes its class’s values, a shift shared by its whole patch, a light haze and noise. 1,200 reference pixels play the field survey:
The lesson's land-cover scene and its 300 m blocks
Text description of the diagram
The scene is 1.8 km wide and 1.2 km tall, 90 by 60 pixels of 20 m, with north at the top. A river winds across it from west to east, a little above the middle, drawn dark blue. The rest is made of irregular patches: crop, the most common class, is white; forest is light grey; built-up land is dark grey. A patch is many pixels of one class, so neighbouring pixels usually share their class.
Dashed lines divide the scene into 24 square blocks of 300 m, 6 across and 4 down. Spatial cross-validation puts each block whole into one of five folds, so a test pixel's neighbours in its own block are never in the training data. Most blocks hold two or three of the four classes.
"""A made-up land-cover scene of 90 by 60 pixels of 20 m (1.8 km by 1.2 km), from a seeded generator: patches of crop,
forest and built-up land, and a river. Each pixel has red, near-infrared and shortwave-infrared reflectance: the
class's own values, a shift shared by its whole patch (soil, crop variety, roof material), a gentle haze across the
scene, and noise. Then 1,200 reference pixels whose true class is known, as from a field survey."""
import math
import random
COLS, ROWS, PIXEL = 90, 60, 20
CLASSES = ["water", "crop", "forest", "built-up"]
MEAN = { # reflectance in the red, near-infrared and shortwave-infrared bands
"water": (0.04, 0.03, 0.02),
"crop": (0.06, 0.34, 0.20),
"forest": (0.04, 0.27, 0.13),
"built-up": (0.15, 0.22, 0.24),
}
def river_y(x):
"""The river's centre line, in pixels, at column x."""
return 34 + 9 * math.sin(x / 14)
def build(seed=21):
"""(classes, bands, patch) for every pixel, row by row; bands are (red, nir, swir)."""
rng = random.Random(seed)
centres = [(rng.uniform(0, COLS), rng.uniform(0, ROWS), rng.choices(CLASSES[1:], weights=(5, 3, 2))[0]) for _ in range(45)]
shift = [tuple(rng.gauss(0, 0.025) for _ in range(3)) for _ in centres]
classes, bands, patch = [], [], []
for r in range(ROWS):
for c in range(COLS):
if abs(r - river_y(c)) < 2.2:
k, p = "water", -1
else:
p = min(range(len(centres)), key=lambda i: (c - centres[i][0]) ** 2 + (r - centres[i][1]) ** 2)
k = centres[p][2]
haze = 0.02 * c / COLS
offset = shift[p] if p >= 0 else (0, 0, 0)
pixel = tuple(max(0.0, MEAN[k][b] + offset[b] + haze + rng.gauss(0, 0.02)) for b in range(3))
classes.append(k)
bands.append(pixel)
patch.append(p)
return classes, bands, patch
def reference(n=1_200, seed=5):
"""Pixel numbers of n reference pixels drawn at random."""
return random.Random(seed).sample(range(COLS * ROWS), n) A classifier sees each pixel as a row of features, and they come in four kinds:
| Kind | In this lesson | What it tells the model |
|---|---|---|
| Bands | red, near-infrared, shortwave infrared | what the surface reflects |
| Index | NDVI (normalised difference vegetation index): (NIR − red) ÷ (NIR + red), from −1 to 1: negative over water and rising with green leaves, which absorb red light and reflect near-infrared strongly. More in “Map algebra, NDVI and reclassification” from red and near-infrared | how green it is, as in map algebra |
| Neighbourhood | mean NDVI of the 3 × 3 pixels around it | what surrounds it |
| Place | distance to the river; x and y | where it is |
Place features are the dangerous kind: they let a model learn where each class lay in the training area, which no new area shares. The classifier here is k-nearest neighbours: A classifier that labels a sample with the majority class of the k training samples nearest to it in feature space, after the features are put on a common scale., which labels a pixel with the majority class of the five training pixels nearest in feature space, after every feature is scaled to the same spread:
"""Features of the scene's pixels, a k-nearest-neighbour classifier, and two ways to split reference pixels into five
folds: at random, and by spatial blocks, so that a whole block is either trained on or tested."""
import heapq
import math
import random
from scene import COLS, PIXEL, ROWS, river_y
def ndvi(band):
red, nir, _ = band
return (nir - red) / (nir + red) if nir + red > 0 else 0.0
def table(bands):
"""For every pixel: its bands, its NDVI, the mean NDVI of its 3 x 3 neighbourhood, its distance to the river in
metres, and its x and y in metres."""
v = [ndvi(b) for b in bands]
rows = []
for i, b in enumerate(bands):
r, c = divmod(i, COLS)
near = [v[(r + dr) * COLS + c + dc] for dr in (-1, 0, 1) for dc in (-1, 0, 1) if 0 <= r + dr < ROWS and 0 <= c + dc < COLS]
rows.append({"red": b[0], "nir": b[1], "swir": b[2], "ndvi": v[i], "ndvi 3x3": sum(near) / len(near),
"river m": abs(r - river_y(c)) * PIXEL, "x": c * PIXEL, "y": r * PIXEL})
return rows
def scaled(rows, names):
"""The chosen features of each row, each scaled to mean 0 and standard deviation 1 over the rows."""
cols = [[row[n] for row in rows] for n in names]
stats = [(sum(col) / len(col), math.sqrt(sum((v - sum(col) / len(col)) ** 2 for v in col) / len(col)) or 1.0) for col in cols]
return [tuple((row[n] - m) / s for n, (m, s) in zip(names, stats)) for row in rows]
def knn(train, labels, test, k=5):
"""The majority class of the k nearest training rows of each test row (ties: the class first in alphabetical order)."""
pairs = list(zip(train, labels))
out = []
for x in test:
votes = {}
for _, label in heapq.nsmallest(k, ((math.dist(x, t), lab) for t, lab in pairs)):
votes[label] = votes.get(label, 0) + 1
out.append(max(sorted(votes), key=lambda lab: votes[lab]))
return out
def random_folds(n, k=5, seed=1):
order = list(range(n))
random.Random(seed).shuffle(order)
return [order[f::k] for f in range(k)]
def block_folds(pixels, k=5, size=15, seed=1):
"""Folds of whole blocks of size x size pixels: every block goes to one fold, the blocks dealt out at random."""
blocks = {}
for j, p in enumerate(pixels):
r, c = divmod(p, COLS)
blocks.setdefault((c // size, r // size), []).append(j)
keys = sorted(blocks)
random.Random(seed).shuffle(keys)
folds = [[] for _ in range(k)]
for n, key in enumerate(keys):
folds[n % k] += blocks[key]
return folds
def cross_validate(x, y, folds, k=5):
"""The predicted class of every sample, each from a model trained on the other folds."""
predicted = [None] * len(y)
for fold in folds:
held = set(fold)
train = [j for j in range(len(y)) if j not in held]
for j, p in zip(fold, knn([x[t] for t in train], [y[t] for t in train], [x[j] for j in fold], k)):
predicted[j] = p
return predicted Checkpoint
Check: Features from bands, neighbours and place
Random folds test memory, spatial blocks test the map
Random five-fold cross-validation holds out a random fifth of the reference pixels in turn. But nearby pixels share their class, so each held-out pixel still has neighbours of its own class in training, and a model can copy them. spatial block cross-validation: Cross-validation that holds out whole blocks of space at a time, so that the samples tested are never next to samples the model was trained on. Blocks should be wider than the distance over which places stay alike. holds out whole blocks instead. Step through it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Blocks. The scene is cut into 24 square blocks of 300 m. Each of the 1,200 reference pixels belongs to one block.
- Folds. The blocks are shuffled with a fixed seed and dealt into five folds, so every block, with all its pixels, sits in exactly one fold.
- Training. A model is trained on the reference pixels of four folds. The fifth fold's blocks, and every pixel in them, stay out of it.
- Testing. The model predicts the fifth fold. A test pixel's nearest neighbours in its own patch are mostly in its own block, so the model cannot copy their class.
- Every fold. The same is done with each fold held out in turn, so every reference pixel is predicted once by a model that never saw its block.
- The score. A model that knows only x and y scored 0.891 with random folds, which leave a pixel's neighbours in training, and 0.561 with blocks, barely above the 0.494 of always answering crop.
A study of structured data found block cross-validation “nearly universally more appropriate than random
cross-validation” when the goal is to predict new data (Roberts and others, Ecography).
In scikit-learn, give each sample its block’s id and split with GroupKFold, which never lets one group sit in both
the training and the test set of a split (scikit-learn).
The program tests the classifier that knows only x and y:
"""A classifier that knows only where a pixel is: k-nearest neighbours on x and y alone, tested by random five-fold
cross-validation and by five folds of spatial blocks 300 m wide. The pixels' bands are not used at all."""
from features import block_folds, cross_validate, random_folds, scaled, table
from scene import CLASSES, build, reference
classes, bands, _ = build()
rows = table(bands)
pixels = reference()
x = scaled([rows[p] for p in pixels], ["x", "y"])
y = [classes[p] for p in pixels]
print(f"{len(pixels):,} reference pixels: " + ", ".join(f"{c} {y.count(c)}" for c in CLASSES))
print(f" always the commonest class, crop: accuracy {y.count('crop') / len(y):.3f}")
for name, folds in (("random folds", random_folds(len(y))), ("blocks of 300 m", block_folds(pixels))):
predicted = cross_validate(x, y, folds)
print(f" {name:<16} accuracy {sum(p == t for p, t in zip(predicted, y)) / len(y):.3f}") Output
1,200 reference pixels: water 103, crop 593, forest 277, built-up 227 always the commonest class, crop: accuracy 0.494 random folds accuracy 0.891 blocks of 300 m accuracy 0.561
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_only.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Random folds gave it 0.891; blocks of 300 m, 0.561. Three more choices decide whether a map’s numbers can be trusted. The block size comes from how far apart pixels must be before their classes are no more alike than chance; two variants hold out one sample with a buffer of that width around it, or a whole region, as if it had no reference data. A confusion matrix: A table of a classifier's results that counts, for every pair of classes, the samples predicted as one class whose true class is the other. Its diagonal holds the right answers., which counts every pair of predicted and true class, reports each class’s producer's accuracy: The share of the samples truly in a class that a map or classifier labels as that class: how much of the real class it finds., the share of its true pixels the map finds, and its user's accuracy: The share of the samples a map or classifier labels as a class that truly belong to it: how often the label is right when a user reads it., the share of the pixels mapped as it that are right. And class areas come from a reference sample, because counting a map’s pixels inherits its errors. Real products publish such checks: ESA’s global 10 m WorldCover maps report overall accuracies of 74.4 % and 76.7 % from independent validation (ESA WorldCover).
Checkpoint
Check: Random folds against spatial blocks
Spatial validation measured: six feature sets, block sizes and the range Pro: opens with a Pro, Premium or Ultimate pass
Random folds do more than inflate a score: they choose the wrong features. This part tests six feature sets both ways, measures how far apart pixels must be to stop sharing their class, and watches accuracy fall as the blocks grow.
In this part:
- Six feature sets, two kinds of folds
- How far apart pixels must be: the range of the classes
- Accuracy as the blocks grow
- Building the folds in practice
2 runnable examples (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The confusion matrix, worked: producer's and user's accuracy Pro: opens with a Pro, Premium or Ultimate pass
One overall accuracy hides which classes the map gets wrong. This part builds the confusion matrix of the bands-and-NDVI classifier from its spatial blocks and reads it class by class.
In this part:
- The confusion matrix of the spatial blocks, worked
- Reading it down and across
- What the matrix says to do next
- What a credible accuracy report holds
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Class areas adjusted for the map's errors, with intervals Pro: opens with a Pro, Premium or Ultimate pass
A map’s pixel counts are areas with the map’s errors built in. This part maps the whole scene, draws an independent sample in each mapped class, and estimates every class’s area with a confidence interval.
In this part:
- Pixel counts against areas adjusted for the map’s errors
- How the estimator works
- One sample is one draw
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: blocks and accuracy
Write the two validation steps of this lesson: folds of whole spatial blocks, and producer’s and user’s accuracy for every class. The sample tests check that a block never splits across folds, that the same seed deals the same folds, and that a class the model never predicts gets no user’s accuracy:
Exercise · Medium · Python
Folds of spatial blocks, and accuracy class by class
Write two functions in validate.py.
block_folds(points, size, k=5, seed=1) takes points, a list of (x, y) positions in metres, and returns k folds, each a list of indices into points. A point belongs to the block (int(x // size), int(y // size)). Every block goes whole into one fold: sort the block keys, shuffle them with random.Random(seed).shuffle, and deal them out in turn, the first block to fold 0, the second to fold 1 and so on, adding each block's indices to its fold in the order the points come.
accuracies(predicted, truth) takes two lists of class names of the same length and returns a dictionary with an entry for every class that appears in either list, in alphabetical order: a pair (producer's accuracy, user's accuracy). The producer's accuracy of a class is the share of its true cases predicted as that class; the user's accuracy, the share of its predictions that are right. Round each to 3 decimals, and give None for a share whose denominator is 0.
Starter code · validate.py
import random
def block_folds(points, size, k=5, seed=1):
"""k folds of point indices, each block of size x size metres whole in one fold (see the prompt)."""
return [[] for _ in range(k)]
def accuracies(predicted, truth):
"""{class: (producer's accuracy, user's accuracy)} (see the prompt)."""
return {} The sample tests · test_validate.py
from validate import accuracies, block_folds
POINTS = [(10, 10), (20, 30), (150, 20), (160, 40), (310, 310), (20, 260), (260, 20), (390, 390)]
def test_every_point_once():
"""puts every point in exactly one of the k folds"""
folds = block_folds(POINTS, 100, k=3)
assert len(folds) == 3
assert sorted(i for fold in folds for i in fold) == list(range(len(POINTS)))
def test_a_block_stays_together():
"""keeps the points of one block in one fold"""
folds = block_folds(POINTS, 100, k=3)
fold_of = {i: n for n, fold in enumerate(folds) for i in fold}
assert fold_of[0] == fold_of[1] # both in the block (0, 0)
assert fold_of[2] == fold_of[3] # both in (1, 0)
assert fold_of[4] == fold_of[7] # both in (3, 3)
def test_the_blocks_are_dealt_from_a_seeded_shuffle():
"""deals the shuffled blocks in turn, so the same seed gives the same folds"""
assert block_folds(POINTS, 100, k=3, seed=1) == [[2, 3, 0, 1], [6, 5], [4, 7]]
assert block_folds(POINTS, 100, k=3, seed=2) == [[2, 3, 4, 7], [5, 0, 1], [6]]
def test_accuracies_by_class():
"""gives each class its producer's and user's accuracy"""
result = accuracies(["A", "A", "B", "B", "C", "C"], ["A", "A", "A", "B", "B", "C"])
assert result == {"A": (0.667, 1.0), "B": (0.5, 0.5), "C": (1.0, 0.5)}
def test_forest_and_crop():
"""reads producer's accuracy down the true class and user's accuracy across the predicted one"""
truth = ["forest"] * 40 + ["crop"] * 60
predicted = ["forest"] * 30 + ["crop"] * 10 + ["forest"] * 6 + ["crop"] * 54
assert accuracies(predicted, truth) == {"crop": (0.9, 0.844), "forest": (0.75, 0.833)}
def test_a_class_never_predicted():
"""gives None for the user's accuracy of a class the model never predicts"""
assert accuracies(["A", "A"], ["A", "B"]) == {"A": (1.0, 0.5), "B": (0.0, None)} A hint
Collect the blocks in a dictionary, blocks.setdefault(key, []).append(i), then shuffle sorted(blocks) and use n % k to choose each block's fold. For a class c, the right answers are the positions where both lists say c; divide by truth.count(c) for the producer's accuracy and by predicted.count(c) for the user's.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on features and on spatial validation; the session opens with the idea you have answered least well:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why does random cross-validation overstate accuracy on spatial data?
Nearby places are alike, so a randomly chosen test pixel usually has training pixels of its own class a few metres away, and a model can score well by copying them. Random folds then measure how well the model recalls the training area, not how it will do on ground it has not seen. In the lesson’s scene, a classifier that knew only each pixel’s x and y scored 0.891 with random folds and 0.561 with blocks of 300 m held out whole, against 0.494 for always answering crop. Validate with spatial blocks at least as wide as the distance over which places stay alike.
Three longer questions test this lesson against a field team, a teammate’s features and a carbon report; the module’s bank has the first step and the follow-ups of each:
-
Mid to senior
Your land-cover classifier scores 96 % in ten-fold cross-validation, but a field team finds it often wrong in a district it was not trained on. What went wrong, and how do you validate it properly?
-
Fresher to mid
A teammate adds each pixel's x and y as features, and the cross-validated accuracy of a land-cover classifier rises from 93 % to 99 %. Should the model keep them? How would you decide?
-
Mid to senior
A classified map shows 48.2 ha of forest in a project area, and the client wants the forest area for a carbon report. What do you report, and what do you need to produce it?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which four kinds of features describe a pixel to a land-cover classifier?
Bands such as red, near-infrared and shortwave infrared; an index such as NDVI; its neighbourhood, such as the mean NDVI of the 3 × 3 pixels around it; and its place, such as the distance to the river or x and y.
-
Why are place features such as x and y dangerous in a land-cover classifier?
They let the model learn where each class lay in the training area, which no new area shares. Given x and y alone, a classifier reached 0.891 under random folds but only 0.561 under spatial blocks, barely above the 0.494 of always answering crop.
-
Why does random cross-validation overstate a spatial model's accuracy, and what replaces it?
Nearby pixels share their class, so each held-out pixel keeps neighbours of its class in training, and the model can copy them. Spatial block cross-validation holds whole blocks out; in scikit-learn, give each sample its block's id and split with GroupKFold.
-
How large should spatial cross-validation blocks be?
No narrower than the distance beyond which two pixels' classes are no more alike than chance would make them. Variants hold out one sample with a buffer of that width around it, or a whole region as if it had no reference data.
-
What are producer's accuracy and user's accuracy?
Two readings of a confusion matrix, which counts every pair of predicted and true class: producer's accuracy is the share of a class's true pixels that the map finds, and user's accuracy the share of the pixels mapped as that class that are right.
-
Why estimate class areas from a reference sample rather than by counting a map's pixels?
A pixel count inherits every error of the map. Credible products publish independent checks as well: ESA's global 10 m WorldCover maps report overall accuracies of 74.4 % and 76.7 %.
Key takeaways
- Features describe a pixel by its bands, indices such as NDVI, its neighbourhood and its place, and place features let a model learn the training area itself.
- Random cross-validation leaves each test pixel’s neighbours in training; spatial blocks hold whole areas out, and x and y alone fell from 0.891 to 0.561 under them.
- A credible map reports producer’s and user’s accuracy per class and estimates areas from a reference sample.
Terms in this lesson
- confusion matrix
- A table of a classifier's results that counts, for every pair of classes, the samples predicted as one class whose true class is the other. Its diagonal holds the right answers.
- k-nearest neighbours
- A classifier that labels a sample with the majority class of the k training samples nearest to it in feature space, after the features are put on a common scale.
- producer's accuracy
- The share of the samples truly in a class that a map or classifier labels as that class: how much of the real class it finds.
- spatial block cross-validation
- Cross-validation that holds out whole blocks of space at a time, so that the samples tested are never next to samples the model was trained on. Blocks should be wider than the distance over which places stay alike.
- user's accuracy
- The share of the samples a map or classifier labels as a class that truly belong to it: how often the label is right when a user reads it.
References
- Cross-validation: evaluating estimator performance (scikit-learn user guide) (scikit-learn developers)
- Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure (Roberts and others) (Ecography (Wiley))
- Good practices for estimating area and assessing accuracy of land change (Olofsson and others) (Remote Sensing of Environment (Elsevier))
- ESA WorldCover data access (European Space Agency (WorldCover consortium))
- GATE 2027 syllabus: Geomatics Engineering (GE) (IIT Madras, organising institute of GATE 2027)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress