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.
- land_cover_random_forest.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython land_cover_random_forest.pyland_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 fromESA 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 randomsplit overstates accuracy), ranks the features with permutation importance and predicts a full land cover map.Run with: python land_cover_random_forest.py"""import platformimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import daskimport matplotlib
matplotlib.use("Agg")import matplotlib.pyplot as pltimport numpy as npimport odc.geo.xr # noqa: F401 (adds the .odc accessor to xarray objects)import odc.stacimport pandas as pdimport planetary_computerimport pystac_clientimport rasterioimport sklearnimport xarray as xrfrom matplotlib.colors import BoundaryNorm, ListedColormapfrom matplotlib.patches import Patchfrom scipy import ndimagefrom sklearn.ensemble import RandomForestClassifierfrom sklearn.inspection import permutation_importancefrom sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_scorefrom sklearn.model_selection import GroupKFold, KFold, cross_val_predict
warnings.filterwarnings("ignore", category=RuntimeWarning) # all-NaN medians where a season has no clear viewHERE = Path(__file__).parentOUT, 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 eastCRS, RES = "EPSG:32630", 20 # UTM zone 30N at 20 m: the native grid of the red-edge and SWIR bandsYEAR = 2021 # match the year of the ESA WorldCover map used for the labelsSEASONS = {"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 twicefor 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] = ititems = 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.geoboxprint(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 chlorophyllFEATURES = 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.shapesection("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"].valuessection("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 areaUSE = [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 = 2000BLOCK = 2000 # 2 km x 2 km spatial blocks for the train/test splitrng = 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 // RESblock_id = (rows // block_px) * (W // block_px + 1) + cols // block_pxsection("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 entirelyrf = 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 / 1e4areas = 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.boundingboxext = [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.75rgb = np.nan_to_num(rgb, nan=0.85)
# 1. summer composite and WorldCoverfig, 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 blocksfig, 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 mapf1r = 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 importancetop = 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 WorldCoverfig, 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")