Source code: Land cover classification in Python: a Sentinel-2 random forest with scikit-learn

Runnable Python scripts, requirements and console output for the tutorial “Land cover classification in Python: a Sentinel-2 random forest with…

Last updated

These are the exact scripts used to produce every figure and number in Land cover classification in Python: a Sentinel-2 random forest with scikit-learn. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python land_cover_random_forest.py

land_cover_random_forest.py

land_cover_random_forest.py
"""Land cover classification with Sentinel-2 and a scikit-learn random forest: Sheffield and the Peak District.
Builds seasonal Sentinel-2 L2A median composites for 2021 with odc-stac, samples training labels from
ESA WorldCover 2021 with a stratified random sample, trains a random forest on bands and spectral indices,
assesses it with a spatially blocked train/test split and blocked cross-validation (and shows how a random
split overstates accuracy), ranks the features with permutation importance and predicts a full land cover map.
Run with: python land_cover_random_forest.py
"""
import platform
import time
import warnings
from importlib.metadata import version
from pathlib import Path
import dask
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import odc.geo.xr # noqa: F401 (adds the .odc accessor to xarray objects)
import odc.stac
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import sklearn
import xarray as xr
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.patches import Patch
from scipy import ndimage
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score
from sklearn.model_selection import GroupKFold, KFold, cross_val_predict
warnings.filterwarnings("ignore", category=RuntimeWarning) # all-NaN medians where a season has no clear view
HERE = Path(__file__).parent
OUT, DATA = HERE / "output", HERE / "data"
for d in (OUT, DATA):
d.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 140)
SEED = 42
print("=== versions ===")
print(f"python {platform.python_version()} | scikit-learn {sklearn.__version__} | odc-stac {odc.stac.__version__} | "
f"xarray {xr.__version__} | dask {dask.__version__} | pystac-client {pystac_client.__version__} | "
f"planetary-computer {version('planetary-computer')} | numpy {np.__version__}")
def section(title: str) -> None:
print(f"\n=== {title} ===")
# --- Step 1: search Sentinel-2 for one growing season ---------------------------------------------
BBOX = (-1.75, 53.25, -1.05, 53.45) # Peak District moors, Sheffield and the farmland to the east
CRS, RES = "EPSG:32630", 20 # UTM zone 30N at 20 m: the native grid of the red-edge and SWIR bands
YEAR = 2021 # match the year of the ESA WorldCover map used for the labels
SEASONS = {"spring": [3, 4, 5], "summer": [6, 7, 8], "autumn": [9, 10]}
BANDS = ["B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B11", "B12"]
STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"
catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)
items = catalog.search(collections=["sentinel-2-l2a"], bbox=BBOX, datetime=f"{YEAR}-03-01/{YEAR}-10-31",
query={"eo:cloud_cover": {"lt": 60}}).item_collection()
best = {} # keep the newest processing baseline when a date and tile exist twice
for it in items:
key = (it.datetime.date(), it.properties["s2:mgrs_tile"])
if key not in best or it.properties["s2:processing_baseline"] > best[key].properties["s2:processing_baseline"]:
best[key] = it
items = sorted(best.values(), key=lambda i: i.datetime)
section("STAC search")
print(f"{len(items)} Sentinel-2 L2A items (tiles {sorted({i.properties['s2:mgrs_tile'] for i in items})}) "
f"with < 60% cloud, {items[0].datetime:%d %b} to {items[-1].datetime:%d %b %Y}")
# --- Step 2: lazy datacube, cloud mask and seasonal median composites -----------------------------
odc.stac.configure_rio(cloud_defaults=True)
ds = odc.stac.load(items, bands=BANDS + ["SCL"], bbox=BBOX, crs=CRS, resolution=RES,
chunks={"time": 1, "x": 2048, "y": 2048}, groupby="solar_day", dtype="uint16", nodata=0,
resampling={"*": "average", "SCL": "nearest"})
ds = ds.assign_coords(time=ds.time.dt.floor("D"))
gbox = ds.odc.geobox
print(f"lazy cube: {dict(ds.sizes)}, {ds.nbytes / 1e9:.1f} GB if loaded in full")
dask.config.set(scheduler="threads", num_workers=16)
baseline = {}
for it in items:
baseline.setdefault(pd.Timestamp(it.datetime.date()), set()).add(float(it.properties["s2:processing_baseline"]) >= 4.0)
# The two tiles of one date are mosaicked, so they must share the same offset convention.
mixed = [t for t, v in baseline.items() if len(v) > 1]
ds = ds.drop_sel(time=mixed)
print(f"dropped {len(mixed)} dates whose tiles mix processing baselines before and after 04.00")
offset = xr.DataArray([1000 if baseline[pd.Timestamp(t)] == {True} else 0 for t in ds.time.values],
dims="time", coords={"time": ds.time})
comp_file = DATA / f"composites_{YEAR}.nc"
if comp_file.exists():
comp = xr.load_dataset(comp_file)
else:
t0 = time.time()
scl = ds["SCL"].compute()
cloudy = np.isin(scl.values, [3, 8, 9, 10]) # shadow, cloud (medium, high), cirrus
grown = ndimage.binary_dilation(cloudy, iterations=2, axes=(1, 2)) # grow by 40 m
clear = scl.isin([4, 5, 6, 7]) & ~xr.DataArray(grown, coords=scl.coords, dims=scl.dims)
frac = clear.mean(("y", "x")).to_series()
print(f"SCL read in {time.time() - t0:.0f} s; {(frac >= 0.1).sum()} of {len(frac)} dates are at least 10% clear")
layers = {}
for season, months in SEASONS.items():
times = frac.index[(frac >= 0.1) & frac.index.month.isin(months)]
sub = ds[BANDS].sel(time=times)
refl = ((sub.astype("float32") - offset.sel(time=times)) / 10000).where(clear.sel(time=times))
med = refl.chunk({"time": -1, "y": 512, "x": 512}).median("time").compute()
n = clear.sel(time=times).sum("time")
print(f"{season:<7} {len(times):>2} dates; pixels with no clear view: {float((n == 0).mean()):.2%}")
for b in BANDS:
layers[f"{season}_{b}"] = med[b]
comp = xr.Dataset(layers).drop_vars("spatial_ref", errors="ignore")
comp.to_netcdf(comp_file)
print(f"composites built in {time.time() - t0:.0f} s")
# --- Step 3: features: bands plus indices for each season ----------------------------------------
def nd(a, b):
return (a - b) / (a + b)
features = {}
for s in SEASONS:
for b in BANDS:
features[f"{s}_{b}"] = comp[f"{s}_{b}"]
features[f"{s}_NDVI"] = nd(comp[f"{s}_B08"], comp[f"{s}_B04"]) # vegetation
features[f"{s}_MNDWI"] = nd(comp[f"{s}_B03"], comp[f"{s}_B11"]) # open water
features[f"{s}_NDBI"] = nd(comp[f"{s}_B11"], comp[f"{s}_B08"]) # built-up and bare surfaces
features[f"{s}_NDRE"] = nd(comp[f"{s}_B8A"], comp[f"{s}_B05"]) # red-edge chlorophyll
FEATURES = list(features)
X_all = np.stack([features[f].values.astype("float32") for f in FEATURES], axis=-1) # (y, x, feature)
H, W, F = X_all.shape
section("feature stack")
print(f"{H} x {W} pixels of {RES} m, {F} features ({len(SEASONS)} seasons x ({len(BANDS)} bands + 4 indices))")
print(f"pixels with at least one missing feature: {np.isnan(X_all).any(-1).mean():.2%}")
# --- Step 4: reference labels from ESA WorldCover 2021 -----------------------------------------------
CLASSES = {10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland", 50: "Built-up",
60: "Bare / sparse", 80: "Water", 90: "Herbaceous wetland", 100: "Moss and lichen"}
WC_COLOURS = {10: "#006400", 20: "#ffbb22", 30: "#ffff4c", 40: "#f096ff", 50: "#fa0000", 60: "#b4b4b4",
80: "#0064c8", 90: "#0096a0", 100: "#fae6a0"}
wc_items = catalog.search(collections=["esa-worldcover"], bbox=BBOX,
query={"esa_worldcover:product_version": {"eq": "2.0.0"}}).item_collection()
wc = odc.stac.load(wc_items, bands=["map"], geobox=gbox, resampling="mode").squeeze("time")["map"].values
section("ESA WorldCover 2021 (resampled to 20 m with the mode)")
share = pd.Series(wc.ravel()).value_counts(normalize=True).sort_index()
print((share * 100).round(2).rename(lambda c: f"{c:>3} {CLASSES.get(c, '?')}").to_string())
MIN_SHARE = 0.005 # drop classes covering less than 0.5% of the area
USE = [c for c in CLASSES if share.get(c, 0) >= MIN_SHARE]
print(f"classes used: {[CLASSES[c] for c in USE]}; dropped: "
f"{[CLASSES[c] for c in CLASSES if c not in USE and share.get(c, 0) > 0]}")
# --- Step 5: stratified random sample of 'pure' pixels -----------------------------------------------
N_PER_CLASS = 2000
BLOCK = 2000 # 2 km x 2 km spatial blocks for the train/test split
rng = np.random.default_rng(SEED)
valid = ~np.isnan(X_all).any(-1)
rows, cols, ys = [], [], []
for c in USE:
# Keep pixels whose 3 x 3 neighbourhood is all the same class: fewer mixed pixels and edge errors.
pure = ndimage.binary_erosion(wc == c, structure=np.ones((3, 3))) & valid
r, cc = np.nonzero(pure)
pick = rng.choice(len(r), size=min(N_PER_CLASS, len(r)), replace=False)
rows.append(r[pick]); cols.append(cc[pick]); ys.append(np.full(len(pick), c))
rows, cols, y = np.concatenate(rows), np.concatenate(cols), np.concatenate(ys)
X = X_all[rows, cols]
block_px = BLOCK // RES
block_id = (rows // block_px) * (W // block_px + 1) + cols // block_px
section("stratified random sample")
print(pd.Series(y).map(CLASSES).value_counts().reindex([CLASSES[c] for c in USE]).to_string())
print(f"{len(y)} samples in {len(np.unique(block_id))} blocks of {BLOCK / 1000:.0f} km")
# --- Step 6: spatially blocked train/test split, and a random split for comparison ------------------
blocks = np.unique(block_id)
fold_of_block = dict(zip(blocks, rng.permutation(len(blocks)) % 5))
fold = np.array([fold_of_block[b] for b in block_id])
test = fold == 0 # one fifth of the blocks are held out entirely
rf = RandomForestClassifier(n_estimators=300, max_features="sqrt", min_samples_leaf=1, n_jobs=-1,
random_state=SEED, oob_score=True)
t0 = time.time()
rf.fit(X[~test], y[~test])
pred = rf.predict(X[test])
section("spatial hold-out test (20% of blocks)")
print(f"trained on {(~test).sum()} samples in {time.time() - t0:.1f} s; tested on {test.sum()} samples")
print(f"overall accuracy {accuracy_score(y[test], pred):.3f} | macro F1 {f1_score(y[test], pred, average='macro'):.3f} | "
f"out-of-bag accuracy {rf.oob_score_:.3f}")
names = [CLASSES[c] for c in USE]
print(classification_report(y[test], pred, labels=USE, target_names=names, digits=3))
recall = pd.Series(np.diag(confusion_matrix(y[test], pred, labels=USE, normalize="true")), index=USE)
w = share.reindex(USE) / share.reindex(USE).sum()
print(f"area-weighted accuracy (class recall weighted by WorldCover area share): {(w * recall).sum():.3f}")
cm = confusion_matrix(y[test], pred, labels=USE)
section("random vs spatially blocked 5-fold cross-validation")
cv = {}
for name, splitter, groups in [("random", KFold(5, shuffle=True, random_state=SEED), None),
("spatial blocks", GroupKFold(5), fold)]:
p = cross_val_predict(RandomForestClassifier(n_estimators=300, n_jobs=-1, random_state=SEED), X, y,
cv=splitter, groups=groups)
cv[name] = p
per_class = f1_score(y, p, labels=USE, average=None)
print(f"{name:<15} accuracy {accuracy_score(y, p):.3f} | macro F1 {f1_score(y, p, average='macro'):.3f} | "
+ " ".join(f"{CLASSES[c].split()[0]} {v:.2f}" for c, v in zip(USE, per_class)))
gap = accuracy_score(y, cv["random"]) - accuracy_score(y, cv["spatial blocks"])
print(f"random CV overstates accuracy by {gap * 100:.1f} percentage points")
# --- Step 7: feature importance ---------------------------------------------------------------------
section("feature importance")
pi = permutation_importance(rf, X[test], y[test], n_repeats=5, random_state=SEED, n_jobs=-1, scoring="f1_macro")
imp = pd.DataFrame({"permutation": pi.importances_mean, "perm_sd": pi.importances_std,
"impurity": rf.feature_importances_}, index=FEATURES).sort_values("permutation", ascending=False)
print(imp.head(12).round(4).to_string())
by_season = imp["impurity"].groupby(imp.index.str.split("_").str[0]).sum()
print("impurity importance by season:", by_season.round(3).to_dict())
# --- Step 8: predict the full map ----------------------------------------------------------------------
final = RandomForestClassifier(n_estimators=300, max_features="sqrt", n_jobs=-1, random_state=SEED).fit(X, y)
flat = X_all.reshape(-1, F)
pred_map = np.zeros(H * W, dtype="uint8")
proba_max = np.zeros(H * W, dtype="float32")
t0 = time.time()
for i in range(0, H * W, 200_000): # predict in chunks to keep memory low
chunk = flat[i:i + 200_000]
proba = final.predict_proba(chunk) # scikit-learn >= 1.4 forests accept missing values
pred_map[i:i + 200_000] = final.classes_[proba.argmax(1)]
proba_max[i:i + 200_000] = proba.max(1)
pred_map, proba_max = pred_map.reshape(H, W), proba_max.reshape(H, W)
section("full map prediction")
print(f"predicted {H * W:,} pixels in {time.time() - t0:.0f} s")
in_use = np.isin(wc, USE)
agree = (pred_map == wc)[in_use].mean()
print(f"agreement with WorldCover over the classes used: {agree:.1%}; "
f"median top-class probability {np.median(proba_max):.2f}")
f1_map = f1_score(wc[in_use], pred_map[in_use], labels=USE, average=None)
print("per-class F1 against WorldCover over every pixel: "
+ " ".join(f"{CLASSES[c].split()[0]} {v:.2f}" for c, v in zip(USE, f1_map)))
ha = RES * RES / 1e4
areas = pd.DataFrame({"WorldCover_ha": pd.Series(wc.ravel()).value_counts().reindex(USE).fillna(0) * ha,
"random_forest_ha": pd.Series(pred_map.ravel()).value_counts().reindex(USE).fillna(0) * ha})
areas.index = [CLASSES[c] for c in USE]
print(areas.round(0).astype(int).to_string())
with rasterio.open(OUT / "land_cover_rf_2021.tif", "w", driver="GTiff", height=H, width=W, count=1, dtype="uint8",
crs=CRS, transform=gbox.affine, compress="deflate", nodata=0) as dst:
dst.write(pred_map, 1)
dst.write_colormap(1, {c: tuple(int(WC_COLOURS[c][i:i + 2], 16) for i in (1, 3, 5)) + (255,) for c in USE})
pd.DataFrame(np.column_stack([rows, cols, y, fold]), columns=["row", "col", "label", "fold"]).to_csv(
OUT / "training_samples.csv", index=False)
# --- Figures ------------------------------------------------------------------------------------------------
bb = gbox.boundingbox
ext = [bb.left, bb.right, bb.bottom, bb.top]
lc_cmap = ListedColormap([WC_COLOURS[c] for c in CLASSES])
lc_norm = BoundaryNorm([c - 0.5 for c in CLASSES] + [100.5], lc_cmap.N)
legend = [Patch(color=WC_COLOURS[c], label=CLASSES[c]) for c in USE]
section("figures")
def frame(ax, title):
ax.set_title(title, loc="left")
xt, yt = np.arange(0, ext[1] - ext[0], 10_000), np.arange(0, ext[3] - ext[2], 5_000)
ax.set_xticks(ext[0] + xt, [f"{v / 1000:.0f}" for v in xt])
ax.set_yticks(ext[2] + yt, [f"{v / 1000:.0f}" for v in yt])
ax.set_xlim(ext[0], ext[1]); ax.set_ylim(ext[2], ext[3])
ax.set_xlabel("km"); ax.set_ylabel("km")
rgb = np.dstack([comp[f"summer_{b}"].values for b in ("B04", "B03", "B02")])
rgb = np.clip(rgb / np.nanpercentile(rgb, 99), 0, 1) ** 0.75
rgb = np.nan_to_num(rgb, nan=0.85)
# 1. summer composite and WorldCover
fig, axes = plt.subplots(2, 1, figsize=(10, 10.2))
axes[0].imshow(rgb, extent=ext)
frame(axes[0], f"Sentinel-2 summer {YEAR} median composite (June–August, cloud-masked)")
axes[1].imshow(np.ma.masked_equal(wc, 0), cmap=lc_cmap, norm=lc_norm, extent=ext, interpolation="nearest")
frame(axes[1], "ESA WorldCover 2021, resampled to 20 m: the reference labels")
axes[1].legend(handles=legend, loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=8, frameon=False)
fig.tight_layout()
fig.savefig(OUT / "01-composite-and-worldcover.png")
plt.close(fig)
# 2. sample points and the spatial blocks
fig, ax = plt.subplots(figsize=(10, 4.6))
ax.imshow(rgb.mean(axis=2), cmap="gray", extent=ext, vmin=0, vmax=1.3)
xs, ys_ = gbox.affine * (cols + 0.5, rows + 0.5)
for c in USE:
m = y == c
ax.scatter(xs[m], ys_[m], s=2, color=WC_COLOURS[c], linewidths=0)
for b in blocks[[fold_of_block[b] == 0 for b in blocks]]:
r0, c0 = divmod(b, W // block_px + 1)
x0, y0 = gbox.affine * (c0 * block_px, r0 * block_px)
ax.add_patch(plt.Rectangle((x0, y0 - BLOCK), BLOCK, BLOCK, fill=False, ec="black", lw=1.1))
frame(ax, f"{len(y):,} stratified random samples; black squares = held-out 2 km test blocks")
ax.legend(handles=legend + [Patch(fill=False, ec="black", label="test block")], loc="upper left",
bbox_to_anchor=(1.01, 1), fontsize=8, frameon=False)
fig.savefig(OUT / "02-samples-and-blocks.png")
plt.close(fig)
# 3. confusion matrix (spatial hold-out)
cmn = cm / cm.sum(1, keepdims=True)
fig, ax = plt.subplots(figsize=(7.2, 6.2))
ax.imshow(cmn, cmap="Blues", vmin=0, vmax=1)
for i in range(len(USE)):
for j in range(len(USE)):
if cm[i, j]:
ax.text(j, i, f"{cmn[i, j]:.0%}\n({cm[i, j]})", ha="center", va="center", fontsize=7,
color="white" if cmn[i, j] > 0.55 else "black")
ax.set_xticks(range(len(USE)), names, rotation=35, ha="right")
ax.set_yticks(range(len(USE)), names)
ax.set_xlabel("predicted by the random forest")
ax.set_ylabel("WorldCover label")
ax.set_title("Confusion matrix on the spatial hold-out blocks (row %, counts)")
fig.savefig(OUT / "03-confusion-matrix.png")
plt.close(fig)
# 4. F1 per class: random CV, spatial CV and the whole map
f1r = f1_score(y, cv["random"], labels=USE, average=None)
f1s = f1_score(y, cv["spatial blocks"], labels=USE, average=None)
fig, ax = plt.subplots(figsize=(9, 4.2))
xx = np.arange(len(USE))
ax.bar(xx - 0.27, f1r, 0.27, color="#b0b7b5", label=f"random 5-fold CV (accuracy {accuracy_score(y, cv['random']):.1%})")
ax.bar(xx, f1s, 0.27, color="#1d6fa3",
label=f"spatially blocked 5-fold CV (accuracy {accuracy_score(y, cv['spatial blocks']):.1%})")
ax.bar(xx + 0.27, f1_map, 0.27, color="#c21f3a", label=f"every pixel of the final map (agreement {agree:.1%})")
for x0, v in zip(xx + 0.27, f1_map):
ax.text(x0, v + 0.01, f"{v:.2f}", ha="center", fontsize=7)
ax.set_xticks(xx, names, rotation=20, ha="right")
ax.set_ylabel("F1 score against WorldCover")
ax.set_ylim(0, 1.05)
ax.legend(loc="lower left", fontsize=8)
ax.grid(axis="y", alpha=0.3)
ax.set_title("Per-class F1: pure-pixel samples look far better than the whole map")
fig.savefig(OUT / "04-f1-three-estimates.png")
plt.close(fig)
# 5. permutation importance
top = imp.head(15).iloc[::-1]
season_col = {"spring": "#2f9e44", "summer": "#d4a017", "autumn": "#c21f3a"}
fig, ax = plt.subplots(figsize=(8, 5.4))
ax.barh(top.index, top["permutation"], xerr=top["perm_sd"], color=[season_col[i.split("_")[0]] for i in top.index])
ax.set_xlabel("drop in macro F1 when the feature is shuffled (spatial hold-out)")
ax.set_title(f"Permutation importance: top 15 of {F} features")
ax.legend(handles=[Patch(color=v, label=k) for k, v in season_col.items()], loc="lower right", fontsize=8)
ax.grid(axis="x", alpha=0.3)
fig.savefig(OUT / "05-feature-importance.png")
plt.close(fig)
# 6. predicted map and disagreement with WorldCover
fig, axes = plt.subplots(2, 1, figsize=(10, 10.2))
axes[0].imshow(pred_map, cmap=lc_cmap, norm=lc_norm, extent=ext, interpolation="nearest")
frame(axes[0], f"Random forest land cover, {YEAR} (agreement with WorldCover {agree:.0%})")
axes[0].legend(handles=legend, loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=8, frameon=False)
dis = np.where(~in_use, np.nan, (pred_map != wc).astype(float))
axes[1].imshow(rgb.mean(axis=2), cmap="gray", extent=ext, vmin=0, vmax=1.3)
axes[1].imshow(np.ma.masked_where(dis != 1, dis), cmap=ListedColormap(["#c21f3a"]), extent=ext,
interpolation="nearest")
frame(axes[1], "Where the random forest and WorldCover disagree (red)")
fig.tight_layout()
fig.savefig(OUT / "06-predicted-map.png")
plt.close(fig)
print(f"wrote {len(list(OUT.glob('*.png')))} figures, land_cover_rf_2021.tif and training_samples.csv")