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.
- habitat_lightgbm.py
- requirements.txt
- console.txt: the console output recorded when we ran it
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.
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython habitat_lightgbm.pyhabitat_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 aLightGBM 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, andcompares 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 itertoolsimport jsonimport platformimport subprocessimport sysimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import matplotlib
matplotlib.use("Agg")import lightgbm as lgbimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport rasteriofrom matplotlib.patches import Patchfrom sklearn.ensemble import RandomForestClassifierfrom sklearn.model_selection import KFold
T0 = time.time()HERE = Path(__file__).resolve().parentCOMMON = 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 viewwarnings.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 indexndvi = 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..8is_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).Tmed.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 tootimes["Random forest"] = time.time() - tpreds["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) - 1print(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 classshap_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 = 10groups = 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 = 250for 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.nanprint(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_mapfor 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 Englandfig, 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 matrixm = 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 modelMC = {"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.16for 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 classtop = 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 gainGC = {"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 pairspairs = ["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 confidencefig, 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) - 1acc = pd.Series(agree[v]).groupby(idx).mean()share = pd.Series(idx).value_counts(normalize=True).sort_index()centres = (bins[:-1] + bins[1:]) / 2ok = acc.index[(acc.index >= 0) & (acc.index < len(centres))]ok = ok[share.reindex(ok).to_numpy() >= 0.001] # skip near-empty binsaxes[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() - T0lo, 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)")