Compute permutation importance separately inside each region, then map the result. A single global table says “elevation matters 0.29”; a map says “elevation carries the model in the uplands and does nothing on the coastal plain, where impervious surface takes over”. The second statement is actionable — it tells you where the model is relying on something that may not transfer, and where a regional sub-model or an extra feature would help.
This page is the per-region method. For the global machinery it builds on — block permutation, SHAP, collinearity — see Model Explainability for Spatial Predictions.
Why This Fails in Geospatial ML Pipelines
Global importance averages away the thing you most want to see. Geospatial processes are rarely stationary: the drivers of flooding in a mountain catchment are not the drivers on a floodplain, and a model fitted across both learns both rules and applies whichever the features indicate. Averaged, the two sets of drivers each look moderately important, and the resulting bar chart describes no place in particular. Teams then make feature decisions — dropping a “weak” variable — that are wrong for half the study area.
The second failure is refitting per region. It seems natural: subset the data, fit, inspect. But now each region has its own model, and comparing their importances compares models, not places. The question worth answering is how the deployed model behaves in each region, which requires one fixed model and per-region permutation.
Third, small regions produce noise that looks like signal. A region with forty samples has a score estimate with wide error bars, so its importance value swings between repeats. Mapped without a sample-count layer, those swings render as bright patches that invite over-interpretation — the same trap as an unsmoothed rate map in epidemiology, and the same fix: report the denominator.
Core Principles
- One model, many measurements. Never refit per region.
- Permute within the region only. Rows outside it stay untouched.
- Score on that region’s rows. A global score would dilute the effect.
- Repeat and report variability. Ten repeats minimum, with a standard deviation.
- Carry the sample count into the map. Thin regions must be visually discountable.
- Use the same regions as your evaluation folds where possible, so the two analyses speak to each other.
Production-Ready Code
from __future__ import annotations
import logging
import geopandas as gpd
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
logger = logging.getLogger(__name__)
def regional_permutation_importance(
model, x: pd.DataFrame, y: np.ndarray, regions: np.ndarray,
features: list[str] | None = None, n_repeats: int = 10,
min_samples: int = 100, seed: int = 0,
) -> pd.DataFrame:
"""Permutation importance computed inside each region, for one fixed model.
Args:
model: A FITTED model. It is never refitted.
regions: Region label per row of `x`.
min_samples: Regions below this are reported with importance NaN.
Returns:
Long DataFrame: region, feature, auc_drop, auc_drop_std, n_samples.
"""
rng = np.random.default_rng(seed)
features = features or list(x.columns)
rows = []
for region in pd.unique(regions):
mask = regions == region
n = int(mask.sum())
xr, yr = x.loc[mask], y[mask]
if n < min_samples or len(np.unique(yr)) < 2:
logger.info("region %s: %d samples, skipped", region, n)
for f in features:
rows.append({"region": region, "feature": f, "auc_drop": np.nan,
"auc_drop_std": np.nan, "n_samples": n})
continue
base = roc_auc_score(yr, model.predict_proba(xr)[:, 1])
for f in features:
drops = []
for _ in range(n_repeats):
xp = xr.copy()
xp[f] = rng.permutation(xp[f].to_numpy())
drops.append(base - roc_auc_score(yr, model.predict_proba(xp)[:, 1]))
rows.append({"region": region, "feature": f,
"auc_drop": float(np.mean(drops)),
"auc_drop_std": float(np.std(drops)),
"n_samples": n})
logger.info("region %s: %d samples, base AUC %.3f", region, n, base)
return pd.DataFrame(rows)
def to_importance_layer(result: pd.DataFrame, region_geoms: gpd.GeoDataFrame,
feature: str, region_col: str = "region") -> gpd.GeoDataFrame:
"""Join one feature's per-region importance onto the region polygons for mapping."""
sub = result.loc[result["feature"] == feature,
["region", "auc_drop", "auc_drop_std", "n_samples"]]
out = region_geoms.merge(sub, left_on=region_col, right_on="region", how="left")
out["reliable"] = out["n_samples"] >= 100
if out["auc_drop"].isna().all():
raise ValueError(f"no region produced an importance for {feature!r}")
return out
def dominant_feature_map(result: pd.DataFrame, region_geoms: gpd.GeoDataFrame,
region_col: str = "region") -> gpd.GeoDataFrame:
"""Which feature dominates in each region — the single most useful map here."""
best = (result.dropna(subset=["auc_drop"])
.sort_values("auc_drop", ascending=False)
.groupby("region", as_index=False)
.first()[["region", "feature", "auc_drop", "n_samples"]])
return region_geoms.merge(best, left_on=region_col, right_on="region", how="left")Step-by-Step Walkthrough
Step 1 — Define regions that mean something
import geopandas as gpd
samples = gpd.read_file("samples.gpkg")
ecoregions = gpd.read_file("ecoregions.gpkg").to_crs(samples.crs)
joined = gpd.sjoin(samples, ecoregions[["eco_name", "geometry"]],
how="left", predicate="within")
regions = joined["eco_name"].fillna("unassigned").to_numpy()
print(pd.Series(regions).value_counts().head())Ecoregions, administrative units and coarse H3 cells all work. What matters is that the boundary corresponds to something that could plausibly change the process — using arbitrary squares produces a map that is harder to interpret, though still valid.
Step 2 — Run the per-region measurement
result = regional_permutation_importance(
model, X_test, y_test, regions[test_mask],
features=["elevation", "slope_deg", "tpi_11", "ndvi_mean",
"impervious_pct", "rain_24h", "upslope_area"],
n_repeats=10, min_samples=150, seed=0,
)
print(result.pivot_table(index="region", columns="feature", values="auc_drop").round(3))Step 3 — Map the surface for one feature
layer = to_importance_layer(result, ecoregions, feature="upslope_area",
region_col="eco_name")
layer.to_file("artifacts/importance_upslope_area.gpkg", driver="GPKG")
print(layer.loc[layer["reliable"], ["eco_name", "auc_drop", "n_samples"]]
.sort_values("auc_drop", ascending=False).head())Render with the unreliable regions hatched or greyed rather than dropped. A blank region is information — it says the model could not be evaluated there.
Step 4 — Map the dominant feature
dominant = dominant_feature_map(result, ecoregions, region_col="eco_name")
print(dominant[["eco_name", "feature", "auc_drop"]].to_string(index=False))This single map usually changes the conversation. When one half of the study area is driven by terrain and the other by land use, the options — a regional interaction term, a stratified model, an extra feature for the weak half — become obvious in a way no global chart makes them.
Verification
import numpy as np
import pandas as pd
def test_permutation_stays_inside_its_region():
"""Rows outside the region under test must be untouched."""
x = pd.DataFrame({"a": np.arange(20.0), "b": np.arange(20.0)})
regions = np.array(["north"] * 10 + ["south"] * 10)
rng = np.random.default_rng(0)
xp = x.copy()
mask = regions == "north"
xp.loc[mask, "a"] = rng.permutation(xp.loc[mask, "a"].to_numpy())
assert np.array_equal(xp.loc[~mask, "a"], x.loc[~mask, "a"])
def test_thin_regions_report_nan_not_a_number():
res = regional_permutation_importance(model, X_small, y_small, regions_small,
min_samples=1_000)
assert res["auc_drop"].isna().all()
assert (res["n_samples"] < 1_000).all()
def test_model_is_not_refitted():
"""The importance routine must not mutate the model."""
before = model.get_booster().save_raw() if hasattr(model, "get_booster") else None
regional_permutation_importance(model, X_test.head(500), y_test[:500],
regions[:500], n_repeats=2)
after = model.get_booster().save_raw() if hasattr(model, "get_booster") else None
assert before == after
def test_dominant_map_has_one_row_per_region():
dom = dominant_feature_map(result, ecoregions, region_col="eco_name")
assert len(dom) == len(ecoregions)Beyond tests, compare the regional map against the residual map from the same held-out set. Regions with large residuals and uniformly low importance are the model’s blind spots: it is neither accurate there nor relying on anything in particular, which usually means a driver is missing from the feature set entirely.
FAQ
How large should the regions be?
Large enough for a few hundred samples and both classes, small enough that the process could plausibly differ between them. Ecoregions, administrative units or coarse hexagons all work; report the sample count so thin regions can be discounted.
Should the model be refitted per region?
No — refitting compares different models rather than different places. Keep one fixed, deployed model and permute inside each region, which measures how that model behaves locally.
What does a region with near-zero importance for every feature mean?
Usually that the model predicts a near-constant value there, so shuffling changes nothing. That is a real finding: the model has no local discrimination in that region, which the global score hides completely.
Related
- Model Explainability for Spatial Predictions — the global methods this extends
- Computing SHAP Values for a Land Cover Classifier — per-sample attributions to map alongside this
- Evaluating Model Performance with Spatial Metrics — the residual maps to cross against
- Leave-One-Region-Out Cross-Validation with scikit-learn — using the same regions as folds
Part of: Model Explainability for Spatial Predictions Part of: Training Geospatial Predictive Models in Python