Choosing a Binary Threshold for SDM Suitability Maps in Python
A continuous suitability surface answers “how suitable?”; every decision made from it answers “suitable or not?”. The threshold that converts one into the other is usually chosen in a line of code and rarely justified, yet it determines the mapped area, the reported range shift, and how much land a conservation designation covers. This guide makes that choice explicit, inside Model Validation & AUC Metrics, part of Species Distribution Modeling with MaxEnt.
When to use each rule
| Rule | Optimises | Tends to give | Use when |
|---|---|---|---|
| Maximum TSS | Sensitivity + specificity − 1 | A balanced, mid-range cut | The general default for presence-background data |
| Maximum Kappa | Agreement beyond chance | Similar to TSS, less stable at low prevalence | Comparability with older literature |
| Equal sensitivity and specificity | Equal error rates | Slightly lower than TSS | The two error types cost the same |
| 10th percentile training presence | Omits the 10% most marginal presences | A conservative, wider map | Records include some misidentification or positional error |
| Minimum training presence | Zero training omission | A very wide map | The species is genuinely everywhere it was recorded |
| Cost-weighted | A stated cost ratio | Whatever the ratio implies | The consequences of the two errors differ, which is usual |
Maximum TSS is the reasonable default. Reach for the 10th percentile rule when the occurrence data is imperfect enough that the most marginal records are probably errors, and for a cost-weighted rule whenever the two mistakes have genuinely different consequences — which, in conservation planning, they almost always do.
Minimal reproducible example
import numpy as np
def threshold_candidates(y_true: np.ndarray, scores: np.ndarray,
presence_scores: np.ndarray | None = None,
n_steps: int = 500) -> dict:
"""Compute the standard threshold rules from evaluation data.
y_true: 1 for presence, 0 for background; scores: model output for those points.
presence_scores: suitability at the TRAINING presences, for percentile rules.
"""
cuts = np.linspace(scores.min(), scores.max(), n_steps)
pos, neg = y_true == 1, y_true == 0
n_pos, n_neg = int(pos.sum()), int(neg.sum())
assert n_pos > 0 and n_neg > 0, "need both classes to choose a threshold"
sens = np.array([(scores[pos] >= c).sum() / n_pos for c in cuts])
spec = np.array([(scores[neg] < c).sum() / n_neg for c in cuts])
tss = sens + spec - 1.0
out = {
"max_tss": float(cuts[int(np.argmax(tss))]),
"max_tss_value": float(tss.max()),
"equal_sens_spec": float(cuts[int(np.argmin(np.abs(sens - spec)))]),
"sensitivity_at_max_tss": float(sens[int(np.argmax(tss))]),
"specificity_at_max_tss": float(spec[int(np.argmax(tss))]),
}
if presence_scores is not None and presence_scores.size:
out["min_training_presence"] = float(np.nanmin(presence_scores))
out["pct10_training_presence"] = float(np.nanpercentile(presence_scores, 10))
return out
def cost_weighted_threshold(y_true, scores, cost_fn: float = 5.0,
cost_fp: float = 1.0, n_steps: int = 500) -> dict:
"""Threshold minimising a stated cost of false negatives against false positives."""
cuts = np.linspace(scores.min(), scores.max(), n_steps)
pos, neg = y_true == 1, y_true == 0
costs = np.array([cost_fn * (scores[pos] < c).sum()
+ cost_fp * (scores[neg] >= c).sum() for c in cuts])
i = int(np.argmin(costs))
return {"threshold": float(cuts[i]), "cost": float(costs[i]),
"cost_ratio_fn_fp": cost_fn / cost_fp}
The cost-weighted rule is the honest one for applied work, because it forces the cost ratio into the open. Declaring that missing an occupied stand is five times worse than flagging an unoccupied one is a defensible management statement; silently accepting maximum TSS asserts a ratio of one without saying so.
What the choice costs in hectares
The threshold does not shift the map slightly — it can change mapped area by a factor of two or more, because suitability distributions are dense in the mid-range where the candidate thresholds sit.
import numpy as np
import rasterio
def area_by_threshold(raster_path: str, thresholds: dict) -> list[dict]:
"""Mapped suitable area under each candidate threshold."""
with rasterio.open(raster_path) as src:
arr = src.read(1, masked=True).astype("float64").filled(np.nan)
cell_ha = abs(src.transform.a) * abs(src.transform.e) / 10_000.0
valid = np.isfinite(arr)
total = float(valid.sum() * cell_ha)
rows = []
for name, t in sorted(thresholds.items(), key=lambda kv: kv[1]):
ha = float((valid & (arr >= t)).sum() * cell_ha)
rows.append({"rule": name, "threshold": round(t, 4),
"area_ha": round(ha, 1),
"pct_of_domain": round(100.0 * ha / max(total, 1), 1)})
return rows
Parameter reference
| Parameter | Type | Default | Notes |
|---|---|---|---|
| evaluation data | — | held-out, spatially blocked | Thresholds fitted on training data are optimistic |
n_steps |
int | 500 | Resolution of the threshold search; more is cheap |
cost_fn / cost_fp |
float | stated ratio | The declaration that makes a cost-weighted rule defensible |
| percentile | float | 10 | 5–20; higher is more conservative about marginal records |
presence_scores |
array | training presences | Percentile rules need the training set, not the held-out one |
| reporting | — | rule + value + area | All three, always |
Expected output and verification
Compute every candidate, report the one you chose and why, and publish the area under each so a reader can substitute their own preference.
def threshold_report(y_true, scores, presence_scores, raster_path,
cost_fn: float = 5.0) -> dict:
"""Every candidate threshold with its mapped area, plus the chosen one."""
cands = threshold_candidates(y_true, scores, presence_scores)
cw = cost_weighted_threshold(y_true, scores, cost_fn=cost_fn)
thresholds = {k: v for k, v in cands.items()
if k in ("max_tss", "equal_sens_spec",
"pct10_training_presence", "min_training_presence")}
thresholds["cost_weighted"] = cw["threshold"]
areas = area_by_threshold(raster_path, thresholds)
span = max(a["area_ha"] for a in areas) / max(min(a["area_ha"] for a in areas), 1)
return {"candidates": cands, "cost_weighted": cw,
"areas": areas, "area_span_ratio": round(span, 2)}
rep = threshold_report(y_test, scores_test, presence_scores, "suitability.tif")
assert rep["candidates"]["max_tss_value"] > 0.4, \
f"TSS too low for any threshold to be meaningful: {rep['candidates']}"
print(f"mapped area varies {rep['area_span_ratio']}x across candidate rules")
The TSS assertion is the gate that matters. Below about 0.4 the model barely separates presences from background, and no threshold produces a useful binary map — the honest output in that case is the continuous surface with a note that it does not support a binary interpretation.
The standing test is one the threshold machinery must fail:
import numpy as np
rng = np.random.default_rng(20260809)
# A model with no skill: scores drawn from the same distribution for both classes.
y = np.r_[np.ones(200), np.zeros(2000)].astype(int)
s = rng.beta(2, 5, size=y.size)
null = threshold_candidates(y, s)
assert null["max_tss_value"] < 0.15, \
f"a skill-free model produced a usable TSS ({null['max_tss_value']:.2f})"
Feeding it scores that carry no information must produce a TSS near zero. An implementation that reports 0.4 on random data has a class-labelling or indexing bug, and every real threshold it produces is meaningless in the same way.
Common pitfalls
- Choosing the threshold on training data. Thresholds are as prone to optimism as any other fitted quantity; derive them from held-out, spatially blocked evaluation data.
- Reporting area without the rule. An area figure with no stated threshold cannot be compared with anything, including a later version of itself.
- Percentile rules from the wrong set. The 10th percentile rule is defined on training presence suitabilities, not on held-out ones.
- Maximum kappa at low prevalence. Kappa is unstable when presences are rare relative to background, and its optimum can jump between runs.
- Thresholding then averaging an ensemble. Threshold each member and average the binary maps to get agreement; thresholding an averaged surface discards the disagreement.
Frequently Asked Questions
Should I threshold at all?
Often not. A continuous suitability surface carries strictly more information, and many downstream uses — ranking sites, weighting a connectivity model, prioritising survey effort — work directly on it. Threshold when the decision is genuinely binary, and keep the continuous layer alongside.
Which rule do reviewers expect?
Maximum TSS and the 10th percentile training presence rule are both common and both accepted. What is expected without exception is that you name the rule and report the threshold value. A paper reporting “suitable area” with neither is asking to be queried.
How do I threshold when the model is an ensemble?
Threshold each member with its own rule, then combine the binary maps into an agreement layer. The commonly used shortcut — threshold the ensemble median — produces a single map that conceals whether the members agreed, which is the main thing an ensemble was built to reveal.
Related
- Model Validation & AUC Metrics — the wider evaluation workflow
- Evaluating SDM with the Continuous Boyce Index — a threshold-free alternative for presence-only data
- Spatial Block Cross-Validation for SDM in Python — the evaluation data a threshold should come from
- Computing Range Shift and Centroid Metrics from SDM Projections — every area metric there rests on this choice
Up: Model Validation & AUC Metrics · Species Distribution Modeling with MaxEnt