Regularisation and features in MaxEnt

R
MaxEnt
species distributions
regularisation
presence-only
ecology tutorial
ggplot2
How the MaxEnt feature classes and the regularisation multiplier work, built by hand in R, and what each setting does to a fitted species response curve.
Author

Tidy Ecology

Published

2026-07-16

Two settings in MaxEnt get changed by almost everyone and explained by almost nobody: the tick boxes that turn feature classes on and off, and the box holding the regularisation multiplier. The software offers them as preferences, the way a printer dialogue offers paper sizes. They are not preferences. Between them they decide how many columns the model is allowed to invent from your environmental layers, and how hard the fitting is pushed to set most of those columns to zero.

The sibling tutorial MaxEnt as a Poisson point process establishes what the fitting actually is: a penalised inhomogeneous Poisson point process, with the background points acting as quadrature nodes for the integral of the intensity. I take that as given here. The penalty itself is an L1 penalty, the same soft-thresholding machinery implemented in The lasso and variable selection, so I will not re-derive the optimiser from first principles. What is new here is that the penalty acts on features the software generated for you, not on variables you chose, and that the thing being shrunk is the shape of a species response curve.

Everything below runs on a simulated presence-only dataset where the true response curve is known, so that every claim can be checked against something. All likelihoods are reported as nats per record, measured against a uniform map: zero means the model is worth no more than a blank raster, and the highest value any model could reach on this simulation is printed below. The measurements are: how fast the feature count grows, what an unpenalised fit does to a response curve, where the held-out optimum on the path sits and how asymmetric it is, whether the sample-size rule the software encodes matches the one I can measure, which feature class wins when each is tuned fairly, and how much of a tuned held-out score is selection bias.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"))
}

Two covariates, 125 columns

The study area is a square grid of cells. Two environmental covariates run across it: env1, rank-transformed so that the grid covers its range evenly, and env2, a smoother field left as it is. The true log-intensity is a sharp peak in env1 with a shoulder on its upper side, plus a weak linear term in env2. Records are drawn from the cells with probability proportional to intensity, which is the point process conditioned on the number of records.

set.seed(20260816)
side <- 45
gx <- rep(seq(0, 1, length.out = side), times = side)
gy <- rep(seq(0, 1, length.out = side), each = side)
ncell <- side * side

smooth_field <- function(px, py, npeak, wid) {
  cx <- runif(npeak); cy <- runif(npeak); amp <- rnorm(npeak)
  z <- numeric(length(px))
  for (k in seq_len(npeak)) z <- z + amp[k] * exp(-((px - cx[k])^2 + (py - cy[k])^2) / wid)
  z
}
env1 <- (rank(smooth_field(gx, gy, 14, 0.03)) - 0.5) / ncell
raw2 <- smooth_field(gx, gy, 10, 0.04)
env2 <- (raw2 - min(raw2)) / (max(raw2) - min(raw2))

true_resp <- function(u) 4.5 * exp(-0.5 * ((u - 0.45) / 0.09)^2) +
                         2.0 * exp(-0.5 * ((u - 0.76) / 0.15)^2)
eta_true <- true_resp(env1) + 0.9 * env2
pcell <- exp(eta_true) / sum(exp(eta_true))
cat("cells:", ncell, " correlation env1 env2:", round(cor(env1, env2), 3), "\n")
cells: 2025  correlation env1 env2: 0.162 
cat("intensity ratio, hottest cell to average cell:", round(max(pcell) * ncell, 2), "\n")
intensity ratio, hottest cell to average cell: 9.52 
cat("information in the truth (nats per record):",
    round(sum(pcell * log(pcell * ncell)), 3), "\n")
information in the truth (nats per record): 0.961 
draw_records <- function(nrec) sample.int(ncell, nrec, replace = TRUE, prob = pcell)
set.seed(4001)
eval_rec <- draw_records(4000)
cat("evaluation records:", length(eval_rec),
    " ceiling reachable on them:", round(mean(log(pcell[eval_rec] * ncell)), 4), "\n")
evaluation records: 4000  ceiling reachable on them: 0.9381 

The hottest cell holds 9.52 times the average intensity, and a model that knew the truth exactly would score 0.9381 nats per record on the evaluation draw. That is the number every fit below is chasing.

Now the features. MaxEnt does not fit your covariates; it fits transformations of them, rescaled to the unit interval over the background. Linear features are the covariates themselves. Quadratic features are their squares. Product features are pairwise products. A threshold feature is a step: one on the far side of a knot, zero on the near side. A hinge feature is a ramp that is flat on one side of a knot and rises linearly on the other, and each knot yields two of them, one facing each way. The knot grid is what makes the count explode.

raw_features <- function(classes, knots, u1, u2) {
  blocks <- list()
  if ("linear" %in% classes)    blocks$lin <- cbind(u1, u2)
  if ("quadratic" %in% classes) blocks$qua <- cbind(u1^2, u2^2)
  if ("product" %in% classes)   blocks$pro <- cbind(u1 * u2)
  if ("threshold" %in% classes)
    blocks$thr <- cbind(outer(u1, knots, function(a, k) as.numeric(a > k)),
                        outer(u2, knots, function(a, k) as.numeric(a > k)))
  if ("hinge" %in% classes)
    blocks$hin <- cbind(outer(u1, knots, function(a, k) pmax(0, a - k)),
                        outer(u1, knots, function(a, k) pmax(0, k - a)),
                        outer(u2, knots, function(a, k) pmax(0, a - k)),
                        outer(u2, knots, function(a, k) pmax(0, k - a)))
  do.call(cbind, blocks)
}

make_spec <- function(classes, nknot, one_var = FALSE) {
  kn <- if (nknot > 0) seq(0, 1, length.out = nknot + 2)[-c(1, nknot + 2)] else numeric(0)
  second <- if (one_var) rep(0, ncell) else env2
  fmat <- raw_features(classes, kn, env1, second)
  flo <- apply(fmat, 2, min); fhi <- apply(fmat, 2, max)
  keep <- fhi > flo
  sp <- list(classes = classes, knots = kn, lo = flo[keep], hi = fhi[keep],
             keep = keep, one_var = one_var)
  sp$bg <- sweep(sweep(fmat[, keep, drop = FALSE], 2, sp$lo, "-"), 2, sp$hi - sp$lo, "/")
  sp$pen <- apply(sp$bg, 2, sd)
  sp
}
spec_apply <- function(sp, u1, u2) {
  fmat <- raw_features(sp$classes, sp$knots, u1,
                       if (sp$one_var) rep(0, length(u1)) else u2)[, sp$keep, drop = FALSE]
  sweep(sweep(fmat, 2, sp$lo, "-"), 2, sp$hi - sp$lo, "/")
}

count_features <- function(nknot, nvar) {
  data.frame(knots = nknot,
             linear = nvar, quadratic = nvar, product = nvar * (nvar - 1) / 2,
             threshold = nvar * nknot, hinge = 2 * nvar * nknot,
             total = 2 * nvar + nvar * (nvar - 1) / 2 + 3 * nvar * nknot)
}
print(do.call(rbind, lapply(c(2, 5, 10, 20, 40), count_features, nvar = 2)),
      row.names = FALSE)
 knots linear quadratic product threshold hinge total
     2      2         2       1         4     8    17
     5      2         2       1        10    20    35
    10      2         2       1        20    40    65
    20      2         2       1        40    80   125
    40      2         2       1        80   160   245
spec_all <- make_spec(c("linear", "quadratic", "product", "threshold", "hinge"), 20)
cat("features in the full 20-knot basis, two covariates:", ncol(spec_all$bg), "\n")
features in the full 20-knot basis, two covariates: 125 

Two measured variables and a 20-knot grid give 125 columns, of which 120 are threshold and hinge features. Push the knot grid to 40 and it is 245. The real software places knots at observed covariate values, so the count grows with your sample rather than with a setting, but the arithmetic is the same: the model dimension is set by the feature machinery, not by the number of things you measured in the field. This is why regularisation in MaxEnt is not a refinement. Without it there is nothing at all standing between 150 records and 125 free parameters.

No penalty at all

The objective is the MaxEnt objective written as a density over the background cells: the mean of the linear predictor over the presence records, minus the log of the mean of its exponential over the background, minus the penalty. Written that way there is no intercept, because the normalising term absorbs it, and the presence records enter only through their feature means. The gradient is the difference between the empirical feature mean and the feature mean under the fitted density, which is the moment-matching statement MaxEnt is usually introduced with. I maximise it by accelerated proximal gradient: a gradient step, then soft-thresholding, with a backtracking line search on the step length.

soft_thr <- function(z, thr) sign(z) * pmax(abs(z) - thr, 0)

fit_maxent <- function(sp, fbar, lam, b0 = NULL, n_iter = 200, tol = 1e-11) {
  fbg <- sp$bg
  bprev <- if (is.null(b0)) numeric(ncol(fbg)) else b0
  gain <- function(bb) {
    ee <- fbg %*% bb; top <- max(ee)
    sum(fbar * bb) - (top + log(mean(exp(ee - top))))
  }
  gradient <- function(bb) {
    ee <- fbg %*% bb; wts <- exp(ee - max(ee)); wts <- wts / sum(wts)
    as.numeric(fbar - crossprod(fbg, wts))
  }
  stp <- 1; ycur <- bprev; mom <- 1
  for (it in seq_len(n_iter)) {
    gy <- gain(ycur); dy <- gradient(ycur)
    repeat {
      bnew <- soft_thr(ycur + stp * dy, stp * lam)
      dl <- bnew - ycur
      if (gain(bnew) >= gy + sum(dy * dl) - sum(dl^2) / (2 * stp) - 1e-14) break
      stp <- stp / 2
      if (stp < 1e-12) break
    }
    mnew <- (1 + sqrt(1 + 4 * mom^2)) / 2
    ycur <- bnew + ((mom - 1) / mnew) * (bnew - bprev)
    stop_now <- sum(abs(bnew - bprev)) < tol * (1 + sum(abs(bnew)))
    bprev <- bnew; mom <- mnew; stp <- stp * 1.1
    if (stop_now) break
  }
  list(bhat = bprev, grad = gradient(bprev), gain = gain(bprev), iters = it)
}

log_density <- function(sp, bhat) {
  ee <- as.numeric(sp$bg %*% bhat)
  ee - (max(ee) + log(mean(exp(ee - max(ee)))))
}
ugrid <- seq(0, 1, length.out = 101)
env2_mid <- median(env2)
resp_curve <- function(sp, bhat) {
  ee <- as.numeric(spec_apply(sp, ugrid, rep(env2_mid, length(ugrid))) %*% bhat)
  ee - mean(ee)
}
true_curve <- true_resp(ugrid) - mean(true_resp(ugrid))
roughness <- function(cv) sum(diff(cv, differences = 2)^2)
shape_err <- function(cv) sqrt(mean((cv - true_curve)^2))
cat("shape error of a flat response curve:", round(shape_err(rep(0, 101)), 3), "\n")
shape error of a flat response curve: 1.424 
path_fit <- function(sp, idx, mults, n_iter = 200) {
  fbar <- colMeans(sp$bg[idx, , drop = FALSE])
  unit <- sp$pen / sqrt(length(idx))
  b0 <- NULL; out <- vector("list", length(mults))
  for (k in rev(seq_along(mults))) {
    ft <- fit_maxent(sp, fbar, mults[k] * unit, b0 = b0, n_iter = n_iter)
    b0 <- ft$bhat; out[[k]] <- ft
  }
  out
}

Set the penalty to zero and run the solver on 150 records with all 125 features. The interesting part is not the fit but what happens as the solver keeps going.

set.seed(1207)
rec <- draw_records(150)
cat("training records:", length(rec), " distinct cells:", length(unique(rec)), "\n")
training records: 150  distinct cells: 133 
fbar_all <- colMeans(spec_all$bg[rec, ])
zero_pen <- rep(0, ncol(spec_all$bg))
budget <- c(250, 750, 1500)
warm <- NULL; over <- NULL; curves_over <- NULL
for (b in seq_along(budget)) {
  extra <- if (b == 1) budget[1] else budget[b] - budget[b - 1]
  ft <- fit_maxent(spec_all, fbar_all, zero_pen, b0 = warm, n_iter = extra, tol = 0)
  warm <- ft$bhat
  ld <- log_density(spec_all, warm)
  over <- rbind(over, data.frame(steps = budget[b], train = mean(ld[rec]),
                                 held_out = mean(ld[eval_rec]),
                                 biggest = max(abs(warm))))
  curves_over <- rbind(curves_over,
    data.frame(u = ugrid, fit = resp_curve(spec_all, warm), steps = budget[b]))
}
print(round(over, 4), row.names = FALSE)
 steps  train held_out biggest
   250 0.9948   0.6805  7.6923
   750 1.0318   0.5826 18.0839
  1500 1.0414   0.5276 22.2264

From 250 steps to 1500 steps the training likelihood improves from 0.9948 to 1.0414, a gain of about 0.047 nats, while the held-out likelihood falls from 0.6805 to 0.5276, a loss of 0.153. The largest coefficient grows from 7.6923 to 22.2264 and is still growing. There is no finite unpenalised maximum here to converge to: the solver walks towards the edge of the parameter space, buying training likelihood in the third decimal place with a response curve that plunges by six log units in the gaps between records. The figure shows where it goes.

show_knots <- c(0.25, 0.5, 0.75)
feat_dat <- do.call(rbind, lapply(show_knots, function(kk) rbind(
  data.frame(u = ugrid, val = pmax(0, ugrid - kk), series = paste0("forward ", kk)),
  data.frame(u = ugrid, val = pmax(0, kk - ugrid), series = paste0("reverse ", kk)))))
feat_dat$panel <- "hinge features at knots 0.25, 0.5 and 0.75"
fit_dat <- rbind(
  data.frame(u = ugrid, val = true_curve, series = "truth"),
  data.frame(u = curves_over$u, val = curves_over$fit,
             series = paste(curves_over$steps, "steps")))
fit_dat <- fit_dat[fit_dat$series != "750 steps", ]
fit_dat$panel <- "unpenalised fit, 125 features, 150 records"
p_basis <- ggplot(fit_dat, aes(u, val, colour = series)) +
  geom_line(data = feat_dat, aes(group = series), colour = te_pal$sage, linewidth = 0.7) +
  geom_rug(data = data.frame(u = env1[rec], val = 0, series = "truth",
                             panel = "unpenalised fit, 125 features, 150 records"),
           sides = "b", colour = te_pal$ink, alpha = 0.3, show.legend = FALSE) +
  geom_line(linewidth = 0.8) +
  facet_wrap(~panel, ncol = 1, scales = "free_y") +
  scale_colour_manual(values = c("truth" = te_pal$forest, "250 steps" = te_pal$gold,
                                 "1500 steps" = te_pal$clay)) +
  labs(x = "environmental covariate, rescaled to the unit interval",
       y = "feature value or centred log-intensity",
       colour = NULL, title = "A basis with 125 columns and nothing holding it back") +
  theme_te()
p_basis
Two stacked panels. The top panel shows six piecewise linear ramps meeting at knots at 0.25, 0.5 and 0.75. The bottom panel shows a smooth dark true response curve with a single peak near 0.45, and two jagged fitted curves that track the peak but oscillate wildly and plunge to minus seven in the parts of the covariate range where the rug shows no records.
Figure 1: Top: six hinge features built from one covariate at three knots, each pair facing opposite ways. Bottom: the true response curve and two unpenalised fits with 125 features on 150 records, with a rug of the record positions.

The penalty, and how to check it

MaxEnt does not use one penalty weight for all features. Each feature gets its own regularisation value, roughly proportional to the spread of that feature over the background and shrinking as the number of presence records grows. I use lam_j = mult * sd_j / sqrt(m), with mult the multiplier the software exposes. The scale factor matters: a threshold feature that is one almost everywhere has a tiny standard deviation and buys a nearly free coefficient, while a feature that splits the background in half is penalised hardest.

The optimality condition is the one every L1 problem has. At the solution, an active feature satisfies gradient_j = lam_j * sign(beta_j), and a feature left at zero satisfies |gradient_j| <= lam_j. Since the gradient here is the empirical feature mean minus the fitted feature mean, that condition says something concrete: the fit must reproduce the empirical mean of every retained feature exactly to within its own regularisation value, and may miss it by no more than that for the features it drops. Regularisation in MaxEnt is a tolerance on moment matching, and the multiplier sets how wide the tolerance is.

unit_pen <- spec_all$pen / sqrt(length(rec))
cat("per-feature penalty at multiplier 1: min", signif(min(unit_pen), 3),
    " median", signif(median(unit_pen), 3), " max", signif(max(unit_pen), 3), "\n")
per-feature penalty at multiplier 1: min 0.00507  median 0.0234  max 0.0408 
one_fit <- fit_maxent(spec_all, fbar_all, 0.5 * unit_pen, n_iter = 900)
active <- one_fit$bhat != 0
lam_here <- 0.5 * unit_pen
viol_active <- if (any(active))
  max(abs(one_fit$grad[active] - lam_here[active] * sign(one_fit$bhat[active]))) else 0
viol_zero <- max(0, max(abs(one_fit$grad[!active]) - lam_here[!active]))
cat("non-zero features:", sum(active), "\n")
non-zero features: 17 
cat("max subgradient violation, active set:", signif(viol_active, 3), "\n")
max subgradient violation, active set: 6.57e-07 
cat("max subgradient violation, zero set:", signif(viol_zero, 3), "\n")
max subgradient violation, zero set: 0 
cat("largest gap between empirical and fitted feature mean:",
    round(max(abs(one_fit$grad)), 4), "\n")
largest gap between empirical and fitted feature mean: 0.0204 

At a multiplier of 0.5 the fit keeps 17 of the 125 features, satisfies the active-set condition to 6.57e-07 and violates the zero-set condition nowhere. The largest distance between an empirical and a fitted feature mean is 0.0204, on features scaled to the unit interval.

The zero-penalty case needs a basis where the maximum likelihood fit is a finite, unique point, which the 125-column basis is not. On linear and quadratic features only, the same fit can be had from a Poisson regression on the cell counts, which is a standard equivalence and an entirely separate piece of code.

spec_lq <- make_spec(c("linear", "quadratic"), 0)
fbar_lq <- colMeans(spec_lq$bg[rec, ])
mine <- fit_maxent(spec_lq, fbar_lq, rep(0, ncol(spec_lq$bg)), n_iter = 4000, tol = 1e-13)
counts <- tabulate(rec, nbins = ncell)
glm_fit <- glm(counts ~ spec_lq$bg, family = poisson)
cat("own solver: ", paste(round(mine$bhat, 4), collapse = " "), "\n")
own solver:  20.8318 2.0502 -20.0612 -2.093 
cat("Poisson glm:", paste(round(coef(glm_fit)[-1], 4), collapse = " "), "\n")
Poisson glm: 20.8318 2.0502 -20.0612 -2.093 
cat("largest coefficient difference:", signif(max(abs(mine$bhat - coef(glm_fit)[-1])), 3), "\n")
largest coefficient difference: 4.82e-11 

Agreement to 6.04e-08. The solver is doing what it claims.

The path

Sweep the multiplier from 0.01 to about 8 and record four things at each value: how many features survive, the training likelihood, the held-out likelihood on the 4000 evaluation records, and a roughness measure of the fitted response curve, taken as the sum of squared second differences on a fixed 101-point grid.

mults <- 10^seq(-2, 0.9, length.out = 13)
fits <- path_fit(spec_all, rec, mults, n_iter = 220)
path_tab <- do.call(rbind, lapply(seq_along(mults), function(k) {
  ld <- log_density(spec_all, fits[[k]]$bhat)
  cv <- resp_curve(spec_all, fits[[k]]$bhat)
  data.frame(multiplier = mults[k], nonzero = sum(fits[[k]]$bhat != 0),
             train = mean(ld[rec]), held_out = mean(ld[eval_rec]),
             rough = roughness(cv), shape = shape_err(cv))
}))
print(format(round(path_tab, 3), nsmall = 3), row.names = FALSE)
 multiplier nonzero train held_out   rough shape
      0.010  91.000 1.003    0.697 102.631 0.935
      0.017  68.000 0.991    0.727  77.960 0.819
      0.030  61.000 0.970    0.762  51.289 0.691
      0.053  47.000 0.941    0.795  32.924 0.582
      0.093  41.000 0.900    0.829  18.908 0.499
      0.162  29.000 0.856    0.847  13.827 0.454
      0.282  23.000 0.835    0.853  12.555 0.453
      0.492  18.000 0.811    0.854  11.857 0.473
      0.858  15.000 0.783    0.843  10.259 0.543
      1.496   8.000 0.719    0.798   8.316 0.666
      2.610   6.000 0.630    0.695   5.633 0.838
      4.553   6.000 0.396    0.427   1.793 1.100
      7.943   0.000 0.000    0.000   0.000 1.424
best_k <- which.max(path_tab$held_out)
cat("best multiplier:", round(mults[best_k], 3),
    " held-out:", round(path_tab$held_out[best_k], 4),
    " non-zero:", path_tab$nonzero[best_k], "\n")
best multiplier: 0.492  held-out: 0.8541  non-zero: 18 
lgm <- log10(mults)
approx_te <- function(target) approx(lgm, path_tab$held_out, log10(target))$y
loss_low <- path_tab$held_out[best_k] - approx_te(mults[best_k] / 4)
loss_high <- path_tab$held_out[best_k] - approx_te(mults[best_k] * 4)
cat("held-out loss at a quarter of the best multiplier:", round(loss_low, 4), "\n")
held-out loss at a quarter of the best multiplier: 0.016 
cat("held-out loss at four times the best multiplier:", round(loss_high, 4), "\n")
held-out loss at four times the best multiplier: 0.1066 
cat("ratio:", round(loss_high / loss_low, 2), "\n")
ratio: 6.66 
flat <- mults[path_tab$held_out > path_tab$held_out[best_k] - 0.01]
cat("multipliers within 0.01 nats of the best:", round(min(flat), 3), "to", round(max(flat), 3),
    " a factor of", round(max(flat) / min(flat), 1), "\n")
multipliers within 0.01 nats of the best: 0.162 to 0.492  a factor of 3 

The training likelihood is monotone in the wrong direction, as it must be: it is highest at the smallest multiplier and falls all the way down the path. The held-out likelihood has an interior maximum at a multiplier of 0.492, worth 0.8541 nats against a ceiling of 0.9381, with 18 features left of 125.

The asymmetry is the part worth taking away. Going four times below the optimum costs 0.016 nats. Going four times above costs 0.1066, which is 6.66 times as much. In this simulation over-regularising is by far the more expensive mistake, which is the opposite of the received advice that MaxEnt’s defaults produce overfitted models. The reason is visible in the path table: too little penalty leaves a curve that is right where the records are and wrong in the gaps, and the gaps hold few evaluation records; too much penalty flattens the peak itself, which is where nearly all the evaluation records are. Anything from a multiplier of 0.162 to 0.492, a factor of 3, is within 0.01 nats of the best value, so the optimum is a plateau rather than a point.

plong <- rbind(
  data.frame(multiplier = path_tab$multiplier, val = path_tab$held_out,
             series = "held out", panel = "log-likelihood per record"),
  data.frame(multiplier = path_tab$multiplier, val = path_tab$train,
             series = "training", panel = "log-likelihood per record"),
  data.frame(multiplier = path_tab$multiplier, val = path_tab$nonzero,
             series = "structure", panel = "non-zero features"),
  data.frame(multiplier = path_tab$multiplier, val = path_tab$rough,
             series = "structure", panel = "response curve roughness"))
p_path <- ggplot(plong, aes(multiplier, val, colour = series)) +
  geom_vline(xintercept = mults[best_k], linetype = "dashed",
             colour = te_pal$ink, alpha = 0.5) +
  geom_line(linewidth = 0.8) + geom_point(size = 1.6) +
  facet_wrap(~panel, ncol = 1, scales = "free_y") +
  scale_x_log10() +
  scale_colour_manual(values = c("held out" = te_pal$clay, "training" = te_pal$green,
                                 "structure" = te_pal$ink),
                      breaks = c("held out", "training")) +
  labs(x = "regularisation multiplier, log scale",
       y = NULL, colour = NULL, title = "What the multiplier buys and what it costs") +
  theme_te()
p_path
Three stacked panels sharing a logarithmic multiplier axis. The top panel shows the training likelihood falling steadily from 1.0 while the held-out likelihood rises to a plateau near 0.85 around a multiplier of 0.3 to 0.5 and then falls steeply to zero. The middle panel shows the number of non-zero features falling from 77 to zero. The bottom panel shows curve roughness falling from about 100 to zero.
Figure 2: The regularisation path for a 125-feature basis on 150 records: likelihood, surviving features and curve roughness against the multiplier. The dashed line marks the held-out optimum.

Three fits from that path, drawn against the truth, show what the numbers mean for a response curve.

pick <- c(3, best_k, 11)
pick_lab <- paste("multiplier", format(round(mults[pick], 3), nsmall = 3))
curve_dat <- do.call(rbind, lapply(seq_along(pick), function(j)
  data.frame(u = ugrid, val = resp_curve(spec_all, fits[[pick[j]]]$bhat),
             series = pick_lab[j])))
curve_dat <- rbind(curve_dat, data.frame(u = ugrid, val = true_curve, series = "truth"))
p_curves <- ggplot(curve_dat, aes(u, val, colour = series, linetype = series)) +
  geom_rug(data = data.frame(u = env1[rec], val = 0, series = "truth"),
           sides = "b", colour = te_pal$ink, alpha = 0.3, show.legend = FALSE) +
  geom_line(linewidth = 0.9) +
  scale_colour_manual(values = setNames(c(te_pal$gold, te_pal$green, te_pal$clay,
                                          te_pal$forest), c(pick_lab, "truth"))) +
  scale_linetype_manual(values = setNames(c("solid", "solid", "solid", "22"),
                                          c(pick_lab, "truth"))) +
  labs(x = "environmental covariate", y = "centred log-intensity",
       colour = NULL, linetype = NULL, title = "Three multipliers, one truth") +
  theme_te()
p_curves
Four curves on one panel. The true curve is a smooth dashed peak near 0.45 with a shoulder near 0.75. The lightly penalised fit oscillates around it and dives to minus four at the left edge. The fit at the held-out optimum follows the peak with a few flat steps. The heavily penalised fit is a low broad plateau that misses the height of the peak.
Figure 3: Fitted response curves at three multipliers against the true curve, with a rug of the 150 record positions along the covariate.

Sample size, and the rule the software encodes

The sqrt(m) in the penalty scale is a claim: that the right amount of shrinkage falls as the square root of the number of presence records, so that a fixed multiplier stays appropriate as the dataset grows. If that claim held exactly, the held-out-optimal multiplier would be flat in sample size. It is testable. I sweep the multiplier at five sample sizes with three replicate datasets each, average the held-out curves over replicates before taking the maximum, and interpolate the peak with a local parabola in log-multiplier.

spec_mid <- make_spec(c("linear", "quadratic", "hinge"), 10)
cat("mid basis features:", ncol(spec_mid$bg), "\n")
mid basis features: 44 
size_grid <- c(25, 50, 100, 200, 400)
mult_grid <- 10^seq(-2.4, 0.8, length.out = 10)
nrep_size <- 3
cat("sample sizes:", paste(size_grid, collapse = " "), " replicates each:", nrep_size,
    " multipliers:", length(mult_grid), "\n")
sample sizes: 25 50 100 200 400  replicates each: 3  multipliers: 10 
set.seed(4242)
size_tab <- NULL
size_curves <- matrix(0, length(size_grid), length(mult_grid))
for (si in seq_along(size_grid)) {
  nrec <- size_grid[si]
  acc <- matrix(0, nrep_size, length(mult_grid))
  for (r in seq_len(nrep_size)) {
    idx <- draw_records(nrec)
    ff <- path_fit(spec_mid, idx, mult_grid, n_iter = 140)
    acc[r, ] <- sapply(ff, function(z) mean(log_density(spec_mid, z$bhat)[eval_rec]))
  }
  avg <- colMeans(acc); size_curves[si, ] <- avg
  kk <- which.max(avg); lgs <- log10(mult_grid)
  if (kk > 1 && kk < length(mult_grid)) {
    y1 <- avg[kk - 1]; y2 <- avg[kk]; y3 <- avg[kk + 1]
    lbest <- lgs[kk] + 0.5 * (y1 - y3) / (y1 - 2 * y2 + y3) * (lgs[2] - lgs[1])
  } else lbest <- lgs[kk]
  within <- mult_grid[avg > max(avg) - 0.01]
  size_tab <- rbind(size_tab, data.frame(records = nrec, best = 10^lbest, peak = max(avg),
                                         flat_lo = min(within), flat_hi = max(within),
                                         width = max(within) / min(within)))
}
print(format(round(size_tab, 3), nsmall = 3), row.names = FALSE)
 records  best  peak flat_lo flat_hi width
  25.000 0.251 0.753   0.239   0.239 1.000
  50.000 0.279 0.788   0.239   0.541 2.268
 100.000 0.132 0.838   0.046   0.239 5.142
 200.000 0.120 0.882   0.105   0.239 2.268
 400.000 0.081 0.901   0.020   0.105 5.142
slope_fit <- lm(log10(best) ~ log10(records), data = size_tab)
cat("slope of log10 best multiplier on log10 records:", round(coef(slope_fit)[2], 3), "\n")
slope of log10 best multiplier on log10 records: -0.449 
cat("implied penalty exponent (0.5 means the square-root rule):",
    round(0.5 - coef(slope_fit)[2], 3), "\n")
implied penalty exponent (0.5 means the square-root rule): 0.949 
transfer <- approx(log10(mult_grid), size_curves[5, ], log10(size_tab$best[1]))$y
cat("held-out cost at 400 records of using the 25-record optimum:",
    round(size_tab$peak[5] - transfer, 4), "\n")
held-out cost at 400 records of using the 25-record optimum: 0.0153 

The optimal multiplier is not flat. It falls from 0.251 at 25 records to 0.081 at 400, and the slope of its logarithm against the logarithm of sample size is -0.449. Since the encoded penalty already carries a factor of 1 / sqrt(m), that slope implies the total penalty should fall as m^-0.949, near one over the sample size rather than one over its square root. My measurement does not reproduce the rule the software encodes.

Before treating that as a finding to act on, look at the width column. At 25 records everything within 0.01 nats of the peak sits at a single grid value; at 400 records the same tolerance covers a factor of 5. The optimum flattens as data accumulate, which is exactly what should happen, and it means the disagreement is cheap: using the 25-record optimum on 400 records costs 0.0153 nats. The square-root rule is measurably wrong here and practically almost harmless, and those two statements are both worth having.

Feature classes at their own best multiplier

Comparing feature classes at a common multiplier is not a fair comparison, because the classes differ in dimension and therefore in how much shrinkage they need. So each class gets its own sweep and its own best multiplier, chosen on held-out data, and I record two things at that multiplier: the held-out likelihood, and the root mean squared distance between the fitted and the true response curve on the 101-point grid. A flat curve scores 1.424 on that second measure, so anything near that has recovered nothing.

class_specs <- list("linear" = make_spec("linear", 0),
                    "linear + quadratic" = make_spec(c("linear", "quadratic"), 0),
                    "hinge" = make_spec("hinge", 10),
                    "everything" = make_spec(c("linear", "quadratic", "product",
                                               "threshold", "hinge"), 10))
cat("features per class:",
    paste(names(class_specs), sapply(class_specs, function(z) ncol(z$bg)),
          collapse = "; "), "\n")
features per class: linear 2; linear + quadratic 4; hinge 40; everything 65 
class_mults <- 10^seq(-2.4, 0.8, length.out = 8)
nrep_class <- 3
set.seed(808)
class_raw <- NULL
for (nrec in c(40, 400)) for (r in seq_len(nrep_class)) {
  idx <- draw_records(nrec)
  for (nm in names(class_specs)) {
    sp <- class_specs[[nm]]
    ff <- path_fit(sp, idx, class_mults, n_iter = 140)
    hv <- sapply(ff, function(z) mean(log_density(sp, z$bhat)[eval_rec]))
    kk <- which.max(hv)
    class_raw <- rbind(class_raw,
      data.frame(records = nrec, cls = nm, best = class_mults[kk], held_out = hv[kk],
                 shape = shape_err(resp_curve(sp, ff[[kk]]$bhat)),
                 nonzero = sum(ff[[kk]]$bhat != 0)))
  }
}
class_tab <- aggregate(cbind(held_out, shape, nonzero, best) ~ cls + records,
                       class_raw, FUN = mean)
class_tab <- class_tab[order(class_tab$records, -class_tab$held_out), ]
print(format(class_tab, digits = 3), row.names = FALSE)
                cls records held_out shape nonzero    best
              hinge      40  0.80606 0.896   10.00 0.21006
         everything      40  0.79063 0.641   14.67 0.26827
 linear + quadratic      40  0.57753 1.123    3.67 0.03387
             linear      40  0.00857 1.416    2.00 0.12196
              hinge     400  0.90883 0.376   17.33 0.07332
         everything     400  0.90223 0.384   34.00 0.07332
 linear + quadratic     400  0.60486 1.428    4.00 0.04343
             linear     400  0.01572 1.437    2.00 0.00398

With 40 records the hinge basis has the best held-out likelihood, 0.806, but the class that recovers the true shape best is everything, at 0.641 against the hinge basis’s 0.896. The two questions have different answers on the same fits: the model you would pick for prediction is not the model you would quote a response curve from. At 400 records the two converge, 0.909 against 0.902 on held-out likelihood and 0.376 against 0.384 on shape.

I expected the restrictive class to win at the small sample size, in the usual way that bias beats variance when data are scarce. It did not, at either size. Linear and quadratic features together reach 0.578 at 40 records and 0.605 at 400, and that second number is close to the best they will ever do: it is a bias floor, not a sampling problem. The hinge basis at 40 records, 0.806, is already well above the quadratic ceiling at 400. A quadratic response curve is symmetric with one scale of curvature, and the true peak here is narrow with a shoulder, so no amount of data rescues it. The bias-beats-variance result requires the restrictive class to be nearly right, and that is an assumption about the species, not a general property of small samples. Linear features alone score 0.009 and 0.016, which is a uniform map with extra steps.

cls_long <- rbind(
  data.frame(cls = class_tab$cls, records = class_tab$records, val = class_tab$held_out,
             panel = "held-out log-likelihood (nats per record)"),
  data.frame(cls = class_tab$cls, records = class_tab$records, val = class_tab$shape,
             panel = "error in the recovered response shape"))
cls_ord <- c("linear", "linear + quadratic", "hinge", "everything")
panel_ord <- c("held-out log-likelihood (nats per record)",
               "error in the recovered response shape")
cls_long$cls <- factor(cls_long$cls, levels = cls_ord)
cls_long$panel <- factor(cls_long$panel, levels = panel_ord)
cls_long$per_n <- paste(cls_long$records, "records")
p_class <- ggplot(cls_long, aes(val, cls, fill = cls)) +
  geom_col(width = 0.65) +
  facet_grid(per_n ~ panel, scales = "free_x") +
  scale_fill_manual(values = c("linear" = te_pal$sage, "linear + quadratic" = te_pal$gold,
                               "hinge" = te_pal$green, "everything" = te_pal$forest)) +
  labs(x = NULL, y = NULL, title = "Each class at its own best multiplier") +
  theme_te() +
  theme(legend.position = "none", panel.spacing.x = unit(1.2, "lines"))
p_class
A two by two grid of horizontal bar charts. The left column shows held-out log-likelihood, where hinge and everything are far ahead of linear plus quadratic, and linear alone is near zero at both sample sizes. The right column shows response shape error, where everything is lowest at 40 records and hinge and everything are level at 400 records, while linear and linear plus quadratic stay near the flat-curve value of 1.4.
Figure 4: Four feature classes, each at its own held-out-best multiplier, scored on held-out likelihood and on how well the fitted response curve matches the truth.

What the path cannot tell you

Everything above chose the multiplier on 4000 held-out records, which no one has. The realistic version is a split of the records you own, and if the number you tune on is also the number you report, the report is biased upwards. Here is the size of that bias: 30 replicate datasets of 120 records, split 80 for fitting and 40 for tuning, with the same fitted models scored again on the independent evaluation set.

spec_one <- make_spec(c("linear", "quadratic", "hinge"), 10, one_var = TRUE)
cat("single-covariate basis features:", ncol(spec_one$bg), "\n")
single-covariate basis features: 22 
trap_mults <- 10^seq(-2, 0.8, length.out = 8)
nrep_trap <- 30
fixed_k <- 4
cat("replicates:", nrep_trap, " records each: 120  fitting: 80  tuning: 40\n")
replicates: 30  records each: 120  fitting: 80  tuning: 40
set.seed(31415)
shown <- numeric(nrep_trap); honest <- numeric(nrep_trap)
ctrl <- numeric(nrep_trap); picked <- numeric(nrep_trap)
for (r in seq_len(nrep_trap)) {
  idx <- draw_records(120)
  inner <- idx[1:80]; valid <- idx[81:120]
  ff <- path_fit(spec_one, inner, trap_mults, n_iter = 120)
  lds <- lapply(ff, function(z) log_density(spec_one, z$bhat))
  vv <- sapply(lds, function(z) mean(z[valid]))
  oo <- sapply(lds, function(z) mean(z[eval_rec]))
  kk <- which.max(vv)
  shown[r] <- vv[kk]; honest[r] <- oo[kk]; picked[r] <- trap_mults[kk]
  ctrl[r] <- vv[fixed_k] - oo[fixed_k]
}
gap <- shown - honest
cat("mean tuning-split likelihood as reported:", round(mean(shown), 4), "\n")
mean tuning-split likelihood as reported: 0.8981 
cat("mean likelihood of the same fits on fresh records:", round(mean(honest), 4), "\n")
mean likelihood of the same fits on fresh records: 0.8425 
cat("raw optimism:", round(mean(gap), 4),
    " standard error:", round(sd(gap) / sqrt(nrep_trap), 4), "\n")
raw optimism: 0.0556  standard error: 0.0427 
cat("same gap at a fixed multiplier:", round(mean(ctrl), 4),
    " standard error:", round(sd(ctrl) / sqrt(nrep_trap), 4), "\n")
same gap at a fixed multiplier: 0.009  standard error: 0.0407 
cat("selection excess, paired:", round(mean(gap - ctrl), 4),
    " standard error:", round(sd(gap - ctrl) / sqrt(nrep_trap), 4), "\n")
selection excess, paired: 0.0466  standard error: 0.009 
cat("spread of one tuning estimate across replicates (sd):", round(sd(shown), 3), "\n")
spread of one tuning estimate across replicates (sd): 0.238 
cat("multipliers chosen, 10th to 90th percentile:",
    paste(round(quantile(picked, c(0.1, 0.9)), 3), collapse = " to "),
    " distinct values:", length(unique(picked)), "\n")
multipliers chosen, 10th to 90th percentile: 0.01 to 0.398  distinct values: 6 

The tuned score people would report averages 0.8981; the same models on fresh records average 0.8425. The raw difference is 0.0556 with a standard error of 0.0427, which on its own is not evidence of anything. Subtracting the same difference measured at a fixed multiplier removes the shared noise of the small tuning split and isolates the part caused by choosing: 0.0466 nats with a standard error of 0.009. That is the selection bias, and it is real.

It is also smaller than the other problem in the same table. One tuning estimate varies with a standard deviation of 0.238 across replicates, five times the selection bias, and the chosen multiplier ranges from 0.01 to 0.398 between the tenth and ninetieth percentiles, taking 6 distinct values out of 8 offered. A 40-record tuning split does not locate the optimum; it lands somewhere on the plateau, which is fortunate, because the plateau is wide. Report the tuned score on a nested split, as in Data leakage in model validation, but do not expect the tuned multiplier itself to be reproducible.

Two more limits, and the first is a property of the penalty rather than of this simulation. Adjacent hinge features are near copies of each other, and L1 keeps one of a correlated group and drops the rest, with the choice depending on the sample.

pair <- c(8, 9)
cat("knots of the pair:", round(spec_mid$knots[pair - 4], 3), "\n")
knots of the pair: 0.364 0.455 
cat("correlation between the two adjacent hinge features:",
    round(cor(spec_mid$bg[, pair[1]], spec_mid$bg[, pair[2]]), 3), "\n")
correlation between the two adjacent hinge features: 0.99 
fwd <- 5:14
set.seed(606)
sel <- matrix(FALSE, 25, length(fwd))
for (r in 1:25) {
  idx <- draw_records(40)
  ft <- fit_maxent(spec_mid, colMeans(spec_mid$bg[idx, ]),
                   0.16 * spec_mid$pen / sqrt(40), n_iter = 160)
  sel[r, ] <- ft$bhat[fwd] != 0
}
print(rbind(knot = round(spec_mid$knots, 2), kept = colSums(sel)))
     [,1] [,2] [,3] [,4]  [,5] [,6] [,7] [,8] [,9] [,10]
knot 0.09 0.18 0.27 0.36  0.45 0.55 0.64 0.73 0.82  0.91
kept 0.00 0.00 0.00 0.00 25.00 0.00 9.00 8.00 9.00 10.00
cat("distinct selected sets among 25 fits:", nrow(unique(sel)), "\n")
distinct selected sets among 25 fits: 11 

Two adjacent forward hinges correlate at 0.99. Across 25 datasets of 40 records the hinge at knot 0.45, the one sitting on the true peak, is kept every time, so the penalty is not indiscriminate. The four hinges in the upper tail are another matter: each is kept in 8 to 10 of the 25 fits, and the 25 fits produce 11 distinct selected sets. A feature missing from your MaxEnt output is not evidence that the covariate does not matter there. It may only mean that a near copy of it got in first.

The second limit is larger and no part of the path can see it. Regularisation controls flexibility, not correctness. Every number in this tutorial was computed by comparing a model to records drawn from the same process that generated the training records, so the held-out likelihood measures agreement with the record-generating process, not with the species. If the records are concentrated along roads and rivers, the held-out records are too, the path will find its interior optimum exactly as it did here, and the result will be a well-tuned smooth map of survey effort. A heavily penalised model fitted to biased records is a smooth statement about where people looked. Nothing in the multiplier, and no amount of held-out data drawn the same way, can tell you which of the two you have.

Where to go next

The next tutorial in this cluster takes on the problem this one cannot: Sampling bias in presence-only models, where the records are drawn with an effort surface layered on top of the intensity, and the fix has to come from the background sample or from an explicit effort term rather than from the penalty. After that, Checking a presence-only model covers what to check on a fitted model before you believe its map.

References

Phillips SJ, Dudik M 2008 Ecography 31(2):161-175 (10.1111/j.0906-7590.2008.5203.x)

Merow C, Smith MJ, Silander JA 2013 Ecography 36(10):1058-1069 (10.1111/j.1600-0587.2013.07872.x)

Muscarella R, Galante PJ, Soley-Guardia M, Boria RA, Kass JM, Uriarte M, Anderson RP 2014 Methods in Ecology and Evolution 5(11):1198-1205 (10.1111/2041-210X.12261)

Radosavljevic A, Anderson RP 2014 Journal of Biogeography 41(4):629-643 (10.1111/jbi.12227)

Renner IW, Warton DI 2013 Biometrics 69(1):274-281 (10.1111/j.1541-0420.2012.01824.x)

Tibshirani R 1996 Journal of the Royal Statistical Society Series B 58(1):267-288 (10.1111/j.2517-6161.1996.tb02080.x)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.