---
title: "When does adaptive sampling pay?"
description: "Two ways to count the cost of adaptive cluster sampling: compare it fairly with simple random sampling in R at equal initial effort and at equal total effort."
date: "2026-06-27 13:00"
categories: [R, adaptive sampling, survey design, abundance, ecology tutorial]
image: thumbnail.png
image-alt: "A comparison of sampling efficiency against how clumped a population is, with adaptive sampling ahead only for the most strongly aggregated case."
---
The first two posts in this thread built adaptive cluster sampling and two unbiased estimators for it. Both showed the design in a flattering light, because both compared estimators on the same design. This post asks the harder question: should you run the design at all, or would a simple random sample of the same effort do better?
The answer turns on how you count effort, and a design that looks like a large gain at equal initial effort looks much weaker once the extra visits are paid for. That is not a trick; it is the central practical fact about adaptive designs, and getting it wrong is how a survey ends up over budget.
```{r}
#| label: setup
#| include: false
options(digits = 6)
make_population <- function(side = 20L, n_clusters = 5L, per_cluster = 22,
spread = 0.9, seed = 101L) {
set.seed(seed); N <- side * side; y <- integer(N)
cx0 <- runif(n_clusters, 2, side - 1); cy0 <- runif(n_clusters, 2, side - 1)
for (k in seq_len(n_clusters)) {
np <- rpois(1, per_cluster); if (np == 0) next
cx <- pmin(pmax(round(rnorm(np, cx0[k], spread)), 1), side)
cy <- pmin(pmax(round(rnorm(np, cy0[k], spread)), 1), side)
cell <- (cy - 1) * side + cx
tb <- table(cell); idx <- as.integer(names(tb)); y[idx] <- y[idx] + as.integer(tb)
}
list(side = side, N = N, y = y)
}
rook <- function(i, side) {
x <- ((i - 1) %% side) + 1L; yy <- ((i - 1) %/% side) + 1L
nb <- integer(0)
if (x > 1) nb <- c(nb, i - 1L); if (x < side) nb <- c(nb, i + 1L)
if (yy > 1) nb <- c(nb, i - side); if (yy < side) nb <- c(nb, i + side)
nb
}
build_networks <- function(pop, cval = 1L) {
side <- pop$side; N <- pop$N; y <- pop$y; sat <- y >= cval
net_id <- integer(N); cur <- 0L; seen <- logical(N)
for (i in seq_len(N)) {
if (seen[i]) next
if (!sat[i]) { cur <- cur + 1L; net_id[i] <- cur; seen[i] <- TRUE; next }
cur <- cur + 1L; stack <- i; seen[i] <- TRUE; net_id[i] <- cur
while (length(stack)) {
u <- stack[length(stack)]; stack <- stack[-length(stack)]
for (v in rook(u, side)) if (!seen[v] && sat[v]) {
seen[v] <- TRUE; net_id[v] <- cur; stack <- c(stack, v) }
}
}
m <- as.integer(table(net_id)); tot <- as.numeric(tapply(y, net_id, sum))
list(net_id = net_id, m = m, tot = tot, sat = sat,
net_mean_of_unit = (tot / m)[net_id],
net_is_sat = as.logical(tapply(sat, net_id, function(z) z[1])))
}
choose2 <- function(a, b) { out <- exp(lchoose(a, b)); out[b < 0 | b > a] <- 0; out }
acs_draw <- function(pop, nets, n1) {
N <- pop$N; y <- pop$y; side <- pop$side
init <- sample.int(N, n1); hit <- unique(nets$net_id[init])
in_s <- logical(N); in_s[init] <- TRUE
ubn <- split(seq_len(N), nets$net_id)
for (k in hit) { uk <- ubn[[k]]; in_s[uk] <- TRUE
if (nets$net_is_sat[k]) for (u in uk) for (v in rook(u, side)) in_s[v] <- TRUE }
a <- 1 - choose2(N - nets$m[hit], n1) / choose2(N, n1)
list(ht = sum(nets$tot[hit] / a) / N, n_final = sum(in_s))
}
vmr <- function(pop) var(pop$y) / mean(pop$y)
pop <- make_population(); nets <- build_networks(pop, 1L)
N <- pop$N; mu <- mean(pop$y); n1 <- 30L
library(ggplot2)
te_canvas <- theme(plot.background = element_rect(fill = "#f5f4ee", colour = NA),
panel.background = element_rect(fill = "#f5f4ee", colour = NA))
```
## Two ways to count the cost
An adaptive design starts from an initial sample of `r n1` quadrats and then grows. So there are two honest baselines. You can give simple random sampling the same **initial** sample size, which asks "given that I am going out to 30 randomly chosen quadrats, is it worth expanding around the ones with animals?" Or you can give it the same **total** number of quadrats actually visited, which asks "given a fixed field budget of quadrat-visits, which design spends it better?"
```{r}
#| label: mc-compare
#| echo: true
srs_mean <- function(pop, n, B, seed) {
set.seed(seed); N <- pop$N; y <- pop$y
vapply(1:B, function(b) mean(y[sample.int(N, n)]), numeric(1))
}
acs_mc <- function(pop, nets, n1, B, seed) {
set.seed(seed); ht <- nf <- numeric(B)
for (b in 1:B) { d <- acs_draw(pop, nets, n1); ht[b] <- d$ht; nf[b] <- d$n_final }
list(ht = ht, nf = nf)
}
A <- acs_mc(pop, nets, n1, 20000L, 202L)
srs1 <- srs_mean(pop, n1, 20000L, 303L) # same INITIAL sample size
c(sd_srs_30 = round(sd(srs1), 4), sd_adaptive = round(sd(A$ht), 4),
efficiency = round(var(srs1) / var(A$ht), 2))
```
At equal initial effort the adaptive design is `r sprintf("%.1f", var(srs1) / var(A$ht))` times as efficient: a standard error of `r sprintf("%.4f", sd(A$ht))` against `r sprintf("%.4f", sd(srs1))`. If you stopped here you would conclude that adaptive sampling is a large free gain.
It is not free. The expansion visits extra quadrats, and those visits are the real currency of field work.
```{r}
#| label: final-n
#| echo: true
c(mean_final_n = round(mean(A$nf), 1), sd_final_n = round(sd(A$nf), 1),
min_final_n = min(A$nf), max_final_n = max(A$nf))
```
```{r}
#| label: fig-final-n
#| echo: false
#| fig-cap: "Distribution of the realised sample size over 20,000 adaptive draws. The initial sample is fixed at 30 quadrats; what you actually visit is not, and it ranges from 30 to over 130."
#| fig-alt: "A histogram of final sample size, spread broadly between about 30 and 137 quadrats, with a dashed vertical line at 30 marking the fixed initial sample size and another at the mean of about 97."
#| fig-width: 6.0
#| fig-height: 3.5
ggplot(data.frame(n = A$nf), aes(n)) +
geom_histogram(binwidth = 4, fill = "#93a87f", colour = "white", linewidth = 0.2) +
geom_vline(xintercept = n1, linetype = 2, colour = "#b5534e") +
geom_vline(xintercept = mean(A$nf), linetype = 2, colour = "#16241d") +
annotate("text", x = n1 + 3, y = Inf, label = "initial n", vjust = 1.6,
hjust = 0, size = 3.2, colour = "#b5534e") +
annotate("text", x = mean(A$nf) + 3, y = Inf, label = "mean final n", vjust = 1.6,
hjust = 0, size = 3.2, colour = "#16241d") +
labs(x = "quadrats actually visited", y = "draws") +
theme_minimal(base_size = 11) + te_canvas + theme(panel.grid.minor = element_blank())
```
On average the design visits `r sprintf("%.0f", mean(A$nf))` quadrats, `r sprintf("%.1f", mean(A$nf) / n1)` times the initial `r n1`, and in the worst draw it visited `r max(A$nf)`. So the fair budget comparison is against a simple random sample of about `r sprintf("%.0f", mean(A$nf))` quadrats.
```{r}
#| label: equal-effort
#| echo: true
ne <- round(mean(A$nf))
srsE <- srs_mean(pop, ne, 20000L, 404L) # same TOTAL effort
c(n = ne, sd_srs = round(sd(srsE), 4), sd_adaptive = round(sd(A$ht), 4),
efficiency = round(var(srsE) / var(A$ht), 2))
```
```{r}
#| label: eff-nums
#| include: false
eff_init <- var(srs1) / var(A$ht); eff_tot <- var(srsE) / var(A$ht)
```
The comparison changes at equal total effort. The efficiency is `r sprintf("%.2f", eff_tot)`, against a break-even value of one: below one a plain simple random sample of `r ne` quadrats does better than the adaptive design that visits `r ne` quadrats on average. The adaptive gain at equal initial effort was real, but it was bought with visits, and paying for the same visits in the simple random sample takes most of that gain back.
## So when does it pay?
Thompson and Seber (1996) argue that the design earns its keep when the species is both **rare** and **strongly clumped**. Push the clumping down and the design starts expanding everywhere for little return.
To test that, hold total abundance fixed and vary only how tightly the individuals are packed. The variance-to-mean ratio of the counts measures the clumping: it is one for a random (Poisson) field and rises with aggregation.
```{r}
#| label: sweep
#| echo: true
sweep_agg <- function(spreads, B = 8000L, seed = 11L) {
do.call(rbind, lapply(spreads, function(s) {
p <- make_population(spread = s, seed = 55L); nn <- build_networks(p, 1L)
r <- acs_mc(p, nn, n1, B, seed)
ne <- round(mean(r$nf)); sr <- srs_mean(p, ne, B, seed + 1L)
data.frame(spread = s, total = sum(p$y), vmr = vmr(p),
occupancy = mean(p$y >= 1), mean_final_n = mean(r$nf),
efficiency = var(sr) / var(r$ht))
}))
}
sw <- sweep_agg(c(0.5, 0.9, 1.5, 2.5))
round(sw, 3)
```
```{r}
#| label: sweep-nums
#| include: false
best <- sw[which.max(sw$vmr), ]
worst <- sw[which.min(sw$vmr), ]
```
All four populations hold the same `r sw$total[1]` individuals; only their packing differs. The tightest one (variance-to-mean ratio `r sprintf("%.1f", best$vmr)`, occupancy `r sprintf("%.1f%%", 100 * best$occupancy)`) returns an efficiency of `r sprintf("%.2f", best$efficiency)`, and the design visits `r sprintf("%.0f", best$mean_final_n)` quadrats there. The loosest (ratio `r sprintf("%.1f", worst$vmr)`) returns `r sprintf("%.2f", worst$efficiency)`. Each row's efficiency is measured against a simple random sample of that row's own cost, so read the two columns together.
```{r}
#| label: fig-efficiency
#| echo: false
#| fig-cap: "Efficiency against clumping, at equal total effort. The dashed line marks break-even, an efficiency of one. The points do not lie on a clean ramp, because the design's cost moves with the clumping too."
#| fig-alt: "Efficiency plotted against the variance-to-mean ratio for four populations plus a random-field control, with a horizontal break-even line drawn at one. The point at a ratio near nine is plotted above that line; the other four are plotted below it, and they do not lie on a smooth curve."
#| fig-width: 6.0
#| fig-height: 3.8
set.seed(88); popC <- list(side = pop$side, N = N, y = rpois(N, mu))
netsC <- build_networks(popC, 1L)
rC <- acs_mc(popC, netsC, n1, 8000L, 99L)
neC <- round(mean(rC$nf)); srC <- srs_mean(popC, neC, 8000L, 111L)
csr <- data.frame(vmr = vmr(popC), efficiency = var(srC) / var(rC$ht),
mean_final_n = mean(rC$nf), occupancy = mean(popC$y >= 1))
pd <- rbind(data.frame(vmr = sw$vmr, efficiency = sw$efficiency,
kind = "clustered populations"),
data.frame(vmr = csr$vmr, efficiency = csr$efficiency,
kind = "random field (control)"))
ggplot(pd, aes(vmr, efficiency, colour = kind)) +
geom_hline(yintercept = 1, linetype = 2, colour = "#16241d") +
geom_point(size = 3) +
scale_colour_manual(values = c("clustered populations" = "#275139",
"random field (control)" = "#b5534e"), name = NULL) +
annotate("text", x = max(pd$vmr) * 0.62, y = 1.04,
label = "break-even", size = 3.2, colour = "#16241d") +
labs(x = "variance-to-mean ratio (clumping)",
y = "efficiency vs simple random sampling") +
theme_minimal(base_size = 11) + te_canvas +
theme(legend.position = "top", panel.grid.minor = element_blank())
```
## An honest limit: it is not a clean ramp
The four points do not lie on a tidy increasing curve. Efficiency is `r sprintf("%.2f", sw$efficiency[2])` at a ratio of `r sprintf("%.1f", sw$vmr[2])` but `r sprintf("%.2f", sw$efficiency[3])` at a ratio of `r sprintf("%.1f", sw$vmr[3])`, which reverses the expected order. Each spread value here is a single realised population, so one pair of neighbouring points is thin evidence on its own, but there is a structural reason not to expect a clean order anyway.
The reason is that clumping moves two things at once. It changes how much information sits in the networks, and it changes how much the design expands: the mean final sample size runs `r paste(sprintf("%.0f", sw$mean_final_n), collapse = ", ")` across the four populations. Since the comparison re-baselines simple random sampling to whatever the adaptive design happened to cost, the yardstick moves with the design. There is no single number that summarises "aggregation" and predicts efficiency on its own, which is why the practical advice is to simulate your own case rather than trust a rule of thumb.
The control confirms the easy half of the story. On a random field of the same mean, with a variance-to-mean ratio of `r sprintf("%.2f", csr$vmr)`, the adaptive design still expands to `r sprintf("%.0f", csr$mean_final_n)` quadrats (occupancy is `r sprintf("%.0f%%", 100 * csr$occupancy)`, so it triggers constantly) and returns an efficiency of `r sprintf("%.2f", csr$efficiency)`. When there are no clusters to find, expanding is a pure cost.
## What to take to the field
Run the design when the species is rare enough that expansion stays rare, and clumped enough that hitting one occupied quadrat tells you where more are. Sparse, patchy, hard-to-find organisms are exactly the case Thompson and Seber (1996) had in mind, and where field applications have paid off (Smith and colleagues 1995). Outside that regime, a well-spread fixed design is the better buy.
Before committing, simulate your own population, and budget for the sample size you might actually get rather than the one you planned. The [next post](../checking-an-adaptive-sampling-design/) turns that into three concrete checks you can run before the field season starts.
## References
Thompson SK 1990 J Am Stat Assoc 85(412):1050-1059 (10.1080/01621459.1990.10474975)
Thompson SK 1991 Biometrics 47(3):1103-1115 (10.2307/2532662)
Smith DR, Conroy MJ, Brakhage DH 1995 Biometrics 51(2):777-788 (10.2307/2532964)
Brown JA 2003 Environ Ecol Stat 10(1):95-105 (10.1023/A:1021933424344)
Thompson SK, Seber GAF 1996 Adaptive Sampling. Wiley (ISBN 978-0-471-55871-2)
## Related tutorials
- [Adaptive cluster sampling in R](../adaptive-cluster-sampling/)
- [Horvitz-Thompson for adaptive samples](../horvitz-thompson-for-adaptive-samples/)
- [What a detection time is worth](../what-a-detection-time-is-worth/)
- [Checking an adaptive sampling design](../checking-an-adaptive-sampling-design/)