Hierarchical distance sampling

R
distance sampling
abundance
imperfect detection
ecology tutorial
One visit with distances beats three visits of counts, and the reason is a biological bet about whether your animals hold still between visits.
Author

Tidy Ecology

Published

2026-08-26

Two families of models estimate abundance from counts that miss animals, and they buy their detection estimate in different currencies. N-mixture models buy it with repeat visits: go back three times, and the variation between visits tells you how often you miss things. Distance sampling buys it with a ruler: the shape of the distance distribution tells you the same thing from a single pass.

Hierarchical distance sampling (Royle, Dawson and Bates 2004) is the second currency in the first family’s shape. You get a site-level abundance model with covariates, exactly as in an N-mixture, and the detection parameter comes from the distances rather than from going back. This post builds it in base R, then runs the two designs against each other in a world where the assumptions differ, which turns out to be the whole story.

library(ggplot2)

g_hn  <- function(x, s) exp(-x^2/(2*s^2))
mu_hn <- function(s, w) sqrt(2*pi)*s*(pnorm(w, 0, s) - 0.5)

w   <- 0.1                             # truncation, km
R   <- 200                             # transect segments (sites)
ell <- 0.5                             # segment length, km
A   <- 2*w*ell                         # covered area per site, km2
cut <- seq(0, w, length.out = 6); B <- 5   # five 20 m distance bins

## cell probability: the chance a site's animal is detected AND lands in bin b
pi_b <- function(s) sqrt(2*pi)*s*(pnorm(cut[-1], 0, s) - pnorm(cut[-length(cut)], 0, s))/w
sprintf("sigma 0.05: bin probabilities %s | missed %.4f | detected %.4f (= mu/w = %.4f)",
        paste(sprintf("%.4f", pi_b(0.05)), collapse = " "),
        1 - sum(pi_b(0.05)), sum(pi_b(0.05)), mu_hn(0.05, w)/w)
[1] "sigma 0.05: bin probabilities 0.1948 0.1663 0.1213 0.0755 0.0402 | missed 0.4019 | detected 0.5981 (= mu/w = 0.5981)"

The identity that makes it easy

The model has a latent variable in it. Site \(s\) holds \(N_s \sim \text{Poisson}(\lambda_s)\) animals; each is detected and assigned to a distance bin with probability \(\pi_b\), or missed with probability \(\pi_0 = 1 - \sum_b \pi_b\). You observe the bin counts \(y_{s1}, \ldots, y_{sB}\) and never \(N_s\).

Writing the likelihood the obvious way means summing over every possible \(N_s\) from the number you saw up to infinity. Royle (2004) noticed you do not have to. Poisson thinning says the bin counts are independent Poisson variables:

\[y_{sb} \sim \text{Poisson}(\lambda_s \pi_b) \quad \text{independently across } b\]

No latent variable, no sum, no truncation ceiling. That is a strong enough claim to be worth checking by brute force rather than by algebra.

lam <- 4.7; s <- 0.043; y <- c(3, 2, 1, 1, 0)
n <- sum(y); P <- pi_b(s); p0 <- 1 - sum(P); Nmax <- n + 400

closed <- sum(dpois(y, lam*P, log = TRUE))
summed <- log(sum(sapply(n:Nmax, function(N)
  dpois(N, lam) *
  exp(lfactorial(N) - sum(lfactorial(y)) - lfactorial(N - n)) *  # multinomial coefficient
  prod(P^y) * p0^(N - n))))
sprintf("latent-N sum to %d: %.12f | independent Poissons: %.12f | difference %.1e",
        Nmax, summed, closed, summed - closed)
[1] "latent-N sum to 407: -7.986214065061 | independent Poissons: -7.986214065061 | difference 8.9e-16"

Agreement to 9e-16, which is machine arithmetic rather than a good approximation. The identity is exact, and it is exact because the abundance distribution is Poisson. Swap in a negative binomial and it breaks:

th <- 1.5
summed_nb <- log(sum(sapply(n:Nmax, function(N)
  dnbinom(N, mu = lam, size = th) *
  exp(lfactorial(N) - sum(lfactorial(y)) - lfactorial(N - n)) *
  prod(P^y) * p0^(N - n))))
sprintf("N ~ NB(mu = %g, size = %g): true log-likelihood %.6f vs Poisson shortcut %.6f (out by %.4f)",
        lam, th, summed_nb, closed, summed_nb - closed)
[1] "N ~ NB(mu = 4.7, size = 1.5): true log-likelihood -6.971343 vs Poisson shortcut -7.986214 (out by 1.0149)"

Which is a check worth having in the file: it can fail, and it does fail when it should.

The world

Density and detectability both respond to the same covariate, which is the ordinary situation rather than a contrived one. Thicker understorey holds more animals and hides them.

\[\log \lambda_s = \beta_0 + \beta_1 z_s, \qquad \log \sigma_s = \alpha_0 + \alpha_1 z_s\]

b0 <- log(4); b1 <- 0.6; a0 <- log(0.05); a1 <- -0.35
sim_hds <- function(seed, alpha1 = a1) {
  set.seed(seed)
  z <- rnorm(R); lam <- exp(b0 + b1*z); sg <- exp(a0 + alpha1*z)
  N <- rpois(R, lam)
  y <- t(sapply(seq_len(R), function(i) {
    if (N[i] == 0) return(rep(0, B))
    x <- runif(N[i], 0, w); d <- x[runif(N[i]) < g_hn(x, sg[i])]
    tabulate(findInterval(d, cut, rightmost.closed = TRUE), B)
  }))
  list(y = y, z = z, N = N)
}
nll_hds <- function(p, y, z, sig_cov = TRUE) {
  lam <- exp(p[1] + p[2]*z)
  sg  <- if (sig_cov) exp(p[3] + p[4]*z) else rep(exp(p[3]), length(z))
  -sum(dpois(y, lam * t(sapply(sg, pi_b)), log = TRUE))   # y is R x B, so t() the R x B back
}
fit <- function(par, ...) {
  o <- optim(par, nll_hds, ..., method = "Nelder-Mead")
  o <- optim(o$par, nll_hds, ..., method = "Nelder-Mead", hessian = TRUE)
  list(par = o$par, nll = o$value,
       se = tryCatch(sqrt(diag(solve(o$hessian))), error = function(e) rep(NA_real_, length(par))))
}
d <- sim_hds(4282)
sprintf("seed 4282: %d animals present, %d detected (%.1f%%), %d of %d sites detected nothing",
        sum(d$N), sum(d$y), 100*sum(d$y)/sum(d$N), sum(rowSums(d$y) == 0), R)
[1] "seed 4282: 902 animals present, 453 detected (50.2%), 20 of 200 sites detected nothing"

At 50 per cent detected, this is not a hard survey by the standards of the literature. Now fit the full model, and fit the version that models abundance carefully and treats detection as a constant.

f4 <- fit(c(1, 0.5, log(0.05), -0.3), y = d$y, z = d$z)
f3 <- fit(c(1, 0.5, log(0.05)), y = d$y, z = d$z, sig_cov = FALSE)
rbind(FULL  = c(b0 = f4$par[1], b1 = f4$par[2], a0 = f4$par[3], a1 = f4$par[4]),
      truth = c(b0, b1, a0, a1))
            b0        b1        a0         a1
FULL  1.304548 0.5753264 -2.969489 -0.2855361
truth 1.386294 0.6000000 -2.995732 -0.3500000
sprintf("FULL : b1 %.4f (SE %.4f) | a1 %.4f (SE %.4f) | N total %.0f (true %d, raw count %d)",
        f4$par[2], f4$se[2], f4$par[4], f4$se[4],
        sum(exp(f4$par[1] + f4$par[2]*d$z)), sum(d$N), sum(d$y))
[1] "FULL : b1 0.5753 (SE 0.0587) | a1 -0.2855 (SE 0.0501) | N total 827 (true 902, raw count 453)"
sprintf("NAIVE: b1 %.4f (SE %.4f, %+.1f%%) | 95%% CI [%.3f, %.3f] contains 0.6? %s | dAIC %.1f",
        f3$par[2], f3$se[2], 100*(f3$par[2]/b1 - 1),
        f3$par[2] - 1.96*f3$se[2], f3$par[2] + 1.96*f3$se[2],
        f3$par[2] - 1.96*f3$se[2] < b1 && b1 < f3$par[2] + 1.96*f3$se[2],
        (2*f3$nll + 6) - (2*f4$nll + 8))
[1] "NAIVE: b1 0.3551 (SE 0.0473, -40.8%) | 95% CI [0.262, 0.448] contains 0.6? FALSE | dAIC 31.4"

One visit. Not three, not a mark, not a second observer: one pass with a rangefinder, and both the abundance slope and the detection slope come back with usable standard errors.

The naive model returns a habitat effect 41 per cent too small, with a confidence interval that excludes the truth. This is the pooling trade in hierarchical clothing: detection falls where density rises, and a model with nowhere to put the detection effect books it as fewer animals.

The exact version of that

One survey is one draw. The asymptotic value the naive slope converges to can be computed directly, by maximising the expected log-likelihood under the true world over a grid of \(z\).

zg <- seq(-5, 5, length.out = 601); wz <- dnorm(zg); wz <- wz/sum(wz)
Ey <- t(sapply(seq_along(zg), function(i) exp(b0 + b1*zg[i])*pi_b(exp(a0 + a1*zg[i]))))
negQ <- function(p, sig_cov = FALSE) {
  lm_ <- exp(p[1] + p[2]*zg)
  sg  <- if (sig_cov) exp(p[3] + p[4]*zg) else rep(exp(p[3]), length(zg))
  M <- t(sapply(seq_along(zg), function(i) lm_[i]*pi_b(sg[i])))
  Q <- Ey*log(M); Q[Ey == 0] <- 0        # 0 log 0 = 0, else the truth scores as undefined
  -sum(wz * rowSums(Q - M))
}
tight <- function(par, ...) {
  o <- optim(par, negQ, ..., method = "Nelder-Mead",
             control = list(reltol = 1e-14, maxit = 5000))
  for (i in 1:2) o <- optim(o$par, negQ, ..., method = "Nelder-Mead",
                            control = list(reltol = 1e-14, maxit = 5000))
  o$par
}
o4 <- tight(c(1, 0.5, log(0.05), -0.3), sig_cov = TRUE)
o3 <- tight(c(1, 0.5, log(0.05)))
sprintf("FULL model recovers the truth: largest error %.1e", max(abs(o4 - c(b0, b1, a0, a1))))
[1] "FULL model recovers the truth: largest error 3.9e-08"
sprintf("NAIVE converges to: b1 %.4f (truth %.2f, %+.1f%%) | sigma %.5f (true range %.4f to %.4f)",
        o3[2], b1, 100*(o3[2]/b1 - 1), exp(o3[3]), exp(a0 + a1*2), exp(a0 - a1*2))
[1] "NAIVE converges to: b1 0.3200 (truth 0.60, -46.7%) | sigma 0.04431 (true range 0.0248 to 0.1007)"
sprintf("implied mean abundance per site: truth %.4f | naive %.4f (%+.1f%%)",
        sum(wz*exp(b0 + b1*zg)), sum(wz*exp(o3[1] + o3[2]*zg)),
        100*(sum(wz*exp(o3[1] + o3[2]*zg))/sum(wz*exp(b0 + b1*zg)) - 1))
[1] "implied mean abundance per site: truth 4.7888 | naive 4.4893 (-6.3%)"

The first line is the check, and it earned its place: the correctly specified model must recover the truth exactly, because the expected log-likelihood is maximised at the truth for every value of \(z\) at once. It initially did not, by two per cent, which is how the 0 log 0 in the line above got found. A check that cannot fail is decoration.

With that fixed, the naive slope has an exact target: 0.3200, or 47 per cent low. Note what is not badly wrong: mean abundance is out by only 6.3 per cent. The total survives, the covariate does not, which is the same shape of damage as pooling a detection function across strata, arriving from a different direction.

M <- 200
r <- t(sapply(1:M, function(i) {
  dd <- sim_hds(52830 + i)
  a <- fit(c(1, 0.5, log(0.05), -0.3), y = dd$y, z = dd$z)
  b <- fit(c(1, 0.5, log(0.05)), y = dd$y, z = dd$z, sig_cov = FALSE)
  c(a$par[2], a$se[2], b$par[2], b$se[2],
    sum(exp(a$par[1] + a$par[2]*dd$z))/sum(dd$N),
    sum(exp(b$par[1] + b$par[2]*dd$z))/sum(dd$N))
}))
cvg <- function(e, se) mean(abs(e - b1) < 1.96*se)
sprintf("FULL : b1 %.4f (SD %.4f, mean SE %.4f) | CI coverage %.3f | N_hat/N %.4f",
        mean(r[, 1]), sd(r[, 1]), mean(r[, 2]), cvg(r[, 1], r[, 2]), mean(r[, 5]))
[1] "FULL : b1 0.5972 (SD 0.0593, mean SE 0.0587) | CI coverage 0.965 | N_hat/N 1.0087"
sprintf("NAIVE: b1 %.4f (SD %.4f, mean SE %.4f) | CI coverage %.3f | N_hat/N %.4f",
        mean(r[, 3]), sd(r[, 3]), mean(r[, 4]), cvg(r[, 3], r[, 4]), mean(r[, 6]))
[1] "NAIVE: b1 0.3202 (SD 0.0442, mean SE 0.0456) | CI coverage 0.000 | N_hat/N 0.9448"

The naive slope lands on its asymptotic target of 0.320 with a standard deviation of 0.044, and a nominal 95 per cent interval covers the truth in 0 out of 200 surveys. Not a low coverage: zero. The intervals are the right width for a quantity that is not the one in the title.

Left panel shows observed and fitted distance bin counts for low and high covariate sites, with the high sites having a steeper decline. Right panel shows two histograms of the estimated habitat slope, the full model centred on 0.6 and the naive model centred near 0.32.
Figure 1: Left: observed bin counts against the fitted expectations from the full model, split by whether the site sits above or below the covariate mean. Right: two hundred surveys, showing where each model’s habitat slope lands.

One visit with distances against three visits of counts

Now the comparison that decides which survey to run. Same sites, same abundance model, detection held constant at \(\sigma = 0.03\) so that the two designs are estimating the same thing. The distance sampler visits each site once and measures. The N-mixture surveyor visits three times and counts.

s0 <- 0.03; Tv <- 3
pbar <- mu_hn(s0, w)/w
Eg2  <- mu_hn(s0/sqrt(2), w)/w          # E[g^2] under x ~ U(0, w)
sprintf("per-visit detection probability p = mu/w = %.5f", pbar)
[1] "per-visit detection probability p = mu/w = 0.37567"
sprintf("but g(x) across individuals: mean %.4f, SD %.4f -> intra-class correlation %.4f",
        pbar, sqrt(Eg2 - pbar^2), (Eg2 - pbar^2)/(pbar*(1 - pbar)))
[1] "but g(x) across individuals: mean 0.3757, SD 0.3532 -> intra-class correlation 0.5318"

That second line is the whole comparison. The N-mixture model assumes every animal at a site is detected with the same probability \(p\) on every visit. In a spatial world there is no such thing as the detection probability: an animal on the transect line is seen every time, an animal at 90 m is seen never, and the spread across individuals is enormous.

Whether that breaks the N-mixture depends on something the counts cannot tell you. If the animals hold their positions between visits, as territorial birds do, then each animal carries its own fixed detection probability and the counts are far too consistent between visits. If they shuffle, each animal draws a fresh distance every visit, and the marginal count is exactly \(\text{Binomial}(N, \bar{p})\) with \(\bar{p} = E[g]\): the N-mixture’s assumption holds exactly. Both worlds have the same animals, the same detection function and the same expected count.

sim_world <- function(seed, territorial) {
  set.seed(seed)
  z <- rnorm(R); N <- rpois(R, exp(b0 + b1*z))
  y1 <- matrix(0, R, B); cnt <- matrix(0, R, Tv)
  for (i in seq_len(R)) {
    if (N[i] == 0) next
    x <- runif(N[i], 0, w)                    # where the animals are
    for (t in seq_len(Tv)) {
      xt  <- if (territorial) x else runif(N[i], 0, w)
      det <- runif(N[i]) < g_hn(xt, s0)
      cnt[i, t] <- sum(det)
      if (t == 1) y1[i, ] <- tabulate(findInterval(xt[det], cut, rightmost.closed = TRUE), B)
    }
  }
  list(y1 = y1, cnt = cnt, z = z, N = N)
}
## N-mixture likelihood: the binomial coefficients do not move during the fit, so build once
nmix_setup <- function(cnt, Kmax = 90) {
  Ng <- 0:Kmax
  list(logC = sapply(Ng, function(N) rowSums(lchoose(N, cnt))),
       n = rowSums(cnt), Tv = ncol(cnt),
       Nmat = matrix(Ng, nrow(cnt), Kmax + 1, byrow = TRUE))
}
nll_nmix <- function(p, S, z) {
  lam <- exp(p[1] + p[2]*z); pp <- plogis(p[3])
  LP <- dpois(S$Nmat, lam, log = TRUE) + S$logC +
        S$n*log(pp) + (S$Tv*S$Nmat - S$n)*log1p(-pp)
  -sum(log(rowSums(exp(LP))))
}
run <- function(seed, territorial) {
  dw <- sim_world(seed, territorial)
  fh <- fit(c(1, 0.5, log(0.04)), y = dw$y1, z = dw$z, sig_cov = FALSE)
  S  <- nmix_setup(dw$cnt)
  fn <- optim(c(1, 0.5, qlogis(0.4)), nll_nmix, S = S, z = dw$z, method = "Nelder-Mead")
  fn <- optim(fn$par, nll_nmix, S = S, z = dw$z, method = "Nelder-Mead")
  c(hds_b1 = fh$par[2], hds_N = sum(exp(fh$par[1] + fh$par[2]*dw$z))/sum(dw$N),
    nm_b1 = fn$par[2], nm_p = plogis(fn$par[3]),
    nm_N = sum(exp(fn$par[1] + fn$par[2]*dw$z))/sum(dw$N),
    n1 = sum(dw$y1), n3 = sum(dw$cnt),
    vv = mean(apply(dw$cnt, 1, var)))
}
for (terr in c(TRUE, FALSE)) {
  a <- run(4282, terr)
  cat(sprintf("\n--- %s world (seed 4282): %.0f detections in 1 visit, %.0f across 3 visits\n",
              if (terr) "TERRITORIAL: animals hold position" else "MOBILE: animals redraw position",
              a["n1"], a["n3"]))
  cat(sprintf("    distances, 1 visit : b1 %.4f | N_hat/N %.4f\n", a["hds_b1"], a["hds_N"]))
  cat(sprintf("    counts, 3 visits   : b1 %.4f | p_hat %.4f (true %.4f) | N_hat/N %.4f\n",
              a["nm_b1"], a["nm_p"], pbar, a["nm_N"]))
}

--- TERRITORIAL: animals hold position world (seed 4282): 327 detections in 1 visit, 963 across 3 visits
    distances, 1 visit : b1 0.6583 | N_hat/N 0.9712
    counts, 3 visits   : b1 0.6652 | p_hat 0.6679 (true 0.3757) | N_hat/N 0.5328

--- MOBILE: animals redraw position world (seed 4282): 355 detections in 1 visit, 1003 across 3 visits
    distances, 1 visit : b1 0.6390 | N_hat/N 1.0277
    counts, 3 visits   : b1 0.6171 | p_hat 0.3520 (true 0.3757) | N_hat/N 1.0530
M2 <- 100
mc <- lapply(c(TRUE, FALSE), function(terr)
  t(sapply(1:M2, function(i) run(52840 + i + 1000*terr, terr))))
names(mc) <- c("territorial", "mobile")
for (k in names(mc)) {
  x <- mc[[k]]
  cat(sprintf("\n%s world, %d surveys: %.0f detections in 1 visit vs %.0f across 3\n",
              k, M2, mean(x[, "n1"]), mean(x[, "n3"])))
  cat(sprintf("  distances, 1 visit : b1 %.4f (SD %.4f) | N_hat/N %.4f (SD %.4f)\n",
              mean(x[, "hds_b1"]), sd(x[, "hds_b1"]), mean(x[, "hds_N"]), sd(x[, "hds_N"])))
  cat(sprintf("  counts, 3 visits   : b1 %.4f (SD %.4f) | p_hat %.4f | N_hat/N %.4f (SD %.4f)\n",
              mean(x[, "nm_b1"]), sd(x[, "nm_b1"]), mean(x[, "nm_p"]),
              mean(x[, "nm_N"]), sd(x[, "nm_N"])))
}

territorial world, 100 surveys: 357 detections in 1 visit vs 1074 across 3
  distances, 1 visit : b1 0.5955 (SD 0.0497) | N_hat/N 1.0007 (SD 0.0554)
  counts, 3 visits   : b1 0.5968 (SD 0.0501) | p_hat 0.6758 | N_hat/N 0.5566 (SD 0.0207)

mobile world, 100 surveys: 361 detections in 1 visit vs 1083 across 3
  distances, 1 visit : b1 0.5925 (SD 0.0514) | N_hat/N 0.9963 (SD 0.0439)
  counts, 3 visits   : b1 0.5971 (SD 0.0423) | p_hat 0.3736 | N_hat/N 1.0189 (SD 0.1282)
sprintf("mean within-site variance across the 3 visits: territorial %.4f | mobile %.4f",
        mean(mc$territorial[, "vv"]), mean(mc$mobile[, "vv"]))
[1] "mean within-site variance across the 3 visits: territorial 0.5271 | mobile 1.1276"
sprintf("territorial N_hat/N should be pbar/p_hat = %.4f; simulated %.4f",
        pbar/mean(mc$territorial[, "nm_p"]), mean(mc$territorial[, "nm_N"]))
[1] "territorial N_hat/N should be pbar/p_hat = 0.5559; simulated 0.5566"

Three results, and only the first one is the one people expect.

The N-mixture loses 44 per cent of the population in the territorial world, and it loses it upwards: the fitted detection probability comes out at 0.676 against a truth of 0.376. The mechanism is the within-site variance in that last line. When animals hold still, the same birds are detected every visit and the counts barely move between visits, 0.53 against 1.13. Consistency is what a binomial reads as high detection, and high detection means there is nothing much left to find. The arithmetic is closed: \(\hat{N}/N \to \bar{p}/\hat{p}\), which is 0.5559 against a simulated 0.5566.

Note the direction. Unmodelled individual heterogeneity in capture-recapture inflates abundance, and that is the version most ecologists carry around. Here it deflates it, because the heterogeneity is fixed across visits rather than resampled within them, which is what the between-visit variance is measuring.

Even in the mobile world, where the N-mixture is unbiased, it is 2.9 times noisier, with three times the detections. Repeat visits are an expensive way to buy detection, because they measure it indirectly through a variance.

The habitat slope is fine everywhere, near 0.60 in all four cells. A detection bias that is constant across sites moves the intercept and leaves the slope alone. If your paper is about which habitat has more birds, the N-mixture will not embarrass you. If a number of animals appears in the abstract, it might.

Left panel shows abundance ratios: distance sampling sits on one in both worlds, while the N-mixture sits on one in the mobile world and near 0.56 in the territorial world. Right panel shows the fitted detection probability, correct in the mobile world and inflated in the territorial world.
Figure 2: One hundred surveys in each world. Left: estimated abundance as a fraction of the truth. Right: the fitted per-visit detection probability, which is what the N-mixture gets wrong when animals hold still.

What this does not fix

The comparison above is not a claim that distance sampling is the better method. It is a claim that the two designs bet on different biology, and that the bets are worth stating out loud.

The N-mixture bets that your animals shuffle between visits, or near enough that a common \(p\) is a fair summary. For a territorial songbird in June that bet is poor, and Barker et al. (2018) show the same family of failures from several directions. For a foraging flock it may be fine.

Hierarchical distance sampling bets instead that animals are uniformly distributed with respect to the transect, that everything on the line is detected, and that they hold still within a visit rather than moving in response to you. Those are different assumptions, not weaker ones. They are also mostly design choices: random placement makes the first one true by construction, which is why it is worth being fussy about.

And the model still needs the Poisson. The identity that makes the likelihood two lines long is exactly the property that fails under a negative binomial, and unmodelled extra-Poisson variation in abundance has to be added back by hand, at which point the latent-N sum returns along with its truncation ceiling. Sillett et al. (2012) work a full hierarchical distance sampling analysis on an island endemic and are explicit about which of these are assumptions and which are protocol.

The closing post asks what a fitted detection function will actually tell you when one of these is wrong, and the answer is worse than you would guess.

References

Barker, R. J., Schofield, M. R., Link, W. A. and Sauer, J. R. (2018). On the reliability of N-mixture models for count data. Biometrics 74(1), 369-377. DOI: 10.1111/biom.12734

Buckland, S. T., Rexstad, E. A., Marques, T. A. and Oedekoven, C. S. (2015). Distance Sampling: Methods and Applications. Springer. ISBN 978-3-319-19218-5

Kery, M. and Royle, J. A. (2016). Applied Hierarchical Modeling in Ecology. Academic Press. ISBN 978-0-12-801378-6

Royle, J. A. (2004). N-mixture models for estimating population size from spatially replicated counts. Biometrics 60(1), 108-115. DOI: 10.1111/j.0006-341X.2004.00142.x

Royle, J. A., Dawson, D. K. and Bates, S. (2004). Modeling abundance effects in distance sampling. Ecology 85(6), 1591-1597.

Sillett, T. S., Chandler, R. B., Royle, J. A., Kery, M. and Morrison, S. A. (2012). Hierarchical distance-sampling models to estimate population size and habitat-specific abundance of an island endemic. Ecological Applications 22(7), 1997-2006.

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.