---
title: "Horvitz-Thompson for adaptive samples"
description: "Estimate abundance from an adaptive cluster sample with the Horvitz-Thompson estimator: network inclusion probabilities in R, and why it beats Hansen-Hurwitz."
date: "2026-07-26 15:00"
categories: [R, adaptive sampling, survey design, abundance, ecology tutorial]
image: thumbnail.png
image-alt: "A curve showing how a network's inclusion probability rises with its size, from a low value for single quadrats up towards one for large clusters."
---
The [previous post](../adaptive-cluster-sampling/) built adaptive cluster sampling and fixed the biased naive mean with the Hansen-Hurwitz estimator. Hansen-Hurwitz is unbiased and easy to compute, but it is not the tightest estimator the design allows. This post derives the alternative: the Horvitz-Thompson estimator, which works from the probability that each network makes it into the sample. It is a little more work to set up and it wins on precision.
The idea is the same one that runs through all unequal-probability sampling (Horvitz and Thompson 1952): if you can write down the probability that each thing you observed had of being observed, you can weight each observation by the inverse of that probability and recover an unbiased total. In adaptive cluster sampling the "things" are networks, and their inclusion probabilities have a clean closed form.
```{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 }
pop <- make_population(); nets <- build_networks(pop, 1L)
ubn <- split(seq_len(pop$N), nets$net_id)
mu <- mean(pop$y); N <- pop$N; n1 <- 30L
sat_sizes <- sort(nets$m[nets$net_is_sat], decreasing = TRUE)
acs_draw <- function(pop, nets, ubn, 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
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 <- vapply(hit, function(k) 1 - choose2(N - nets$m[k], n1) / choose2(N, n1), numeric(1))
list(naive = mean(y[in_s]), hh = mean(nets$net_mean_of_unit[init]),
ht = sum(nets$tot[hit] / a) / N, n_final = sum(in_s), init = init, hit = hit)
}
hh_var <- function(pop, nets, init, n1) {
w <- nets$net_mean_of_unit[init]
(N - n1) / (N * n1 * (n1 - 1)) * sum((w - mean(w))^2)
}
ht_var <- function(pop, nets, hit, n1) {
K <- length(hit); if (K < 2) return(NA_real_)
m <- nets$m[hit]; tot <- nets$tot[hit]; Ctot <- choose2(N, n1)
a <- 1 - vapply(m, function(mk) choose2(N - mk, n1), numeric(1)) / Ctot
v <- 0
for (j in 1:K) for (l in 1:K) {
ajl <- if (j == l) a[j] else
1 - (choose2(N - m[j], n1) + choose2(N - m[l], n1) -
choose2(N - m[j] - m[l], n1)) / Ctot
v <- v + (ajl - a[j] * a[l]) / ajl * (tot[j] / a[j]) * (tot[l] / a[l])
}
v / N^2
}
```
## The probability a network gets into the sample
A network of `r "m"` units is included in the sample if and only if the initial simple random sample lands on at least one of its units. The complement is easier to count: the probability the initial sample of `r n1` units avoids the whole network. So the inclusion probability of a network of size $m$ is
$$
\alpha = 1 - \frac{\binom{N-m}{n_1}}{\binom{N}{n_1}},
$$
with $N$ the number of quadrats and $n_1$ the initial sample size. Bigger networks are far more likely to be hit; a single quadrat is included only with the plain initial probability $n_1/N$.
```{r}
#| label: alpha
#| echo: true
alpha <- function(m, N, n1) 1 - choose2(N - m, n1) / choose2(N, n1)
m_big <- max(nets$m) # the largest occupied network
c(largest_network = m_big,
alpha_largest = round(alpha(m_big, N, n1), 4),
alpha_single = round(alpha(1, N, n1), 4), # = n1 / N
n1_over_N = round(n1 / N, 4))
```
The largest occupied network here spans `r m_big` quadrats and has inclusion probability `r sprintf("%.4f", alpha(m_big, N, n1))`: with 30 initial draws it is caught almost four times out of five. A lone quadrat has probability only `r sprintf("%.4f", alpha(1, N, n1))`, exactly $n_1/N$. That spread in inclusion probabilities is what the estimator has to correct for.
```{r}
#| label: fig-alpha
#| echo: false
#| fig-cap: "Inclusion probability against network size for the initial sample of 30 quadrats. A single quadrat enters with probability n1/N; large clusters are nearly certain to be caught."
#| fig-alt: "A rising curve of inclusion probability versus network size. It starts near 0.075 for size one and climbs steeply, passing 0.78 by size 19 and approaching one for large networks."
#| fig-width: 5.6
#| fig-height: 3.6
library(ggplot2)
ad <- data.frame(m = 1:40, alpha = alpha(1:40, N, n1))
pts <- data.frame(m = sat_sizes, alpha = alpha(sat_sizes, N, n1))
ggplot(ad, aes(m, alpha)) +
geom_line(colour = "#275139", linewidth = 0.9) +
geom_point(data = pts, colour = "#b5534e", size = 2.2) +
labs(x = "network size (quadrats)", y = "inclusion probability") +
ylim(0, 1) +
theme_minimal(base_size = 11) + theme(panel.grid.minor = element_blank())
```
## The Horvitz-Thompson estimator
With inclusion probabilities in hand, the estimator writes itself. Take the **distinct** networks the initial sample intersected, divide each network's total by its inclusion probability, add them up, and divide by the number of quadrats for the mean.
```{r}
#| label: ht-definition
#| echo: true
horvitz_thompson <- function(nets, hit, N, n1) {
a <- alpha(nets$m[hit], N, n1)
sum(nets$tot[hit] / a) / N
}
set.seed(7); d1 <- acs_draw(pop, nets, ubn, n1)
horvitz_thompson(nets, d1$hit, N, n1)
```
The word **distinct** is doing real work. If two of your initial units land in the same cluster, that network was still included once, so it enters the sum once, weighted by its single inclusion probability. This is where Horvitz-Thompson parts company with Hansen-Hurwitz, which effectively counts a network once per initial hit.
## Why it beats Hansen-Hurwitz
On a clustered population the initial sample often hits the same network more than once: there are only a few networks and 30 initial draws.
```{r}
#| label: multi-hit
#| echo: true
set.seed(202); B <- 20000L; multi <- logical(B)
for (b in 1:B) {
init <- sample.int(N, n1); tab <- table(nets$net_id[init])
multi[b] <- any(tab > 1 & nets$net_is_sat[as.integer(names(tab))])
}
mean(multi) # fraction of draws with a multiply-hit occupied network
```
```{r}
#| label: multi-num
#| include: false
multi_pct <- 100 * mean(multi)
```
An occupied network is hit more than once in `r sprintf("%.0f%%", multi_pct)` of draws. Hansen-Hurwitz counts each of those hits, so the same cluster enters the average two or three times, and that repetition adds variance for no information. Horvitz-Thompson counts the cluster once. In estimation-theory terms the Horvitz-Thompson estimator is a function of the distinct networks and their values, which is the minimal sufficient statistic for this design, while Hansen-Hurwitz is not; the Rao-Blackwell versions of both improve on the originals (Salehi 1999).
```{r}
#| label: mc-both
#| echo: true
mc <- function(pop, nets, ubn, n1, B, seed) {
set.seed(seed); hh <- ht <- numeric(B); cov_ht <- logical(B); neg <- 0L
mu <- mean(pop$y)
for (b in 1:B) {
d <- acs_draw(pop, nets, ubn, n1)
hh[b] <- d$hh; ht[b] <- d$ht
vt <- ht_var(pop, nets, d$hit, n1)
if (!is.na(vt) && vt < 0) neg <- neg + 1L
cov_ht[b] <- if (is.na(vt) || vt <= 0) NA else abs(d$ht - mu) <= 1.96 * sqrt(vt)
}
list(hh = hh, ht = ht, cov_ht = cov_ht, neg = neg)
}
M <- mc(pop, nets, ubn, n1 = 30L, B = 20000L, seed = 202L)
c(bias_ht = round(mean(M$ht) - mu, 5),
sd_hh = round(sd(M$hh), 4), sd_ht = round(sd(M$ht), 4),
var_ratio_ht_over_hh = round(var(M$ht) / var(M$hh), 3))
```
```{r}
#| label: ht-num
#| include: false
ratio <- var(M$ht) / var(M$hh)
se_ratio <- sd(M$ht) / sd(M$hh)
ht_cover <- mean(M$cov_ht, na.rm = TRUE)
```
Both estimators are unbiased: the Horvitz-Thompson bias over 20,000 samples is `r sprintf("%+.5f", mean(M$ht) - mu)`. But the Horvitz-Thompson variance is `r sprintf("%.3f", ratio)` times the Hansen-Hurwitz variance, a standard error of `r sprintf("%.4f", sd(M$ht))` against `r sprintf("%.4f", sd(M$hh))`, or about `r sprintf("%.0f%%", 100 * (1 - se_ratio))` narrower. On this population the improvement is worth having, and Salehi (2003) recommends Horvitz-Thompson as the default despite the heavier bookkeeping.
```{r}
#| label: fig-hh-vs-ht
#| echo: false
#| fig-cap: "Sampling distributions of the two unbiased estimators over 20,000 initial samples. Both centre on the truth; the Horvitz-Thompson estimator is the tighter of the two."
#| fig-alt: "Two overlaid density curves centred on the same dashed true-mean line. The Horvitz-Thompson curve is taller and narrower than the Hansen-Hurwitz curve."
#| fig-width: 6.0
#| fig-height: 3.7
dd <- rbind(data.frame(est = M$hh, which = "Hansen-Hurwitz"),
data.frame(est = M$ht, which = "Horvitz-Thompson"))
ggplot(dd, aes(est, colour = which)) +
geom_density(linewidth = 0.9) +
geom_vline(xintercept = mu, linetype = 2, colour = "#16241d") +
scale_colour_manual(values = c("Hansen-Hurwitz" = "#93a87f",
"Horvitz-Thompson" = "#275139"), name = NULL) +
labs(x = "estimated mean count per quadrat", y = "density") +
coord_cartesian(xlim = c(0, 0.9)) +
theme_minimal(base_size = 11) +
theme(legend.position = "top", panel.grid.minor = element_blank())
```
## The interval is still the weak point
More precise does not mean better calibrated. The unbiased Horvitz-Thompson variance estimator uses pairwise network inclusion probabilities, and on a skewed population the normal interval built from it undercovers, a touch worse than the Hansen-Hurwitz one from the previous post.
```{r}
#| label: ht-cover
#| echo: true
c(ht_interval_cover = round(ht_cover, 3),
negative_variance_estimates = M$neg)
```
The interval reaches only about `r sprintf("%.1f%%", 100 * ht_cover)` cover here. No variance estimate came out negative on this population, but the Horvitz-Thompson variance estimator can go negative in general, which is one more reason field guidance leans on a bootstrap interval for adaptive samples. The point estimate is excellent; the interval needs care. The [next post](../when-does-adaptive-sampling-pay/) steps back from the estimator to the design itself and asks the question that decides whether any of this is worth doing: when does adaptive sampling actually beat a simple random sample?
## Related tutorials
- [Adaptive cluster sampling in R](../adaptive-cluster-sampling/)
- [When does adaptive sampling pay?](../when-does-adaptive-sampling-pay/)
- [Checking an adaptive sampling design](../checking-an-adaptive-sampling-design/)
- [Unequal-probability spatial sampling](../unequal-probability-spatial-sampling/)
## References
Horvitz DG, Thompson DJ 1952 J Am Stat Assoc 47(260):663-685 (10.1080/01621459.1952.10483446)
Thompson SK 1990 J Am Stat Assoc 85(412):1050-1059 (10.1080/01621459.1990.10474975)
Salehi MM 1999 Environ Ecol Stat 6(2):183-195 (10.1023/A:1009670205509)
Salehi MM 2003 Environ Ecol Stat 10(1):115-127 (10.1023/A:1021989509323)
Thompson SK, Seber GAF 1996 Adaptive Sampling. Wiley (ISBN 978-0-471-55871-2)