Running MaxEnt from Python with elapid

For two decades the standard way to run MaxEnt was to shell out to a Java program, hand it directories of ASCII grids, and parse the results back. A pure-Python implementation removes that boundary: background sampling, fitting, cross-validation and raster prediction all live in one process, which makes the model something a pipeline can call rather than something a person runs. This guide covers doing that with elapid, inside MaxEnt Model Training & Tuning, part of Species Distribution Modeling with MaxEnt.

When to use each implementation

Implementation Strength Weakness
elapid (Python) In-process; scikit-learn API; geospatial sampling built in Small differences from the Java reference
Java MaxEnt The canonical reference; exhaustive HTML output External process, file-based I/O, awkward to automate
maxnet-style GLM Statistically transparent; ordinary regression machinery Needs feature construction by hand
Gradient boosting / random forest Often higher discrimination Poorer extrapolation; less interpretable response curves

Use elapid for anything automated. Where a result must be reproducible against published Java output, run both once on the same inputs and record the difference — typically a few thousandths of AUC, which is smaller than the variation between cross-validation folds.

Minimal reproducible example

The API follows scikit-learn conventions, so the fit is three lines once the data is assembled. Assembling it is where the geospatial care goes.

import elapid as ela
import geopandas as gpd
import numpy as np

RASTERS = ["bio1.tif", "bio4.tif", "bio12.tif", "bio15.tif", "slope.tif", "twi.tif"]


def fit_maxent(presence_path: str, study_area_path: str,
               n_background: int = 10_000, seed: int = 20260809):
    """Fit a MaxEnt model from a presence layer and a study-area polygon."""
    presences = gpd.read_file(presence_path)
    area = gpd.read_file(study_area_path)

    # Background sampled uniformly across the study area, not across the raster extent.
    background = ela.sample_from_vector(area.geometry.union_all(),
                                        count=n_background, overestimate=2)
    background = gpd.GeoDataFrame(geometry=background, crs=area.crs)

    merged = ela.stack_geodataframes(presences, background, add_class_label=True)
    annotated = ela.annotate(merged, RASTERS, drop_na=True)

    x = annotated[[c for c in annotated.columns if c.startswith("bio") or
                   c in ("slope", "twi")]].to_numpy()
    y = annotated["class"].to_numpy()

    model = ela.MaxentModel(
        feature_types=["linear", "hinge", "product"],
        beta_multiplier=2.0,
        tau=1e-6,
        clamp=True,
        scorer="roc_auc",
    )
    model.fit(x, y)
    return model, annotated

Two choices in there deserve their own paragraph. The background is sampled from the study-area polygon, not from the raster extent — a rectangular extent includes ocean, other countries, and habitat the species could never occupy, and background drawn from it makes the model’s job artificially easy and its AUC artificially high. And drop_na=True removes records that fall on a NoData pixel in any predictor, which is the right behaviour and worth logging, because a silent loss of a third of the presences is otherwise invisible.

Feature classes and what they cost

MaxEnt builds features from the raw predictors: linear, quadratic, product (pairwise interactions), hinge (piecewise linear), and threshold (step). More feature classes mean a more flexible fit and worse transferability.

Feature classes and the response curves they produce Four small panels showing the fitted response of suitability to one predictor under different feature class sets. Linear only produces a straight upward line. Linear plus quadratic produces a smooth single-peaked hump. Hinge features produce a piecewise linear shape with two breakpoints. All feature classes together produce a jagged curve with several spurious peaks that follow the sampling noise rather than an ecological response. L LQ LQH LQHPT monotone unimodal optimum flexible, still readable fits the noise ≤ 15 records 15–50 50–200 rarely justified Feature richness should follow sample size; the last panel is what happens when it does not.

The conventional rule ties feature richness to sample size: linear only below about 15 presences, linear plus quadratic to 50, add hinge to a few hundred, and reserve the full set for very large datasets. Applied work with 40 forest tree records has no business fitting product and threshold features, however good the resulting AUC looks.

Predict to a raster

import elapid as ela


def predict_raster(model, raster_paths: list[str], out_path: str,
                   transform: str = "cloglog", windowed: bool = True) -> str:
    """Apply a fitted model across the predictor stack, window by window."""
    ela.apply_model_to_rasters(
        model,
        raster_paths,
        out_path,
        transform=transform,
        template_idx=0,
        windowed=windowed,
        nodata=-9999,
        driver="GTiff",
        compress="deflate",
    )
    return out_path

transform="cloglog" is the modern default and the one to use. The older logistic transform assumes a prevalence of 0.5, which is meaningless for presence-only data; cloglog has a cleaner interpretation as a relative occurrence probability and is what current practice reports. Mixing the two between a model and its published comparison is a common source of apparent disagreement.

windowed=True matters at scale: a continental stack at 1 km is tens of millions of pixels per band, and the windowed path keeps memory bounded.

Parameter reference

Parameter Type Default here Range Rationale
feature_types list L, H, P see above Match richness to sample size
beta_multiplier float 2.0 1.0–4.0 Regularisation; tune it, do not accept the default
tau float 1e-6 Convergence tolerance; rarely needs changing
clamp bool True Holds features at training limits during prediction
n_background int 10,000 5,000–50,000 More background stabilises the fit; beyond ~20,000 gains are small
transform str cloglog cloglog / raw cloglog for reporting; raw for exponent inspection
seed int fixed Background sampling is random; record the seed

Tuning beta_multiplier is not optional and is covered in Tuning MaxEnt Regularization with betamultiplier Grid Search. The default of 1.0 routinely overfits small occurrence sets.

Expected output and verification

Before believing any map, check the fit against a spatially blocked split rather than a random one.

import numpy as np
from sklearn.metrics import roc_auc_score


def evaluate_blocked(model_factory, x, y, block_id) -> dict:
    """Leave-one-block-out evaluation; block_id assigns each record to a spatial block."""
    aucs = []
    for b in np.unique(block_id):
        train, test = block_id != b, block_id == b
        if y[test].sum() < 3 or y[train].sum() < 10:
            continue
        m = model_factory()
        m.fit(x[train], y[train])
        aucs.append(roc_auc_score(y[test], m.predict(x[test])))
    return {
        "n_folds": len(aucs),
        "auc_mean": round(float(np.mean(aucs)), 3),
        "auc_sd": round(float(np.std(aucs)), 3),
        "auc_min": round(float(np.min(aucs)), 3),
    }


res = evaluate_blocked(lambda: ela.MaxentModel(feature_types=["linear", "hinge"],
                                               beta_multiplier=2.0), x, y, blocks)
assert res["auc_mean"] > 0.7, f"blocked AUC too low to use: {res}"
assert res["auc_sd"] < 0.15, f"unstable across blocks — check for a spatial outlier: {res}"

A large fold-to-fold spread is more informative than a low mean. It usually means one block contains a distinct habitat that the rest of the data does not represent, which is exactly the situation in which a projected map will be confidently wrong in that region.

The prediction raster itself needs two checks that catch the plumbing errors:

import numpy as np
import rasterio


def check_prediction(out_path: str, presences, background) -> dict:
    """Suitability must be bounded, and higher at presences than at background."""
    with rasterio.open(out_path) as src:
        arr = src.read(1, masked=True)
        p = np.array([v[0] for v in src.sample(
            zip(presences.geometry.x, presences.geometry.y))])
        b = np.array([v[0] for v in src.sample(
            zip(background.geometry.x, background.geometry.y))])
    return {
        "min": round(float(arr.min()), 4),
        "max": round(float(arr.max()), 4),
        "median_at_presence": round(float(np.nanmedian(p)), 4),
        "median_at_background": round(float(np.nanmedian(b)), 4),
    }


chk = check_prediction("suitability.tif", presences, background)
assert 0.0 <= chk["min"] and chk["max"] <= 1.0, f"cloglog output outside 0-1: {chk}"
assert chk["median_at_presence"] > chk["median_at_background"], \
    f"presences score no higher than background — the fit or the sampling is broken: {chk}"

The second assertion is the one worth keeping permanently. It feeds the finished raster the simplest possible test — do the places the species was actually recorded score higher than random ground — and a model that fails it has a wiring error, not a subtle statistical problem.

Suitability at presences against background as a wiring check Two panels of overlaid distributions. In the working model the background distribution is concentrated at low suitability while the presence distribution is shifted well to the right, with limited overlap. In the broken model, where class labels were reversed, the two distributions sit almost on top of each other and the presence distribution is if anything slightly to the left, which the assertion catches immediately. Working model Labels reversed background presences 0 0.5 1 cloglog suitability indistinguishable 0 0.5 1 cloglog suitability A model that cannot separate its own training presences from background has a plumbing bug.

Common pitfalls

  • Background from the raster extent. Including ocean and unreachable regions inflates AUC and distorts every response curve.
  • The default regularisation. beta_multiplier=1.0 overfits small occurrence sets reliably; tune it against spatially blocked folds.
  • Feature richness beyond the sample size. Product and threshold features on 40 records fit sampling noise and transfer badly.
  • Mixing cloglog and logistic outputs. They are different transforms of the same model; thresholds and comparisons do not carry between them.
  • An unrecorded background seed. Background sampling is random, so an unseeded run is not reproducible even with identical inputs.
  • Ignoring drop_na losses. Presences falling on NoData disappear silently; log the count before and after annotation.
Where the background is drawn from decides what the model learns Two panels of the same region. In the first, background points are drawn over the full rectangular raster extent, which includes ocean and a neighbouring range the species could never reach, so the model separates land from sea and reports a very high AUC. In the second, background is drawn only from the ecologically accessible area, so the model must discriminate within habitat the species could plausibly occupy and the AUC falls to a meaningful value. background from the raster extent background from the accessible area AUC 0.97 — it learned the coastline AUC 0.79 — a real discrimination Filled points are presences; hollow points background. Only the second AUC means anything.

Frequently Asked Questions

Will elapid reproduce Java MaxEnt exactly?

Not bit for bit. The regularisation path and the optimiser differ in detail, so expect small differences in the exponents and a few thousandths of AUC. That is well inside fold-to-fold variation, but if a result must match a published Java run, do the comparison once and record it rather than assuming.

How much background is enough?

Ten thousand points is the conventional default and is adequate for most study areas. The number that actually matters is coverage of the environmental space: in a large, heterogeneous region, 10,000 points may leave whole climate zones sparsely represented, and 20,000–50,000 is safer. Beyond that the gain is negligible and the fit time is not.

Can I use the same code for other presence-only algorithms?

Largely yes, because the interface follows scikit-learn conventions — the same annotated table and class labels feed a gradient-boosted model or a random forest with almost no change. Do that as a comparison, not as a replacement: tree ensembles usually discriminate better and extrapolate worse, which is the wrong trade for a projection.

Up: MaxEnt Model Training & Tuning · Species Distribution Modeling with MaxEnt