Source code: Habitat classification with LightGBM in Python: Sentinel-2, Sentinel-1 and terrain fusion

Runnable Python scripts, requirements and console output for the tutorial “Habitat classification with LightGBM in Python: Sentinel-2, Sentinel-1 and…

Last updated

These are the exact scripts used to produce every figure and number in Habitat classification with LightGBM in Python: Sentinel-2, Sentinel-1 and terrain fusion. Download them, install the requirements into a fresh virtual environment and run them from this folder.

This part also needs the series' shared module and sample design (habitat_common.py, prepare_data.py and the sample CSVs) in a sibling folder named ai-habitat-classification-common.

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

habitat_lightgbm.py

habitat_lightgbm.py
"""Habitat classification with LightGBM on multi-temporal Sentinel-2 and Sentinel-1 features (plus terrain).
Part 2 of the "AI habitat classification with Python" series (Dark Peak, Peak District, UK).
Builds a pixel feature stack from 2023 seasonal Sentinel-2 bands and indices (NDVI, NDWI, NDMI, red-edge),
seasonal Sentinel-1 RTC backscatter (VV, VH, VH/VV ratio) and Copernicus DEM elevation and slope, tunes a
LightGBM classifier with blocked (spatial-fold) cross-validation and class weights, runs an ablation
(S2 only, S2+S1, S2+S1+terrain), explains the model with TreeSHAP and grouped permutation importance, and
compares it with a random forest and the Part 1 rule-based baseline on the identical held-out test blocks.
Run with: python habitat_lightgbm.py
"""
import itertools
import json
import platform
import subprocess
import sys
import time
import warnings
from importlib.metadata import version
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import rasterio
from matplotlib.patches import Patch
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold
T0 = time.time()
HERE = Path(__file__).resolve().parent
COMMON = HERE.parent / "ai-habitat-classification-common"
PART1 = HERE.parent / "habitat-mapping-sentinel-2-python"
sys.path.insert(0, str(COMMON))
import habitat_common as hc # noqa: E402
OUT, DATA = HERE / "output", HERE / "data"
OUT.mkdir(exist_ok=True)
DATA.mkdir(exist_ok=True)
warnings.filterwarnings("ignore", category=RuntimeWarning) # NaN where a season has no clear view
warnings.filterwarnings("ignore", message="X does not have valid feature names")
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 170)
pd.set_option("display.max_columns", 20)
KEYS = [c["key"] for c in hc.CLASSES]
NAMES = {c["key"]: c["name"] for c in hc.CLASSES}
COLOURS = {c["key"]: c["colour"] for c in hc.CLASSES}
KEY_ID = {c["key"]: c["id"] for c in hc.CLASSES}
SHORT = {k: k.split("_")[0][:5] for k in KEYS}
def section(title):
print(f"\n=== {title} ===", flush=True)
print("=== versions ===")
print(f"python {platform.python_version()} | lightgbm {lgb.__version__} | scikit-learn {version('scikit-learn')} | "
f"numpy {np.__version__} | pandas {pd.__version__} | odc-stac {version('odc-stac')} | "
f"rasterio {rasterio.__version__}")
# --- Step 1: samples and the shared split ------------------------------------------------------------
section("Step 1: samples and the shared spatial split")
samples = hc.load_samples()
labels = hc.label_raster()
print(f"{len(samples):,} samples: {(samples.split == 'train').sum():,} train in "
f"{samples[samples.split == 'train'].block_id.nunique()} blocks (5 folds), "
f"{(samples.split == 'test').sum():,} test in {samples[samples.split == 'test'].block_id.nunique()} blocks")
# --- Step 2: the feature stack -----------------------------------------------------------------------
section("Step 2: Sentinel-2, Sentinel-1 and terrain features")
def nd(a, b):
return (a - b) / (a + b)
S2 = hc.s2_seasonal_composites() # median reflectance per season (cached GeoTIFFs)
S1 = {s: hc.s1_composite(s, m) for s, m in hc.SEASONS.items()} # median gamma0 per season
def copernicus_dem():
"""Copernicus DEM GLO-30 on the shared 10 m grid (bilinear), cached as data/cop_dem_10m.tif."""
f = DATA / "cop_dem_10m.tif"
if not f.exists():
import odc.stac
items = hc.stac_client().search(collections=["cop-dem-glo-30"], bbox=hc.aoi_bbox_wgs84()).item_collection()
dem = odc.stac.load(items, bands=["data"], geobox=hc.geobox(), resampling="bilinear",
dtype="float32", nodata=np.nan)["data"].isel(time=0).values
hc.write_geotiff(f, dem, nodata=np.nan, dtype="float32")
print(f"Copernicus DEM: {len(items)} tiles reprojected to the 10 m grid")
with rasterio.open(f) as src:
return src.read(1)
FEAT, GROUP = {}, {}
def add(name, arr, group):
a = np.asarray(arr, dtype="float32")
a[~np.isfinite(a)] = np.nan # e.g. log10(0) or 0/0 at no-data edges
FEAT[name], GROUP[name] = a, group
BAND_GROUP = {"B02": "S2 visible", "B03": "S2 visible", "B04": "S2 visible", "B05": "S2 red-edge bands",
"B06": "S2 red-edge bands", "B07": "S2 red-edge bands", "B8A": "S2 red-edge bands", "B08": "S2 NIR",
"B11": "S2 SWIR", "B12": "S2 SWIR"}
for s, ds in S2.items():
b = {k: ds[k].values for k in hc.S2_BANDS}
for k in hc.S2_BANDS:
add(f"{k}_{s}", b[k], BAND_GROUP[k])
add(f"ndvi_{s}", nd(b["B08"], b["B04"]), "NDVI") # greenness
add(f"ndwi_{s}", nd(b["B03"], b["B08"]), "NDWI") # open water (McFeeters)
add(f"ndmi_{s}", nd(b["B08"], b["B11"]), "NDMI") # leaf and soil moisture
add(f"ndre_{s}", nd(b["B8A"], b["B05"]), "Red-edge indices") # normalised difference red-edge
add(f"cire_{s}", b["B07"] / b["B05"] - 1, "Red-edge indices") # red-edge chlorophyll index
ndvi = np.stack([FEAT[f"ndvi_{s}"] for s in hc.SEASONS])
add("ndvi_range", np.nanmax(ndvi, 0) - np.nanmin(ndvi, 0), "NDVI")
add("dndvi_spring_summer", FEAT["ndvi_summer"] - FEAT["ndvi_spring"], "NDVI")
S2_COLS = list(FEAT)
for s, ds in S1.items():
add(f"vv_{s}", ds["vv_db"].values, "S1 VV")
add(f"vh_{s}", ds["vh_db"].values, "S1 VH")
add(f"ratio_{s}", ds["ratio_db"].values, "S1 VH/VV ratio")
vh = np.stack([FEAT[f"vh_{s}"] for s in hc.SEASONS])
add("vh_range", np.nanmax(vh, 0) - np.nanmin(vh, 0), "S1 VH")
S1_COLS = [c for c in FEAT if c not in S2_COLS]
dem = copernicus_dem()
gy, gx = np.gradient(dem, hc.RES)
add("elevation", dem, "Elevation")
add("slope", np.degrees(np.arctan(np.hypot(gx, gy))), "Slope")
TERRAIN_COLS = ["elevation", "slope"]
FEATURE_SETS = {"S2": S2_COLS, "S2+S1": S2_COLS + S1_COLS, "S2+S1+terrain": S2_COLS + S1_COLS + TERRAIN_COLS}
print(f"{len(S2_COLS)} Sentinel-2 features (4 seasons x 10 bands + 5 indices, plus 2 NDVI change features)")
print(f"{len(S1_COLS)} Sentinel-1 features (4 seasons x VV, VH, VH/VV in dB, plus the VH seasonal range)")
print(f"{len(TERRAIN_COLS)} terrain features; elevation {np.nanmin(dem):.0f} to {np.nanmax(dem):.0f} m, "
f"slope median {np.nanmedian(FEAT['slope']):.1f} deg")
X_all = pd.DataFrame({k: hc.sample_values(v, samples) for k, v in FEAT.items()})
y_all = samples["class_id"].to_numpy() - 1 # LightGBM wants classes 0..8
is_train = (samples.split == "train").to_numpy()
Xtr, Xte, ytr, yte = X_all[is_train], X_all[~is_train], y_all[is_train], y_all[~is_train]
fold = samples.loc[is_train, "fold"].to_numpy()
test_blocks = samples.loc[~is_train, "block_id"].to_numpy()
print(f"sample feature table: {X_all.shape[0]:,} x {X_all.shape[1]}; share of missing values "
f"{X_all.isna().to_numpy().mean():.2%} (mostly winter Sentinel-2: LightGBM handles NaN natively)")
show = ["ndre_summer", "ndmi_spring", "vh_winter", "ratio_summer", "vv_spring", "elevation", "slope"]
med = Xtr.groupby(samples.loc[is_train, "class_key"].to_numpy())[show].median().reindex(KEYS).astype(float).round(2).T
med.columns = [SHORT[k] for k in KEYS]
print("median of selected features by class (training samples only):")
print(med.to_string())
# --- Step 3: class weights and spatial-fold tuning ----------------------------------------------------
section("Step 3: class weights and LightGBM tuning with blocked 5-fold cross-validation")
counts = np.bincount(ytr, minlength=len(KEYS))
cw = len(ytr) / (len(KEYS) * counts) # "balanced" weights: n / (classes x class count)
w_tr = cw[ytr]
print("class weights: " + ", ".join(f"{SHORT[k]} {w:.2f}" for k, w in zip(KEYS, cw)))
FOLDS = [(np.flatnonzero(fold != k), np.flatnonzero(fold == k)) for k in range(5)]
BASE = {"objective": "multiclass", "num_class": len(KEYS), "learning_rate": 0.05, "bagging_fraction": 0.8,
"bagging_freq": 1, "lambda_l2": 1.0, "metric": "multi_logloss", "verbose": -1, "seed": hc.SEED,
"deterministic": True, "force_col_wise": True, "num_threads": 8}
GRID = {"num_leaves": [15, 63], "min_data_in_leaf": [10, 40], "feature_fraction": [0.3, 0.7]}
def tune(cols):
"""Grid search scored by blocked-CV log loss; early stopping picks the number of trees."""
rows = []
for vals in itertools.product(*GRID.values()):
p = {**BASE, **dict(zip(GRID, vals))}
ds = lgb.Dataset(Xtr[cols], ytr, weight=w_tr, free_raw_data=False)
cv = lgb.cv(p, ds, num_boost_round=3000, folds=FOLDS,
callbacks=[lgb.early_stopping(100, verbose=False)])
loss = cv["valid multi_logloss-mean"]
rows.append({**dict(zip(GRID, vals)), "cv_logloss": loss[-1], "n_trees": len(loss)})
return pd.DataFrame(rows).sort_values("cv_logloss").reset_index(drop=True)
def fit(cols, params, n_trees, X=None, y=None, w=None):
X, y, w = (Xtr, ytr, w_tr) if X is None else (X, y, w)
return lgb.train({**BASE, **params}, lgb.Dataset(X[cols], y, weight=w), num_boost_round=n_trees)
def oof_accuracy(cols, params, n_trees, splits):
pred = np.empty(len(ytr), int)
for tr, va in splits:
m = fit(cols, params, n_trees, Xtr.iloc[tr], ytr[tr], w_tr[tr])
pred[va] = m.predict(Xtr.iloc[va][cols]).argmax(1)
return (pred == ytr).mean(), np.array([(pred[va] == ytr[va]).mean() for _, va in splits])
best = {}
for name, cols in FEATURE_SETS.items():
t = time.time()
res = tune(cols)
top = res.iloc[0]
params = {k: (int(top[k]) if k != "feature_fraction" else float(top[k])) for k in GRID}
oa_cv, per_fold = oof_accuracy(cols, params, int(top.n_trees), FOLDS)
best[name] = {"cols": cols, "params": params, "n_trees": int(top.n_trees), "cv_logloss": top.cv_logloss,
"cv_oa": oa_cv, "cv_oa_sd": per_fold.std()}
print(f"\n{name} ({len(cols)} features): {len(res)} settings tuned in {time.time() - t:.0f} s")
if name == "S2+S1+terrain":
print(res.round(4).to_string(index=False))
print(f"best: {params}, {int(top.n_trees)} trees; blocked-CV log loss {top.cv_logloss:.4f}, "
f"OA {oa_cv:.3f} +/- {per_fold.std():.3f}")
FULL = "S2+S1+terrain"
b = best[FULL]
oa_rand, _ = oof_accuracy(b["cols"], b["params"], b["n_trees"],
list(KFold(5, shuffle=True, random_state=hc.SEED).split(Xtr)))
print(f"\nsame model, random 5-fold CV of the training samples: OA {oa_rand:.3f} "
f"(blocked 5-fold: {b['cv_oa']:.3f})")
# --- Step 4: accuracy on the held-out test blocks ----------------------------------------------------
section("Step 4: test-block accuracy, ablation and comparison")
def summarise(y_true, y_pred):
k = len(KEYS) # unclassified predictions (-1) go to an extra column k
yp = np.where(np.asarray(y_pred) < 0, k, y_pred)
full = np.bincount(np.asarray(y_true) * (k + 1) + yp, minlength=k * (k + 1)).reshape(k, k + 1)
m, full = full[:, :k].astype(float), full.astype(float)
n = full.sum()
oa = np.trace(m) / n
pe = (m.sum(0) * full.sum(1)).sum() / n ** 2
pa = np.diag(m) / full.sum(1)
ua = np.diag(m) / np.where(m.sum(0) == 0, np.nan, m.sum(0))
f1 = np.nan_to_num(2 * pa * ua / (pa + ua))
return {"cm": m, "oa": oa, "kappa": (oa - pe) / (1 - pe), "pa": pa, "ua": ua, "f1": f1, "macro_f1": f1.mean()}
rng = np.random.default_rng(hc.SEED)
UB = np.unique(test_blocks)
BOOT = [np.concatenate([np.flatnonzero(test_blocks == bb) for bb in rng.choice(UB, UB.size, replace=True)])
for _ in range(2000)] # the same 2,000 block resamples for every model (paired)
def block_ci(correct):
return np.percentile([correct[i].mean() for i in BOOT], [2.5, 97.5])
def paired_diff(correct_a, correct_b):
d = np.array([correct_a[i].mean() - correct_b[i].mean() for i in BOOT])
return correct_a.mean() - correct_b.mean(), np.percentile(d, [2.5, 97.5]), (d <= 0).mean()
models, preds, times = {}, {}, {}
for name, bb in best.items():
t = time.time()
models[name] = fit(bb["cols"], bb["params"], bb["n_trees"])
times[name] = time.time() - t
preds[name] = models[name].predict(Xte[bb["cols"]]).argmax(1)
t = time.time()
rf = RandomForestClassifier(n_estimators=500, class_weight="balanced", n_jobs=8, random_state=hc.SEED)
rf.fit(Xtr[b["cols"]], ytr) # scikit-learn >= 1.4 forests accept NaN too
times["Random forest"] = time.time() - t
preds["Random forest"] = rf.predict(Xte[b["cols"]])
print("fit times: " + ", ".join(f"{k} {v:.1f} s" for k, v in times.items()))
# Part 1's rule-based map, sampled at the same test pixels (runs Part 1 once if its map is missing)
p1_tif = PART1 / "output" / "habitat_rules_2023.tif"
if not p1_tif.exists():
print("running Part 1 (habitat_rules.py) once to create its map ...", flush=True)
subprocess.run([sys.executable, "habitat_rules.py"], cwd=PART1, check=True, capture_output=True)
with rasterio.open(p1_tif) as src:
p1_map = src.read(1)
preds["Part 1 rules"] = hc.sample_values(p1_map, samples[~is_train]).astype(int) - 1
print(f"Part 1 map sampled at the test pixels; unclassified there: {(preds['Part 1 rules'] < 0).sum()} (scored as errors)")
ORDER = ["Part 1 rules", "Random forest", "S2", "S2+S1", "S2+S1+terrain"]
LABEL = {"Part 1 rules": "Part 1: spectral rules", "Random forest": "Random forest (S2+S1+terrain)",
"S2": "LightGBM: S2", "S2+S1": "LightGBM: S2+S1", "S2+S1+terrain": "LightGBM: S2+S1+terrain"}
R = {k: summarise(yte, preds[k]) for k in ORDER}
CORRECT = {k: preds[k] == yte for k in ORDER}
I = {k: KEYS.index(k) for k in KEYS}
rows = []
for k in ORDER:
r, m = R[k], R[k]["cm"]
lo, hi = block_ci(CORRECT[k])
rows.append({"model": LABEL[k], "n_feat": len(best[k]["cols"]) if k in best else
(len(b["cols"]) if k == "Random forest" else 8), "oa": r["oa"], "ci_lo": lo, "ci_hi": hi,
"macro_f1": r["macro_f1"], "kappa": r["kappa"],
"f1_bog": r["f1"][I["bog"]], "f1_heath": r["f1"][I["heath"]],
"f1_semin": r["f1"][I["seminat_grass"]], "f1_impro": r["f1"][I["improved_grass"]],
"bog<>heath": int(m[I["bog"], I["heath"]] + m[I["heath"], I["bog"]]),
"semin<>impro": int(m[I["seminat_grass"], I["improved_grass"]]
+ m[I["improved_grass"], I["seminat_grass"]])})
table = pd.DataFrame(rows).set_index("model")
print(f"test blocks: n = {len(yte):,} samples in {UB.size} blocks; 95% CI from 2,000 block-bootstrap resamples")
print(table.round(3).to_string())
print("\npaired block-bootstrap differences in overall accuracy (95% CI; share of resamples <= 0):")
for a, c in [("S2+S1", "S2"), ("S2+S1+terrain", "S2+S1"), ("S2+S1+terrain", "S2"),
("S2+S1+terrain", "Random forest"), ("S2", "Part 1 rules"), ("S2+S1+terrain", "Part 1 rules")]:
d, (lo, hi), p = paired_diff(CORRECT[a], CORRECT[c])
print(f" {LABEL[a]:<30} - {LABEL[c]:<30} {d:+.3f} ({lo:+.3f} to {hi:+.3f}; {p:.3f})")
R_full = R[FULL]
cm_df = pd.DataFrame(R_full["cm"].astype(int), index=[f"ref {k}" for k in KEYS], columns=[SHORT[k] for k in KEYS])
print(f"\nconfusion matrix, LightGBM {FULL} (rows = Living England, columns = model):")
print(cm_df.to_string())
per_class = pd.DataFrame({"n_test": R_full["cm"].sum(1).astype(int), "producers_acc": R_full["pa"],
"users_acc": R_full["ua"], "f1": R_full["f1"],
"f1_part1": R["Part 1 rules"]["f1"]}, index=[NAMES[k] for k in KEYS])
print(per_class.round(3).to_string())
lo, hi = block_ci(CORRECT[FULL])
print(f"overall accuracy {R_full['oa']:.3f} (block-bootstrap 95% CI {lo:.3f} to {hi:.3f}); "
f"macro F1 {R_full['macro_f1']:.3f}; kappa {R_full['kappa']:.3f}")
# --- Step 5: what the model uses: TreeSHAP and grouped permutation importance -------------------------
section("Step 5: feature importance (gain, TreeSHAP, grouped permutation)")
model, cols = models[FULL], b["cols"]
gain = pd.Series(model.feature_importance("gain"), index=cols)
gain_grp = (gain.groupby(pd.Series(GROUP)[cols]).sum() / gain.sum()).sort_values(ascending=False)
contrib = model.predict(Xte[cols], pred_contrib=True).reshape(len(Xte), len(KEYS), len(cols) + 1)[:, :, :-1]
shap_abs = pd.DataFrame(np.abs(contrib).mean(0).T, index=cols, columns=KEYS) # mean |SHAP| per class
shap_tot = shap_abs.sum(axis=1).sort_values(ascending=False)
shap_grp = shap_abs.sum(axis=1).groupby(pd.Series(GROUP)[cols]).sum()
shap_grp = (shap_grp / shap_grp.sum()).sort_values(ascending=False)
print("share of total gain and of total mean |SHAP| by feature group:")
print(pd.DataFrame({"gain": gain_grp, "shap": shap_grp}).sort_values("shap", ascending=False).round(3).to_string())
print("top 12 features by mean |SHAP| (summed over classes):")
print(shap_tot.head(12).round(3).to_string())
bh = np.isin(yte, [I["bog"], I["heath"]])
bog_vs_heath = pd.Series(np.abs(contrib[bh, I["bog"], :] - contrib[bh, I["heath"], :]).mean(0), index=cols)
print("top 8 features separating bog from heath (mean |SHAP(bog) - SHAP(heath)| on bog and heath test samples):")
print(bog_vs_heath.sort_values(ascending=False).head(8).round(3).to_string())
N_REP = 10
groups = list(dict.fromkeys(GROUP[c] for c in cols))
base_f1 = R_full["macro_f1"]
perm = {}
prng = np.random.default_rng(hc.SEED)
for g in groups:
gcols = [c for c in cols if GROUP[c] == g]
drops = []
for _ in range(N_REP):
Xp = Xte[cols].copy()
Xp[gcols] = Xp[gcols].to_numpy()[prng.permutation(len(Xp))] # shuffle the group's rows together
drops.append(base_f1 - summarise(yte, model.predict(Xp).argmax(1))["macro_f1"])
perm[g] = (np.mean(drops), np.std(drops), len(gcols))
perm = pd.DataFrame(perm, index=["drop_macro_f1", "sd", "n_features"]).T.sort_values("drop_macro_f1", ascending=False)
perm["n_features"] = perm["n_features"].astype(int)
print(f"grouped permutation importance on the test blocks (drop in macro F1, {N_REP} repeats):")
print(perm.round(3).to_string())
# --- Step 6: classify the whole study area -----------------------------------------------------------
section("Step 6: wall-to-wall habitat map")
t = time.time()
rows_, cols_ = hc.shape()
pred_map = np.zeros(hc.shape(), "uint8")
conf_map = np.full(hc.shape(), np.nan, "float32")
STEP = 250
for r0 in range(0, rows_, STEP):
Xc = np.stack([FEAT[c][r0:r0 + STEP].ravel() for c in cols], axis=1)
p = model.predict(Xc)
pred_map[r0:r0 + STEP] = (p.argmax(1) + 1).reshape(-1, cols_)
conf_map[r0:r0 + STEP] = p.max(1).reshape(-1, cols_)
nodata = np.isnan(FEAT["ndvi_summer"]) & np.isnan(FEAT["ndvi_spring"])
pred_map[nodata], conf_map[nodata] = 0, np.nan
print(f"predicted {rows_ * cols_:,} pixels in {time.time() - t:.0f} s; unclassified (no clear spring or summer "
f"view): {nodata.mean():.2%}")
hc.write_geotiff(OUT / "habitat_lightgbm_2023.tif", pred_map, nodata=0)
hc.write_geotiff(OUT / "habitat_lightgbm_confidence_2023.tif", conf_map, nodata=np.nan, dtype="float32")
area = pd.DataFrame({"lightgbm_km2": [(pred_map == KEY_ID[k]).sum() / 1e4 for k in KEYS],
"part1_km2": [(p1_map == KEY_ID[k]).sum() / 1e4 for k in KEYS],
"living_england_km2": [(labels == KEY_ID[k]).sum() / 1e4 for k in KEYS]},
index=[NAMES[k] for k in KEYS])
print(area.round(1).to_string())
ys, xs = np.mgrid[0:rows_, 0:cols_]
in_test = np.isin(hc.block_id(hc.AOI_BOUNDS[0] + (xs + 0.5) * hc.RES, hc.AOI_BOUNDS[3] - (ys + 0.5) * hc.RES), UB)
for nm, mp in (("LightGBM", pred_map), ("Part 1 rules", p1_map)):
v = (labels > 0) & (mp > 0)
print(f"wall-to-wall agreement with Living England, {nm}: test blocks {(labels == mp)[v & in_test].mean():.3f}, "
f"whole area {(labels == mp)[v].mean():.3f}")
v = (labels > 0) & (pred_map > 0)
agree = labels == pred_map
for lo_, hi_ in ((0, 0.5), (0.5, 0.8), (0.8, 1.01)):
sel = v & (conf_map >= lo_) & (conf_map < hi_)
print(f" pixels with top-class probability {lo_:.1f}-{min(hi_, 1):.1f}: {sel.sum() / v.sum():5.1%} of the map, "
f"agreement {agree[sel].mean():.3f}")
# --- Figures -----------------------------------------------------------------------------------------
ext = (hc.AOI_BOUNDS[0], hc.AOI_BOUNDS[2], hc.AOI_BOUNDS[1], hc.AOI_BOUNDS[3])
cmap, norm = hc.class_cmap()
legend = [Patch(color=COLOURS[k], label=NAMES[k]) for k in KEYS]
def km_axes(ax, ylabel=True):
ax.set_xticks(np.arange(400000, 425001, 5000))
ax.set_yticks(np.arange(380000, 400001, 5000))
ax.set_xticklabels([f"{v / 1000:.0f}" for v in ax.get_xticks()])
ax.set_yticklabels([f"{v / 1000:.0f}" for v in ax.get_yticks()])
ax.set_xlabel("Easting (km, BNG)")
if ylabel:
ax.set_ylabel("Northing (km)")
# Figure 1: Part 1 rules, LightGBM and Living England
fig, axes = plt.subplots(1, 3, figsize=(15, 5.0), sharey=True)
for ax, mp, title in zip(axes, (p1_map, pred_map, labels),
(f"Part 1: spectral rules (test OA {R['Part 1 rules']['oa']:.0%})",
f"Part 2: LightGBM, S2+S1+terrain (test OA {R_full['oa']:.0%})",
"Living England 2022-23 (reference)")):
ax.imshow(np.ma.masked_equal(mp, 0), cmap=cmap, norm=norm, extent=ext, interpolation="nearest")
ax.set_title(title, fontsize=9.5)
km_axes(ax, ylabel=ax is axes[0])
fig.legend(handles=legend, loc="lower center", ncol=9, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.05))
fig.tight_layout()
fig.savefig(OUT / "01-lightgbm-habitat-map.png")
plt.close(fig)
# Figure 2: confusion matrix
m = R_full["cm"].astype(int)
rown = m / m.sum(1, keepdims=True)
fig, ax = plt.subplots(figsize=(8.6, 7.4))
ax.imshow(rown, cmap="YlGnBu", vmin=0, vmax=1)
for i in range(len(KEYS)):
for j in range(len(KEYS)):
if m[i, j]:
ax.text(j, i, m[i, j], ha="center", va="center", fontsize=8, color="white" if rown[i, j] > 0.55 else "black")
short = [NAMES[k].replace(" woodland", "").replace(" grassland", " grass") for k in KEYS]
ax.set_xticks(range(len(KEYS)), [f"{s}\nUA {u:.0%}" for s, u in zip(short, R_full["ua"])], rotation=45, ha="right",
fontsize=7.5)
ax.set_yticks(range(len(KEYS)), [f"{s} PA {p:.0%}" for s, p in zip(short, R_full["pa"])], fontsize=7.5)
ax.set_xlabel("LightGBM, S2+S1+terrain (UA = user's accuracy)")
ax.set_ylabel("Living England reference (PA = producer's accuracy)")
ax.set_title(f"Test blocks: overall accuracy {R_full['oa']:.1%} (block-bootstrap 95% CI {lo:.1%} to {hi:.1%}), "
f"kappa {R_full['kappa']:.2f}", fontsize=9)
fig.savefig(OUT / "02-confusion-matrix.png")
plt.close(fig)
# Figure 3: ablation, per-class F1 for every model
MC = {"Part 1 rules": "#bababa", "Random forest": "#878787", "S2": "#abd9e9", "S2+S1": "#2c7bb6",
"S2+S1+terrain": "#d7191c"}
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6), gridspec_kw={"width_ratios": [4.2, 1]})
x = np.arange(len(KEYS))
wd = 0.16
for i, k in enumerate(ORDER):
axes[0].bar(x + (i - 2) * wd, R[k]["f1"], wd, color=MC[k], label=LABEL[k])
axes[0].set_xticks(x, [NAMES[k].replace(" woodland", "").replace(" grassland", "\ngrassland") for k in KEYS],
fontsize=8)
axes[0].set_ylabel("F1 score on the test blocks")
axes[0].set_ylim(0, 1.05)
axes[0].legend(fontsize=7.5, ncol=5, loc="upper center", bbox_to_anchor=(0.5, 1.13), frameon=False)
for i, k in enumerate(ORDER):
lo_, hi_ = block_ci(CORRECT[k])
axes[1].errorbar(i, R[k]["oa"], yerr=[[R[k]["oa"] - lo_], [hi_ - R[k]["oa"]]], fmt="o", color=MC[k], ms=7,
capsize=3, mec="#333333", mew=0.6)
axes[1].text(i + 0.12, R[k]["oa"], f"{R[k]['oa']:.1%}", fontsize=7.5, va="center")
axes[1].set_xticks(range(len(ORDER)), ["P1", "RF", "S2", "+S1", "+ter."], fontsize=8)
axes[1].set_xlim(-0.5, len(ORDER) + 0.1)
axes[1].set_ylabel("Overall accuracy (block-bootstrap 95% CI)")
fig.tight_layout()
fig.savefig(OUT / "03-ablation-f1-by-class.png")
plt.close(fig)
# Figure 4: TreeSHAP, top 20 features, stacked by class
top = shap_tot.head(20).index[::-1]
fig, ax = plt.subplots(figsize=(8.6, 6.4))
left = np.zeros(len(top))
for k in KEYS:
ax.barh(range(len(top)), shap_abs.loc[top, k], left=left, color=COLOURS[k], label=NAMES[k], ec="white", lw=0.3)
left += shap_abs.loc[top, k].to_numpy()
ax.set_yticks(range(len(top)), [f"{c} ({GROUP[c]})" for c in top], fontsize=7.5)
ax.set_xlabel("Mean |SHAP value| on the test samples (log-odds), stacked by class")
ax.legend(fontsize=7.5, loc="lower right", framealpha=0.95)
ax.set_title("TreeSHAP: the 20 most influential features of the S2+S1+terrain model", fontsize=9.5)
fig.savefig(OUT / "04-shap-feature-importance.png")
plt.close(fig)
# Figure 5: grouped permutation importance and share of gain
GC = {"Elevation": "#8c510a", "Slope": "#bf812d", "S1 VV": "#2c7bb6", "S1 VH": "#4575b4", "S1 VH/VV ratio": "#74add1"}
fig, axes = plt.subplots(1, 2, figsize=(12, 4.4), sharey=True)
order_g = perm.index[::-1]
colours_g = [GC.get(g, "#66bd63") for g in order_g]
axes[0].barh(range(len(order_g)), perm.loc[order_g, "drop_macro_f1"], xerr=perm.loc[order_g, "sd"],
color=colours_g, capsize=2)
axes[0].set_yticks(range(len(order_g)), [f"{g} ({perm.loc[g, 'n_features']})" for g in order_g], fontsize=8)
axes[0].set_xlabel(f"Drop in test macro F1 when the group is shuffled (mean and s.d. of {N_REP})")
axes[0].axvline(0, color="#333333", lw=0.6)
axes[1].barh(range(len(order_g)), gain_grp.reindex(order_g), color=colours_g)
axes[1].set_xlabel("Share of total split gain")
fig.legend(handles=[Patch(color="#66bd63", label="Sentinel-2"), Patch(color="#4575b4", label="Sentinel-1"),
Patch(color="#8c510a", label="Terrain")], loc="lower center", ncol=3, fontsize=8, frameon=False,
bbox_to_anchor=(0.5, -0.06))
fig.tight_layout()
fig.savefig(OUT / "05-permutation-importance.png")
plt.close(fig)
# Figure 6: radar and terrain signatures of the confusable pairs
pairs = ["bog", "heath", "seminat_grass", "improved_grass"]
feats = ["vh_winter", "ratio_summer", "vv_spring", "ndmi_spring", "elevation", "slope"]
units = {"vh_winter": "dB", "ratio_summer": "dB", "vv_spring": "dB", "ndmi_spring": "", "elevation": "m",
"slope": "degrees"}
fig, axes = plt.subplots(2, 3, figsize=(12, 6.2))
trk = samples.loc[is_train, "class_key"].to_numpy()
for ax, f in zip(axes.flat, feats):
data = [Xtr.loc[trk == k, f].dropna() for k in pairs]
bp = ax.boxplot(data, showfliers=False, patch_artist=True, widths=0.6, medianprops={"color": "black"})
for patch, k in zip(bp["boxes"], pairs):
patch.set_facecolor(COLOURS[k])
patch.set_edgecolor("#333333")
ax.set_xticks(range(1, 5), ["Blanket\nbog", "Heath", "Semi-natural\ngrass", "Improved\ngrass"], fontsize=7.5)
ax.set_title(f"{f} ({GROUP[f]})", fontsize=9)
ax.set_ylabel(units[f])
ax.axvline(2.5, color="#999999", lw=0.6, ls=":")
fig.suptitle("Training-sample distributions for the two hardest pairs (box = interquartile range, whiskers = 1.5 IQR)",
fontsize=9.5)
fig.tight_layout()
fig.savefig(OUT / "06-radar-terrain-signatures.png")
plt.close(fig)
# Figure 7: model confidence
fig, axes = plt.subplots(1, 2, figsize=(12.6, 5.0), gridspec_kw={"width_ratios": [1.15, 1]})
im = axes[0].imshow(conf_map, cmap="viridis", vmin=0.2, vmax=1, extent=ext, interpolation="nearest")
fig.colorbar(im, ax=axes[0], shrink=0.85, label="Top-class probability")
axes[0].set_title("LightGBM confidence: probability of the predicted class", fontsize=9.5)
km_axes(axes[0])
bins = np.linspace(0.1, 1, 19)
idx = np.digitize(conf_map[v], bins) - 1
acc = pd.Series(agree[v]).groupby(idx).mean()
share = pd.Series(idx).value_counts(normalize=True).sort_index()
centres = (bins[:-1] + bins[1:]) / 2
ok = acc.index[(acc.index >= 0) & (acc.index < len(centres))]
ok = ok[share.reindex(ok).to_numpy() >= 0.001] # skip near-empty bins
axes[1].bar(centres[ok], share.reindex(ok), width=0.045, color="#abd9e9", label="Share of map pixels")
ax2 = axes[1].twinx()
ax2.plot(centres[ok], acc.reindex(ok), "o-", color="#d7191c", ms=4, label="Agreement with Living England")
ax2.plot([0, 1], [0, 1], ls="--", color="#999999", lw=0.8, label="Agreement = probability")
ax2.set_ylim(0, 1)
ax2.set_ylabel("Pixel agreement with Living England", color="#d7191c")
axes[1].set_xlabel("Top-class probability")
axes[1].set_ylabel("Share of map pixels")
axes[1].set_title("Every labelled pixel in the study area", fontsize=9.5)
h1, l1 = axes[1].get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
axes[1].legend(h1 + h2, l1 + l2, fontsize=7.5, loc="upper left")
fig.tight_layout()
fig.savefig(OUT / "07-confidence-map.png")
plt.close(fig)
# --- metrics.json for the series comparison ---------------------------------------------------------
runtime = time.time() - T0
lo, hi = block_ci(CORRECT[FULL])
metrics = {"oa": round(R_full["oa"], 4), "oa_ci95": [round(lo, 4), round(hi, 4)],
"macro_f1": round(R_full["macro_f1"], 4), "kappa": round(R_full["kappa"], 4),
"per_class": {k: {"pa": round(R_full["pa"][i], 4), "ua": round(R_full["ua"][i], 4),
"f1": round(R_full["f1"][i], 4)} for i, k in enumerate(KEYS)},
"n_test": int(len(yte)), "runtime_s": round(runtime, 1),
"method": f"LightGBM {lgb.__version__} on {len(cols)} pixel features (seasonal Sentinel-2 bands and "
f"indices, seasonal Sentinel-1 VV/VH/ratio, Copernicus DEM elevation and slope), tuned with "
f"blocked 5-fold CV, balanced class weights"}
(OUT / "metrics.json").write_text(json.dumps(metrics, indent=2))
print(f"\nsaved 7 figures, habitat_lightgbm_2023.tif, the confidence GeoTIFF and metrics.json to {OUT.name}/")
print(f"total runtime {runtime / 60:.1f} min (features, tuning three feature sets, random forest, importance, "
f"map and figures)")