"""Geospatial foundation model embeddings in practice: AlphaEarth Foundations for habitat mapping. Part 5 of the "AI habitat classification with Python" series (Dark Peak, Peak District, UK). Reads Google DeepMind's AlphaEarth Foundations 2023 annual satellite embeddings (64 dimensions, 10 m) from the public Source Cooperative mirror, classifies nine habitats with a linear classifier, and compares them with raw seasonal Sentinel-2 bands: a few-shot learning curve, uncertainty and calibration, simulated active learning, and area-adjusted accuracy with class areas and 95% confidence intervals (Olofsson et al. 2014) from a stratified random sample of the map. Run with: python alphaearth_embeddings.py """ import json import platform import sys import time from importlib.metadata import version from pathlib import Path T_START = time.perf_counter() import matplotlib # noqa: E402 matplotlib.use("Agg") import matplotlib.patheffects # noqa: E402 import matplotlib.pyplot as plt # noqa: E402 import numpy as np # noqa: E402 import pandas as pd # noqa: E402 import rasterio # noqa: E402 from lightgbm import LGBMClassifier # noqa: E402 from matplotlib.patches import Patch # noqa: E402 from sklearn.decomposition import PCA # noqa: E402 from sklearn.impute import SimpleImputer # noqa: E402 from sklearn.linear_model import LogisticRegression # noqa: E402 from sklearn.neighbors import KNeighborsClassifier # noqa: E402 from sklearn.pipeline import make_pipeline # noqa: E402 from sklearn.preprocessing import StandardScaler # noqa: E402 HERE = Path(__file__).resolve().parent for p in (HERE, HERE.parent / "ai-habitat-classification-common"): # habitat_common.py: shared series module sys.path.insert(0, str(p)) import aef # noqa: E402 (aef.py: AlphaEarth reader, next to this script) import habitat_common as hc # noqa: E402 OUT = HERE / "output" DATA = HERE / "data" OUT.mkdir(exist_ok=True) DATA.mkdir(exist_ok=True) plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9}) pd.set_option("display.width", 160) pd.set_option("display.max_columns", 20) KEYS = [c["key"] for c in hc.CLASSES] IDS = np.array([c["id"] 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} RED, ORANGE, GREEN, BLUE, PURPLE = "#d7191c", "#fdae61", "#1a9641", "#2b83ba", "#5e3c99" PART1_OA = 0.600 def section(title): print(f"\n=== {title} ===") print("=== versions ===") print(f"python {platform.python_version()} | numpy {np.__version__} | pandas {pd.__version__} | " f"scikit-learn {version('scikit-learn')} | lightgbm {version('lightgbm')} | rasterio {version('rasterio')} | " f"matplotlib {matplotlib.__version__}") # --- Step 1: AlphaEarth embeddings on the series grid ------------------------------------------------ section("Step 1: AlphaEarth Foundations 2023 embeddings from Source Cooperative") AEF_TIF = DATA / f"aef_{hc.YEAR}_bng.tif" tiles = aef.find_tiles(hc.aoi_bbox_wgs84(), hc.YEAR, DATA) print(f"{len(tiles)} COGs cover the study area:") print(aef.tile_table(tiles).to_string(index=False)) if not AEF_TIF.exists(): t0 = time.perf_counter() raw = aef.load_embeddings(tiles, hc.shape(), hc.transform(), hc.CRS) hc.write_geotiff(AEF_TIF, raw, nodata=aef.NODATA, dtype="int8") print(f"read the study-area windows and reprojected to EPSG:27700 in {time.perf_counter() - t0:.0f} s") with rasterio.open(AEF_TIF) as src: raw = src.read() # int8 (64, 2250, 2500) valid = raw[0] != aef.NODATA print(f"embedding cube {raw.shape} int8, {raw.nbytes / 1e6:.0f} MB; valid pixels {valid.mean():.2%}") samples = hc.load_samples() XE = aef.dequantise(hc.sample_values(raw, samples)) # (9000, 64) float32 in [-1, 1] norms = np.linalg.norm(XE, axis=1) print(f"sample embeddings {XE.shape}; vector length min {norms.min():.3f}, median {np.median(norms):.3f}, " f"max {norms.max():.3f} (unit vectors up to 8-bit quantisation)") # The comparison: the same samples described by raw 2023 seasonal Sentinel-2 bands (4 seasons x 10 bands) S = hc.s2_seasonal_composites() S2_COLS = [f"{b}_{s}" for s in S for b in hc.S2_BANDS] XS = np.column_stack([hc.sample_values(S[s][b].values, samples) for s in S for b in hc.S2_BANDS]) print(f"Sentinel-2 seasonal features {XS.shape}; missing values (no clear view) {np.isnan(XS).mean():.2%}") y = samples.class_id.to_numpy() is_tr = (samples.split == "train").to_numpy() is_te = ~is_tr fold = samples.fold.to_numpy() FEATS = {"AlphaEarth embeddings (64)": XE, "Sentinel-2 seasonal bands (40)": XS} def logreg(C=1.0): return make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression(C=C, max_iter=3000)) # --- Step 2: a linear classifier on all training samples ---------------------------------------------- section("Step 2: tune C with blocked 5-fold CV on the training blocks") cv_rows = [] for fname, X in FEATS.items(): for C in (0.01, 0.1, 1.0, 10.0): accs = [] for k in range(5): trk, vak = is_tr & (fold != k), is_tr & (fold == k) accs.append((logreg(C).fit(X[trk], y[trk]).predict(X[vak]) == y[vak]).mean()) cv_rows.append({"features": fname, "C": C, "cv_oa": np.mean(accs), "cv_sd": np.std(accs)}) cv_df = pd.DataFrame(cv_rows) print(cv_df.pivot(index="C", columns="features", values="cv_oa").round(3).to_string()) BEST_C = {f: cv_df[cv_df.features == f].sort_values("cv_oa").iloc[-1].C for f in FEATS} print("chosen C: " + "; ".join(f"{f}: {c:g}" for f, c in BEST_C.items())) section("Step 2b: test-block accuracy, all 6,544 training samples") rows = [] for fname, X in FEATS.items(): models = {"logistic regression": logreg(BEST_C[fname]), "k-nearest neighbours (k=10)": make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), KNeighborsClassifier(10)), "LightGBM (300 trees)": LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31, random_state=hc.SEED, verbose=-1)} for mname, m in models.items(): t0 = time.perf_counter() m.fit(X[is_tr], y[is_tr]) rows.append({"features": fname, "classifier": mname, "test_oa": (m.predict(X[is_te]) == y[is_te]).mean(), "fit_s": time.perf_counter() - t0}) full_df = pd.DataFrame(rows) print(full_df.pivot(index="classifier", columns="features", values="test_oa").round(3).to_string()) model = logreg(BEST_C["AlphaEarth embeddings (64)"]).fit(XE[is_tr], y[is_tr]) proba_te = model.predict_proba(XE[is_te]) yp = model.classes_[proba_te.argmax(1)] yt = y[is_te] def confusion(y_true, y_pred): return pd.crosstab(pd.Categorical(y_true, IDS), pd.Categorical(y_pred, IDS), dropna=False).to_numpy() def summarise(m): m = m.astype(float) n = m.sum() oa = np.trace(m) / n pe = (m.sum(0) * m.sum(1)).sum() / n ** 2 pa = np.diag(m) / m.sum(1) ua = np.diag(m) / np.where(m.sum(0) == 0, np.nan, m.sum(0)) return {"oa": oa, "kappa": (oa - pe) / (1 - pe), "pa": pa, "ua": ua, "f1": 2 * pa * ua / (pa + ua)} cm = confusion(yt, yp) res = summarise(cm) rng = np.random.default_rng(hc.SEED) tb = samples.block_id.to_numpy()[is_te] ub = np.unique(tb) correct = yt == yp boot = [] for _ in range(2000): # resample whole test blocks, as in Part 1 pick = rng.choice(ub, ub.size, replace=True) idx = np.concatenate([np.flatnonzero(tb == b) for b in pick]) boot.append(correct[idx].mean()) lo, hi = np.percentile(boot, [2.5, 97.5]) cm_show = pd.DataFrame(cm, index=[f"ref {k}" for k in KEYS], columns=[k.split("_")[0][:5] for k in KEYS]) print("confusion matrix (rows = Living England reference, columns = AlphaEarth + logistic regression):") print(cm_show.to_string()) per_class = pd.DataFrame({"n_test": cm.sum(1), "producers_acc": res["pa"], "users_acc": res["ua"], "f1": res["f1"]}, index=[NAMES[k] for k in KEYS]) print(per_class.round(3).to_string()) print(f"overall accuracy {res['oa']:.3f} (n = {len(yt):,}); block-bootstrap 95% CI {lo:.3f} to {hi:.3f}") print(f"macro F1 {np.nanmean(res['f1']):.3f}; Cohen's kappa {res['kappa']:.3f}; Part 1 rules: OA {PART1_OA:.3f}") # --- Step 3: wall-to-wall map and per-pixel probabilities ------------------------------------------ section("Step 3: classify every pixel") rows_, cols_ = hc.shape() pred = np.zeros((rows_, cols_), np.uint8) pmax = np.full((rows_, cols_), np.nan, np.float32) margin = np.full((rows_, cols_), np.nan, np.float32) entropy = np.full((rows_, cols_), np.nan, np.float32) t0 = time.perf_counter() for r0 in range(0, rows_, 250): blk = raw[:, r0:r0 + 250, :] X = aef.dequantise(blk.reshape(64, -1).T) ok = blk[0].ravel() != aef.NODATA P = np.zeros((X.shape[0], len(IDS)), np.float32) P[ok] = model.predict_proba(X[ok]) srt = np.sort(P, axis=1) with np.errstate(divide="ignore", invalid="ignore"): H = -(P * np.log(np.where(P > 0, P, 1))).sum(1) / np.log(len(IDS)) sl = slice(r0, r0 + blk.shape[1]) pred[sl] = np.where(ok, model.classes_[P.argmax(1)], 0).reshape(-1, cols_) pmax[sl] = np.where(ok, srt[:, -1], np.nan).reshape(-1, cols_) margin[sl] = np.where(ok, srt[:, -1] - srt[:, -2], np.nan).reshape(-1, cols_) entropy[sl] = np.where(ok, H, np.nan).reshape(-1, cols_) print(f"predicted {valid.sum():,} pixels in {time.perf_counter() - t0:.0f} s") hc.write_geotiff(OUT / "habitat_alphaearth_2023.tif", pred, nodata=0) hc.write_geotiff(OUT / "margin_alphaearth_2023.tif", margin.astype("float32"), nodata=np.nan, dtype="float32") labels = hc.label_raster() xc = hc.AOI_BOUNDS[0] + (np.arange(cols_) + 0.5) * hc.RES yc = hc.AOI_BOUNDS[3] - (np.arange(rows_) + 0.5) * hc.RES blk_split = hc.load_blocks().set_index("block_id").split pix_block = hc.block_id(*np.meshgrid(xc, yc)) in_test = np.isin(pix_block, blk_split[blk_split == "test"].index.to_numpy()) both = (labels > 0) & (pred > 0) agree_all = (labels[both] == pred[both]).mean() agree_te = (labels[both & in_test] == pred[both & in_test]).mean() agree_tr = (labels[both & ~in_test] == pred[both & ~in_test]).mean() print(f"wall-to-wall agreement with Living England: whole area {agree_all:.3f}; " f"test blocks {agree_te:.3f}; training blocks {agree_tr:.3f} (Part 1 rules: 0.490 and 0.483)") print(f"mean entropy {np.nanmean(entropy):.3f}; pixels with margin < 0.2: {np.nanmean(margin < 0.2):.1%}") # --- Step 4: few-shot learning curve ----------------------------------------------------------------- section("Step 4: few-shot learning curve (labels per class drawn from the training blocks)") SHOTS = [5, 10, 25, 50, 100, 250] REPEATS = 20 tr_idx_by_class = {c: np.flatnonzero(is_tr & (y == c)) for c in IDS} fs_rows = [] for fname, X in FEATS.items(): for n in SHOTS + ["all"]: for rep in range(REPEATS if n != "all" else 1): g = np.random.default_rng(10_000 + rep) if n == "all": sel = np.flatnonzero(is_tr) else: sel = np.concatenate([g.choice(tr_idx_by_class[c], n, replace=False) for c in IDS]) for mname, m in (("logistic", logreg(1.0)), ("kNN", make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), KNeighborsClassifier(min(5, len(sel) // 9))))): m.fit(X[sel], y[sel]) fs_rows.append({"features": fname, "classifier": mname, "per_class": n, "rep": rep, "test_oa": (m.predict(X[is_te]) == y[is_te]).mean()}) fs = pd.DataFrame(fs_rows) fs_mean = fs.groupby(["classifier", "per_class", "features"], sort=False).test_oa.mean().unstack(2) fs_sd = fs[fs.per_class == 5].groupby(["classifier", "features"], sort=False).test_oa.std() for mname, label in (("logistic", "logistic regression"), ("kNN", "k-nearest neighbours (k=5)")): print(f"test OA, {label}, mean of {REPEATS} random draws ('all' = every training sample, about 727 per class):") print(fs_mean.loc[mname].round(3).to_string()) print("s.d. across draws with 5 labels per class: " + "; ".join(f"{m} / {f.split(' (')[0]} {v:.3f}" for (m, f), v in fs_sd.items())) lr = fs[fs.classifier == "logistic"].groupby(["features", "per_class"], sort=False).test_oa.mean().unstack(0) e_col, s_col = list(FEATS) match = next((n for n in SHOTS if lr.loc[n, e_col] >= lr.loc["all", s_col]), SHOTS[-1]) print(f"AlphaEarth + logistic regression with {match} labels per class ({lr.loc[match, e_col]:.3f}) already matches " f"Sentinel-2 bands with all {is_tr.sum():,} training labels ({lr.loc['all', s_col]:.3f})") # --- Step 5: uncertainty and calibration ------------------------------------------------------------- section("Step 5: uncertainty and calibration") conf_te = proba_te.max(1) srt = np.sort(proba_te, 1) margin_te = srt[:, -1] - srt[:, -2] q = pd.qcut(margin_te, 5, labels=["lowest", "low", "middle", "high", "highest"]) print("test samples by margin quintile (margin = top probability - second):") print(pd.DataFrame({"margin": margin_te, "error": ~correct}).groupby(q, observed=True) .agg(margin_from=("margin", "min"), margin_to=("margin", "max"), error_rate=("error", "mean"), n=("error", "size")) .round(3).to_string()) def reliability(conf, ok, w=None, bins=np.linspace(0, 1, 11), min_n=25): """Mean confidence and accuracy per confidence bin (optionally weighted); bins with < min_n samples dropped.""" w = np.ones(len(conf)) if w is None else w b = np.clip(np.digitize(conf, bins) - 1, 0, len(bins) - 2) df = pd.DataFrame({"b": b, "cw": conf * w, "okw": ok * w, "w": w}) df = df.groupby("b").agg(cw=("cw", "sum"), okw=("okw", "sum"), w=("w", "sum"), n=("w", "size")) df["conf"], df["acc"] = df.cw / df.w, df.okw / df.w ece = (df.w * (df.conf - df.acc).abs()).sum() / df.w.sum() return df[df.n >= min_n], ece rel_te, ece_te = reliability(conf_te, correct) print(f"expected calibration error on the pure test samples: {ece_te:.3f}") # --- Step 6: simulated active learning --------------------------------------------------------------- section("Step 6: active learning, uncertainty (margin) sampling vs random sampling") pool = np.flatnonzero(is_tr) # the training blocks are the unlabelled pool scaler = StandardScaler().fit(XE[pool]) # unlabelled pool: scaling needs no labels Zp, Zte = scaler.transform(XE[pool]), scaler.transform(XE[is_te]) yp_pool = y[pool] START, BATCH, ROUNDS, AL_REPEATS = 2, 18, 20, 10 al_rows = [] for rep in range(AL_REPEATS): g0 = np.random.default_rng(20_000 + rep) seed_idx = np.concatenate([g0.choice(np.flatnonzero(yp_pool == c), START, replace=False) for c in IDS]) for strategy in ("random", "uncertainty"): g = np.random.default_rng(30_000 + rep) lab = list(seed_idx) for rnd in range(ROUNDS + 1): m = LogisticRegression(C=1.0, max_iter=3000).fit(Zp[lab], yp_pool[lab]) al_rows.append({"strategy": strategy, "rep": rep, "n_labels": len(lab), "test_oa": (m.predict(Zte) == y[is_te]).mean(), "classes_seen": len(np.unique(yp_pool[lab]))}) if rnd == ROUNDS: break rest = np.setdiff1d(np.arange(len(pool)), lab) if strategy == "random": new = g.choice(rest, BATCH, replace=False) else: P = np.sort(m.predict_proba(Zp[rest]), 1) new = rest[np.argsort(P[:, -1] - P[:, -2])[:BATCH]] # smallest margin first lab.extend(new.tolist()) al = pd.DataFrame(al_rows) al_sum = al.groupby(["n_labels", "strategy"]).test_oa.agg(["mean", "std"]).unstack(1) print(f"test OA by number of labels (mean and s.d. over {AL_REPEATS} runs; start {START} per class, " f"batches of {BATCH}):") print(al_sum.iloc[::4].round(3).to_string()) al_mean = al.groupby(["strategy", "n_labels"]).test_oa.mean().unstack(0) target = al_mean.loc[al_mean.index.max(), "random"] n_unc = al_mean.index[al_mean.uncertainty >= target].min() print(f"random sampling reaches {target:.3f} with {al_mean.index.max()} labels; uncertainty sampling reaches it with " f"{n_unc} labels") # --- Step 7: area-adjusted accuracy and class areas (Olofsson et al. 2014) -------------------------- section("Step 7: stratified random sample of the map, area-adjusted accuracy and areas") popn = (labels > 0) & (pred > 0) # Living England labels every pixel except fen and the edges mapped = np.array([(pred[popn] == c).sum() for c in IDS], float) W = mapped / mapped.sum() N_TOTAL, N_MIN = 2000, 100 n_h = np.maximum(N_MIN, np.round(W * N_TOTAL)).astype(int) g = np.random.default_rng(hc.SEED) flat_pred = np.where(popn, pred, 0).ravel() pts = [] for c, n in zip(IDS, n_h): pts.append(g.choice(np.flatnonzero(flat_pred == c), n, replace=False)) pts = np.concatenate(pts) s_map = flat_pred[pts] s_ref = labels.ravel()[pts] s_te = in_test.ravel()[pts] print(f"population {popn.sum():,} pixels ({popn.sum() * 0.01:,.0f} ha); stratified sample n = {len(pts):,} " f"(at least {N_MIN} per mapped class, the rest proportional to mapped area; seed {hc.SEED})") print("reference = Living England 2022-23 at each sampled pixel, any reliability, no purity filter") def olofsson(map_cls, ref_cls, mapped_px, px_ha=0.01): """Stratified estimators of Olofsson et al. (2014): strata = map classes, weights = mapped area shares. Returns overall accuracy and its standard error, and per class: user's and producer's accuracy, estimated area (ha) and their standard errors. Valid only if the sample is a stratified random sample of the map. """ nij = pd.crosstab(pd.Categorical(map_cls, IDS), pd.Categorical(ref_cls, IDS), dropna=False).to_numpy().astype(float) ni = nij.sum(1) W = mapped_px / mapped_px.sum() p = W[:, None] * nij / ni[:, None] # estimated area proportions (map i, reference j) pj = p.sum(0) ua = np.diag(nij) / ni se_ua = np.sqrt(ua * (1 - ua) / (ni - 1)) se_oa = np.sqrt((W ** 2 * ua * (1 - ua) / (ni - 1)).sum()) se_pj = np.sqrt((W[:, None] ** 2 * (nij / ni[:, None]) * (1 - nij / ni[:, None]) / (ni[:, None] - 1)).sum(0)) pa = np.diag(p) / pj Nj = (mapped_px[:, None] * nij / ni[:, None]).sum(0) # estimated reference pixels of each class se_pa = np.empty(len(IDS)) for j in range(len(IDS)): others = sum(mapped_px[i] ** 2 * nij[i, j] / ni[i] * (1 - nij[i, j] / ni[i]) / (ni[i] - 1) for i in range(len(IDS)) if i != j) se_pa[j] = np.sqrt((mapped_px[j] ** 2 * (1 - pa[j]) ** 2 * ua[j] * (1 - ua[j]) / (ni[j] - 1) + pa[j] ** 2 * others) / Nj[j] ** 2) A = mapped_px.sum() * px_ha return {"oa": np.trace(p), "se_oa": se_oa, "ua": ua, "se_ua": se_ua, "pa": pa, "se_pa": se_pa, "area_ha": pj * A, "se_area_ha": se_pj * A} est = olofsson(s_map, s_ref, mapped) oa, se_oa = est["oa"], est["se_oa"] census = np.array([(labels[popn] == c).sum() for c in IDS]) * 0.01 area = pd.DataFrame({ "n_sample": n_h, "mapped_ha": mapped * 0.01, "adjusted_ha": est["area_ha"], "ci95_ha": 1.96 * est["se_area_ha"], "living_england_ha": census, "users_acc": est["ua"], "ua_ci95": 1.96 * est["se_ua"], "producers_acc": est["pa"], "pa_ci95": 1.96 * est["se_pa"]}, index=[NAMES[k] for k in KEYS]) area["le_inside_ci"] = (area.living_england_ha - area.adjusted_ha).abs() <= area.ci95_ha print("areas (ha) and the Living England census of the same population:") print(area[["n_sample", "mapped_ha", "adjusted_ha", "ci95_ha", "living_england_ha", "le_inside_ci"]].round(0) .astype({"mapped_ha": int, "adjusted_ha": int, "ci95_ha": int, "living_england_ha": int}) .rename(columns={"living_england_ha": "le_ha", "le_inside_ci": "le_in_ci"}).to_string()) print("area-adjusted user's and producer's accuracy with 95% CI half-widths:") print(area[["users_acc", "ua_ci95", "producers_acc", "pa_ci95"]].rename(columns={"producers_acc": "prod_acc"}) .round(3).to_string()) print(f"area-adjusted overall accuracy {oa:.3f} +/- {1.96 * se_oa:.3f} (95% CI); " f"compare {res['oa']:.3f} on the balanced, pure test samples") print(f"sample points in test blocks: {s_te.sum()} (agreement {(s_map == s_ref)[s_te].mean():.3f}); " f"in training blocks: {(~s_te).sum()} (agreement {(s_map == s_ref)[~s_te].mean():.3f})") bog = area.loc[NAMES["bog"]] print(f"blanket bog: mapped {bog.mapped_ha:,.0f} ha; adjusted {bog.adjusted_ha:,.0f} ha " f"(95% CI {bog.adjusted_ha - bog.ci95_ha:,.0f} to {bog.adjusted_ha + bog.ci95_ha:,.0f}); " f"Living England census {bog.living_england_ha:,.0f} ha") print(f"Living England census inside the 95% CI for {area.le_inside_ci.sum()} of {len(area)} classes") # What goes wrong if the balanced, pure test samples are treated as if they were a stratified map sample bad = olofsson(yp, yt, mapped) bog_i = KEYS.index("bog") print(f"misuse check, the 2,456 pure test samples plugged into the same estimator: 'area-adjusted' OA " f"{bad['oa']:.3f}; blanket bog {bad['area_ha'][bog_i]:,.0f} +/- {1.96 * bad['se_area_ha'][bog_i]:,.0f} ha") # map-sample calibration: the same probabilities on the stratified sample, weighted by inclusion probability conf_map = pmax.ravel()[pts] w_map = (mapped / n_h)[np.searchsorted(IDS, s_map)] # each sampled pixel stands for this many map pixels rel_map, ece_map = reliability(conf_map, s_map == s_ref, w=w_map) print(f"expected calibration error on the stratified map sample (area-weighted): {ece_map:.3f} " f"(pure test samples: {ece_te:.3f})") print(f"area-weighted mean confidence on the map {np.average(conf_map, weights=w_map):.3f} vs area-adjusted " f"overall accuracy {oa:.3f}") # --- metrics.json for the series comparison ---------------------------------------------------------- runtime = time.perf_counter() - T_START metrics = { "oa": round(float(res["oa"]), 4), "oa_ci95": [round(float(lo), 4), round(float(hi), 4)], "macro_f1": round(float(np.nanmean(res["f1"])), 4), "kappa": round(float(res["kappa"]), 4), "per_class": {k: {"pa": round(float(res["pa"][i]), 4), "ua": round(float(res["ua"][i]), 4), "f1": round(float(res["f1"][i]), 4)} for i, k in enumerate(KEYS)}, "n_test": int(len(yt)), "runtime_s": round(runtime, 1), "method": f"AlphaEarth Foundations 2023 embeddings (64-d) + logistic regression (C={BEST_C[e_col]:g})", "wall_to_wall_agreement": round(float(agree_all), 4), "wall_to_wall_agreement_test_blocks": round(float(agree_te), 4), "area_adjusted_oa": round(float(oa), 4), "area_adjusted_oa_ci95": round(float(1.96 * se_oa), 4), "bog_area_ha": {"mapped": round(float(bog.mapped_ha)), "adjusted": round(float(bog.adjusted_ha)), "ci95": round(float(bog.ci95_ha)), "living_england": round(float(bog.living_england_ha))}, } (OUT / "metrics.json").write_text(json.dumps(metrics, indent=2)) # --- 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] PLACES = {"Kinder Scout": (408600, 388400), "Bleaklow": (409800, 395800), "Edale": (412300, 385700), "Castleton": (415000, 382700), "Glossop": (403300, 394000), "Ladybower Reservoir": (420000, 386800), "Derwent Valley": (417700, 393000), "Hathersage": (423200, 381400)} def km_axes(ax): 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, British National Grid)") ax.set_ylabel("Northing (km)") def place_labels(ax, colour="white"): for name, (x, y_) in PLACES.items(): ax.plot(x, y_, "o", ms=3, color=colour, mec="black", mew=0.5) ax.annotate(name, (x, y_), xytext=(4, 3), textcoords="offset points", fontsize=7.5, color=colour, fontweight="bold", path_effects=[matplotlib.patheffects.withStroke(linewidth=2, foreground="black")]) # Figure 1: Sentinel-2 true colour and the first three principal components of the embeddings rgb = np.dstack([S["summer"][b].values for b in ("B04", "B03", "B02")]) rgb = np.nan_to_num(np.clip(rgb / np.nanpercentile(rgb, 99), 0, 1) ** 0.8, nan=1.0) gpix = np.random.default_rng(hc.SEED).choice(np.flatnonzero(valid.ravel()), 200_000, replace=False) pca = PCA(3, random_state=hc.SEED).fit(aef.dequantise(raw.reshape(64, -1)[:, gpix].T)) pcs = np.zeros((rows_, cols_, 3), np.float32) for r0 in range(0, rows_, 250): blk = raw[:, r0:r0 + 250, :] pcs[r0:r0 + blk.shape[1]] = pca.transform(aef.dequantise(blk.reshape(64, -1).T)).reshape(blk.shape[1], cols_, 3) lo_p, hi_p = np.percentile(pcs.reshape(-1, 3), [2, 98], axis=0) pcs = np.clip((pcs - lo_p) / (hi_p - lo_p), 0, 1) print(f"\nPCA of the embeddings: first three components explain " f"{pca.explained_variance_ratio_.sum():.0%} of the variance ({', '.join(f'{v:.0%}' for v in pca.explained_variance_ratio_)})") fig, axes = plt.subplots(1, 2, figsize=(13, 5.6), sharey=True) axes[0].imshow(rgb, extent=ext) axes[0].set_title("Sentinel-2 summer 2023 median (true colour)") axes[1].imshow(pcs, extent=ext, interpolation="nearest") axes[1].set_title(f"AlphaEarth 2023 embeddings: principal components 1-3 as RGB " f"({pca.explained_variance_ratio_.sum():.0%} of variance)") for ax in axes: km_axes(ax) place_labels(ax) axes[1].set_ylabel("") fig.savefig(OUT / "01-embeddings-pca-rgb.png") plt.close(fig) # Figure 2: few-shot learning curve fig, ax = plt.subplots(figsize=(8.6, 5)) xs = SHOTS + [int(is_tr.sum() / 9)] styles = {(e_col, "logistic"): (BLUE, "-", "o"), (e_col, "kNN"): (BLUE, "--", "s"), (s_col, "logistic"): (RED, "-", "o"), (s_col, "kNN"): (RED, "--", "s")} for (fname, mname), (col, ls, mk) in styles.items(): d = fs[(fs.features == fname) & (fs.classifier == mname)].groupby("per_class", sort=False).test_oa mu, lo10, hi90 = d.mean().to_numpy(), d.quantile(0.1).to_numpy(), d.quantile(0.9).to_numpy() lbl = f"{fname.split(' (')[0]} + {'logistic regression' if mname == 'logistic' else 'kNN (k = 5)'}" ax.plot(xs, mu, ls=ls, marker=mk, color=col, label=lbl, lw=1.6, ms=4) ax.fill_between(xs, lo10, hi90, color=col, alpha=0.12, lw=0) ax.axhline(PART1_OA, color="#555555", lw=1, ls=":") ax.text(5, PART1_OA + 0.008, "Part 1 spectral rules (0.600)", fontsize=8, color="#555555") ax.set_xscale("log") ax.set_xticks(xs, [str(v) for v in SHOTS] + [f"all\n(~{xs[-1]})"]) ax.minorticks_off() ax.set_xlabel("Training labels per class (drawn from the training blocks)") ax.set_ylabel("Overall accuracy on the 2,456 test samples") ax.set_ylim(0.3, 0.95) ax.grid(alpha=0.3) ax.legend(loc="lower right", fontsize=8) ax.set_title(f"Few-shot learning curve: mean of {REPEATS} random draws, shading = 10th to 90th percentile") fig.savefig(OUT / "02-few-shot-learning-curve.png") plt.close(fig) # Figure 3: wall-to-wall map against Living England fig, axes = plt.subplots(1, 2, figsize=(13, 5.6), sharey=True) axes[0].imshow(np.ma.masked_equal(pred, 0), cmap=cmap, norm=norm, extent=ext, interpolation="nearest") axes[0].set_title(f"AlphaEarth embeddings + logistic regression (test OA {res['oa']:.1%})") axes[1].imshow(np.ma.masked_equal(labels, 0), cmap=cmap, norm=norm, extent=ext, interpolation="nearest") axes[1].set_title(f"Living England 2022-23 (pixel agreement {agree_all:.0%})") for ax in axes: km_axes(ax) axes[1].set_ylabel("") fig.legend(handles=legend, loc="lower center", ncol=9, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.04)) fig.savefig(OUT / "03-alphaearth-map-vs-living-england.png") plt.close(fig) # Figure 4: confusion matrix rown = cm / cm.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 cm[i, j]: ax.text(j, i, cm[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, res["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, res["pa"])], fontsize=7.5) ax.set_xlabel("AlphaEarth + logistic regression (UA = user's accuracy)") ax.set_ylabel("Living England reference (PA = producer's accuracy)") ax.set_title(f"Test blocks: overall accuracy {res['oa']:.1%} (block-bootstrap 95% CI {lo:.1%} to {hi:.1%}), " f"kappa {res['kappa']:.2f}", fontsize=9) fig.savefig(OUT / "04-confusion-matrix.png") plt.close(fig) # Figure 5: uncertainty map and reliability diagram fig, axes = plt.subplots(1, 2, figsize=(13, 5.4), gridspec_kw={"width_ratios": [1.25, 1]}) im = axes[0].imshow(margin, cmap="Spectral", vmin=0, vmax=1, extent=ext, interpolation="nearest") km_axes(axes[0]) place_labels(axes[0], colour="white") axes[0].set_title("Uncertainty: margin between the two most likely classes (red = unsure)") fig.colorbar(im, ax=axes[0], shrink=0.8, label="Probability margin (top class - runner-up)") ax = axes[1] ax.plot([0, 1], [0, 1], color="#888888", lw=1, ls=":", label="Perfect calibration") ax.plot(rel_te.conf, rel_te.acc, "o-", color=BLUE, label=f"Pure test samples (ECE {ece_te:.3f})") ax.plot(rel_map.conf, rel_map.acc, "s-", color=RED, label=f"Stratified map sample, area-weighted (ECE {ece_map:.3f})") ax.set_xlabel("Predicted probability of the chosen class (binned)") ax.set_ylabel("Share of samples that agree with Living England") ax.set_xlim(0, 1.02) ax.set_ylim(0, 1.02) ax.set_aspect("equal") ax.grid(alpha=0.3) ax.legend(loc="upper left", fontsize=8) ax.set_title("Reliability diagram") fig.tight_layout() fig.savefig(OUT / "05-uncertainty-reliability.png") plt.close(fig) # Figure 6: active learning curve fig, ax = plt.subplots(figsize=(8.6, 4.8)) for strategy, col, lbl in (("random", RED, "Random sampling"), ("uncertainty", BLUE, "Uncertainty (margin) sampling")): d = al[al.strategy == strategy].groupby("n_labels").test_oa ax.plot(d.mean().index, d.mean(), "o-", color=col, ms=3.5, lw=1.6, label=lbl) ax.fill_between(d.mean().index, d.min(), d.max(), color=col, alpha=0.12, lw=0) ax.axhline(lr.loc["all", e_col], color=BLUE, lw=1, ls=":") ax.text(al.n_labels.max(), lr.loc["all", e_col] - 0.014, f"all {is_tr.sum():,} training labels ({lr.loc['all', e_col]:.3f})", ha="right", fontsize=8, color=BLUE) ax.set_xlabel("Labelled training samples (start: 2 per class, then batches of 18 from the training-block pool)") ax.set_ylabel("Overall accuracy on the test samples") ax.grid(alpha=0.3) ax.legend(loc="lower right", fontsize=8) ax.set_title(f"Simulated active learning with AlphaEarth + logistic regression: mean of {AL_REPEATS} runs, " "shading = min to max", fontsize=9) fig.savefig(OUT / "06-active-learning.png") plt.close(fig) # Figure 7: class areas with 95% confidence intervals fig, ax = plt.subplots(figsize=(9.4, 5)) pos = np.arange(len(KEYS)) ax.bar(pos - 0.27, area.mapped_ha / 1000, 0.27, color=ORANGE, label="Pixel counting (mapped area)") ax.bar(pos, area.adjusted_ha / 1000, 0.27, color=BLUE, yerr=area.ci95_ha / 1000, capsize=3, label="Area-adjusted estimate with 95% CI") ax.bar(pos + 0.27, area.living_england_ha / 1000, 0.27, color="#999999", label="Living England census") ax.set_xticks(pos, [NAMES[k].replace(" woodland", "\nwoodland").replace(" grassland", "\ngrassland") .replace(" and bare", "\nand bare") for k in KEYS], fontsize=8) ax.set_ylabel("Area (thousand hectares)") ax.grid(axis="y", alpha=0.3) ax.legend(fontsize=8) ax.set_title(f"Habitat areas from a stratified random sample of {len(pts):,} map pixels (Olofsson et al. 2014)", fontsize=9) fig.savefig(OUT / "07-area-estimates.png") plt.close(fig) print(f"\nsaved 7 figures, habitat_alphaearth_2023.tif, margin_alphaearth_2023.tif and metrics.json to {OUT.name}/") print(f"total runtime {time.perf_counter() - T_START:.0f} s")