The abundance-occupancy relationship

R
macroecology
occupancy
ecology tutorial
ggplot2
Occupancy is a censored count, so much of the abundance-occupancy relationship is arithmetic: simulate it in R and measure what its scatter really records.
Author

Tidy Ecology

Published

2026-07-25

The atlas arrives with a map for every species and a table at the back. The table has two columns that everybody looks at: the number of grid squares a species was found in, and the mean count per square. Plot one against the other and the picture is always the same. The species that turn up everywhere are the ones that turn up in numbers where they turn up at all, and the species confined to a handful of squares are scarce inside those squares too. On log axes the cloud is close to a straight line, the correlation is high, and it holds for birds, beetles, plants and diatoms.

The interspecific abundance-occupancy relationship is among the most repeated patterns in macroecology, and it gets read as biology almost automatically. Species with broad niches can use more of the landscape and reach higher densities in it. Species with large populations send out enough dispersers to rescue local patches from extinction, so they occupy more of them. Both stories are plausible, both have literature behind them, and neither is what this post is about.

The objection is arithmetic. Occupancy is not an independent measurement of a species. It is the count data with everything above zero thrown away. Once a species has a mean density and a spatial distribution of individuals, its occupancy is fixed: there is nothing left to explain. A positive relationship between the two axes is therefore what you get from a model that contains no ecology at all, and the interesting question is not whether the relationship is there but whether anything is left in it after the arithmetic has been taken out.

So this post measures four things. What occupancy an identity says a species must have, given its density and how clumped it is. How strong an abundance-occupancy relationship a generating model with no biology in it produces. How much of the scatter around that relationship is a readout of spatial aggregation rather than of niche breadth. And what changing the grid or missing the species does to the slope everybody quotes.

The landscape is simulated, for the usual reason: a simulated assemblage comes with the generating parameters written down, so the mean density and the aggregation of every species are known rather than estimated, and any claim about what drives occupancy can be checked against what actually did. Mean density here is the generating parameter and not a sample statistic, which also means that nothing in the relationships below comes from sampling error shared between the two axes.

Two neighbouring posts cover the adjacent jobs and are not repeated here. Imperfect detection and occupancy fits the occupancy model that separates detection from presence, and zeta diversity across many sites treats the occupancy frequency distribution as a readout of turnover. This post stays on the ground between the two axes: what occupancy inherits from abundance, and what is left over.

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 = te_pal$ink),
          legend.position = "bottom")
}

Occupancy is a censored count

Take one species with mean density mu individuals per cell, and suppose the individuals fall independently, so the count in a cell is Poisson. The probability that a cell holds nothing is exp(-mu), so the expected occupancy is

\[\psi = 1 - e^{-\mu}\]

and that is the whole derivation. No parameter has been fitted and no biology has been assumed beyond the independence of individuals.

Individuals are not independent. They aggregate, and the standard one-parameter description of that is the negative binomial, whose aggregation parameter k runs from small values for a heavily clumped species up to infinity for a species with no clumping at all, where it reduces to the Poisson. Its probability of a zero is (1 + mu/k)^(-k), so

\[\psi = 1 - \left(1 + \frac{\mu}{k}\right)^{-k}\]

Both are identities, not models to be fitted, and both say the same thing: occupancy is a function of mean density and of one shape parameter, and nothing else enters. A simulator that disagrees with them is broken, so the first thing to do is check that this one does not.

occ_poisson <- function(mu) 1 - exp(-mu)
occ_nb <- function(mu, k) 1 - (1 + mu / k)^(-k)

set.seed(20260725)
n_cal <- 2e6
mu_cal <- c(0.1, 0.5, 2, 8)
k_cal <- c(1, 0.25)
lab <- c("Poisson", "NB k = 1", "NB k = 0.25")

cal <- NULL
for (j in seq_along(lab)) for (mu in mu_cal) {
  if (j == 1) {
    from_formula <- occ_poisson(mu)
    simulated <- mean(rpois(n_cal, mu) > 0)
  } else {
    kk <- k_cal[j - 1]
    from_formula <- occ_nb(mu, kk)
    simulated <- mean(rnbinom(n_cal, mu = mu, size = kk) > 0)
  }
  se <- sqrt(from_formula * (1 - from_formula) / n_cal)
  cal <- rbind(cal, c(mu = mu, formula = from_formula, simulated = simulated,
                      difference = simulated - from_formula,
                      z = (simulated - from_formula) / se))
}
rownames(cal) <- paste(rep(lab, each = length(mu_cal)), rep(mu_cal, 3))
print(round(cal, 5))
                 mu formula simulated difference        z
Poisson 0.1     0.1 0.09516   0.09497   -0.00020 -0.94260
Poisson 0.5     0.5 0.39347   0.39316   -0.00031 -0.89261
Poisson 2       2.0 0.86466   0.86464   -0.00002 -0.08358
Poisson 8       8.0 0.99966   0.99965   -0.00001 -0.89099
NB k = 1 0.1    0.1 0.09091   0.09098    0.00007  0.35129
NB k = 1 0.5    0.5 0.33333   0.33323   -0.00011 -0.31900
NB k = 1 2      2.0 0.66667   0.66679    0.00012  0.36400
NB k = 1 8      8.0 0.88889   0.88894    0.00005  0.22550
NB k = 0.25 0.1 0.1 0.08068   0.08080    0.00013  0.65282
NB k = 0.25 0.5 0.5 0.24016   0.24065    0.00048  1.59796
NB k = 0.25 2   2.0 0.42265   0.42225   -0.00040 -1.15584
NB k = 0.25 8   8.0 0.58277   0.58313    0.00036  1.02859
round(c(largest_absolute_difference = max(abs(cal[, "difference"])),
        largest_difference_in_ses = max(abs(cal[, "z"])),
        occupancy_at_density_1_poisson = occ_poisson(1),
        occupancy_at_density_1_k_1 = occ_nb(1, 1),
        occupancy_at_density_1_k_025 = occ_nb(1, 0.25)), 4)
   largest_absolute_difference      largest_difference_in_ses 
                        0.0005                         1.5980 
occupancy_at_density_1_poisson     occupancy_at_density_1_k_1 
                        0.6321                         0.5000 
  occupancy_at_density_1_k_025 
                        0.3313 

Twelve combinations of density and aggregation, each one counted on a simulated landscape of two million cells. The largest disagreement between the count and the formula is 0.0005 in occupancy, which is 1.598 Monte Carlo standard errors, so the simulator and the identity are the same model. That check is worth the two minutes it takes to write. A previous post in this blog nearly published a reversed conclusion because the simulator and the closed form were quietly different things.

The numbers at the foot of the output are the point of the section. A species at a mean density of one individual per cell occupies 0.6321 of the landscape if its individuals are independent, 0.5 if they follow a negative binomial with k of 1, and 0.3313 if k is 0.25. Same density, same landscape, three different range sizes, and the only thing that changed was how the individuals are arranged.

grid_mu <- exp(seq(log(0.02), log(30), length.out = 300))
curve_df <- data.frame(
  mu = rep(grid_mu, 3),
  occ = c(occ_poisson(grid_mu), occ_nb(grid_mu, 1), occ_nb(grid_mu, 0.25)),
  model = factor(rep(lab, each = length(grid_mu)), levels = lab))
point_df <- data.frame(mu = cal[, "mu"], occ = cal[, "simulated"],
                       model = factor(rep(lab, each = length(mu_cal)),
                                      levels = lab))

ggplot(curve_df, aes(mu, occ, colour = model)) +
  geom_line(linewidth = 1.1) +
  geom_point(data = point_df, aes(shape = model), size = 2.8, stroke = 1.1) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$gold, te_pal$clay),
                      name = NULL) +
  scale_shape_manual(values = c(16, 17, 15), name = NULL) +
  scale_x_log10(breaks = c(0.02, 0.1, 0.5, 2, 8, 30)) +
  scale_y_continuous(limits = c(0, 1), breaks = seq(0, 1, 0.2)) +
  labs(x = "Mean density (individuals per cell)", y = "Occupancy",
       title = "Occupancy is what is left of a count after censoring") +
  theme_te()
Three rising curves on a horizontal axis of mean density per cell running on a log scale from 0.02 to 30, and a vertical axis of occupancy from 0 to 1. All three leave the bottom left corner together and separate above a density of 0.1. The dark green Poisson curve rises fastest and is flat at 1 from a density of 5 onwards. The gold curve for the negative binomial with k of 1 rises less steeply and is close to 1 at the right edge. The dark red curve for k of 0.25 is lowest and reaches only 0.7 at the right edge. Four plotted points sit on each curve, one per simulated landscape.
Figure 1: Occupancy as a function of mean density under the Poisson identity and under two negative binomial identities, with the simulated landscapes plotted on top of the curves. Occupancy rises steeply and then saturates, and at any given density a more aggregated species (smaller k) occupies less of the landscape. Below a density of 0.1 the three curves coincide, because a species that rare is alone in the cells it occupies whatever its clumping. The curves are exact, not fitted.

An assemblage with no biology in it

Now build an assemblage. A hundred and twenty species on a 128 by 128 grid of 16384 cells. Log mean densities are drawn from one normal distribution, which is the only species-level variation in the whole simulation. Every species gets the same aggregation parameter, k of 0.7. Nothing links occupancy to density except the censoring: there is no niche in the generator, no resource use, no dispersal, no metapopulation, no habitat.

set.seed(521)
n_side <- 128
n_cell <- n_side^2
n_sp <- 120
mu <- exp(rnorm(n_sp, log(0.7), 1.15))
k_one <- 0.7

cnt1 <- matrix(rnbinom(n_cell * n_sp, mu = rep(mu, each = n_cell), size = k_one),
               nrow = n_cell)
occ1 <- colMeans(cnt1 > 0)
aor1 <- lm(log(occ1) ~ log(mu))
slope_of <- function(f) as.numeric(coef(f)[2])
icept_of <- function(f) as.numeric(coef(f)[1])

round(c(grid_side = n_side, cells = n_cell, species = n_sp,
        aggregation_of_every_species = k_one,
        lowest_density = min(mu), highest_density = max(mu),
        lowest_occupancy = min(occ1), highest_occupancy = max(occ1)), 4)
                   grid_side                        cells 
                    128.0000                   16384.0000 
                     species aggregation_of_every_species 
                    120.0000                       0.7000 
              lowest_density              highest_density 
                      0.0150                       6.5440 
            lowest_occupancy            highest_occupancy 
                      0.0135                       0.8044 
round(c(log_log_slope = slope_of(aor1), log_log_intercept = icept_of(aor1),
        log_log_correlation = cor(log(mu), log(occ1)),
        log_log_r_squared = summary(aor1)$r.squared,
        logit_slope = slope_of(lm(qlogis(occ1) ~ log(mu))),
        logit_correlation = cor(log(mu), qlogis(occ1)),
        largest_gap_from_the_identity = max(abs(occ1 - occ_nb(mu, k_one)))), 4)
                log_log_slope             log_log_intercept 
                       0.6304                       -0.8719 
          log_log_correlation             log_log_r_squared 
                       0.9744                        0.9495 
                  logit_slope             logit_correlation 
                       0.9264                        0.9990 
largest_gap_from_the_identity 
                       0.0095 

The mean densities span 0.0150 to 6.5440 individuals per cell and the occupancies span 0.0135 to 0.8044, so the assemblage spans the range an atlas spans, from species with a toehold in the grid to species in four squares out of five. The relationship across those 120 species has a log-log slope of 0.6304 and a correlation of 0.9744. On the logit scale, which is the other common way to fit it, the slope is 0.9264 and the correlation is 0.9990.

That is a stronger relationship than most published ones, from a generating model in which the species differ in exactly one thing. It is worth saying plainly what it means. If a paper reports a positive abundance-occupancy relationship with a correlation of 0.9744 and offers niche breadth as the explanation, the pattern it reports is fully reproduced by a model containing no niches. The pattern is not evidence for the explanation, because it is not evidence against anything.

The other number in the output says why the relationship is so tight. Every simulated occupancy is within 0.0095 of the value the identity predicts from that species’ density and k. There is no scatter to speak of, because in this assemblage there is nothing for the scatter to be made of: two species with the same density have the same occupancy, up to the sampling variation of 16384 cells.

line_mu <- exp(seq(log(min(mu)), log(max(mu)), length.out = 300))
aor_lines <- data.frame(
  mu = rep(line_mu, 2),
  occ = c(occ_nb(line_mu, k_one),
          exp(icept_of(aor1) + slope_of(aor1) * log(line_mu))),
  series = factor(rep(c("Censoring identity at k = 0.7", "Fitted power law"),
                      each = length(line_mu)),
                  levels = c("Censoring identity at k = 0.7",
                             "Fitted power law")))

ggplot(data.frame(mu = mu, occ = occ1), aes(mu, occ)) +
  geom_point(colour = te_pal$sage, size = 2.4, alpha = 0.9) +
  geom_line(data = aor_lines, aes(colour = series, linetype = series),
            linewidth = 1.0) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  scale_linetype_manual(values = c(1, 2), name = NULL) +
  scale_x_log10() +
  scale_y_log10() +
  labs(x = "Mean density (individuals per cell)", y = "Occupancy",
       title = "A tight relationship out of a model with no ecology in it") +
  theme_te()
A log-log scatter plot with mean density per cell on the horizontal axis from 0.015 to 6.5 and occupancy on the vertical axis from 0.013 to 0.8. A hundred and twenty pale green points rise from bottom left to top right and lie on a smooth curve that flattens towards the top right. A dark green solid line traces that curve exactly. A dark red dashed straight line crosses it, sitting above the points at both ends of the range and below them through the middle.
Figure 2: The abundance-occupancy relationship for 120 simulated species whose only difference is mean density. Points are species, the solid line is the censoring identity evaluated at k of 0.7 and the dashed line is the fitted power law. The generating model contains no niche breadth, no resource use and no metapopulation dynamics, and the fitted relationship still has a correlation of 0.9744. The identity is a curve, so the straight-line fit misses at both ends.

The figure carries a second point that the correlation hides. The identity is a curve and the fitted power law is a straight line, so the fit is wrong in a systematic way at both ends even though its R squared is 0.9495. Five per cent of unexplained variance, in an assemblage with no biological variation whatsoever, is not noise: it is the wrong functional form. That matters in the next section, where residuals from the straight line are asked to carry a meaning.

The scatter is a map of aggregation

Real assemblages do not have one value of k. Some species are heavily clumped and some are spread out, and the difference is a property worth having: it is the thing the censoring identity says should move occupancy at a fixed density. So draw k from a distribution as well, independently of density, and repeat.

set.seed(913)
kv <- exp(rnorm(n_sp, log(0.7), 0.6))
cnt2 <- matrix(rnbinom(n_cell * n_sp, mu = rep(mu, each = n_cell),
                       size = rep(kv, each = n_cell)), nrow = n_cell)
occ2 <- colMeans(cnt2 > 0)
aor2 <- lm(log(occ2) ~ log(mu))
res2 <- resid(aor2)

k_mid <- median(kv)
dev_obs <- log(occ2) - log(occ_nb(mu, k_mid))
dev_pred <- log(occ_nb(mu, kv)) - log(occ_nb(mu, k_mid))
crude <- lm(res2 ~ log(kv))
against_identity <- lm(dev_obs ~ dev_pred)

round(c(lowest_k = min(kv), median_k = k_mid, highest_k = max(kv),
        correlation_of_density_and_k = cor(log(mu), log(kv)),
        log_log_slope = slope_of(aor2),
        r_squared_of_the_fit = summary(aor2)$r.squared,
        residual_sd = sd(res2),
        residual_on_log_k_r_squared = summary(crude)$r.squared,
        deviation_on_prediction_r_squared = summary(against_identity)$r.squared,
        deviation_on_prediction_slope = slope_of(against_identity),
        deviation_on_prediction_icept = icept_of(against_identity),
        share_above_the_median_k_identity = mean(occ2 > occ_nb(mu, k_mid)),
        share_above_the_highest_k_identity = mean(occ2 > occ_nb(mu, max(kv)))), 4)
                          lowest_k                           median_k 
                            0.1724                             0.7242 
                         highest_k       correlation_of_density_and_k 
                            2.6241                            -0.0264 
                     log_log_slope               r_squared_of_the_fit 
                            0.6095                             0.9176 
                       residual_sd        residual_on_log_k_r_squared 
                            0.2211                             0.3978 
 deviation_on_prediction_r_squared      deviation_on_prediction_slope 
                            0.9874                             1.0031 
     deviation_on_prediction_icept  share_above_the_median_k_identity 
                            0.0019                             0.5250 
share_above_the_highest_k_identity 
                            0.0333 

Aggregation now ranges from 0.1724 to 2.6241 across the assemblage and is uncorrelated with density by construction, at -0.0264. The relationship survives: slope 0.6095, R squared 0.9176. What changed is that there is now real scatter around it, with a residual standard deviation of 0.2211 on the log scale.

An ecologist looking at that scatter has a decision to make about what it means. The usual reading is that the residual is the biology: species sitting above the line occupy more than their abundance warrants, so they must be good dispersers or habitat generalists, and species below it must be specialists. Regress the residual on the aggregation parameter and 39.78 per cent of it goes at once: that much of the scatter around the fitted line is accounted for by how clumped each species is, and by nothing else.

The rest of that scatter is not biology either. It is the curvature from the previous section: the straight line is the wrong shape, so its residuals contain a smooth function of density on top of everything else. Take that out by comparing like with like. Each species has a deviation from the identity curve evaluated at the assemblage median k of 0.7242, and the identity, given that species’ own k, predicts exactly what that deviation should be. Regressing the observed deviation on the predicted one gives a slope of 1.0031, an intercept of 0.0019 and an R squared of 0.9874.

So the answer is that essentially all of it is aggregation. The scatter about an abundance-occupancy relationship is, in this assemblage, a measurement of how clumped the species are, dressed up as a measurement of how they use the landscape. Whatever niche breadth contributes has to live in what is left once that 0.9874 has been taken out, and in a real dataset it is competing with sampling error as well.

The size of the effect is easier to feel with two species than with a regression. Take the median density in the assemblage and give it to a species at the fifth percentile of aggregation and to one at the ninety-fifth.

set.seed(77)
mu_fixed <- median(mu)
k_low <- as.numeric(quantile(kv, 0.05))
k_high <- as.numeric(quantile(kv, 0.95))

round(c(density_of_both = mu_fixed,
        aggregation_clumped = k_low, aggregation_even = k_high,
        occupancy_clumped = mean(rnbinom(n_cell, mu = mu_fixed,
                                         size = k_low) > 0),
        occupancy_even = mean(rnbinom(n_cell, mu = mu_fixed,
                                      size = k_high) > 0),
        identity_clumped = occ_nb(mu_fixed, k_low),
        identity_even = occ_nb(mu_fixed, k_high),
        ratio_of_the_identities = occ_nb(mu_fixed, k_high) /
          occ_nb(mu_fixed, k_low)), 4)
        density_of_both     aggregation_clumped        aggregation_even 
                 0.7129                  0.2678                  1.5915 
      occupancy_clumped          occupancy_even        identity_clumped 
                 0.2925                  0.4438                  0.2936 
          identity_even ratio_of_the_identities 
                 0.4451                  1.5160 

Two species at a density of 0.7129 individuals per cell. The clumped one, k of 0.2678, occupies 0.2925 of the landscape. The even one, k of 1.5915, occupies 0.4438, which is 1.5160 times as much. On an atlas map that is the difference between a species that looks locally distributed and a species that looks widespread, and the two have identical population sizes. Both counted occupancies sit where the identity puts them: 0.2925 against 0.2936, and 0.4438 against 0.4451.

k_show <- c(min(kv), median(kv), max(kv))
band_df <- data.frame(
  mu = rep(line_mu, 3),
  occ = c(occ_nb(line_mu, k_show[1]), occ_nb(line_mu, k_show[2]),
          occ_nb(line_mu, k_show[3])),
  k = rep(k_show, each = length(line_mu)),
  grp = factor(rep(seq_along(k_show), each = length(line_mu))))

# the top two curves sit within a few pixels of each other, so each one says
# which k it is rather than leaving the colour bar to be read backwards
band_lab <- data.frame(mu = max(line_mu) * 1.07,
                       occ = occ_nb(max(line_mu), k_show),
                       txt = sprintf("k = %.2f", k_show),
                       vj = c(0.5, 1.2, -0.3))

ggplot(data.frame(mu = mu, occ = occ2, k = kv), aes(mu, occ, colour = k)) +
  geom_line(data = band_df, aes(group = grp), linewidth = 1.0) +
  geom_point(size = 2.6) +
  geom_text(data = band_lab, aes(mu, occ, label = txt, vjust = vj),
            inherit.aes = FALSE, hjust = 0, size = 3.1, colour = te_pal$ink) +
  scale_colour_gradient(low = te_pal$gold, high = te_pal$forest,
                        trans = "log10", breaks = c(0.2, 0.5, 1, 2),
                        name = "Aggregation k (log scale)") +
  scale_x_log10() +
  scale_y_log10() +
  expand_limits(x = max(line_mu) * 2.1) +
  guides(colour = guide_colourbar(barwidth = 12, barheight = 0.6)) +
  labs(x = "Mean density (individuals per cell)", y = "Occupancy",
       title = "The scatter sorts by aggregation, not by chance") +
  theme_te()
A log-log scatter of occupancy against mean density for 120 species. Points are coloured on a logarithmic scale from gold for low aggregation parameter k, meaning heavily clumped, to dark green for high k. Three smooth curves run across the cloud on the same colour scale, each labelled at its right hand end with its own value of k. The gold curve for the lowest k runs along the lower edge of the points on the left half and then below all of them on the right. The olive curve for the median k and the dark green curve for the highest k both run near the top of the cloud, close enough together to need their labels to tell them apart, with about half the points in the thin band between the olive curve and the dark green one and the rest spread out well below. Within any narrow vertical slice the gold points sit at the bottom and the dark green points at the top.
Figure 3: The same relationship once aggregation varies across species, with each species coloured by its aggregation parameter and three exact identity curves drawn on the same colour scale at the lowest, median and highest k in the assemblage. Each curve carries its own k, because the colour scale is logarithmic and the top two curves run close together. The points do not scatter at random about the middle curve, they sort by colour, clumped species below and even species above. The outer two curves are identities at the extreme k in the assemblage, so they pen the cloud in rather than passing through it: only 0.0333 of the species sit above the highest-k curve, and those are sparse ones on the left where the three curves have not yet parted. The lowest-k curve touches the bottom of the cloud in the middle of the density range and then runs clear below every point towards the right, because the most clumped species in this draw are not the densest. The middle curve splits the points, 0.5250 of them above it, but the cloud is lopsided about it: the room above is the narrow band up to the highest-k curve, while below it the points reach much further. Regressed on the deviation the identity predicts from each species and its own k, the scatter has an R squared of 0.9874.

Occupancy is a property of the grid

Nothing so far has mentioned the size of a cell, and that is the next problem, because occupancy is not a property of a species. It is a property of a species and a grid. Change the grid and every number in the table at the back of the atlas changes, including the slope.

Aggregating the same landscape into blocks of 2 by 2, 4 by 4 and 8 by 8 cells answers it directly. The individuals do not move: only the lines drawn round them change. A second landscape is built alongside for comparison, with the same species, the same mean densities and the same aggregation, but with the clumping arranged spatially: a gamma multiplier shared by all the cells in a patch of 8 by 8, and Poisson counts within cells. A Poisson count with a gamma mean is a negative binomial, so the two landscapes have the same count distribution in a cell and the same expected occupancy at the finest grain. What differs is where the empty cells are.

row_of <- rep(seq_len(n_side), times = n_side)
col_of <- rep(seq_len(n_side), each = n_side)
block_of <- function(g) ((row_of - 1) %/% g) * (n_side / g) +
  ((col_of - 1) %/% g) + 1
aggregate_to <- function(m, g) if (g == 1) m else rowsum(m, block_of(g))

set.seed(3311)
patch <- block_of(8)
gam <- matrix(rgamma(n_sp * max(patch), shape = k_one, scale = 1 / k_one),
              nrow = max(patch))
cnt_patchy <- matrix(rpois(n_cell * n_sp, gam[patch, ] * rep(mu, each = n_cell)),
                     nrow = n_cell)
occ_patchy1 <- colMeans(cnt_patchy > 0)

grains <- c(1, 2, 4, 8)
grain_tab <- NULL
for (g in grains) {
  o_ind <- colMeans(aggregate_to(cnt1, g) > 0)
  o_pat <- colMeans(aggregate_to(cnt_patchy, g) > 0)
  grain_tab <- rbind(grain_tab, c(
    block_width = g, blocks = n_cell / g^2,
    slope_independent = slope_of(lm(log(o_ind) ~ log(mu))),
    slope_patchy = slope_of(lm(log(o_pat) ~ log(mu))),
    mean_occ_independent = mean(o_ind), mean_occ_patchy = mean(o_pat),
    saturated_independent = sum(o_ind > 0.999),
    saturated_patchy = sum(o_pat > 0.999),
    spearman_independent = cor(occ1, o_ind, method = "spearman"),
    spearman_patchy = cor(occ_patchy1, o_pat, method = "spearman"),
    gap_from_the_independence_rule =
      max(abs(o_ind - (1 - (1 - occ_nb(mu, k_one))^(g^2))))))
}
print(round(grain_tab, 4))
     block_width blocks slope_independent slope_patchy mean_occ_independent
[1,]           1  16384            0.6304       0.6268               0.3906
[2,]           2   4096            0.3813       0.3774               0.7641
[3,]           4   1024            0.1190       0.1824               0.9485
[4,]           8    256            0.0169       0.0740               0.9938
     mean_occ_patchy saturated_independent saturated_patchy
[1,]          0.3915                     0                0
[2,]          0.6316                     0                0
[3,]          0.8135                    69                0
[4,]          0.9176                   113                0
     spearman_independent spearman_patchy gap_from_the_independence_rule
[1,]               1.0000          1.0000                         0.0095
[2,]               0.9994          0.9977                         0.0197
[3,]               0.9193          0.9846                         0.0347
[4,]               0.4062          0.9355                         0.0193
round(c(mean_occupancy_independent = mean(occ1),
        mean_occupancy_patchy = mean(occ_patchy1),
        largest_gap_between_landscapes = max(abs(occ1 - occ_patchy1)),
        correlation_between_landscapes = cor(occ1, occ_patchy1)), 4)
    mean_occupancy_independent          mean_occupancy_patchy 
                        0.3906                         0.3915 
largest_gap_between_landscapes correlation_between_landscapes 
                        0.0416                         0.9977 

The two landscapes start in the same place, as they were built to: mean occupancy 0.3906 against 0.3915, correlated at 0.9977 across species, no species differing by more than 0.0416. At the finest grain they are the same assemblage by every measure an atlas would report.

Four steps of coarsening take them apart. In the landscape with independent cells the slope falls from 0.6304 to 0.3813 to 0.1190 to 0.0169, and mean occupancy climbs from 0.3906 to 0.9938. By the coarsest grain 113 of the 120 species are in every block, so there is no relationship left to have a slope: everything is ubiquitous and the atlas has become a list. In the patchy landscape the same coarsening leaves the slope at 0.0740 and mean occupancy at 0.9176, with no species saturating at all, because a species whose empty cells are next to each other keeps empty blocks for longer.

The independent case has an exact answer, which is worth having because it is the fastest possible saturation. If cells are independent, a block of m cells is empty when all m are, so occupancy at block size m is 1 - (1 - psi)^m. Across all four grains the counted occupancy never departs from that rule by more than 0.0347. Coarsening is not mysterious. It is the same censoring applied to a bigger container.

The last two columns are the practical part. The rank order of species survives coarsening far better than their occupancy values do: the Spearman correlation with the finest grain stays at 0.9994 and 0.9193 through the first two steps in the independent landscape, and 0.9977 and 0.9846 in the patchy one. It collapses to 0.4062 at the coarsest grain in the independent landscape, but that is saturation rather than reordering, because 113 tied species cannot be ranked. In the patchy landscape, where nothing saturates, the rank correlation is still 0.9355 after the cell has grown from one square to a block of 8 by 8. So an occupancy is comparable across studies only as a rank, and only while there is something left to rank.

What imperfect detection does, and does not, do

The last thing between the atlas and the truth is that a species present in a square is not always recorded there. Two ways of writing that down look similar and behave completely differently.

The first is the standard occupancy model’s detection process: each visit to an occupied cell finds the species with probability p, independent of how many individuals are in it. With three visits at p of 0.4, an occupied cell is recorded with probability 0.7840.

The second lets detection depend on abundance, which is what happens in the field: a cell holding one individual is harder to work than a cell holding twenty. Give each individual a probability r of 0.15 of being detected on a visit, so a cell holding N individuals is recorded across three visits with probability 1 - (1 - r)^(3N). That is 0.3859 for a single individual and 0.9126 for five.

I expected both to steepen the relationship, on the reasoning that rare species are missed most. Only one of them does.

p_visit <- 0.4
n_visit <- 3
r_ind <- 0.15
set.seed(8080)

det_tab <- NULL
keep_flat <- keep_abund <- NULL
for (g in grains) {
  a <- aggregate_to(cnt1, g)
  nb <- nrow(a)
  o_true <- colMeans(a > 0)
  o_flat <- colMeans((a > 0) & (matrix(runif(nb * n_sp), nb) <
                                  1 - (1 - p_visit)^n_visit))
  o_abund <- colMeans(matrix(runif(nb * n_sp), nb) <
                        1 - (1 - r_ind)^(a * n_visit))
  if (g == 1) {
    keep_flat <- o_flat / o_true
    keep_abund <- o_abund / o_true
  }
  det_tab <- rbind(det_tab, c(
    block_width = g,
    slope_true = slope_of(lm(log(o_true) ~ log(mu))),
    slope_flat = slope_of(lm(log(o_flat) ~ log(mu))),
    slope_abundance = slope_of(lm(log(o_abund) ~ log(mu))),
    icept_true = icept_of(lm(log(o_true) ~ log(mu))),
    icept_flat = icept_of(lm(log(o_flat) ~ log(mu))),
    occupancy_kept_flat = mean(o_flat / o_true),
    occupancy_kept_abundance = mean(o_abund / o_true)))
}
print(round(det_tab, 4))
     block_width slope_true slope_flat slope_abundance icept_true icept_flat
[1,]           1     0.6304     0.6311          0.7795    -0.8719    -1.1148
[2,]           2     0.3813     0.3814          0.5958    -0.1873    -0.4308
[3,]           4     0.1190     0.1186          0.2783    -0.0152    -0.2592
[4,]           8     0.0169     0.0158          0.0697     0.0004    -0.2467
     occupancy_kept_flat occupancy_kept_abundance
[1,]              0.7841                   0.5511
[2,]              0.7838                   0.7012
[3,]              0.7837                   0.8901
[4,]              0.7818                   0.9760
round(c(visits = n_visit, detection_per_visit_flat = p_visit,
        detection_per_individual = r_ind,
        cell_recorded_if_occupied_flat = 1 - (1 - p_visit)^n_visit,
        cell_recorded_if_it_holds_one = 1 - (1 - r_ind)^n_visit,
        cell_recorded_if_it_holds_five = 1 - (1 - r_ind)^(5 * n_visit),
        slope_ratio_flat = as.numeric(det_tab[1, 3] / det_tab[1, 2]),
        slope_ratio_abundance = as.numeric(det_tab[1, 4] / det_tab[1, 2]),
        intercept_shift_flat = as.numeric(det_tab[1, 6] - det_tab[1, 5]),
        intercept_shift_expected = log(1 - (1 - p_visit)^n_visit),
        occupancy_kept_rarest = as.numeric(keep_abund[which.min(mu)]),
        occupancy_kept_commonest = as.numeric(keep_abund[which.max(mu)])), 4)
                        visits       detection_per_visit_flat 
                        3.0000                         0.4000 
      detection_per_individual cell_recorded_if_occupied_flat 
                        0.1500                         0.7840 
 cell_recorded_if_it_holds_one cell_recorded_if_it_holds_five 
                        0.3859                         0.9126 
              slope_ratio_flat          slope_ratio_abundance 
                        1.0011                         1.2366 
          intercept_shift_flat       intercept_shift_expected 
                       -0.2429                        -0.2433 
         occupancy_kept_rarest       occupancy_kept_commonest 
                        0.4661                         0.8226 

Detection that ignores abundance does not touch the slope. It multiplies every species’ occupancy by the same 0.7840, and multiplying by a constant is an additive shift on the log scale, so the slope ratio is 1.0011 and the intercept moves by -0.2429 against an expected -0.2433. The relationship is displaced downwards and is otherwise exactly the relationship you had. If detection really were flat across species, an unadjusted atlas would give the correct abundance-occupancy slope from biased occupancies, which is a strange and useful fact.

Detection that depends on abundance steepens it, by a factor of 1.2366 at the finest grain, from 0.6304 to 0.7795. The mechanism is in the last two numbers. The commonest species keeps 0.8226 of its occupancy and the rarest keeps 0.4661, because the cells an uncommon species occupies mostly hold one individual, and a cell holding one individual is recorded with probability 0.3859. The bias is not in the occupancy of a species, it is in the difference between species, and it is the largest where the pattern is read most confidently: at the bottom left of the plot.

The practical version: a survey with fixed effort per square makes the abundance-occupancy relationship look steeper and tighter than it is, and the steepening is worse the shorter the visit list. This is exactly the heterogeneity that the Royle-Nichols model was built for, and abundance from repeat detections fits it rather than assuming it away.

ser <- c("Independent cells", "Patchy landscape",
         "Constant detection per visit", "Detection rises with abundance")
sweep_df <- rbind(
  data.frame(g = grains, slope = grain_tab[, "slope_independent"],
             series = ser[1], panel = "Spatial arrangement"),
  data.frame(g = grains, slope = grain_tab[, "slope_patchy"],
             series = ser[2], panel = "Spatial arrangement"),
  data.frame(g = grains, slope = det_tab[, "slope_true"],
             series = ser[1], panel = "Detection"),
  data.frame(g = grains, slope = det_tab[, "slope_flat"],
             series = ser[3], panel = "Detection"),
  data.frame(g = grains, slope = det_tab[, "slope_abundance"],
             series = ser[4], panel = "Detection"))
sweep_df$series <- factor(sweep_df$series, levels = ser)
sweep_df$panel <- factor(sweep_df$panel,
                         levels = c("Spatial arrangement", "Detection"))

ggplot(sweep_df, aes(g, slope, colour = series, shape = series,
                     linetype = series, linewidth = series)) +
  geom_line() +
  geom_point(size = 2.9) +
  facet_wrap(~panel) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$sage, te_pal$gold,
                                 te_pal$clay), name = NULL) +
  scale_shape_manual(values = c(16, 17, 15, 18), name = NULL) +
  scale_linetype_manual(values = c(1, 1, 2, 1), name = NULL) +
  scale_linewidth_manual(values = c(1.6, 1.0, 0.9, 1.0), name = NULL) +
  scale_x_continuous(trans = "log2", breaks = grains) +
  scale_y_continuous(limits = c(0, 0.85)) +
  guides(colour = guide_legend(nrow = 2), shape = guide_legend(nrow = 2),
         linetype = guide_legend(nrow = 2),
         linewidth = guide_legend(nrow = 2)) +
  labs(x = "Block width (cells)", y = "Fitted log-log slope",
       title = "Grid and effort move the slope more than biology needs to") +
  theme_te() +
  theme(strip.text = element_text(colour = te_pal$ink, face = "bold", size = 9),
        plot.margin = margin(6, 14, 6, 6))
Two panels sharing a horizontal axis of block width in cells, marked at 1, 2, 4 and 8 on a log scale, and a vertical axis of fitted log-log slope from 0 to about 0.8. In the left panel a dark green line falls steeply from 0.63 at width 1 to almost 0 at width 8; a pale green line for the patchy landscape lies on top of it at widths 1 and 2, then lifts away and falls less far, ending near 0.07. In the right panel the same dark green reference line is repeated, a gold dashed line for constant detection runs along it and the two coincide at every point, so the gold shows only as dashes on the dark green, and a dark red line for abundance dependent detection lies above both, starting at 0.78 and closing on them by width 8.
Figure 4: The fitted abundance-occupancy slope against the size of the grid cell, on the left for the two spatial arrangements under perfect detection and on the right for the two detection processes on the landscape with independent cells. Coarsening the grid drives the slope towards zero as species saturate, faster when the empty cells are scattered than when they are clumped into patches. Detection that ignores abundance leaves the slope where it was; detection that depends on abundance raises it at every grain. The legend names four series and neither panel shows four separate lines, which is the result rather than a drawing fault. On the left the two arrangements are 0.0036 and 0.0038 of slope apart at block widths one and two, far too little to draw apart, and they only separate at four and eight. On the right constant detection tracks the true slope to within 0.0011 at every grain, so its gold line lies under the dark green one and shows only where the dashes fall.

The honest limit: what occurrence data cannot settle

Everything above shows that censoring is sufficient to produce the pattern. It does not show that censoring is what produced any particular one, and it cannot, because the alternative explanations predict the same picture. Here is that degeneracy as a measurement rather than as a caveat.

Build a second assemblage on a different mechanism. Each species has a niche breadth. Broad-niched species find a larger fraction of the landscape suitable and reach higher densities where they occur, which is the resource use hypothesis in its simplest form. Inside its suitable habitat every species has the same aggregation, k of 0.7, so there is no variation in clumping to explain anything. Then choose the five free parameters so that this assemblage reproduces the first one’s fitted line, its residual spread and its distribution of mean densities, and simulate it.

target <- c(icept_of(aor2), slope_of(aor2), sd(res2), mean(log(mu)), sd(log(mu)))
set.seed(404)
breadth <- rnorm(n_sp)
noise_d <- rnorm(n_sp)
noise_s <- rnorm(n_sp)

brown <- function(p) {
  s <- plogis(p[1] + exp(p[2]) * breadth + p[5] * noise_s)
  d <- exp(p[3] + 0.3 * exp(p[2]) * breadth + p[4] * noise_d)
  list(suitable = s, density = d, mu = s * d,
       occ = s * (1 - (1 + d / k_one)^(-k_one)))
}
cost <- function(p) {
  z <- brown(p)
  if (any(!is.finite(log(z$occ)))) return(1e8)
  f <- lm(log(z$occ) ~ log(z$mu))
  sum((c(icept_of(f), slope_of(f), sd(resid(f)), mean(log(z$mu)),
         sd(log(z$mu))) - target)^2 * c(20, 20, 200, 20, 20))
}
op <- optim(c(0, 0, 0, 0.2, 0.2), cost,
            control = list(maxit = 9000, reltol = 1e-13))
op <- optim(op$par, cost, control = list(maxit = 9000, reltol = 1e-13))
z <- brown(op$par)

set.seed(2626)
cntB <- matrix(0L, n_cell, n_sp)
for (i in seq_len(n_sp)) {
  ns <- round(z$suitable[i] * n_cell)
  cntB[sample.int(n_cell, ns), i] <- rnbinom(ns, mu = z$density[i],
                                             size = k_one)
}
occB <- colMeans(cntB > 0)
aorB <- lm(log(occB) ~ log(z$mu))

round(c(suitable_fraction_lowest = min(z$suitable),
        suitable_fraction_highest = max(z$suitable),
        density_where_suitable_lowest = min(z$density),
        density_where_suitable_highest = max(z$density),
        slope_censoring = slope_of(aor2), slope_niche = slope_of(aorB),
        icept_censoring = icept_of(aor2), icept_niche = icept_of(aorB),
        r_squared_censoring = summary(aor2)$r.squared,
        r_squared_niche = summary(aorB)$r.squared,
        residual_sd_censoring = sd(res2), residual_sd_niche = sd(resid(aorB))), 4)
      suitable_fraction_lowest      suitable_fraction_highest 
                        0.0333                         1.0000 
 density_where_suitable_lowest density_where_suitable_highest 
                        0.0180                        14.7415 
               slope_censoring                    slope_niche 
                        0.6095                         0.6125 
               icept_censoring                    icept_niche 
                       -0.9022                        -0.9031 
           r_squared_censoring                r_squared_niche 
                        0.9176                         0.9162 
         residual_sd_censoring              residual_sd_niche 
                        0.2211                         0.2242 
pool <- data.frame(log_occ = c(log(occ2), log(occB)),
                   log_mu = c(log(mu), log(z$mu)),
                   model = factor(rep(c("censoring", "niche"), each = n_sp),
                                  levels = c("censoring", "niche")))
both <- lm(log_occ ~ log_mu * model, data = pool)
ks <- ks.test(res2, resid(aorB))
skew <- function(x) mean((x - mean(x))^3) / sd(x)^3

round(c(model_shift_p = summary(both)$coefficients[3, 4],
        interaction_p = summary(both)$coefficients[4, 4],
        ks_statistic = as.numeric(ks$statistic), ks_p_value = ks$p.value,
        residual_skew_censoring = skew(res2),
        residual_skew_niche = skew(resid(aorB))), 4)
          model_shift_p           interaction_p            ks_statistic 
                 0.9767                  0.9031                  0.2417 
             ks_p_value residual_skew_censoring     residual_skew_niche 
                 0.0018                 -1.0078                 -2.5294 

The two assemblages come from mechanisms with nothing in common. In one, occupancy is censored abundance and the scatter is aggregation. In the other, every species is equally aggregated and the pattern comes from how much of the landscape suits it. Their abundance-occupancy relationships are the same relationship: slope 0.6095 against 0.6125, intercept -0.9022 against -0.9031, R squared 0.9176 against 0.9162. Fit them together with an interaction and the difference in intercept has a p-value of 0.9767 and the difference in slope 0.9031.

Everything a paper reports about an abundance-occupancy relationship is in that list, and none of it separates the two. What does separate them is the shape of the scatter: the residuals are more left-skewed under the niche mechanism, -2.5294 against -1.0078, and a two-sample test on them gives a statistic of 0.2417 with a p-value of 0.0018. That test needed both assemblages side by side. A real study has one, so the comparison is not available to it, and the residual skew of a single sample of 120 species is not something anyone would bet a mechanism on.

The evidence that would separate them is not in the occurrence data at all. Two kinds would do it. Aggregation measured directly, within occupied habitat, so that the identity’s prediction can be tested rather than assumed: the censoring account says the residual is a function of k and the niche account says k is constant. Or density estimated independently at fixed occupancy, from counts rather than from presences, so that the two axes stop being computed from the same numbers. This post supplies neither. It measures the size of the arithmetic component and shows it is large enough to account for the whole pattern, which is a claim about what the data cannot rule out and not about what is true in any particular assemblage.

Three further limits are worth stating. The aggregation used throughout is a single negative binomial parameter, which is a summary of clumping and not a description of it: two very different spatial patterns can share a k, and the patchy landscape in the grain section is one demonstration of that. The species here are independent of one another, so nothing in this post says anything about how competition or shared habitat moves the pattern. And the grain sweep coarsens a fixed landscape rather than surveying at a coarser grain, so it isolates the effect of the container from the effect of the effort, which in a real atlas arrive together.

Where to go next

The most direct follow-on is range size distributions, which asks what the distribution of the occupancy axis looks like on its own and where its shape comes from. The other half of that pair is species abundance distributions, the distribution of the density axis, which is the input this post censors. If you want to test whether a macroecological pattern of this kind is doing any work at all, checking a macroecological pattern sets out the null models to run before believing one.

For the modelling rather than the pattern, imperfect detection and occupancy is the estimator that removes the flat part of the detection bias measured above, and N-mixture models go after the abundance behind the detections, which is what you need if you want the density axis and the occupancy axis to come from different data. For the count model that produced every landscape here, GLMs for count data covers where the negative binomial and its aggregation parameter come from, and species-area relationships is the same grain problem asked of richness rather than of one species at a time.

References

Brown JH 1984 The American Naturalist 124(2):255-279 (10.1086/284267)

Hanski I 1982 Oikos 38(2):210 (10.2307/3544021)

Wright DH 1991 Journal of Biogeography 18(4):463 (10.2307/2845487)

He F, Gaston KJ 2000 The American Naturalist 156(5):553-559 (10.1086/303403)

Holt AR, Gaston KJ, He F 2002 Basic and Applied Ecology 3(1):1-13 (10.1078/1439-1791-00083)

Kunin WE 1998 Science 281(5382):1513-1515 (10.1126/science.281.5382.1513)

Royle JA, Nichols JD 2003 Ecology 84(3):777-790 (10.1890/0012-9658(2003)084[0777:EAFRPA]2.0.CO;2)

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.