Step 3(b) groups the training pool into HEALPix cells (WGS84) and holds whole cells out
together. A reader could ask whether the blocked accuracies (0.854–0.869) are an artefact
of that grid. Here the same procedure (pool, GroupKFold(5), per-fold training
subsample, seeds) is repeated with:
HEALPix NESTED on WGS84 (as in step 3(b); must reproduce its numbers),
HEALPix NESTED on the sphere,
latitude/longitude squares with the same area as the HEALPix cells of each depth,
the same squares shifted by half a block (to show how much an arbitrary placement of block boundaries moves the result).
Only the grouping changes; the model, data and folds’ sizes are otherwise identical.
import os
from pathlib import Path
import numpy as np
import pandas as pd
from healpix_geo.nested import lonlat_to_healpix
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, StratifiedKFoldSMOKE = os.environ.get("SMOKE", "0") == "1"
CLEAN = Path("../data/clean_smoke" if SMOKE else "../data/clean")
RESULTS = Path("../results/smoke" if SMOKE else "../results")
N_TREES = 100 if SMOKE else 500
SEED = 20261003
COL_SHARE = 188_921 / 4_780_000
N_TRAIN = 2_000 if SMOKE else int(round(1_000_000 * COL_SHARE))
FINAL_VARS = ["forest_density_2000", "dist_forest_2000", "ocdens", "phihox", "pc1", "pc2", "pc3", "pc4", "lc_2000", "biome"]
DEPTHS = [6, 7, 8]
R_AUTHALIC = 6371007.181 # m; HEALPix cell area = 4 pi R^2 / (12 * 4^depth)
KM_PER_DEG = 111.32
samples = pd.read_parquet(CLEAN / "samples.parquet")
pool = samples[samples.set == "train_pool"].reset_index(drop=True)
pv = pool.dropna(subset=FINAL_VARS)
Xp, yp = pv[FINAL_VARS].to_numpy(), pv.label.to_numpy()
lon, lat = pv.lon.to_numpy(), pv.lat.to_numpy()
print(f"pool records {len(pv):,}")
def make_rf() -> RandomForestClassifier:
return RandomForestClassifier(n_estimators=N_TREES, max_features=int(np.floor(np.sqrt(len(FINAL_VARS)))),
min_samples_leaf=1, oob_score=True, n_jobs=-1, random_state=SEED)
def cell_side_deg(depth: int) -> float:
area = 4 * np.pi * R_AUTHALIC**2 / (12 * 4**depth)
return float(np.sqrt(area) / 1000 / KM_PER_DEG)
def squares(side: float, shift: float = 0.0) -> np.ndarray:
i = np.floor((lon + 180 + shift) / side).astype("int64")
j = np.floor((lat + 90 + shift) / side).astype("int64")
return i * 100_000 + jpool records 151,980
schemes: dict[str, tuple[str, int | None, np.ndarray | None]] = {"random": ("random", None, None)}
for d in DEPTHS:
side = cell_side_deg(d)
schemes[f"healpix_wgs84_d{d}"] = ("HEALPix, WGS84", d, lonlat_to_healpix(lon, lat, d, ellipsoid="WGS84"))
schemes[f"healpix_sphere_d{d}"] = ("HEALPix, sphere", d, lonlat_to_healpix(lon, lat, d, ellipsoid="sphere"))
schemes[f"latlon_d{d}"] = ("lat/lon squares", d, squares(side))
schemes[f"latlon_shifted_d{d}"] = ("lat/lon squares, shifted half a block", d, squares(side, side / 2))
rows = []
for name, (family, depth, groups) in schemes.items():
splits = (StratifiedKFold(5, shuffle=True, random_state=SEED).split(Xp, yp) if groups is None
else GroupKFold(5).split(Xp, yp, groups=groups))
accs = []
for k, (tri, tei) in enumerate(splits):
sub = np.random.default_rng(SEED + k).choice(tri, min(N_TRAIN, len(tri)), replace=False)
m = make_rf().fit(Xp[sub], yp[sub])
accs.append(float(((m.predict_proba(Xp[tei])[:, 1] > 0.5) == yp[tei]).mean()))
rows.append(dict(scheme=name, family=family, depth=depth,
block_km=None if depth is None else round(cell_side_deg(depth) * KM_PER_DEG, 1),
n_blocks=None if groups is None else int(len(np.unique(groups))),
mean_accuracy=float(np.mean(accs)), sd=float(np.std(accs, ddof=1)),
folds=" ".join(f"{a:.4f}" for a in accs)))
print(rows[-1])
res = pd.DataFrame(rows)
res.to_csv(RESULTS / "step3_blocking_sensitivity.csv", index=False){'scheme': 'random', 'family': 'random', 'depth': None, 'block_km': None, 'n_blocks': None, 'mean_accuracy': 0.8866100802737202, 'sd': 0.0012736655622142049, 'folds': '0.8885 0.8871 0.8856 0.8854 0.8864'}
{'scheme': 'healpix_wgs84_d6', 'family': 'HEALPix, WGS84', 'depth': 6, 'block_km': 101.9, 'n_blocks': 131, 'mean_accuracy': 0.8538238025590899, 'sd': 0.03631765639121536, 'folds': '0.8177 0.8251 0.8421 0.8980 0.8862'}
{'scheme': 'healpix_sphere_d6', 'family': 'HEALPix, sphere', 'depth': 6, 'block_km': 101.9, 'n_blocks': 131, 'mean_accuracy': 0.8530537453628717, 'sd': 0.043118298587277046, 'folds': '0.8026 0.8131 0.8641 0.8963 0.8892'}
{'scheme': 'latlon_d6', 'family': 'lat/lon squares', 'depth': 6, 'block_km': 101.9, 'n_blocks': 135, 'mean_accuracy': 0.8544939597128389, 'sd': 0.02678373810081146, 'folds': '0.8084 0.8586 0.8768 0.8598 0.8688'}
{'scheme': 'latlon_shifted_d6', 'family': 'lat/lon squares, shifted half a block', 'depth': 6, 'block_km': 101.9, 'n_blocks': 135, 'mean_accuracy': 0.8346036437250559, 'sd': 0.02386701904348329, 'folds': '0.8455 0.8002 0.8221 0.8620 0.8431'}
{'scheme': 'healpix_wgs84_d7', 'family': 'HEALPix, WGS84', 'depth': 7, 'block_km': 50.9, 'n_blocks': 443, 'mean_accuracy': 0.8640413212264771, 'sd': 0.026135875843856918, 'folds': '0.8915 0.8360 0.8672 0.8385 0.8870'}
{'scheme': 'healpix_sphere_d7', 'family': 'HEALPix, sphere', 'depth': 7, 'block_km': 50.9, 'n_blocks': 444, 'mean_accuracy': 0.8620476378470852, 'sd': 0.01639330104494262, 'folds': '0.8787 0.8670 0.8605 0.8690 0.8351'}
{'scheme': 'latlon_d7', 'family': 'lat/lon squares', 'depth': 7, 'block_km': 50.9, 'n_blocks': 444, 'mean_accuracy': 0.8618568232662194, 'sd': 0.031972416643230264, 'folds': '0.8923 0.8987 0.8270 0.8397 0.8516'}
{'scheme': 'latlon_shifted_d7', 'family': 'lat/lon squares, shifted half a block', 'depth': 7, 'block_km': 50.9, 'n_blocks': 439, 'mean_accuracy': 0.862988551125148, 'sd': 0.011464193866177719, 'folds': '0.8663 0.8699 0.8641 0.8716 0.8432'}
{'scheme': 'healpix_wgs84_d8', 'family': 'HEALPix, WGS84', 'depth': 8, 'block_km': 25.5, 'n_blocks': 1441, 'mean_accuracy': 0.8685550730359258, 'sd': 0.01699018727589654, 'folds': '0.8621 0.8765 0.8563 0.8945 0.8535'}
{'scheme': 'healpix_sphere_d8', 'family': 'HEALPix, sphere', 'depth': 8, 'block_km': 25.5, 'n_blocks': 1443, 'mean_accuracy': 0.8653046453480719, 'sd': 0.013419580907581732, 'folds': '0.8779 0.8453 0.8617 0.8775 0.8641'}
{'scheme': 'latlon_d8', 'family': 'lat/lon squares', 'depth': 8, 'block_km': 25.5, 'n_blocks': 1440, 'mean_accuracy': 0.8679102513488617, 'sd': 0.008926050669580713, 'folds': '0.8662 0.8780 0.8546 0.8669 0.8739'}
{'scheme': 'latlon_shifted_d8', 'family': 'lat/lon squares, shifted half a block', 'depth': 8, 'block_km': 25.5, 'n_blocks': 1448, 'mean_accuracy': 0.8680352677983946, 'sd': 0.009272209306422098, 'folds': '0.8768 0.8595 0.8575 0.8772 0.8692'}
Same block size, different grids¶
tab = res[res.depth.notna()].pivot_table(index=["depth", "block_km"], columns="family", values="mean_accuracy")
tab["max - min"] = tab.max(axis=1) - tab.min(axis=1)
print(f"random CV: {res.loc[res.scheme == 'random', 'mean_accuracy'].iloc[0]:.4f}")
print(tab.round(4).to_string())random CV: 0.8866
family HEALPix, WGS84 HEALPix, sphere lat/lon squares lat/lon squares, shifted half a block max - min
depth block_km
6.0 101.9 0.8538 0.8531 0.8545 0.8346 0.0199
7.0 50.9 0.8640 0.8620 0.8619 0.8630 0.0022
8.0 25.5 0.8686 0.8653 0.8679 0.8680 0.0033