---
title: "Integrated species distribution models in R"
description: "Combine presence-only records with a structured occupancy survey in one likelihood, correcting sampling bias and sharpening the environmental response."
date: "2026-07-23 09:00"
categories: [species distribution, data integration, point process, occupancy]
image: thumbnail.png
image-alt: "Two occupancy maps, true and integrated, on a paper to forest colour gradient."
---
Two kinds of data describe where a species lives, and they have opposite strengths. Opportunistic presence-only records are plentiful but sampled wherever people happen to go, so their density confounds the environment with survey effort. A structured survey visits chosen sites on a fixed protocol, so it is unbiased and carries an absolute scale, but it is expensive and therefore sparse. An integrated model fits both in a single likelihood, letting the structured survey calibrate the bias in the presence-only stream while the presence-only stream lends its coverage to the estimate.
This post builds that model from scratch in base R. We simulate one landscape with a known environmental response, sample it two ways, and compare four estimators: presence-only alone, presence-only with an effort covariate, the survey alone, and the integrated fit.
## A landscape and two samples
The intensity of a point process gives the expected number of individuals per unit area. We set a log-linear intensity driven by one environmental covariate, then thin the points by an accessibility surface that stands in for survey effort. Accessibility is correlated with the environment, which is what makes the naive presence-only estimate go wrong.
```{r setup}
#| include: false
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
fig.width = 7.5, fig.height = 4.6, dpi = 150, dev = "png")
library(ggplot2)
te <- list(ink="#16241d", body="#2c3a31", forest="#275139", label="#46604a",
sage="#93a87f", paper="#f5f4ee", line="#dad9ca", card="#ffffff",
faint="#5d6b61", grazed="#cda23f", mown="#2f8f63", abandoned="#b5534e",
grad_low="#c9b458", grad_high="#1d5b4e")
theme_te <- function(base_size = 12) {
theme_minimal(base_size = base_size) +
theme(text=element_text(colour=te$body),
plot.title=element_text(colour=te$ink, face="bold", size=base_size+1),
plot.subtitle=element_text(colour=te$faint, size=base_size-1),
axis.title=element_text(colour=te$label),
axis.text=element_text(colour=te$faint, size=base_size-2),
panel.grid.major=element_line(colour=te$line, linewidth=0.3),
panel.grid.minor=element_blank(),
panel.background=element_rect(fill=te$card, colour=NA),
plot.background=element_rect(fill=te$card, colour=NA),
legend.title=element_text(colour=te$label, size=base_size-2),
legend.text=element_text(colour=te$faint, size=base_size-2),
strip.text=element_text(colour=te$ink, face="bold", size=base_size-1),
strip.background=element_rect(fill=te$paper, colour=NA),
plot.margin=margin(10,12,10,10))
}
```
```{r smooth-field}
# a smooth spatial field on a g x g grid, standardised
smooth_field <- function(g, l, seed) {
set.seed(seed)
u <- (seq_len(g) - 0.5) / g
D <- outer(u, u, function(a, b) exp(-(a - b)^2 / (2 * l^2)))
Sm <- D / rowSums(D)
as.numeric(scale(as.numeric(Sm %*% matrix(rnorm(g * g), g, g) %*% t(Sm))))
}
g <- 50 # grid resolution
Acell <- 1 / (g * g) # area of one grid cell (unit square)
x <- smooth_field(g, 0.18, 4711) # environmental covariate
raw <- smooth_field(g, 0.18, 9001)
acc <- as.numeric(scale(0.6 * x + sqrt(1 - 0.36) * raw)) # accessibility, rho ~ 0.6
b1_true <- 1.0 # true environmental response
a1_true <- 1.6 # effort effect on detection of records
p_true <- 0.40 # per-visit detection in the survey
Asite <- 0.0004 # area of one survey site
EN <- 4000 # expected number of individuals
b0_true <- log(EN / (Acell * sum(exp(b1_true * x))))
round(c(cor_x_acc = cor(x, acc), b0_true = b0_true), 3)
```
```{r simulate}
set.seed(1)
lambda <- exp(b0_true + b1_true * x) # true intensity surface
N <- rpois(1, Acell * sum(lambda))
cell <- sample(length(x), N, replace = TRUE, prob = lambda / sum(lambda))
bias <- exp(a1_true * acc) # effort-driven thinning
keep <- rbinom(N, 1, pmin(1, bias[cell] / max(bias)))
po <- tabulate(cell[keep == 1], nbins = length(x)) # presence-only counts per cell
nsite <- 300
ssite <- sample(length(x), nsite) # structured survey sites
K <- 4 # repeat visits
psi <- 1 - exp(-Asite * exp(b0_true + b1_true * x[ssite])) # cloglog occupancy
z <- rbinom(nsite, 1, psi)
y <- rbinom(nsite, K, z * p_true)
c(records = sum(po), sites_detected = sum(y > 0), true_occupancy = round(mean(psi), 3))
```
The complementary log-log link is the glue here. If individuals fall as a Poisson process of intensity lambda, the probability that a site of area A holds at least one individual is 1 minus exp(-A times lambda), which is exactly a cloglog occupancy model with an offset of log(A). That shared form lets the survey and the point process speak about the same intensity surface.
## Four estimators
We fit each model by maximising its likelihood with `optim`. The presence-only likelihood is the standard inhomogeneous Poisson process likelihood evaluated on the grid; the occupancy likelihood marginalises over the latent presence state.
```{r fits}
# presence-only point process likelihood on the grid
nll_po <- function(par, use_acc) {
b1 <- par[1]; s0 <- par[2]
lp <- s0 + b1 * x
if (use_acc) lp <- lp + par[3] * acc
mu <- exp(lp)
-(sum(po * log(mu)) - Acell * sum(mu))
}
fit_po_naive <- optim(c(1, 0), nll_po, use_acc = FALSE, method = "BFGS", hessian = TRUE)
fit_po_effort <- optim(c(1, 0, 0), nll_po, use_acc = TRUE, method = "BFGS", hessian = TRUE)
# survey-only occupancy, marginalising the latent state
nll_occ <- function(par) {
b0 <- par[1]; b1 <- par[2]; p <- plogis(par[3])
ps <- 1 - exp(-Asite * exp(b0 + b1 * x[ssite]))
-sum(log(ps * dbinom(y, K, p) + (1 - ps) * (y == 0)))
}
fit_occ <- optim(c(b0_true, 1, 0), nll_occ, method = "BFGS", hessian = TRUE)
# integrated: shared b0, b1; the records add their own intercept and effort slope
nll_joint <- function(par) {
b0 <- par[1]; b1 <- par[2]; a0 <- par[3]; p <- plogis(par[4]); a1 <- par[5]
mu <- exp(b0 + b1 * x + a0 + a1 * acc)
ll_po <- sum(po * log(mu)) - Acell * sum(mu)
ps <- 1 - exp(-Asite * exp(b0 + b1 * x[ssite]))
ll_occ <- sum(log(ps * dbinom(y, K, p) + (1 - ps) * (y == 0)))
-(ll_po + ll_occ)
}
fit_joint <- optim(c(b0_true, 1, -3, 0, 0), nll_joint, method = "BFGS", hessian = TRUE)
se <- function(f, i) sqrt(diag(solve(f$hessian)))[i]
b1_naive <- fit_po_naive$par[1]
b1_effort <- fit_po_effort$par[1]
b1_occ <- fit_occ$par[2]; se_occ <- se(fit_occ, 2)
b1_joint <- fit_joint$par[2]; se_joint <- se(fit_joint, 2)
round(c(naive = b1_naive, effort = b1_effort, occupancy = b1_occ, integrated = b1_joint), 3)
```
The naive presence-only slope is `r round(b1_naive, 2)`, well above the truth of `r b1_true`, because accessibility masquerades as the environment. Add the effort covariate and the point process recovers `r round(b1_effort, 2)`, but its intercept mixes the true abundance with the sampling rate, so it has no absolute scale. The survey alone gives `r round(b1_occ, 2)` and does carry a scale, but with a wide standard error of `r round(se_occ, 3)`. The integrated fit matches the survey slope at `r round(b1_joint, 2)` while cutting the standard error to `r round(se_joint, 3)`, about `r round(100 * se_joint / se_occ)` per cent of the survey-only value.
```{r}
#| label: fig-response
#| fig-cap: "Relative environmental response by estimator. The naive presence-only curve is too steep; the integrated fit tracks the truth."
#| fig-alt: "Line plot of relative response against the environmental covariate for four estimators, with the naive curve steepest and the integrated curve matching the dashed truth."
xg <- seq(-2.5, 2.5, length.out = 100)
rel <- function(b1) b1 * xg - b1 * mean(xg)
dl <- rbind(
data.frame(x = xg, y = rel(b1_true), who = "truth"),
data.frame(x = xg, y = rel(b1_naive), who = "naive presence-only"),
data.frame(x = xg, y = rel(b1_occ), who = "survey only"),
data.frame(x = xg, y = rel(b1_joint), who = "integrated"))
ggplot(dl, aes(x, y, colour = who, linetype = who)) +
geom_line(linewidth = 0.9) +
scale_colour_manual(values = c("truth" = te$abandoned, "naive presence-only" = te$grazed,
"survey only" = te$sage, "integrated" = te$forest)) +
scale_linetype_manual(values = c("truth" = "dashed", "naive presence-only" = "solid",
"survey only" = "solid", "integrated" = "solid")) +
labs(x = "environmental covariate", y = "relative log-intensity",
title = "The integrated model recovers the true response",
subtitle = "Naive presence-only overstates the slope; effort correction and integration fix it",
colour = NULL, linetype = NULL) +
theme_te() + theme(legend.position = c(0.28, 0.8))
```
## Does it hold up across data sets?
One realisation could be luck. We repeat the whole exercise many times and record the slope from the survey-only and integrated fits.
```{r mc}
run_one <- function(seed) {
set.seed(seed)
lam <- exp(b0_true + b1_true * x)
Nn <- rpois(1, Acell * sum(lam))
cl <- sample(length(x), Nn, replace = TRUE, prob = lam / sum(lam))
bi <- exp(a1_true * acc)
kp <- rbinom(Nn, 1, pmin(1, bi[cl] / max(bi)))
po2 <- tabulate(cl[kp == 1], nbins = length(x))
ss <- sample(length(x), nsite)
ps <- 1 - exp(-Asite * exp(b0_true + b1_true * x[ss]))
zz <- rbinom(nsite, 1, ps); yy <- rbinom(nsite, K, zz * p_true)
nll_o <- function(par) { b0<-par[1];b1<-par[2];p<-plogis(par[3])
q<-1-exp(-Asite*exp(b0+b1*x[ss])); -sum(log(q*dbinom(yy,K,p)+(1-q)*(yy==0))) }
nll_j <- function(par) { b0<-par[1];b1<-par[2];a0<-par[3];p<-plogis(par[4]);a1<-par[5]
mu<-exp(b0+b1*x+a0+a1*acc); lpo<-sum(po2*log(mu))-Acell*sum(mu)
q<-1-exp(-Asite*exp(b0+b1*x[ss])); lo<-sum(log(q*dbinom(yy,K,p)+(1-q)*(yy==0))); -(lpo+lo) }
fo <- tryCatch(optim(c(b0_true,1,0), nll_o, method="BFGS", hessian=TRUE), error=function(e) NULL)
fj <- tryCatch(optim(c(b0_true,1,-3,0,0), nll_j, method="BFGS", hessian=TRUE), error=function(e) NULL)
if (is.null(fo) || is.null(fj)) return(rep(NA, 4))
so <- sqrt(diag(solve(fo$hessian)))[2]; sj <- sqrt(diag(solve(fj$hessian)))[2]
c(fo$par[2], so, fj$par[2], sj)
}
M <- 100
mc <- t(sapply(1:M, function(m) run_one(1000 + m)))
mc <- mc[stats::complete.cases(mc), ]
cover <- function(est, s) mean(abs(est - b1_true) <= 1.96 * s)
occ_sd <- sd(mc[, 1]); joint_sd <- sd(mc[, 3])
round(c(occ_mean = mean(mc[, 1]), joint_mean = mean(mc[, 3]),
occ_sd = occ_sd, joint_sd = joint_sd,
sd_ratio = joint_sd / occ_sd,
joint_coverage = cover(mc[, 3], mc[, 4])), 3)
```
Across `r nrow(mc)` data sets both estimators are unbiased, centred near `r b1_true`, but the integrated slope varies less: its standard deviation is about `r round(100 * joint_sd / occ_sd)` per cent of the survey-only spread. That is the whole point of integration. You are not choosing between the two data sets; you are letting each cover the other's weakness.
```{r}
#| label: fig-forest
#| fig-cap: "Slope estimates across four estimators with the truth marked. Presence-only alone is badly biased; the intercept is only identified once the survey enters."
#| fig-alt: "Point estimates with error bars for the slope from four estimators and a facet for the intercept, showing the naive slope high and the integrated slope on the truth."
est <- data.frame(
method = factor(c("naive\npresence-only", "presence-only\n+ effort", "survey\nonly", "integrated"),
levels = c("naive\npresence-only", "presence-only\n+ effort", "survey\nonly", "integrated")),
b1 = c(b1_naive, b1_effort, b1_occ, b1_joint),
se = c(se(fit_po_naive, 1), se(fit_po_effort, 1), se_occ, se_joint))
ggplot(est, aes(method, b1)) +
geom_hline(yintercept = b1_true, colour = te$abandoned, linetype = "dashed") +
geom_errorbar(aes(ymin = b1 - 1.96 * se, ymax = b1 + 1.96 * se), width = 0.16,
colour = te$forest, linewidth = 0.7) +
geom_point(size = 2.8, colour = te$forest) +
labs(x = NULL, y = "environmental response (b1)",
title = "Only integration gives both a correct slope and a scale",
subtitle = "Dashed line is the true response") +
theme_te()
```
## When to reach for this
Integration earns its keep when a large biased data set sits beside a small clean one, and when a plausible covariate explains the bias. It cannot rescue you if the bias is unmodelled and correlated with the environment, and it assumes both streams see the same underlying surface. The next tutorial checks exactly that assumption, showing how a joint model can hide a disagreement between its two data sources.
## Related tutorials
- [Logistic regression for presence-absence](../logistic-regression-presence-absence/)
- [GLMs for count data](../glm-count-data-abundance/)
- [Cleaning GBIF occurrence data](../cleaning-gbif-occurrence-data/)
- [Checking an integrated SDM](../checking-an-integrated-sdm/)
## References
Dorazio RM (2014) Global Ecology and Biogeography 23: 1472-1484. doi:10.1111/geb.12216
Fithian W, Elith J, Hastie T, Keith DA (2015) Methods in Ecology and Evolution 6: 424-438. doi:10.1111/2041-210X.12242
Koshkina V, Wang Y, Gordon A, Dorazio RM, White M, Stone L (2017) Methods in Ecology and Evolution 8: 420-430. doi:10.1111/2041-210X.12738
Fletcher RJ, Hefley TJ, Robertson EP, Zuckerberg B, McCleery RA, Dorazio RM (2019) Ecology 100: e02710. doi:10.1002/ecy.2710
Miller DAW, Pacifici K, Sanderlin JS, Reich BJ (2019) Methods in Ecology and Evolution 10: 22-37. doi:10.1111/2041-210X.13110
Isaac NJB, Jarzyna MA, Keil P, et al. (2020) Trends in Ecology and Evolution 35: 56-67. doi:10.1016/j.tree.2019.08.006