EGS 2405 · Jomo Kenyatta University of Agriculture and Technology
A course reader for fourth-year Geomatic Engineering and Geospatial Information Systems.
Each chapter covers one teaching week and has the same five parts: learning outcomes, the reading itself, a practical brief, self-test questions, and further reading. The reading is the lecture in written form — come to the lecture having read it, and the two hours can be spent on the parts that are actually difficult rather than on transcription.
Practicals are written in two tiers. The Core task is what everyone must complete; a scaffolded notebook is supplied so that the geostatistics is the exercise and the file handling is not. The Extension is for those who finish early or who already write Python fluently — it is never assessed separately, but it is where the interesting work is.
Work through Appendix A. It is a self-check, not a test: twelve short Python tasks that tell you — privately — whether you need to spend a weekend on revision before the practicals start. You have all met Python in GIS programming, but the gap between "I have seen a for loop" and "I can write a function that returns a NumPy array" is exactly the gap that makes week 3 painful. Find out now, while it is cheap to fix.
One habit is worth forming immediately. Every figure you produce in this unit should be able to answer the question "how confident are you, and how do you know?" A geostatistician who cannot answer that has produced a picture, not an estimate. The whole unit is arranged around getting you to the point where you can answer it.
Everything runs in Python. Install with conda (recommended, because it handles the GDAL dependency chain) or pip into a fresh virtual environment:
# conda — recommended
conda create -n geostat python=3.11
conda activate geostat
conda install -c conda-forge geopandas rasterio matplotlib scipy scikit-learn jupyterlab
pip install scikit-gstat pykrige
# check it works
python -c "import geopandas, skgstat, pykrige; print('ok')"
If the lab machines will not cooperate, everything in this unit also runs in Google Colab with !pip install scikit-gstat pykrige geopandas in the first cell. Colab is the fallback, not the default: you should leave this unit able to set up a working environment yourself, because no employer will do it for you.
| Package | What we use it for |
|---|---|
geopandas | Reading point data, coordinate reference systems, spatial joins |
skgstat | Empirical variograms, model fitting, directional variograms |
pykrige | Ordinary, universal and regression kriging |
numpy / scipy | Linear algebra — you will solve the kriging system directly in week 5 |
scikit-learn | Random forest and cross-validation machinery in week 10 |
Datasets are listed in Appendix C. Two run through the whole unit: the Meuse floodplain heavy-metal dataset, because the entire literature uses it and you should be able to read that literature; and a Kenyan soil dataset drawn from the Africa Soil Information Service, because a method you have only ever applied to Dutch cadmium is a method you do not yet own.
Geostatistics has an unusually inconsistent notation across textbooks. This reader uses the following throughout, and where a source uses something different it is flagged.
| Symbol | Meaning |
|---|---|
| x | A location — a coordinate pair, not a variable value |
| z(x) | The observed value at location x |
| Z(x) | The random function of which z(x) is one realisation |
| h, h | The lag vector separating two locations, and its length |
| γ(h) | Semivariance at lag h — the variogram |
| C(h) | Covariance at lag h |
| N(h) | Number of point pairs separated by lag h |
| c0 | Nugget — variance not resolved at the shortest sampled lag |
| c1 | Partial sill — the structured component of variance |
| a | Range — lag beyond which there is no spatial dependence |
| λi | Kriging weight assigned to sample i |
| ψ | Lagrange multiplier in the ordinary kriging system |
| σ²OK | Ordinary kriging variance |
| ẑ(x0) | The estimate at an unsampled location x0 |
Week one
You have 155 soil samples from a 4 km × 4 km block in Kiambu. You need a map of soil organic carbon for the whole block. Classical statistics — the statistics you learned in second year — will give you a mean and a standard deviation, and both are useless for the question, because they assume your 155 observations are independent draws from a population. They are not. Two samples 20 m apart are telling you nearly the same thing. Two samples 3 km apart are telling you different things. That structure is not a nuisance to be corrected for; it is the entire signal you need in order to predict at the 3,999,845 locations you did not sample.
This is the founding observation of geostatistics, made by Georges Matheron in the early 1960s while working on the estimation of ore grades in French mines, building on the empirical work of Danie Krige in the South African gold fields. Their problem was commercial and unforgiving: mining a block of ore that turns out to be waste costs money, and so does leaving payable ore in the ground. They needed estimates with a stated error, not just estimates.
Regionalised variable. A variable distributed in space whose values show some structure — neighbouring values are related — but which is too irregular to describe with a deterministic function. Soil carbon, rainfall, ore grade, groundwater fluoride, air pollution: all regionalised.
The insight is to treat such a variable as both deterministic and random. It has structure, so we can predict; it is irregular, so the prediction has error. Neither a purely deterministic model (a polynomial surface) nor a purely statistical one (an i.i.d. sample) captures both.
Formally we treat the value at every location x as a random variable Z(x). The collection of all of them across the region is a random function. Our data — the 155 measurements — are one realisation of that random function, denoted z(x1), …, z(x155).
There is an immediate and serious problem with this. In ordinary statistics you estimate the properties of a random variable by taking many independent draws from it. Here we have one draw from Z(x1), one from Z(x2), and so on: one realisation of the whole field. There is only one Kiambu, and we sampled it once. How can you estimate a variance from a single observation?
The answer is to trade an assumption for the data we lack. If we are willing to assume that the statistical properties are the same everywhere in the region, then observations at different locations can substitute for repeated observations at the same location. That assumption is stationarity, and it is the price of admission to the entire subject.
Stationarity comes in strengths. We want the weakest one that still lets us do the work, because every assumption we make is a claim about reality that could be wrong.
| Assumption | What it claims | Verdict |
|---|---|---|
| Strict | The entire joint distribution is invariant under translation. Everything about the statistics is the same everywhere. | Far too strong; unverifiable |
| Second-order | The mean is constant, and the covariance between two points depends only on the vector separating them, not on where they are. | Usable, but requires a finite variance |
| Intrinsic | The mean of the difference between two points is zero, and the variance of that difference depends only on the separation. | Weakest; what we actually assume |
Intrinsic stationarity is weaker in a way that matters. It says nothing about the variance of Z itself — which may not even be finite — only about the variance of differences. Many real phenomena drift upward indefinitely across a region and have no meaningful global variance, but still have well-behaved local differences. This is precisely why the variogram, which is built from differences, became the central tool of the subject rather than the covariance function.
Stated formally, the intrinsic hypothesis is two conditions:
The second line defines the variogram 2γ(h). The factor of two is why γ is called the semivariogram — a distinction most practitioners now ignore, using "variogram" for γ. This reader does the same.
Stationarity is a property you assume about a model, not a property you test in the data. You cannot prove your field is stationary. What you can do is look for evidence that the assumption is untenable — an obvious trend, a variance that visibly changes across the region — and then either restrict the region, remove the trend (week 6), or admit the method does not apply. Deciding this is a judgement call, and it is one of the things you are being trained to make.
Support is the size, shape and orientation of the volume on which a measurement is made. A soil sample taken with a 5 cm auger has a different support from a bulked sample composited across a 10 m plot, which has a different support again from a 30 m Landsat pixel.
Support is not a technicality. As support increases, the variance of the measurements decreases — averaging over a larger volume smooths out small-scale variation. A dataset of 5 cm cores and a dataset of 10 m composites from the same field will have genuinely different histograms, different variances, and different variograms, and neither is wrong. This is why mining engineers estimate block grades rather than point grades: the mine extracts blocks, so the block is the relevant support, and the variance of block grades is much lower than the variance of drill-core grades. We return to this in week 6 under change of support.
MAUP — the modifiable areal unit problem. When point data are aggregated into areal units, the results depend on both the size of the units (the scale effect) and where the boundaries are drawn (the zonation effect). Redistricting a county into different wards can change a correlation from strong positive to weak negative without a single underlying value changing.
Every choropleth map of county-level statistics is a MAUP artefact to some degree. Geostatistics does not solve MAUP, but working with point support and explicit change-of-support operations at least makes the problem visible rather than hidden.
You will usually inherit a dataset rather than design the survey, but you must be able to read what the design has done to your data. Four patterns recur:
The last point is worth holding on to. A survey designed to estimate a mean and a survey designed to estimate a variogram are different surveys. If you are ever asked to design one, ask which it is.
zinc column.EPSG:4326, then reproject to EPSG:32737 — UTM zone 37S. Explain in one sentence why the reprojection is not optional.import geopandas as gpd
import pandas as pd
import matplotlib.pyplot as plt
# CSV with columns: lon, lat, soc (soil organic carbon, g/kg)
df = pd.read_csv("data/kiambu_soc.csv")
gdf = gpd.GeoDataFrame(
df,
geometry=gpd.points_from_xy(df.lon, df.lat),
crs="EPSG:4326", # degrees — NOT a distance unit
)
gdf = gdf.to_crs("EPSG:32737") # UTM 37S — metres
gdf["X"] = gdf.geometry.x
gdf["Y"] = gdf.geometry.y
ax = gdf.plot(column="soc", markersize=gdf.soc * 3,
legend=True, cmap="viridis", figsize=(7, 7))
ax.set_title("Soil organic carbon, Kiambu (g/kg)")
ax.set_axis_off()
plt.show()
Computing distances in degrees. Every variogram, every range, every kriging neighbourhood in this unit is a distance, and a degree is not a distance — one degree of longitude is about 111 km at the equator and shrinks to nothing at the poles. If you leave your data in EPSG:4326, everything downstream is quietly, plausibly wrong. Project to UTM first, every single time. For Kenya that is EPSG:32736 (zone 36S, west of 36°E) or EPSG:32737 (zone 37S, east of 36°E).
Reading. Webster & Oliver, Geostatistics for Environmental Scientists, ch. 2–3. Isaaks & Srivastava, ch. 1. For the historical account, Cressie (1990), "The origins of kriging", Mathematical Geology 22(3).
Week two
There is a strong temptation, once you know how to fit a variogram, to load the data and fit one. Resist it. Almost every failed geostatistical analysis fails here, in the hour before any modelling, because someone did not look at their data. The variogram is a summary statistic and, like every summary statistic, it can be dominated by a handful of values that should never have entered the calculation.
Kriging is a linear estimator and, strictly, it does not require normality. But two things make the distribution matter anyway. First, the variogram estimator is a squared difference, so it is highly sensitive to extreme values — a single erroneous outlier can inflate the entire variogram. Second, the simulation methods in week 8, and the confidence intervals you will want to attach to your predictions, do assume a Gaussian distribution.
So: plot the histogram, plot the Q–Q plot, compute the skewness. As a working rule, a coefficient of skewness beyond about ±1 is worth acting on. Two transforms are standard:
A transform is a modelling decision that must be reported, and the back-transformation must be stated. "We log-transformed, kriged, and exponentiated" is an incomplete method statement, because it does not say whether the result is a mean or a median, and the difference for a skewed variable can be tens of percent. If you cannot explain what your back-transformed number is an estimate of, do not transform.
Classical outlier detection asks whether a value is extreme relative to the distribution. Spatial data lets you ask a better question: is a value extreme relative to its neighbours? A cadmium concentration of 18 mg/kg might be perfectly ordinary in the distribution and yet be a clear error if every sample within 200 m reads below 2.
The practical tool is the local deviation: for each point, compare its value to the mean of its k nearest neighbours, and standardise. Points with a large standardised deviation are candidates for investigation. Note the word: investigation, not deletion. Delete a value only when you have a reason external to the statistics — a transcription error, a known contaminated sample, a GPS fix that puts the point in a lake. An extreme value that is real is often the most important observation in the dataset, and a hotspot you deleted for tidiness is a hotspot you failed to map.
A trend (or drift) is a systematic change in the mean across the region — soil depth increasing downslope, temperature decreasing with altitude, contamination decreasing with distance from a smelter. Trend violates the constant-mean assumption, and if you ignore it the variogram will not reach a sill: it climbs indefinitely, because points far apart differ systematically rather than randomly.
Detect it by regressing the variable on the coordinates, and by looking at the shape of the empirical variogram at long lags. Then choose:
Real surveys are rarely evenly spread. Geologists drill densely where the ore is; environmental agencies sample intensively where they suspect contamination; field teams sample near roads. The result is that the naive sample mean is biased toward whatever the clustered areas contain — usually the high values, because that is why people clustered there.
Cell declustering is the standard correction. Lay a grid over the region; each occupied cell receives a total weight of one, shared equally among the points inside it. A point alone in its cell gets weight 1; each of ten points crowded into one cell gets weight 0.1. The declustered mean is then the weighted mean.
where nj is the number of samples in occupied cell j, and ncells the number of occupied cells. Cell size is chosen by plotting the declustered mean against cell size and taking the size at which it is a minimum (for high-value clustering) or a maximum (for low-value clustering).
scipy.stats.norm.ppf and interpolation. Verify that the round trip returns the original values.import numpy as np
from scipy import stats
from sklearn.neighbors import NearestNeighbors
def local_deviation(coords, values, k=8):
"""Standardised difference between each value and its k nearest neighbours."""
nn = NearestNeighbors(n_neighbors=k + 1).fit(coords)
_, idx = nn.kneighbors(coords)
nbr_mean = values[idx[:, 1:]].mean(axis=1) # column 0 is the point itself
resid = values - nbr_mean
return resid / resid.std()
coords = gdf[["X", "Y"]].values
gdf["ld"] = local_deviation(coords, gdf.soc.values)
print("skewness:", stats.skew(gdf.soc))
print(gdf.reindex(gdf.ld.abs().sort_values(ascending=False).index).head(5)
[["X", "Y", "soc", "ld"]])
# --- cell declustering -------------------------------------------------
def declustered_mean(coords, values, cell):
keys = np.floor(coords / cell).astype(int)
_, inv = np.unique(keys, axis=0, return_inverse=True)
counts = np.bincount(inv)
w = 1.0 / counts[inv] # each occupied cell contributes weight 1
return np.average(values, weights=w)
for cell in [100, 200, 400, 800, 1600]:
print(cell, round(declustered_mean(coords, gdf.soc.values, cell), 3))
print("naive ", round(gdf.soc.mean(), 3))
Reading. Isaaks & Srivastava, ch. 2–4 — still the best treatment of exploratory work in the subject. Deutsch & Journel, GSLIB, on declustering. Diggle, Menezes & Su (2010), "Geostatistical inference under preferential sampling", JRSS-C 59(2), for the serious version of the problem.
Week three
Last week's claim was that neighbouring values are related. This week we measure it. Take every pair of points separated by roughly the same distance h, square the difference in their values, and average. If nearby things really are similar, that average will be small for small h and grow as h increases.
That is the whole idea. The estimator, due to Matheron, is:
N(h) is the number of pairs separated by approximately h. The 2 in the denominator makes γ a semivariance, which is what makes γ(h) directly comparable to a variance rather than to twice one.
Under second-order stationarity the variogram and the covariance function carry the same information:
C(0) is the variance of the process. Where covariance is high, semivariance is low, and vice versa — the variogram is the covariance function turned upside down. Note that this identity requires C(0) to exist; under the weaker intrinsic hypothesis it may not, and only γ is defined. That is the technical reason the variogram is the primary tool.
Five samples along a transect at 100 m spacing, values in g/kg:
| Position (m) | 0 | 100 | 200 | 300 | 400 |
|---|---|---|---|---|---|
| Value | 12 | 14 | 11 | 17 | 16 |
Lag h = 100 m. Four pairs: (12,14), (14,11), (11,17), (17,16). Squared differences: 4, 9, 36, 1. Sum = 50.
γ̂(100) = 50 / (2 × 4) = 6.25
Lag h = 200 m. Three pairs: (12,11), (14,17), (11,16). Squared differences: 1, 9, 25. Sum = 35.
γ̂(200) = 35 / (2 × 3) = 5.83
Lag h = 300 m. Two pairs: (12,17), (14,16). Squared differences: 25, 4. Sum = 29.
γ̂(300) = 29 / (2 × 2) = 7.25
Lag h = 400 m. One pair: (12,16). Squared difference 16.
γ̂(400) = 16 / (2 × 1) = 8.00
Two lessons are already visible. The variogram is not monotonic — γ̂(200) is below γ̂(100) — because with three or four pairs the estimate is extremely noisy. And the number of pairs collapses as the lag grows, from four to one. This is why we do not trust long lags, and why the rule of thumb exists: at least about 30 pairs per lag class, and no lag beyond half the survey extent. On a real dataset every one of these numbers would be computed from hundreds of pairs.
In two dimensions, no two pairs have exactly the same separation, so we bin. Three decisions follow, and all three change the answer.
Change the bin count and you get a different variogram from the same data. This is not a flaw to be hidden; it is a property to be reported. Two analysts working honestly on the same dataset can produce different variograms and both be defensible. What is not defensible is trying five binnings, picking the prettiest, and reporting it as though it were the only one. Report your choices and show that your conclusions are not fragile to them.
The variogram cloud plots every individual pair — separation on the x-axis, half the squared difference on the y-axis — before any averaging. Points sitting far above the general mass at short lags mark pairs of nearby locations with wildly different values, and tracing them back to specific samples is often how a data error is caught.
The h-scatterplot plots z(x) against z(x+h) for one lag class. At short lags the cloud should hug the 1:1 line; as the lag grows it should fatten toward a circular blob. Watching that sequence is the most intuitive picture of spatial correlation decaying that the subject offers, and it makes the variogram feel less like an incantation.
skgstat. Use 15 bins and a maximum lag of half the diagonal.skgstat's output to three decimal places. This is the best hour you can spend on understanding the tool.import numpy as np
import skgstat as skg
import matplotlib.pyplot as plt
coords = gdf[["X", "Y"]].values
vals = np.log(gdf.zinc.values) # week 2 said transform; we did
# half the diagonal of the bounding box — the sensible maximum lag
xmin, ymin, xmax, ymax = gdf.total_bounds
maxlag = 0.5 * np.hypot(xmax - xmin, ymax - ymin)
V = skg.Variogram(coords, vals,
n_lags=15,
maxlag=maxlag,
estimator="matheron",
normalize=False)
print(V) # bins, pair counts, fitted parameters
print("pairs per bin:", V.bin_count) # check none is below ~30
# sensitivity to binning — never report just one
fig, ax = plt.subplots(figsize=(7, 4.5))
for n in (8, 15, 30):
Vn = skg.Variogram(coords, vals, n_lags=n, maxlag=maxlag)
ax.plot(Vn.bins, Vn.experimental, "o-", label=f"{n} bins", alpha=0.8)
ax.set_xlabel("lag h (m)"); ax.set_ylabel("semivariance γ(h)"); ax.legend()
plt.show()
Reading. Webster & Oliver, ch. 4. Isaaks & Srivastava, ch. 7. Cressie & Hawkins (1980) on the robust estimator, Mathematical Geology 12(2). The scikit-gstat documentation is unusually good and should be read as a text, not consulted as a reference.
Week four
Kriging needs a semivariance value for every separation that arises in the calculation — including separations that fall between your bins, and separations shorter than anything you sampled. The empirical variogram gives you fifteen points. So we fit a continuous function through them, and from that point on the model, not the data, is what kriging uses.
There is a second and less obvious reason. The kriging system requires that the matrix of semivariances be invertible for any configuration of points. Only functions with a particular mathematical property — conditional negative-definiteness — guarantee this. Fit an arbitrary curve, and you can obtain negative kriging variances, which are meaningless. This is why we choose from a small catalogue of permissible models rather than fitting whatever passes closest through the points.
Each parameter has a physical reading, and you should always give one.
Reaches the sill only asymptotically. The practical range — where γ reaches 95% of the sill — is 3r. Software differs on whether the parameter it reports is r or 3r; check, because a factor of three in your range is not a small error.
Practical range r√3. Implies an extremely smooth, twice-differentiable phenomenon. With a zero nugget it makes the kriging matrix badly conditioned and produces unstable, oscillating estimates — so if you fit a Gaussian model, keep a small nugget.
The general case. The smoothness parameter ν controls behaviour at the origin: ν = 0.5 reproduces the exponential, ν → ∞ approaches the Gaussian. Rather than choosing between models you estimate ν from the data, which is the more honest procedure — and the reason the Matérn dominates the modern statistical literature. Kν is the modified Bessel function of the second kind.
A sum of permissible models is itself permissible, and real data often needs one. Soil properties frequently show variation at two scales — a short-range structure from local soil formation processes at 50–200 m, and a long-range structure from geology or topography at several kilometres. Fitting a single model to such a variogram fits neither structure. Fit a nested model: c0 plus a short-range spherical plus a long-range spherical. Each component then has a physical interpretation you can name, which is a useful discipline — if you cannot name the process behind a component, ask whether you are fitting noise.
So far we have assumed the variogram depends only on the length of the lag vector, not its direction. That is often false. Soil properties along a valley behave differently from soil properties across it; a plume of contamination is elongated in the direction of groundwater flow; rainfall correlates further along a mountain range than across it.
Detect it by computing directional variograms: restrict pairs to those whose separation vector falls within an angular tolerance of a chosen direction. Compute at 0°, 45°, 90° and 135° with ±22.5° tolerance, which covers the plane without overlap. Then read the result:
Report anisotropy as a ratio and an angle: "geometric anisotropy, ratio 2.4, major axis 35°" tells a reader everything they need. And beware the trap — with four directions and typical sample sizes, each directional variogram has a quarter of the pairs of the omnidirectional one, so apparent anisotropy in a small dataset is often noise. Check whether it survives a change of binning before you believe it.
Two approaches, and you should know both:
skgstat does by default. Fast, robust, and standard in practice.Whichever you use, do not treat fitting as automatic. Plot the fit over the empirical points and look at it. A fit that is excellent at long lags and poor at short ones is a bad fit for kriging purposes, no matter what the objective function says, because short lags are what determine the weights.
Every fitting routine will return parameters. It will return them for pure noise, for a variogram built from six pairs, and for data with an unremoved trend. The routine has no way to tell you that the model is meaningless. You do — by plotting the fit, checking the sill against the sample variance, checking the range against the survey extent, and asking whether the nugget is physically plausible. Get into the habit now, because in week 5 these parameters go straight into a prediction that someone may act on.
# --- compare models on the same empirical variogram --------------------
V = skg.Variogram(coords, vals, n_lags=15, maxlag=maxlag)
for m in ["spherical", "exponential", "gaussian", "matern"]:
V.model = m
c0 = V.parameters[-1] # nugget is reported last
print(f"{m:12s} range={V.parameters[0]:8.1f} "
f"sill={V.parameters[1]:6.3f} nugget={c0:6.3f} rmse={V.rmse:6.4f}")
# --- directional variograms -------------------------------------------
fig, ax = plt.subplots(figsize=(7, 4.5))
for az in (0, 45, 90, 135):
DV = skg.DirectionalVariogram(coords, vals,
azimuth=az,
tolerance=22.5, # four non-overlapping sectors
n_lags=12,
maxlag=maxlag)
ax.plot(DV.bins, DV.experimental, "o-", label=f"{az}°", alpha=0.85)
ax.set_xlabel("lag h (m)"); ax.set_ylabel("γ(h)")
ax.legend(title="azimuth"); plt.show()
Reading. Webster & Oliver, ch. 5–6. Chilès & Delfiner, Geostatistics: Modeling Spatial Uncertainty, ch. 2, for the mathematics of permissible models. Stein, Interpolation of Spatial Data (1999), ch. 2, on why the Matérn deserves its status.
Week five · CAT 1 this week
We want an estimate at an unsampled location x0, formed as a weighted average of the samples:
Every interpolation method you have met is of this form. Nearest neighbour sets one weight to 1 and the rest to 0. Inverse distance weighting sets λi ∝ 1/dip. What distinguishes kriging is that it does not assert the weights; it derives them, from two requirements.
Unbiasedness. On average the estimate should equal the truth, E[ẑ(x0) − Z(x0)] = 0. With an unknown but constant mean, this forces the weights to sum to one.
Minimum variance. Among all weight sets satisfying that constraint, choose the one minimising the estimation variance Var[ẑ(x0) − Z(x0)].
This is a constrained optimisation, handled with a Lagrange multiplier ψ. Differentiating and setting to zero gives a linear system of n+1 equations:
In matrix form, Γλ = γ0, where Γ is the (n+1)×(n+1) matrix of semivariances between samples bordered by ones, and γ0 holds the semivariances between each sample and the target, with a 1 appended.
Having solved for the weights, the minimised variance itself is available:
Look carefully at the left-hand matrix in (5.2). It contains γ between samples — how redundant the samples are with each other — and the right-hand side contains γ between samples and the target — how relevant each sample is. Kriging balances relevance against redundancy. That is the entire idea, and the worked example below makes it concrete.
Model: spherical, nugget c0 = 2, partial sill c1 = 8 (total sill 10), range a = 250 m. Evaluating equation (4.1) at each separation:
| Separation | Distance (m) | γ |
|---|---|---|
| P₁–P₂ | 30.4 | 3.452 |
| P₁–P₃ | 200.0 | 9.552 |
| P₂–P₃ | 197.3 | 9.504 |
| P₁–x₀ | 100.0 | 6.544 |
| P₂–x₀ | 99.6 | 6.528 |
| P₃–x₀ | 100.0 | 6.544 |
The system, from (5.2), is:
P₁ and P₂ are nearly interchangeable — the same distance from the target and the same distance from P₃ — so set λ₁ = λ₂ = λ and λ₃ = 1 − 2λ. Substituting into the first and third equations gives −15.652λ + ψ = −3.008 and 19.056λ + ψ = 6.544. Subtracting: 34.708λ = 9.552, so λ = 0.2752 and ψ = 1.299.
λ₁ = 0.275 λ₂ = 0.275 λ₃ = 0.450
The estimate is ẑ = 0.275(10) + 0.275(11) + 0.450(6) = 8.48 g/kg, and from (5.3) the kriging variance is 0.275(6.544) + 0.275(6.528) + 0.450(6.544) + 1.299 = 7.84, giving a standard error of 2.80 g/kg.
Now look at what happened. All three samples sit the same distance from the target. Inverse distance weighting would therefore give each exactly one third, producing an estimate of 9.00. Kriging gives P₃ — the lone sample on the far side — a weight of 0.45, almost as much as P₁ and P₂ combined. It has recognised that P₁ and P₂, only 30 m apart, are largely telling it the same thing, and has declustered them automatically. No interpolator that looks only at distance to the target can do this. That is what you are paying for.
Equation (5.3) yields a variance at every location, and mapping it alongside the prediction is one of the most useful things geostatistics gives you. But read (5.3) again and notice what is not in it: the data values. The kriging variance is built entirely from the variogram model and the geometry of the sampling configuration. Move all your sample values around at random, keeping the locations fixed, and the kriging variance map does not change by a single unit.
It is a measure of configuration quality — how well-surrounded a location is by samples, given an assumed variogram. It is not a measure of how wrong your estimate is at that location. It cannot detect that your variogram model is poor, that your data contain an error, or that the phenomenon is more variable in one part of the region than another.
Presenting a kriging variance map captioned "uncertainty" or "error" is one of the most common misrepresentations in applied geostatistics, and one you will be marked down for here. Say "kriging variance" and explain what it measures. If you need a genuine picture of uncertainty in the values, you need simulation — week 8.
Using all n samples for every prediction means inverting an n×n matrix at every grid node, which is impractical beyond a few thousand points and unnecessary anyway: samples beyond the range contribute essentially nothing. So we use a moving neighbourhood — typically the nearest 16 to 32 samples, sometimes with quadrant or octant constraints forcing the search to draw from all directions rather than taking 20 points from one dense cluster.
The choice is a real one. Too few samples gives noisy, discontinuous maps with visible artefacts at the boundaries where the neighbourhood changes composition. Too many is slow for no benefit. Sixteen to twenty-four with quadrant balancing is a reasonable default; report what you used.
np.linalg.solve. Confirm you get the same weights. You have now written kriging yourself.import numpy as np
def spherical(h, c0=2.0, c1=8.0, a=250.0):
h = np.asarray(h, dtype=float)
g = np.where(h <= a,
c0 + c1 * (1.5 * h / a - 0.5 * (h / a) ** 3),
c0 + c1)
return np.where(h == 0, 0.0, g) # γ(0) = 0 exactly
pts = np.array([[0., 100.], [30., 95.], [0., -100.]])
z = np.array([10., 11., 6.])
x0 = np.array([0., 0.])
n = len(pts)
# Γ: semivariances between samples, bordered with ones for the constraint
D = np.linalg.norm(pts[:, None, :] - pts[None, :, :], axis=-1)
G = np.ones((n + 1, n + 1))
G[:n, :n] = spherical(D)
G[n, n] = 0.0
# γ₀: semivariances between each sample and the target
g0 = np.ones(n + 1)
g0[:n] = spherical(np.linalg.norm(pts - x0, axis=1))
sol = np.linalg.solve(G, g0)
w, psi = sol[:n], sol[n]
print("weights ", np.round(w, 4)) # [0.2755 0.2749 0.4496]
print("estimate ", round(float(w @ z), 3)) # 8.475
print("variance ", round(float(w @ g0[:n] + psi), 3))
print("naive mean", round(z.mean(), 3)) # 9.0 — what IDW would give
from pykrige.ok import OrdinaryKriging
gridx = np.arange(xmin, xmax, 40.0)
gridy = np.arange(ymin, ymax, 40.0)
OK = OrdinaryKriging(
coords[:, 0], coords[:, 1], vals,
variogram_model="spherical",
variogram_parameters={"sill": 0.62, "range": 900.0, "nugget": 0.05},
nlags=15, enable_plotting=False,
)
zhat, var = OK.execute("grid", gridx, gridy)
se = np.sqrt(var)
fig, (a1, a2) = plt.subplots(1, 2, figsize=(12, 5))
a1.imshow(zhat, origin="lower", cmap="viridis"); a1.set_title("OK prediction, log-zinc")
a2.imshow(se, origin="lower", cmap="magma"); a2.set_title("kriging standard error")
a2.scatter(*((coords.T - [[xmin], [ymin]]) / 40.0), s=5, c="white")
plt.show()
Reading. Isaaks & Srivastava, ch. 12 — the clearest derivation in print, worked with actual numbers. Webster & Oliver, ch. 8. Goovaerts, ch. 5.
Week six
Ordinary kriging assumes the mean is constant but unknown, at least within the search neighbourhood. Often it is not. Rainfall increases with altitude. Soil depth increases downslope. Contamination decreases away from a source. When the mean varies systematically, ordinary kriging is inefficient at best and biased at worst, and the variogram will not reach a sill.
The family of methods below all decompose the variable into a deterministic part and a stochastic part:
m(x) is the trend or drift — the structured, explainable part — and ε(x) is the spatially correlated residual. The methods differ only in what they assume about m(x).
| Method | Assumption about m(x) | Use when |
|---|---|---|
| Simple kriging | Constant and known | Rarely — but it is the building block for simulation in week 8 |
| Ordinary kriging | Constant, unknown | The default; no covariate available |
| Universal kriging | A polynomial function of the coordinates | A smooth regional gradient with no physical covariate |
| KED / external drift | A linear function of a covariate known everywhere | The best case: a covariate that is physically related and mapped densely |
| Regression kriging | Any regression model, fitted separately | Several covariates, or a non-linear relationship |
This is the method to reach for, because it exploits the situation you are usually in: a sparse set of expensive point measurements, and a cheap covariate available everywhere. The pairings are familiar — rainfall with elevation, temperature with elevation, soil organic carbon with NDVI or with terrain wetness index, groundwater level with topography.
KED does everything at once: it estimates the trend coefficients and the spatial prediction within a single system, using the residual variogram. Two conditions must hold, and both are frequently violated in student work.
The critical technical point: the variogram used in KED is the variogram of the residuals, not of the raw variable. Fit the trend, take the residuals, compute and model their variogram, and use that. The residual variogram will typically have a shorter range and a lower sill than the raw one — that difference is precisely the structure the covariate has explained.
Regression kriging is the same idea run as three explicit steps, which makes it easier to understand, easier to debug, and more flexible:
With OLS in step 1 and consistent variogram estimation, regression kriging and KED are theoretically equivalent. In practice RK is preferred whenever you want several covariates, a non-linear model, or simply to see the two components separately — and being able to map the trend surface and the residual surface individually is genuinely informative about where your covariate is working.
Fitting the trend by OLS and then computing a variogram from those residuals is slightly circular: OLS assumed independent errors, which is exactly what you are about to demonstrate is false. The residual variogram is therefore mildly biased, typically underestimating the range. For teaching purposes and most applied work this is acceptable and universally done — but you should know it, and say so. The principled fix is generalised least squares iterated with the variogram estimation, or REML, which estimates both jointly.
Everything so far has predicted a value at a point. Often the decision is about an area: the mean grade of a 10 m mining block, the mean soil carbon of a one-hectare field, the mean rainfall over a catchment. Block kriging estimates the average over a block V directly, by replacing the point-to-point semivariances on the right-hand side of the kriging system with point-to-block average semivariances.
The consequence to remember: block estimates have substantially lower kriging variance than point estimates, because averaging over an area removes short-scale variability. This is not a trick — the block mean genuinely is more predictable than any individual point within it. It also means that comparing a point-kriging standard error with a block-kriging standard error is meaningless unless you say which is which.
When the secondary variable is also sparsely sampled — not available everywhere — KED is unavailable and co-kriging is the formally correct method. It requires modelling the variogram of each variable plus the cross-variogram between them, all under the linear model of coregionalisation, which constrains the models to share ranges and produces a positive-definite system.
It is a lot of machinery. Be honest about when it pays: co-kriging helps materially only when the secondary variable is markedly more densely sampled than the primary and strongly correlated with it. If both are sampled at the same locations, the gain is usually negligible and not worth the modelling burden. Know that it exists, know the condition under which it is worth doing, and reach for KED or RK first.
pykrige.UniversalKriging using elevation as the drift term. Map the difference between the RK and KED surfaces and comment on its magnitude.from sklearn.linear_model import LinearRegression
from pykrige.uk import UniversalKriging
# ---- regression kriging, done as three explicit steps -----------------
X = stations[["elev"]].values
y = stations["rain"].values
lm = LinearRegression().fit(X, y)
print(f"R² = {lm.score(X, y):.3f} slope = {lm.coef_[0]*100:.1f} mm per 100 m")
resid = y - lm.predict(X)
# the variogram that matters is the RESIDUAL variogram, not the raw one
Vr = skg.Variogram(stations[["X", "Y"]].values, resid,
n_lags=12, model="exponential")
print("residual variogram:", Vr.parameters)
OKr = OrdinaryKriging(stations.X, stations.Y, resid,
variogram_model="exponential",
variogram_parameters={"sill": Vr.parameters[1],
"range": Vr.parameters[0],
"nugget": Vr.parameters[2]})
resid_grid, _ = OKr.execute("grid", gridx, gridy)
trend_grid = lm.predict(dem_grid.reshape(-1, 1)).reshape(dem_grid.shape)
rk_grid = trend_grid + resid_grid # the two components, added back
# ---- the same thing in one step, as KED -------------------------------
UK = UniversalKriging(stations.X, stations.Y, y,
variogram_model="exponential",
drift_terms=["specified"],
specified_drift=[stations.elev.values])
ked_grid, ked_var = UK.execute("grid", gridx, gridy,
specified_drift_arrays=[dem_grid])
Reading. Hengl, Heuvelink & Stein (2004), "A generic framework for spatial prediction of soil variables based on regression-kriging", Geoderma 120 — the standard reference for RK. Goovaerts, ch. 6. Webster & Oliver, ch. 9.
Week seven
You have a map. Someone is going to spend money on the basis of it. How good is it, and how do you know?
The kriging variance will not answer this — week 5 established that it never saw your data values. The only honest route is to withhold observations, predict them, and compare. That is cross-validation, and it is the closest thing this subject has to a truth serum.
Leave-one-out cross-validation removes each observation in turn, predicts it from the remainder, and collects the errors ei = z(xi) − ẑ(xi). Four statistics follow, and you should report all four, because each catches a different failure.
ME should be near zero — it tests unbiasedness. A large ME means systematic over- or under-prediction and usually points to an unhandled trend. RMSE measures accuracy and is the number to compare between methods; it has the units of your variable, which makes it interpretable.
The mean squared deviation ratio is the diagnostic most students skip and the one that matters most. It divides each squared error by the kriging variance predicted at that location. If the variogram model is honest, MSDR should be close to 1. Substantially above 1 means your model is overconfident — errors are larger than it claims. Substantially below 1 means it is over-cautious.
MSDR is the only one of the four that tests the variance model rather than the prediction. A model can have an excellent RMSE and an MSDR of 3, which means the map is reasonably accurate but the error bars you would quote from it are wrong by a factor of nearly two. If you are going to attach uncertainty to a map — and this unit insists you do — this is the number that says whether that uncertainty is honest.
Standard k-fold cross-validation assigns observations to folds at random. On spatially autocorrelated data this means almost every held-out point has a near neighbour in the training set — often a few metres away. The model is not being asked to predict; it is being asked to interpolate between points it has already seen. The resulting RMSE can be optimistic by 30% or more, and the effect grows with how clustered your sampling is.
The fix is spatial block cross-validation: partition the region into blocks larger than the variogram range and hold out whole blocks. Now the model must genuinely predict into unsampled ground, which is what it will be asked to do in production. Expect your error to increase. That increase is not a regression in your model; it is the removal of a lie. This becomes even more important in week 10, when machine learning gives you many more ways to leak information between folds.
Kriging minimises estimation variance, and one consequence is that the resulting map is smoother than reality: high values are under-estimated, low values over-estimated, and the variance of the kriged surface is lower than the variance of the data. Plotting predicted against observed makes it obvious — the regression line has a slope below one, fanning away from the 1:1 line at both ends.
This is not a bug. It is the correct behaviour for minimising squared error, and it is unavoidable in any optimal linear estimator. But it matters enormously for a particular class of question. If you are asked "what fraction of this district exceeds the WHO fluoride guideline?", counting cells above the threshold on a kriged map will give you a systematically wrong answer, because the map has flattened exactly the extremes you are counting. Kriging is the right tool for "what is the value here"; it is the wrong tool for "how much of the area exceeds a threshold". That is a simulation question, and it is next week.
Kriging is not automatically the best method. Compare it, on identical folds, against inverse distance weighting, nearest neighbour and a thin-plate spline. There are real situations where the simpler methods win: a very high nugget-to-sill ratio leaves little structure for kriging to exploit; very dense, evenly spaced sampling makes almost any interpolator adequate; small datasets make the variogram too unstable to be worth the trouble.
Report the comparison whichever way it comes out. A study that concludes "IDW performed as well as kriging here, and is simpler, so we used it" is a good study. A study that reports only the kriging results because they were expected is not.
import numpy as np
from sklearn.model_selection import KFold
def loo_cv(coords, values, model, params):
"""Leave-one-out CV returning the four diagnostics that matter."""
n = len(values)
pred = np.empty(n); var = np.empty(n)
for i in range(n):
keep = np.arange(n) != i
ok = OrdinaryKriging(coords[keep, 0], coords[keep, 1], values[keep],
variogram_model=model, variogram_parameters=params)
z, s = ok.execute("points", coords[i:i+1, 0], coords[i:i+1, 1])
pred[i], var[i] = z[0], s[0]
e = values - pred
return {
"ME": e.mean(), # → 0 if unbiased
"RMSE": np.sqrt((e ** 2).mean()), # accuracy, in data units
"MAE": np.abs(e).mean(), # less outlier-sensitive
"MSDR": ((e ** 2) / var).mean(), # → 1 if the variance model is honest
}
def spatial_blocks(coords, block_size):
"""Fold labels from a spatial grid — hold out whole blocks, not points."""
keys = np.floor(coords / block_size).astype(int)
_, fold = np.unique(keys, axis=0, return_inverse=True)
return fold
# the comparison that matters: same data, two ways of splitting it
fold_random = KFold(10, shuffle=True, random_state=0)
fold_spatial = spatial_blocks(coords, block_size=2 * fitted_range)
# ... score both; the gap between them is your optimism
Reading. Webster & Oliver, ch. 8 §8.4. Roberts et al. (2017), "Cross-validation strategies for data with temporal, spatial, hierarchical or phylogenetic structure", Ecography 40(8) — the definitive treatment. Meyer & Pebesma (2022) on area of applicability, Methods in Ecology and Evolution 12(9).
Week eight
Compare these:
The reason is last week's smoothing effect. A kriged map has less variance than reality: it has flattened the peaks and filled in the troughs. Counting cells above a threshold on such a map systematically undercounts the area at risk, and the undercount is worst exactly where the threshold is far from the mean — which is usually where the policy question sits.
Simulation gives up optimality at each individual location in exchange for getting the statistical properties of the whole field right. A simulated realisation is not the best estimate anywhere; it is a plausible version of reality that reproduces the correct histogram, the correct variogram, and honours the data at the sample points. Generate many of them and you have a picture of what the field might be, not just a single flattened best guess.
Conditional simulation. The generation of multiple equally probable realisations of a random field, each of which (i) reproduces the observed values exactly at sampled locations, (ii) reproduces the sample histogram, and (iii) reproduces the modelled variogram.
Each realisation is less accurate than the kriged map at any single location. The set of realisations is far more informative than the kriged map, because the spread between them at each location is a genuine, data-aware measure of uncertainty — unlike the kriging variance.
SGS is the standard algorithm and it is simpler than it sounds. The key insight is that under a multivariate Gaussian model, the conditional distribution at any location — given the data and the previously simulated values — is normal, with mean equal to the simple kriging estimate and variance equal to the simple kriging variance. So you can build a realisation one node at a time:
Two details are worth pausing on. Step 4's phrase "and all previously simulated nodes" is what makes the realisation reproduce the variogram — each drawn value becomes data for its neighbours, propagating spatial structure across the field. And the random path in step 3 matters: a systematic path would introduce directional artefacts, which is why it must be random and different for each realisation.
You now have, say, 200 values at every grid node — one from each realisation. That is an empirical distribution, and everything useful follows from it:
A single realisation looks more realistic than a kriged map — it has the right texture and roughness, and it is seductive for exactly that reason. It is also wrong nearly everywhere, and it is no more likely to be right than any of the other 199. Present realisations only in sets, to show variability. If you need one map, present the E-type or the kriged map, and say which it is.
import numpy as np
import gstools as gs # pip install gstools — the practical SGS route in Python
model = gs.Exponential(dim=2, var=0.85, len_scale=420.0, nugget=0.15)
krige = gs.krige.Ordinary(model, cond_pos=(x_obs, y_obs), cond_val=ns_values)
cond = gs.CondSRF(krige)
N = 100
sims = np.empty((N, gridy.size, gridx.size))
for i in range(N):
cond.set_pos((gridx, gridy), "structured")
sims[i] = cond(seed=i) # one realisation
sims = back_transform(sims) # inverse normal score
etype = sims.mean(axis=0) # → converges toward the kriged map
cstd = sims.std(axis=0) # depends on DATA, unlike kriging variance
p_exc = (sims > 1.5).mean(axis=0) # P(exceeding the guideline)
# uncertainty in an aggregate — the number a decision-maker can use
cell_km2 = 0.04
area_risk = (sims > 1.5).sum(axis=(1, 2)) * cell_km2
print(f"area above guideline: {np.median(area_risk):.1f} km² "
f"(90% interval {np.percentile(area_risk, 5):.1f}–"
f"{np.percentile(area_risk, 95):.1f})")
Reading. Goovaerts, ch. 8 — the standard treatment. Deutsch & Journel, GSLIB, on sgsim. Chilès & Delfiner, ch. 7. The gstools documentation for the Python implementation.
Week nine
Simulation last week required a normal-score transform, which requires a continuous variable with a well-defined distribution. Plenty of real problems do not fit: heavily skewed data with a long tail no transform tames; data with a spike of values at a detection limit; and variables that are not numeric at all — soil class, land cover, rock type, presence or absence.
The indicator approach handles all of these by giving up on estimating the value and estimating a probability instead.
Pick a threshold zk and replace every observation with a 0 or a 1:
This is a remarkable trick, because the expected value of the indicator is the probability of being below the threshold. So if you krige the indicator variable, the estimate you get back — a number between 0 and 1 — is a direct estimate of P(Z(x0) ≤ zk), with no distributional assumption at all.
Two further benefits come free. Extreme values lose their leverage: a wildly high outlier becomes a 0, exactly like a moderately high value, so the indicator variogram is far more robust than the raw one. And you can use a different variogram at each threshold, which lets the spatial structure of the high values differ from that of the low ones — often physically true, and something ordinary kriging cannot express.
Repeat the procedure at a series of thresholds — typically the nine deciles of the sample distribution — and krige each indicator separately. At every prediction location you now have nine estimated probabilities, which together trace out the local cumulative distribution function. From that you can read the median, any quantile, an interquartile range as an uncertainty measure, and the probability of exceeding any threshold you care about.
Because each threshold is kriged independently, nothing forces the results to be a valid CDF. You will regularly find estimated probabilities that decrease as the threshold increases, or that fall outside [0, 1]. These are order-relation violations and they are a normal, expected feature of indicator kriging, not evidence you have made a mistake.
The standard repair is to average the results of an upward and a downward pass through the thresholds, then truncate to [0, 1] and enforce monotonicity. Report how many violations occurred: a large number signals too few data per threshold, or thresholds set too far into the tails where almost every indicator is the same value.
For a categorical variable with K classes, define one indicator per class and krige each. At every location you obtain K probabilities, which after correction sum to one. Two products follow: assign each location its most probable class for a map, and map the probability itself for a picture of where the classification is confident and where it is not. That second map is usually the more useful of the two, and it is the sort of thing that would improve a great many published soil and land-cover maps.
Sequential indicator simulation follows the same logic as SGS but draws from the indicator-derived local CDF instead of a Gaussian one — which is how you produce realisations of a categorical field, such as alternative plausible soil maps, each internally consistent.
A probability map is not yet a decision. Turning it into one requires a threshold on the probability, and choosing that threshold is a value judgement about the relative cost of the two errors, not a statistical one.
Consider a fluoride exceedance map used to prioritise borehole testing. Flagging every location with P > 0.1 casts a wide net: few contaminated boreholes missed, many clean ones tested unnecessarily. Flagging at P > 0.7 tests far fewer, but misses more contaminated sites. The right answer depends on the cost of a test against the cost of a child drinking fluoride-contaminated water for a year, and that is a decision for the public health authority, not the analyst.
Your job is to make the trade-off visible: present the probability map, state the consequences of several candidate thresholds, and let the decision-maker choose with their eyes open. This is what it means to communicate uncertainty rather than merely compute it, and it is a skill this unit is assessing.
# --- indicator kriging at a regulatory threshold -----------------------
threshold = 1.5 # mg/L, WHO guideline
ind = (gdf.fluoride.values > threshold).astype(float)
print(f"proportion above guideline: {ind.mean():.3f}")
Vi = skg.Variogram(coords, ind, n_lags=12, model="spherical")
OKi = OrdinaryKriging(coords[:, 0], coords[:, 1], ind,
variogram_model="spherical",
variogram_parameters={"sill": Vi.parameters[1],
"range": Vi.parameters[0],
"nugget": Vi.parameters[2]})
prob, _ = OKi.execute("grid", gridx, gridy)
prob = np.clip(prob, 0, 1) # kriging does not respect [0,1]
# --- make the decision trade-off explicit ------------------------------
for p in (0.1, 0.3, 0.7):
flagged = (prob > p).sum()
print(f"P > {p:.1f}: flag {flagged:5d} cells "
f"({100*flagged/prob.size:.1f}% of the area)")
Reading. Goovaerts, ch. 7. Journel (1983), "Nonparametric estimation of spatial distributions", Mathematical Geology 15(3). Deutsch & Journel, GSLIB, on ik3d and sisim.
Week ten · CAT 2 this week
Most operational mapping in soils, ecology and Earth observation is now done with machine learning rather than kriging. SoilGrids, which produces global soil property maps at 250 m, is built on gradient-boosted trees and random forests, not on variograms. It would be dishonest to teach this unit as though that had not happened.
It would be equally dishonest to present it as a replacement. The two traditions answer overlapping but different questions, and the sharpest way to see the difference is this: a random forest gives you a prediction; kriging gives you a prediction and a model of the error. Where a stated uncertainty is required — a resource statement, a regulatory exceedance assessment, anything that will be challenged — that difference decides the matter.
Give a random forest X and Y as features and it will fit the training data well and generalise badly. Trees partition space with axis-parallel splits, producing blocky, rectangular artefacts; the model has no notion of distance and cannot interpolate smoothly; and outside the convex hull of the training data it predicts a constant, because trees cannot extrapolate at all.
Two established fixes:
Machine learning gives you far more ways to leak information between training and test sets than kriging does, and spatial data makes all of them worse. Three to watch for.
Random splits. Week 7's lesson, now more damaging: with many features a flexible model can effectively memorise locations, and a randomly held-out point sitting 20 m from a training point is nearly free to predict. Always use spatial blocking.
Preprocessing before splitting. Scaling, imputing, or selecting features on the full dataset before splitting leaks test information into training. Fit every transformation inside the fold.
Tuning on the test set. If you choose hyperparameters by looking at test performance, the test set is now part of training and your reported accuracy is optimistic. Use a separate inner loop for tuning — nested cross-validation.
| Kriging | Random forest | |
|---|---|---|
| Uncertainty | Explicit variance from the model; simulation gives full distributions | None natively; quantile forests and conformal methods approximate it |
| Covariates | Few, and linear unless you work at it | Many, non-linear, with interactions, essentially free |
| Extrapolation | Degrades gracefully toward the mean with growing variance | Fails silently — predicts a constant with no warning |
| Interpretability | Three parameters, each physically meaningful | Variable importance and partial dependence, both easy to over-read |
| Small samples | Workable from about 50–100 points | Needs several hundred at minimum |
The practical conclusion is unglamorous and correct: use both, compare them under a fair protocol, and report what you find. A candidate who can say "random forest with kriged residuals beat plain kriging by 8% RMSE under spatial block cross-validation, but I used kriging for the exceedance assessment because the client needed confidence intervals" is demonstrating exactly the judgement this unit exists to build.
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import GroupKFold
# Spatial blocks become GROUPS — GroupKFold then never splits a block.
groups = spatial_blocks(coords, block_size=2 * fitted_range)
gkf = GroupKFold(n_splits=5)
results = []
for train, test in gkf.split(covars, y, groups=groups):
rf = RandomForestRegressor(n_estimators=500, min_samples_leaf=3,
random_state=0, n_jobs=-1)
rf.fit(covars[train], y[train]) # fit INSIDE the fold
# --- RF + regression kriging of the residuals ----------------------
res = y[train] - rf.predict(covars[train])
ok = OrdinaryKriging(coords[train, 0], coords[train, 1], res,
variogram_model="exponential")
res_hat, _ = ok.execute("points", coords[test, 0], coords[test, 1])
pred_rf = rf.predict(covars[test])
pred_rfrk = pred_rf + res_hat
results.append({
"rf": np.sqrt(((y[test] - pred_rf) ** 2).mean()),
"rfrk": np.sqrt(((y[test] - pred_rfrk) ** 2).mean()),
})
import pandas as pd
print(pd.DataFrame(results).mean().round(3))
Reading. Hengl et al. (2018), "Random forest as a generic framework for predictive modeling of spatial and spatio-temporal variables", PeerJ 6:e5518. Meyer & Pebesma (2021) on the area of applicability. Ploton et al. (2020), Nature Communications 11, on spatial validation and overoptimism in the ecological literature.
Week eleven
Digital soil mapping. The largest current application. Sparse expensive samples, abundant cheap covariates from remote sensing and terrain analysis — the ideal setting for regression kriging and its machine-learning descendants. AfSIS and SoilGrids are the reference programmes; both publish their methods, and reading one properly is worth a week of lectures.
Mineral resource estimation. Where the subject began, and the one field where the reporting is legally constrained. Codes such as JORC and SAMREC govern how a resource may be stated publicly, and a Competent Person signs off personally. Block kriging on the mining block support, careful treatment of the high-grade tail, and conditional simulation for grade-tonnage uncertainty are all standard. This is geostatistics at its most rigorous, because being wrong is actionable.
Environmental and public health. Contamination, air quality, disease incidence. Almost always a threshold question rather than a value question, which means indicator methods and simulation rather than plain kriging. The audience is a regulator or a health officer, so communication of uncertainty is half the work.
Climate and agriculture. Rainfall and temperature surfaces from station networks, almost always with elevation as a covariate, feeding drought indices and yield models. Kenya's station network is sparse and unevenly distributed, which makes declustering and honest error reporting matter more here than in the textbooks.
Before it is a map, an analysis is a set of decisions someone else must be able to check. Every one of these belongs in the report:
Point 8 separates a professional from a technician. Every map has regions where the data do not support the prediction: outside the sampling envelope, in areas of a covariate range never observed, in unsampled corners. Say where they are. A report that identifies its own limits is far more likely to be trusted than one that does not.
Ten minutes of presentation, five minutes of questions, in pairs. Questions come from the class and from me, and answering them is a substantial part of the assessment — the reasoning behind your choices is what is being examined, not the appearance of your maps.
A report of no more than eight pages covering the eight points above, submitted with your notebook. The notebook must run start to finish on a clean machine. Include the AI-use declaration described in the unit outline: which tools, which prompts, what they produced, and what you changed and why.
You will forget the spherical model's formula; it is in every textbook and in Appendix B. What should stay with you is the habit the whole unit has been arranged around: every prediction carries an uncertainty, that uncertainty can be quantified, and quoting a number without one is not a professional act.
Software will keep making the computation easier. It has never made the judgement easier — deciding whether the data support the question, whether the model is defensible, whether the map should be trusted where someone is about to act on it. That judgement is what you were doing all semester, and it is what you are being hired for.
Reading. Hengl & MacMillan, Predictive Soil Mapping with R (open access). Rossiter (2018), "Past, present and future of information technology in pedometrics", Geoderma 324. The JORC Code (2012) for the mineral reporting context.
Appendix A
You met Python in GIS programming. This appendix tells you — privately, before anyone is watching — whether that is enough for what follows. Work through the twelve tasks below without help of any kind: no notes, no search, no AI assistant. The point is to find out what you actually have, and using help defeats the entire purpose.
samples.csv into a pandas DataFrame and print the number of rows and the column names.soc is greater than 20 and depth is less than 30.soc_pct equal to soc divided by 10.euclid(x1, y1, x2, y2) that returns the distance between two points. Test it on a case whose answer you know.xs and ys, use a loop to build a list of distances from every point to the point (0, 0).D of pairwise distances, return the mean of all values strictly greater than 0 and less than 100.x against y, coloured by soc, with a colourbar and axis labels.IndexError: index 10 is out of bounds for axis 0 with size 10. Explain why, and fix it:for i in range(len(v) + 1): print(v[i])np.linalg.norm(pts[:, None, :] - pts[None, :, :], axis=-1)Tasks 1–5 are the floor. Tasks 6–9 are the working level for this unit. Tasks 10–12 are what the Extension work assumes — task 12 in particular is the broadcasting idiom that appears in week 5's kriging code, and understanding it is worth more than memorising any formula in this reader.
b = a[2:5] gives you a view — writing to b changes a. Use .copy() when you mean a copy. This causes silent, baffling bugs.df[df.a > 5]["b"] = 0 silently fails. Use df.loc[df.a > 5, "b"] = 0..values or .reset_index(drop=True) deliberately.a == b on floats. Use np.isclose. This matters in week 5, where γ(0) must be exactly zero.They are permitted, and declared. Use them for what they are good at — explaining an error message, drafting a plotting routine, reminding you of an argument name. Do not use them for the four tasks marked on paper first in the practicals, and do not use them for the self-check above.
The reason is not integrity policing; it is that these tools produce geostatistical code that runs and is wrong, in ways that are hard to see. A model will happily hand you random k-fold cross-validation on autocorrelated data, or a variogram fitted in degrees, or a kriging variance map captioned "uncertainty". Each of those has appeared in this unit as an error you are being trained to catch. If you cannot catch them, the tool is a liability rather than an asset — and the paper exercises are what build the ability to catch them.
Appendix B
This sheet is supplied in the CATs and in the final examination. You are not expected to memorise these; you are expected to know which one to reach for, and what each symbol means.
| Maximum lag | ≤ ½ the survey diagonal |
| Pairs per lag class | ≥ 30 |
| First lag width | ≈ mean nearest-neighbour distance |
| Angular tolerance, directional | ±22.5° |
| Kriging neighbourhood | 16–24 points, quadrant-balanced |
| Nugget / sill > 0.5 | weak structure — reconsider |
| CV block size | ≥ 2 × variogram range |
| Realisations for tail probabilities | ≥ 500 |
| Kenya UTM zones | EPSG:32736 (W of 36°E) · EPSG:32737 (E of 36°E) |
Appendix C
| Dataset | Used in | Notes |
|---|---|---|
| Meuse — 155 topsoil samples, heavy metals, Dutch floodplain | 1–5, 7 | The canonical teaching set. Ships with geopandas examples and appears throughout the literature, so you can compare your results with published ones. |
| Kiambu soil — soil organic carbon, pH, texture | 1–2, 10 | Compiled from AfSIS. Real Kenyan sampling, with real gaps and clustering. |
| Kenya rainfall stations — long-term annual means with elevation | 6 | Sparse, unevenly distributed, strongly related to elevation. The natural KED dataset. |
| SRTM 30 m DEM, clipped to the study areas | 6, 10 | The covariate for KED and the source of slope, aspect and wetness index. |
| Groundwater fluoride — Rift Valley boreholes | 8–9 | Skewed, threshold-driven, genuinely consequential. The WHO guideline is 1.5 mg/L. |
| Covariate stack — terrain derivatives, mean NDVI, CHIRPS rainfall | 10 | Pre-extracted at the sample points and supplied as a raster stack. |
| Synthetic fields — simulated from known variograms | 3–4, 7 | Generated for this unit. Because the true variogram is known, you can measure how close your fitted model came — which no real dataset allows. |
All datasets are distributed through the unit's shared folder as CSV and GeoTIFF, with a README giving the CRS, units and provenance of each. If you use them outside this unit, check the licence file — AfSIS and CHIRPS are openly licensed with attribution requirements, and the Kenyan borehole data is redistributed for teaching only.
EGS 2405 Geostatistics · Course reader, edition 1.0 · Jomo Kenyatta University of Agriculture and Technology · September 2026. Corrections and suggestions are welcome and will be credited in the next edition.