Detecting Extrapolation with MESS Maps in Python
A projected suitability map looks identical whether the model is interpolating within familiar conditions or extrapolating into climates it has never seen. The multivariate environmental similarity surface — MESS — is the layer that tells the two apart, pixel by pixel, and it costs one pass over the predictor stack. This guide computes it and uses it, inside SDM Projection and Transferability, part of Species Distribution Modeling with MaxEnt.
When to use each novelty measure
| Measure | Detects | Cost | Limitation |
|---|---|---|---|
| MESS | Values outside any single variable’s training range | One pass | Blind to novel combinations of familiar values |
| MoD (most dissimilar variable) | Which variable is responsible | Free with MESS | Descriptive only |
| Mahalanobis distance | Novel combinations, assuming ellipsoidal training data | Covariance inverse | Assumes multivariate normality |
| ExDet | Both univariate and combinational novelty | Two passes | Less widely implemented |
| Convex hull membership | Exact novelty in low dimensions | Exponential in dimension | Impractical above ~6 variables |
Compute MESS always — it is cheap and it is what reviewers expect. Add Mahalanobis or ExDet when the projection domain is a different region rather than a different time, because that is where novel combinations of individually familiar values are common: the same temperature and the same rainfall, in a pairing that does not occur in the training area.
Minimal reproducible example
import numpy as np
def mess_and_mod(train: np.ndarray, proj: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""MESS surface and the index of the most dissimilar variable per pixel.
train: (n_train, n_vars) values at the training background;
proj: (n_pixels, n_vars) values in the projection domain.
"""
n_train, n_vars = train.shape
sims = np.empty((proj.shape[0], n_vars), dtype="float64")
for j in range(n_vars):
ref = np.sort(train[:, j])
lo, hi = float(ref[0]), float(ref[-1])
rng = max(hi - lo, 1e-12)
v = proj[:, j]
# Percentile of v within the training distribution.
f = np.searchsorted(ref, v, side="right") / n_train * 100.0
sims[:, j] = np.where(
f == 0.0, (v - lo) / rng * 100.0,
np.where(f <= 50.0, 2.0 * f,
np.where(f < 100.0, 2.0 * (100.0 - f),
(hi - v) / rng * 100.0)))
mod = np.argmin(sims, axis=1).astype("int16")
return sims.min(axis=1).astype("float32"), mod
The four branches encode the whole definition. Below the training minimum the score goes negative in proportion to how far below; in the lower half it rises to 100 at the median; in the upper half it falls symmetrically; above the maximum it goes negative again. A value of 100 means the pixel sits exactly at the training median for that variable; zero means it sits exactly on the boundary; negative means outside.
The most-dissimilar-variable layer is free and is the more actionable of the two. A MESS map says “novel”; the MoD map says “because precipitation is outside anything the model has seen”, which is a sentence you can act on — by adding data, dropping the variable, or restricting the projection domain.
import rasterio
def write_mess(mess: np.ndarray, mod: np.ndarray, shape, profile,
variables: list[str], out_path: str) -> str:
"""Write MESS and the most-dissimilar-variable index as a two-band raster."""
profile = profile | {"count": 2, "dtype": "float32", "nodata": np.nan,
"compress": "deflate", "tiled": True}
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(mess.reshape(shape).astype("float32"), 1)
dst.write(mod.reshape(shape).astype("float32"), 2)
dst.set_band_description(1, "mess")
dst.set_band_description(2, "most_dissimilar_variable_index")
dst.update_tags(variables=",".join(variables))
return out_path
Parameter reference
| Parameter | Type | Typical | Notes |
|---|---|---|---|
train sample |
array | 10,000 background points | The reference set defines the envelope; use the same background the model trained on |
| MESS threshold | float | 0 | Negative is outside the range; stricter masks use −10 or −25 |
| variables | list | model’s predictors | Must match the projection stack exactly, in order |
| chunk size | int | 10⁶ pixels | The percentile search is memory-hungry on a full continental stack |
| training source | — | background, not presences | Presence-only training envelopes are far too narrow |
The last row matters and is often wrong. MESS should be computed against the background the model saw — the environmental envelope it was fitted across — not against the presence records. Using presences alone produces a much narrower envelope and flags most of the projection domain as novel.
Expected output and verification
A correct MESS surface has properties you can assert. Computing it against the training domain itself is the strongest single test.
import numpy as np
def verify_mess(train: np.ndarray) -> dict:
"""MESS computed on the training data itself must be non-negative everywhere."""
mess_self, _ = mess_and_mod(train, train)
# A deliberately out-of-range probe must come back negative.
far = train.copy()[:200]
far[:, 0] = train[:, 0].max() + 5.0 * train[:, 0].std()
mess_far, mod_far = mess_and_mod(train, far)
return {
"self_min": round(float(mess_self.min()), 3),
"self_median": round(float(np.median(mess_self)), 1),
"probe_max": round(float(mess_far.max()), 3),
"probe_mod_is_var0": bool((mod_far == 0).all()),
}
v = verify_mess(train_background)
assert v["self_min"] >= -1e-6, f"MESS negative inside the training data: {v}"
assert v["probe_max"] < 0, f"an out-of-range probe was not flagged: {v}"
assert v["probe_mod_is_var0"], f"the wrong variable was blamed: {v}"
Those three assertions are the standing test this machinery needs. The second and third deliberately feed it input it must reject — a probe pushed five standard deviations beyond the training maximum of variable zero — and check both that it refuses and that it attributes the refusal to the right variable. An implementation that scores everything positive will pass the first assertion and fail the other two.
Once the surface exists, the reporting use is to quantify how much of the projected suitable area depends on extrapolation:
import numpy as np
def novelty_report(mess: np.ndarray, suitability: np.ndarray,
threshold: float, mod: np.ndarray,
variables: list[str]) -> dict:
"""How much of the suitable area is novel, and which variable drives it?"""
ok = np.isfinite(mess) & np.isfinite(suitability)
suitable = ok & (suitability >= threshold)
novel = ok & (mess < 0)
counts = np.bincount(mod[novel & suitable], minlength=len(variables))
return {
"suitable_px": int(suitable.sum()),
"novel_px": int(novel.sum()),
"suitable_and_novel_frac": round(
float((suitable & novel).sum() / max(suitable.sum(), 1)), 3),
"driver": variables[int(counts.argmax())] if counts.sum() else None,
"driver_share": round(float(counts.max() / max(counts.sum(), 1)), 3)
if counts.sum() else None,
}
Common pitfalls
- Computing MESS against presence records. The envelope is then the species’ realised niche rather than the model’s training range, and almost everything reads as novel.
- Masking rather than flagging. Setting novel pixels to NoData hides that the model made a prediction there. Keep the suitability and publish MESS beside it.
- Treating MESS as sufficient. It is univariate per variable and cannot see a novel combination of familiar values, which is the dominant failure mode for spatial transfer.
- Recomputing MESS per model run. It depends only on the predictor stacks, not on the fitted model, so one computation serves an entire ensemble sharing a climate future.
- Comparing MESS values across different variable sets. Adding a variable can only lower the minimum, so a six-variable and a twelve-variable MESS are not comparable.
Frequently Asked Questions
What MESS threshold should mark a pixel as unreliable?
Zero is the definitional boundary and the usual choice. Some workflows use −10 or −25 to allow marginal extrapolation, which is defensible for variables whose response is known to be monotone and indefensible for quadratics. Whatever you pick, publish the continuous surface too, so a reader can apply their own.
Does clamping remove the need for MESS?
No — it changes what happens at novel pixels but not the fact that they are novel. Clamping holds the response flat at the boundary, which is a reasonable default, but a flat response is still an assumption rather than an observation. MESS is what records where that assumption was invoked.
Can I use MESS to choose which variables to keep?
Indirectly, and usefully. If the most-dissimilar-variable layer shows that one predictor drives almost all the novelty, ask whether that variable is earning its place. Dropping a weakly contributing variable that is responsible for most of the extrapolation often improves transferability more than any amount of regularisation tuning.
Related
- SDM Projection and Transferability — the wider workflow this check belongs to
- Projecting MaxEnt Models to Future Climate Scenarios in Python — the projection this surface accompanies
- Reducing Predictor Collinearity with VIF in Python — fewer, less correlated predictors extrapolate better
- Environmental Predictor Stacking — where the training envelope is defined
Up: SDM Projection & Transferability · Species Distribution Modeling with MaxEnt