Source code: AlphaEarth satellite embeddings in Python: few-shot habitat mapping, uncertainty and area estimates
Runnable Python scripts, requirements and console output for the tutorial “AlphaEarth satellite embeddings in Python: few-shot habitat mapping…
Last updated
These are the exact scripts used to produce every figure and number in AlphaEarth satellite embeddings in Python: few-shot habitat mapping, uncertainty and area estimates. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- aef.py
- alphaearth_embeddings.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 aef.pyaef.py
"""Read AlphaEarth Foundations annual satellite embeddings for the series grid from Source Cooperative.
The public COG mirror (s3://us-west-2.opendata.source.coop/tge-labs/aef/, HTTPS at data.source.coop) needs nologin. Each COG is 8192 x 8192 pixels in a UTM zone, 64 int8 bands (A00-A63), stored "bottom-up" (positivey pixel size). We read just the window over the study area, flip it to the usual top-down order, reprojectit onto the shared 10 m British National Grid with nearest-neighbour resampling and cache it as one GeoTIFF."""from pathlib import Path
import numpy as npimport pandas as pdimport pyarrow.parquet as pqimport rasterioimport requestsfrom affine import Affinefrom pyproj import Transformerfrom rasterio.warp import Resampling, reprojectfrom rasterio.windows import Window
BASE = "https://data.source.coop/tge-labs/aef/v1/annual"INDEX_URL = f"{BASE}/aef_index.parquet"NODATA = -128GDAL_OPTS = {"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", "GDAL_HTTP_MULTIRANGE": "YES", "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES", "GDAL_HTTP_MAX_RETRY": "5", "GDAL_HTTP_RETRY_DELAY": "2", "VSI_CACHE": "TRUE", "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tiff"}
def dequantise(raw): """int8 -> float32 in [-1, 1]: (v / 127.5)^2 * sign(v); -128 (no data) -> NaN.""" v = raw.astype(np.float32) out = (v / 127.5) ** 2 * np.sign(v) out[raw == NODATA] = np.nan return out
def find_tiles(bbox_wgs84, year, cache_dir): """Rows of the AEF index (one per COG) that intersect a lon/lat box for one year.""" f = Path(cache_dir) / "aef_index.parquet" if not f.exists(): f.parent.mkdir(parents=True, exist_ok=True) with requests.get(INDEX_URL, stream=True, timeout=300) as r: # 78 MB, downloaded once r.raise_for_status() with open(f, "wb") as fh: for chunk in r.iter_content(1 << 20): fh.write(chunk) cols = ["path", "crs", "year", "utm_zone", "utm_west", "utm_south", "utm_east", "utm_north", "wgs84_west", "wgs84_south", "wgs84_east", "wgs84_north"] df = pq.read_table(f, columns=cols, filters=[("year", "=", year)]).to_pandas() w, s, e, n = bbox_wgs84 hit = (df.wgs84_west < e) & (df.wgs84_east > w) & (df.wgs84_south < n) & (df.wgs84_north > s) df = df[hit].copy() df["url"] = df.path.str.replace("s3://us-west-2.opendata.source.coop/tge-labs/aef/v1/annual", BASE, regex=False) return df.reset_index(drop=True)
def load_embeddings(tiles, dst_shape, dst_transform, dst_crs, margin=200): """Mosaic the AEF tiles onto a target grid. Returns int8 (64, rows, cols), NODATA where not covered.""" rows, cols = dst_shape out = np.full((64, rows, cols), NODATA, dtype=np.int8) x0, y1 = dst_transform * (0, 0) x1, y0 = dst_transform * (cols, rows) with rasterio.Env(**GDAL_OPTS): for t in tiles.itertuples(): to_utm = Transformer.from_crs(dst_crs, t.crs, always_xy=True) xs, ys = to_utm.transform([x0, x1, x0, x1], [y0, y0, y1, y1]) west, east = max(min(xs) - margin, t.utm_west), min(max(xs) + margin, t.utm_east) south, north = max(min(ys) - margin, t.utm_south), min(max(ys) + margin, t.utm_north) if west >= east or south >= north: continue with rasterio.open(f"/vsicurl/{t.url}") as src: tr = src.transform # bottom-up: origin at the south-west corner, +10 m in y c0, c1 = int((west - tr.c) // tr.a), int(np.ceil((east - tr.c) / tr.a)) r0, r1 = int((south - tr.f) // tr.e), int(np.ceil((north - tr.f) / tr.e)) win = Window(c0, r0, c1 - c0, r1 - r0) raw = src.read(window=win)[:, ::-1, :] # flip rows so that row 0 is the northern edge src_tr = Affine(tr.a, 0, tr.c + c0 * tr.a, 0, -tr.e, tr.f + r1 * tr.e) part = np.full_like(out, NODATA) reproject(raw, part, src_transform=src_tr, src_crs=t.crs, src_nodata=NODATA, dst_transform=dst_transform, dst_crs=dst_crs, dst_nodata=NODATA, resampling=Resampling.nearest) fill = (out[0] == NODATA) & (part[0] != NODATA) out[:, fill] = part[:, fill] return out
def tile_table(tiles): return pd.DataFrame({"file": tiles.url.str.split("/").str[-1], "zone": tiles.utm_zone, "utm_west": tiles.utm_west, "utm_east": tiles.utm_east})alphaearth_embeddings.py
"""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 thepublic Source Cooperative mirror, classifies nine habitats with a linear classifier, and compares them withraw seasonal Sentinel-2 bands: a few-shot learning curve, uncertainty and calibration, simulated activelearning, 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 jsonimport platformimport sysimport timefrom importlib.metadata import versionfrom pathlib import Path
T_START = time.perf_counter()
import matplotlib # noqa: E402
matplotlib.use("Agg")import matplotlib.patheffects # noqa: E402import matplotlib.pyplot as plt # noqa: E402import numpy as np # noqa: E402import pandas as pd # noqa: E402import rasterio # noqa: E402from lightgbm import LGBMClassifier # noqa: E402from matplotlib.patches import Patch # noqa: E402from sklearn.decomposition import PCA # noqa: E402from sklearn.impute import SimpleImputer # noqa: E402from sklearn.linear_model import LogisticRegression # noqa: E402from sklearn.neighbors import KNeighborsClassifier # noqa: E402from sklearn.pipeline import make_pipeline # noqa: E402from sklearn.preprocessing import StandardScaler # noqa: E402
HERE = Path(__file__).resolve().parentfor 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.NODATAprint(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_trfold = 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 == ypboot = []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.RESyc = hc.AOI_BOUNDS[3] - (np.arange(rows_) + 0.5) * hc.RESblk_split = hc.load_blocks().set_index("block_id").splitpix_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 = 20tr_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 poolscaler = StandardScaler().fit(XE[pool]) # unlabelled pool: scaling needs no labelsZp, Zte = scaler.transform(XE[pool]), scaler.transform(XE[is_te])yp_pool = y[pool]START, BATCH, ROUNDS, AL_REPEATS = 2, 18, 20, 10al_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 edgesmapped = np.array([(pred[popn] == c).sum() for c in IDS], float)W = mapped / mapped.sum()N_TOTAL, N_MIN = 2000, 100n_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.01area = 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_haprint("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 samplebad = 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 probabilityconf_map = pmax.ravel()[pts]w_map = (mapped / n_h)[np.searchsorted(IDS, s_map)] # each sampled pixel stands for this many map pixelsrel_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_STARTmetrics = { "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 embeddingsrgb = 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 curvefig, 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 Englandfig, 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 matrixrown = 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 diagramfig, 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 curvefig, 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 intervalsfig, 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")