---
title: "When does adaptive sampling pay?"
description: "Adaptive cluster sampling beats simple random sampling on some populations and loses on others. Compare them fairly in R and find the condition that decides it."
date: "2026-07-27 09: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 the two reasonable ways of counting give opposite verdicts on the same population. 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 and less precise than the plain design it replaced.
```{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)
```
## 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) + theme(panel.grid.minor = element_blank())
```
On average the design visits `r sprintf("%.0f", mean(A$nf))` quadrats, more than three 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 verdict flips. At equal total effort the efficiency is `r sprintf("%.2f", eff_tot)`, which is below one: on this population a plain simple random sample of `r ne` quadrats beats 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 buying the same visits outright buys more precision.
## So when does it pay?
The design earns its keep when the species is both **rare** and **strongly clumped**, because then the expansion is triggered rarely and, when it fires, it lands you inside the few units that carry the information. 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)`) is the only case where the efficiency clears one, at `r sprintf("%.2f", best$efficiency)`. Notice why: with the animals in a few tiny knots the design almost never triggers, so it visits only `r sprintf("%.0f", best$mean_final_n)` quadrats. It stays cheap **and** it finds the knots. As the packing loosens, the efficiency falls to `r sprintf("%.2f", worst$efficiency)` at a ratio of `r sprintf("%.1f", worst$vmr)`.
```{r}
#| label: fig-efficiency
#| echo: false
#| fig-cap: "Efficiency against clumping, at equal total effort. Only the most strongly aggregated population puts adaptive sampling above the break-even line; the relationship is not 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. Only the point at a ratio near nine sits above the horizontal break-even line at one; the others fall below it and 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) +
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. That is not simulation noise; it repeats across seeds.
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.
## Related tutorials
- [Adaptive cluster sampling in R](../adaptive-cluster-sampling/)
- [Horvitz-Thompson for adaptive samples](../horvitz-thompson-for-adaptive-samples/)
- [Checking an adaptive sampling design](../checking-an-adaptive-sampling-design/)
- [Allocating effort across strata](../allocating-effort-across-strata/)
## 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)