Prevalence from an imperfect test

R
disease ecology
survey design
epidemiology
ecology tutorial
Correcting apparent prevalence in a wildlife disease survey: the Rogan-Gladen estimator, negative estimates, and intervals that carry the validation study.
Author

Tidy Ecology

Published

2026-08-04

A wild boar population is being screened for a virus. Hunters submit blood samples through one shooting season, four hundred and eighty animals in all, and each sample is run on a pen-side lateral flow strip that gives a result in fifteen minutes without a cold chain. The strip was validated the previous year against a virus neutralisation assay, on a panel of one hundred sera from animals known to be infected and one hundred from animals known to be clear. The validation returned a sensitivity of 0.95 and a specificity of 0.98, and those two figures went into the methods section of the report as though they were constants.

The result that gets circulated is the proportion of strips that turned positive, the apparent prevalence. It is easy to compute and it is not the prevalence of the virus in the boar population: it mixes the animals that are infected and tested positive with the animals that are not infected and tested positive anyway, and at the prevalences that matter in wildlife disease work the second group is not a minor contamination of the first. It can be the larger of the two.

The correction for a known sensitivity and specificity dates from the nineteen seventies, takes one line of arithmetic, and works: the point estimate comes back to the truth. Everything difficult is in the interval around it, because the sensitivity and the specificity were themselves estimated, from one hundred animals each, and a report that treats them as constants is quoting a precision it has not got.

Four neighbouring posts on this site touch the same machinery and none of them does this. Score thresholds and precision in bioacoustics runs the problem in the other direction: it takes prevalence as given and asks what fraction of the flagged clips are real, which is the false discovery proportion rather than the corrected prevalence. Imperfect detection and occupancy bias in R is the sensitivity-only case, where a species that is present can be missed but a species that is absent can never be recorded, so the naive estimate is biased down and repeat visits fix it. False positives in occupancy models puts imperfect specificity inside the likelihood and estimates it from the detection history, which is a different move from correcting a survey with an externally supplied number. Automated acoustic detections as data subtracts an externally estimated false-positive floor and reports that fifteen of two hundred repeats went negative, and then stops there. This post is the one that carries the validation study’s own uncertainty through the correction and measures the coverage that comes out.

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 = "#2c3a31"),
          axis.text = element_text(colour = "#2c3a31"))
}

Apparent prevalence is a mixture

An animal tests positive in one of two ways. It is infected and the test finds it, which happens with probability equal to the sensitivity, or it is not infected and the test says it is anyway, which happens with probability one minus the specificity. Writing \(p\) for the true prevalence, \(Se\) for sensitivity and \(Sp\) for specificity, the expected apparent prevalence is

\[AP = Se \cdot p + (1 - Sp)(1 - p)\]

Which term dominates is settled entirely by how rare the pathogen is: the first shrinks as the pathogen gets rarer, the second, proportional to \(1 - p\), does not.

se_true <- 0.95
sp_true <- 0.98
p_true <- 0.02
n_survey <- 480
n_val_pos <- 100
n_val_neg <- 100

ap_of <- function(p, se, sp) se * p + (1 - sp) * (1 - p)
rg_of <- function(ap, se, sp) (ap + sp - 1) / (se + sp - 1)

ap_exp <- ap_of(p_true, se_true, sp_true)
fp_term <- (1 - sp_true) * (1 - p_true)
fn_term <- (1 - se_true) * p_true
over_pct <- 100 * (ap_exp / p_true - 1)
fp_of_positives <- 100 * fp_term / ap_exp
fp_of_error <- 100 * fp_term / (fp_term + fn_term)
youden <- se_true + sp_true - 1

print(round(c(true_prevalence = p_true, sensitivity = se_true, specificity = sp_true,
              animals = n_survey, youden_index = youden), 4))
true_prevalence     sensitivity     specificity         animals    youden_index 
           0.02            0.95            0.98          480.00            0.93 
print(round(c(expected_apparent = ap_exp, true_positive_term = se_true * p_true,
              false_positive_term = fp_term, missed_positive_term = fn_term,
              overestimate_pct = over_pct), 5))
   expected_apparent   true_positive_term  false_positive_term 
              0.0386               0.0190               0.0196 
missed_positive_term     overestimate_pct 
              0.0010              93.0000 
print(round(c(false_share_of_positives = fp_of_positives, fp_over_fn = fp_term / fn_term,
              false_positive_share_of_error = fp_of_error), 4))
     false_share_of_positives                    fp_over_fn 
                      50.7772                       19.6000 
false_positive_share_of_error 
                      95.1456 

At a true prevalence of 2 per cent the expected apparent prevalence is 3.86 per cent, an overestimate of 93 per cent. Infected animals correctly picked up contribute 1.9 per cent and uninfected animals wrongly flagged contribute 1.96 per cent, so 50.78 per cent of the positive strips in this survey come from animals that do not have the virus.

The error decomposes just as cleanly: apparent prevalence exceeds the truth by 1.86 percentage points, a gain of 1.96 from the false positives against a loss of 0.1 from the missed infections, which puts 95.15 per cent of the total movement on specificity. A test advertised as ninety-five per cent sensitive and ninety-eight per cent specific sounds better on the first figure than the second, and at this prevalence the second figure is the only one that matters.

Four straight lines rising from left to right on a warm off-white panel, with true prevalence from zero to twenty per cent on the horizontal axis and apparent prevalence on the vertical axis. A dashed grey one-to-one line runs corner to corner. A dark green line for sensitivity 0.95 and specificity 0.98 starts about two percentage points above the origin and lies above the dashed line throughout. A red line for specificity 0.95 starts five percentage points up and is highest of all. A gold line for sensitivity 0.70 starts at the same height as the green one, rises more slowly, and drops below the dashed line at about six per cent. A pale green line for specificity 0.999 starts almost at the origin and runs along the dashed line, just above it at the left-hand end and just below it across the rest of the panel. A vertical dotted line marks the two per cent prevalence used in the post.
Figure 1: Expected apparent prevalence against true prevalence for four combinations of sensitivity and specificity, with the one-to-one line for a perfect test. Each curve meets the vertical axis at one minus the specificity, and that intercept is what separates the curves at the left-hand end. Halving the sensitivity tilts a curve; moving the specificity from 0.98 to 0.999 shifts it almost onto the diagonal.

Every curve hits the vertical axis at \(1 - Sp\), because a population with no infection still produces false positives at that rate, and sensitivity only controls the slope. At the left-hand end of the panel, where wildlife disease surveys live, the curves separate by intercept, not slope.

The Rogan-Gladen correction

Inverting the mixture is one line. Rogan and Gladen (1978) set the observed apparent prevalence equal to its expectation and solved for \(p\):

\[\hat{p} = \frac{AP + Sp - 1}{Se + Sp - 1}\]

The denominator is the Youden index, 0.93 here, measuring how much of a change in true prevalence survives into the apparent prevalence; the numerator subtracts the false-positive floor. Both operations are linear, which makes the estimator easy and makes it behave badly at the boundary later on.

set.seed(20260804)
x_pos <- rbinom(1, n_survey, ap_exp)
ap_hat <- x_pos / n_survey
p_hat_one <- rg_of(ap_hat, se_true, sp_true)

print(c(positive_strips = x_pos, animals = n_survey))
positive_strips         animals 
             23             480 
print(round(c(apparent_prevalence = ap_hat,
              corrected_prevalence = p_hat_one,
              true_prevalence = p_true), 5))
 apparent_prevalence corrected_prevalence      true_prevalence 
             0.04792              0.03002              0.02000 

One survey of 480 boar returns 23 positive strips, an apparent prevalence of 4.792 per cent, which the correction pulls back to 3.002 per cent against a truth of 2 per cent. Landing close on one draw is luck; landing close on average is a property, and it takes a few thousand surveys to see.

n_rep <- 4000
set.seed(3311)
sv_known <- rbinom(n_rep, n_survey, ap_exp)
ap_known <- sv_known / n_survey
p_known <- rg_of(ap_known, se_true, sp_true)

print(round(c(mean_apparent = mean(ap_known), expected_apparent = ap_exp,
              mean_corrected = mean(p_known), true_prevalence = p_true,
              bias_of_apparent = mean(ap_known) - p_true,
              bias_of_corrected = mean(p_known) - p_true), 6))
    mean_apparent expected_apparent    mean_corrected   true_prevalence 
         0.038639          0.038600          0.020042          0.020000 
 bias_of_apparent bias_of_corrected 
         0.018639          0.000042 
print(round(c(mc_se_corrected = sd(p_known) / sqrt(n_rep), sd_corrected = sd(p_known),
              sd_apparent = sd(ap_known), sd_ratio = sd(p_known) / sd(ap_known)), 6))
mc_se_corrected    sd_corrected     sd_apparent        sd_ratio 
       0.000148        0.009377        0.008721        1.075269 

Over 4000 simulated surveys with the sensitivity and specificity known exactly, the apparent prevalence averages 3.8639 per cent and the corrected estimate averages 2.0042 per cent against a truth of 2, with a Monte Carlo standard error of 0.0148 percentage points. The correction is unbiased to the accuracy this can measure, and it divides the spread by the Youden index, so the standard deviation of the corrected estimate is 1.0753 times that of the apparent prevalence. That is the whole of the good news, and if the two test characteristics really were known constants the post could stop here.

The validation study is data too

They are not constants. Each came from one hundred animals and is a binomial proportion with its own sampling variation. One hundred negatives at a true specificity of 0.98 gives an expected two false positives in the panel, and the distribution of that count is not concentrated.

n_val_rep <- 20000
set.seed(6142)
sp_draws <- rbinom(n_val_rep, n_val_neg, sp_true) / n_val_neg
se_draws <- rbinom(n_val_rep, n_val_pos, se_true) / n_val_pos

print(round(c(mean_sp_hat = mean(sp_draws), sd_sp_hat = sd(sp_draws),
              mean_se_hat = mean(se_draws), sd_se_hat = sd(se_draws)), 5))
mean_sp_hat   sd_sp_hat mean_se_hat   sd_se_hat 
    0.98008     0.01392     0.94983     0.02181 
print(round(c(sp_hat_equals_one = mean(sp_draws == 1),
              sp_hat_at_or_below_0.96 = mean(sp_draws <= 0.96),
              se_hat_equals_one = mean(se_draws == 1),
              sp_hat_quantiles = quantile(sp_draws, c(0.025, 0.5, 0.975))), 4))
      sp_hat_equals_one sp_hat_at_or_below_0.96       se_hat_equals_one 
                 0.1335                  0.1379                  0.0060 
  sp_hat_quantiles.2.5%    sp_hat_quantiles.50%  sp_hat_quantiles.97.5% 
                 0.9500                  0.9800                  1.0000 

The estimated specificity has a standard deviation of 0.01392, and in 13.35 per cent of validation studies of this size the panel of one hundred negatives produces no false positives at all, so the reported specificity is exactly 1.00. That number comes back repeatedly below: a validation study reporting a perfect specificity has not demonstrated one, it has run out of resolution.

Propagating that into the corrected estimate needs the derivatives of the correction. Writing \(D = Se + Sp - 1\),

\[\frac{\partial \hat{p}}{\partial AP} = \frac{1}{D}, \qquad \frac{\partial \hat{p}}{\partial Se} = \frac{-\hat{p}}{D}, \qquad \frac{\partial \hat{p}}{\partial Sp} = \frac{1 - \hat{p}}{D}\]

so with the survey and the two validation arms independent, the delta-method variance is

\[\widehat{\operatorname{Var}}(\hat{p}) = \frac{1}{D^2}\left[ \frac{AP(1-AP)}{n} + \hat{p}^2\frac{Se(1-Se)}{n_{+}} + (1-\hat{p})^2\frac{Sp(1-Sp)}{n_{-}} \right]\]

The third term is the one that does the damage, because it carries the factor \((1-\hat{p})^2\), which at a prevalence of two per cent is essentially one.

delta_sd <- function(ap, se, sp, n, npos, nneg) {
  dd <- se + sp - 1
  ph <- (ap + sp - 1) / dd
  sqrt((ap * (1 - ap) / n + ph^2 * se * (1 - se) / npos +
          (1 - ph)^2 * sp * (1 - sp) / nneg) / dd^2)
}

v_survey <- ap_exp * (1 - ap_exp) / n_survey
v_sens <- p_true^2 * se_true * (1 - se_true) / n_val_pos
v_spec <- (1 - p_true)^2 * sp_true * (1 - sp_true) / n_val_neg
v_all <- v_survey + v_sens + v_spec
sd_fixed <- sqrt(v_survey) / youden
sd_full <- delta_sd(ap_exp, se_true, sp_true, n_survey, n_val_pos, n_val_neg)

print(round(100 * c(survey_share = v_survey, sensitivity_share = v_sens,
                    specificity_share = v_spec) / v_all, 4))
     survey_share sensitivity_share specificity_share 
          29.0932            0.0715           70.8353 
print(round(c(sd_treating_test_as_fixed = sd_fixed,
              sd_carrying_validation = sd_full,
              width_multiplier = sd_full / sd_fixed), 5))
sd_treating_test_as_fixed    sd_carrying_validation          width_multiplier 
                  0.00945                   0.01753                   1.85398 

Split the variance three ways and the survey of 480 boar contributes 29.09 per cent of it, the sensitivity panel 0.071 per cent, and the specificity panel 70.84 per cent. The hundred known negatives are more than twice the source of uncertainty that the four hundred and eighty boar are. Standard errors follow: 0.00945 if the test characteristics are treated as fixed against 0.01753 if they are not, a multiplier of 1.854 on the width of every interval.

Coverage, measured

Three intervals go into the comparison, plus a reference case that could not be built in the field. The reference builds a Wilson (1927) interval for the apparent prevalence and pushes both endpoints through the correction using the true sensitivity and specificity, which separates the cost of the interval method from the cost of the validation study. The naive interval does the same with the estimated sensitivity and specificity substituted in and treated as constants, which is what gets published. The delta interval is the corrected estimate plus and minus 1.96 of the standard error above. The bootstrap resamples the survey and both validation arms from their fitted binomials. The Wilson form is used rather than Clopper and Pearson (1934) because the exact interval is conservative at these sample sizes and would flatter the naive method; nothing below turns on the choice.

zc <- qnorm(0.975)

wilson_ci <- function(x, n) {
  ph <- x / n
  cc <- (ph + zc^2 / (2 * n)) / (1 + zc^2 / n)
  hw <- zc * sqrt(ph * (1 - ph) / n + zc^2 / (4 * n^2)) / (1 + zc^2 / n)
  cbind(cc - hw, cc + hw)
}

boot_ci <- function(ap, se, sp, n, npos, nneg, n_boot) {
  aps <- rbinom(n_boot, n, ap) / n
  ses <- rbinom(n_boot, npos, se) / npos
  sps <- rbinom(n_boot, nneg, sp) / nneg
  ok <- ses + sps - 1 > 0
  qq <- quantile(rg_of(aps[ok], ses[ok], sps[ok]),
                 c(0.025, 0.975), names = FALSE)
  ph <- rg_of(ap, se, sp)
  c(percentile_lo = qq[1], percentile_hi = qq[2],
    basic_lo = 2 * ph - qq[2], basic_hi = 2 * ph - qq[1])
}

The sweep runs six true prevalences, two thousand surveys at each, each survey paired with its own fresh validation study of one hundred positives and one hundred negatives. Six hundred bootstrap resamples per survey is coarse for a tail quantile and it is enough for a coverage comparison at this resolution.

p_sweep <- c(0.005, 0.01, 0.02, 0.04, 0.08, 0.15)
n_cov <- 2000
n_boot <- 599
cov_tab <- data.frame(p = p_sweep)
head_line <- NULL

set.seed(7311)
for (k in seq_along(p_sweep)) {
  ap_k <- ap_of(p_sweep[k], se_true, sp_true)
  sv <- rbinom(n_cov, n_survey, ap_k)
  se_h <- rbinom(n_cov, n_val_pos, se_true) / n_val_pos
  sp_h <- rbinom(n_cov, n_val_neg, sp_true) / n_val_neg
  ap_h <- sv / n_survey
  wi <- wilson_ci(sv, n_survey)
  ph <- rg_of(ap_h, se_h, sp_h)
  sdv <- delta_sd(ap_h, se_h, sp_h, n_survey, n_val_pos, n_val_neg)

  lo_ref <- rg_of(wi[, 1], se_true, sp_true)
  hi_ref <- rg_of(wi[, 2], se_true, sp_true)
  lo_nai <- rg_of(wi[, 1], se_h, sp_h)
  hi_nai <- rg_of(wi[, 2], se_h, sp_h)
  lo_del <- ph - zc * sdv
  hi_del <- ph + zc * sdv
  bt <- matrix(NA_real_, n_cov, 4)
  for (i in seq_len(n_cov)) {
    bt[i, ] <- boot_ci(ap_h[i], se_h[i], sp_h[i],
                       n_survey, n_val_pos, n_val_neg, n_boot)
  }
  hit <- function(lo, hi) mean(lo <= p_sweep[k] & hi >= p_sweep[k])
  cov_tab$reference[k] <- hit(lo_ref, hi_ref)
  cov_tab$naive[k] <- hit(lo_nai, hi_nai)
  cov_tab$delta[k] <- hit(lo_del, hi_del)
  cov_tab$boot[k] <- hit(bt[, 3], bt[, 4])
  cov_tab$w_naive[k] <- mean(hi_nai - lo_nai)
  cov_tab$w_delta[k] <- mean(hi_del - lo_del)
  cov_tab$w_boot[k] <- mean(bt[, 4] - bt[, 3])
  if (p_sweep[k] == p_true) {
    head_line <- list(ph = ph, sdv = sdv, sp_h = sp_h, se_h = se_h,
                      lo_nai = lo_nai, hi_nai = hi_nai,
                      lo_del = lo_del, hi_del = hi_del, bt = bt)
  }
}
print(round(cov_tab, 4))
      p reference  naive  delta   boot w_naive w_delta w_boot
1 0.005    0.9470 0.6080 0.8750 0.8800  0.0304  0.0633 0.0631
2 0.010    0.9510 0.6215 0.8760 0.8765  0.0331  0.0652 0.0651
3 0.020    0.9385 0.7030 0.9085 0.9120  0.0376  0.0668 0.0668
4 0.040    0.9530 0.7710 0.9295 0.9335  0.0450  0.0713 0.0713
5 0.080    0.9455 0.8320 0.9395 0.9485  0.0562  0.0771 0.0772
6 0.150    0.9495 0.8765 0.9475 0.9535  0.0704  0.0876 0.0876
hl <- head_line
in_ok <- function(lo, hi) lo <= p_true & hi >= p_true
row_at <- which(cov_tab$p == p_true)
cov_ref <- cov_tab$reference[row_at]
cov_nai <- cov_tab$naive[row_at]
cov_del <- cov_tab$delta[row_at]
cov_boo <- cov_tab$boot[row_at]
cov_pct <- mean(in_ok(hl$bt[, 1], hl$bt[, 2]))

print(round(c(reference_known_test = cov_ref, naive_fixed = cov_nai,
              delta_propagated = cov_del, bootstrap_basic = cov_boo,
              bootstrap_percentile = cov_pct), 4))
reference_known_test          naive_fixed     delta_propagated 
              0.9385               0.7030               0.9085 
     bootstrap_basic bootstrap_percentile 
              0.9120               0.8925 
print(round(c(naive_miss_low = mean(hl$lo_nai > p_true),
              naive_miss_high = mean(hl$hi_nai < p_true),
              delta_miss_low = mean(hl$lo_del > p_true),
              delta_miss_high = mean(hl$hi_del < p_true),
              lower_limit_below_zero = mean(hl$lo_del < 0)), 4))
        naive_miss_low        naive_miss_high         delta_miss_low 
                0.1895                 0.1075                 0.0880 
       delta_miss_high lower_limit_below_zero 
                0.0035                 0.6685 
print(round(c(width_naive = cov_tab$w_naive[row_at], width_delta = cov_tab$w_delta[row_at],
              width_ratio = cov_tab$w_delta[row_at] / cov_tab$w_naive[row_at]), 4))
width_naive width_delta width_ratio 
     0.0376      0.0668      1.7778 

At the design prevalence of 2 per cent the reference interval covers the truth 93.85 per cent of the time, which is the nominal rate and confirms the machinery. Substituting the estimated sensitivity and specificity and carrying on as though they were exact takes coverage to 70.3 per cent: a nominal ninety-five per cent interval that misses one time in three, with 18.95 per cent of the misses sitting entirely above the truth and 10.75 per cent entirely below.

Propagating the validation study restores most of it. The delta interval covers 90.85 per cent and the bootstrap 91.2 per cent, and the price is width: 0.0668 against 0.0376, a factor of 1.778. The honest interval on a two per cent prevalence is about 6.68 percentage points wide, and its lower limit is below zero in 66.85 per cent of surveys.

A line chart with true prevalence from half a per cent to fifteen per cent on a logarithmic horizontal axis and coverage from about six tenths to one on the vertical axis. A horizontal dashed line marks 0.95. A dark green line of round dots labelled test known exactly runs flat along the dashed line across the whole panel. A red line labelled validation ignored starts at about 0.61 on the left and climbs to about 0.88 at the right, staying far below the dashed line throughout. A gold line and a pale green line, labelled delta method and bootstrap, run together from about 0.88 on the left up to just under the dashed line at the right.
Figure 2: Coverage of a nominal 95 per cent interval for true prevalence, over two thousand simulated surveys of four hundred and eighty animals at each of six true prevalences, each paired with its own validation study of one hundred positives and one hundred negatives. The reference case treats the test characteristics as genuinely known. The naive case substitutes the validation estimates and treats them as fixed.

Coverage of the naive interval is worst where the survey is most likely to be run: at a true prevalence of 0.5 per cent it covers 60.8 per cent of the time, climbing only to 87.65 per cent at 15 per cent, where the survey uncertainty has grown large enough to hide the validation uncertainty inside it.

Where the delta method and the bootstrap part company

On the summary above the two propagated intervals are interchangeable. Their coverages differ by less than a percentage point at every prevalence in the sweep and their mean widths agree to three decimal places. They differ in two places that the summary hides.

sp_one <- hl$sp_h == 1
skew_ph <- mean((hl$ph - mean(hl$ph))^3) / sd(hl$ph)^3
shift_lo <- mean(hl$bt[, 3] - hl$bt[, 1])

print(round(c(skewness_of_corrected = skew_ph, percentile_coverage = cov_pct,
              basic_coverage = cov_boo, lower_limit_shift = shift_lo,
              mean_abs_endpoint_gap = mean(abs(hl$lo_del - hl$bt[, 3]))), 5))
skewness_of_corrected   percentile_coverage        basic_coverage 
             -0.40894               0.89250               0.91200 
    lower_limit_shift mean_abs_endpoint_gap 
              0.00669               0.00366 
by_panel <- function(sel) c(
  share = mean(sel),
  delta_cover = mean(in_ok(hl$lo_del, hl$hi_del)[sel]),
  boot_cover = mean(in_ok(hl$bt[, 3], hl$bt[, 4])[sel]),
  delta_width = mean((hl$hi_del - hl$lo_del)[sel]))
print(round(rbind(sp_hat_one = by_panel(sp_one),
                  sp_hat_below_one = by_panel(!sp_one)), 4))
                  share delta_cover boot_cover delta_width
sp_hat_one       0.1295      0.3822     0.4247      0.0364
sp_hat_below_one 0.8705      0.9868     0.9845      0.0713

The first disagreement is about which end of the bootstrap distribution to use. The sampling distribution of the corrected estimate is left skewed, with a skewness of -0.409, inherited from the estimated specificity: \(\hat{Sp}\) is bounded above by one, piles up near the boundary and tails away downwards, and \(\hat{p}\) increases in \(\hat{Sp}\). The percentile interval, which reports the quantiles of the bootstrap distribution directly, therefore sits low, its lower limit 0.00669 below the basic interval’s on average. That shift is worth 91.2 per cent coverage for the basic form against 89.25 per cent for the percentile form, which is the one people reach for first.

The second disagreement is not between the two methods but between both of them and the world. Split the two thousand surveys by whether the validation panel produced a specificity of exactly 1.00. Where it did not, in 87.05 per cent of surveys, the delta interval covers 98.68 per cent of the time and the bootstrap 98.45 per cent, both well above nominal. Where it did, in 12.95 per cent of surveys, coverage falls to 38.22 per cent and 42.47 per cent respectively.

Both methods fail in the same place for the same reason. A validation panel with no false positives gives \(\hat{Sp}(1-\hat{Sp})/n_{-} = 0\), so the delta method assigns the specificity no uncertainty at all and the interval narrows to 0.0364 from 0.0713; the parametric bootstrap resamples from \(\hat{Sp} = 1\) and every resample also has a perfect specificity, so it inherits the same blind spot. The overall 90.85 per cent is therefore not a well-behaved interval but a mixture of one that over-covers badly and one that covers less than half the time, and which a given survey gets is decided by a Binomial draw in a validation study that may have happened years earlier.

Negative prevalence

The correction is a linear inversion and nothing in it is bounded. The numerator \(AP + Sp - 1\) goes negative whenever the observed apparent prevalence falls below the false-positive rate of the test, and there is no reason it should not: if the pathogen is rare enough, a survey can easily produce fewer positive strips than the test would have produced from an uninfected population.

p_grid <- c(0, 0.005, 0.01, 0.02, 0.03, 0.05)
n_grid <- c(120, 240, 480, 960, 1920)
n_neg_rep <- 4000
neg_tab <- expand.grid(p = p_grid, n = n_grid)
neg_tab$known <- NA_real_
neg_tab$estimated <- NA_real_

set.seed(5510)
for (k in seq_len(nrow(neg_tab))) {
  ap_k <- ap_of(neg_tab$p[k], se_true, sp_true)
  ap_d <- rbinom(n_neg_rep, neg_tab$n[k], ap_k) / neg_tab$n[k]
  se_d <- rbinom(n_neg_rep, n_val_pos, se_true) / n_val_pos
  sp_d <- rbinom(n_neg_rep, n_val_neg, sp_true) / n_val_neg
  neg_tab$known[k] <- mean(rg_of(ap_d, se_true, sp_true) < 0)
  neg_tab$estimated[k] <- mean(rg_of(ap_d, se_d, sp_d) < 0)
}
print(round(neg_tab[neg_tab$n %in% c(120, 480, 1920), ], 4))
       p    n  known estimated
1  0.000  120 0.5800    0.5155
2  0.005  120 0.4345    0.4052
3  0.010  120 0.3005    0.3220
4  0.020  120 0.1550    0.2040
5  0.030  120 0.0665    0.1128
6  0.050  120 0.0110    0.0312
13 0.000  480 0.5075    0.4550
14 0.005  480 0.2560    0.3470
15 0.010  480 0.1020    0.2545
16 0.020  480 0.0090    0.1355
17 0.030  480 0.0005    0.0605
18 0.050  480 0.0000    0.0082
25 0.000 1920 0.5038    0.4582
26 0.005 1920 0.0968    0.3302
27 0.010 1920 0.0063    0.2422
28 0.020 1920 0.0000    0.1098
29 0.030 1920 0.0000    0.0428
30 0.050 1920 0.0000    0.0050
pick <- function(pp, nn, col) neg_tab[[col]][neg_tab$p == pp & neg_tab$n == nn]
print(round(c(at_zero_n120 = pick(0, 120, "known"), at_zero_n1920 = pick(0, 1920, "known"),
              at_two_pct_n120_known = pick(0.02, 120, "known"),
              at_two_pct_n1920_known = pick(0.02, 1920, "known"),
              at_two_pct_n120_est = pick(0.02, 120, "estimated"),
              at_two_pct_n1920_est = pick(0.02, 1920, "estimated"),
              at_one_pct_n1920_est = pick(0.01, 1920, "estimated")), 4))
          at_zero_n120          at_zero_n1920  at_two_pct_n120_known 
                0.5800                 0.5038                 0.1550 
at_two_pct_n1920_known    at_two_pct_n120_est   at_two_pct_n1920_est 
                0.0000                 0.2040                 0.1098 
  at_one_pct_n1920_est 
                0.2422 

With the specificity known exactly the frequency of a negative estimate behaves the way intuition says it should, for every prevalence except one. At a true prevalence of 2 per cent it falls from 15.5 per cent of surveys at 120 animals to 0 per cent at 1920. The exception is a genuinely uninfected population, where the expected apparent prevalence is exactly \(1 - Sp\) and the observed one falls below it about half the time whatever the sample size: 58 per cent at 120 animals and 50.38 per cent at 1920. No survey effort will move that, because the estimator is centred on zero and symmetric about it. With the specificity estimated, sample size stops helping much earlier.

Two panels side by side, each with true prevalence from zero to five per cent on the horizontal axis and the proportion of negative estimates from zero to about six tenths on the vertical axis. Five falling curves in each panel, coloured from red for one hundred and twenty animals through gold and pale green to dark green for one thousand nine hundred and twenty. In the left panel, headed specificity known, all five curves start near half at zero prevalence and fan out steeply downwards, the dark green one reaching the axis by a prevalence of two per cent. In the right panel, headed specificity estimated from one hundred negatives, the five curves start near half and fall much more slowly, staying bunched together and still above one tenth at a prevalence of two per cent. A dotted vertical line stands at two per cent in both panels.
Figure 3: Frequency of a negative Rogan-Gladen estimate over four thousand simulated surveys, against true prevalence, for five survey sizes. In the left panel the specificity is known exactly; in the right it is estimated from one hundred known negatives, as in the worked example. The vertical dotted line marks a true prevalence equal to one minus the specificity.

The right-hand panel is the finding. At a true prevalence of 2 per cent, which is exactly \(1 - Sp\) for this test, the frequency of a negative estimate goes from 20.4 per cent at 120 animals to 10.97 per cent at 1920. Sixteen times the fieldwork buys a reduction of 9.42 percentage points, and the curve is flattening out towards a floor set by the validation study rather than by the survey. At half that prevalence the picture is worse: one survey in four returns a negative estimate at 1920 animals, against 0.62 per cent when the specificity is known.

A negative estimate is not a mistake. It says the survey saw fewer positives than an uninfected population of that size would be expected to produce, which is an ordinary thing for data to say when the pathogen is rare or absent. The estimator returns a number outside the parameter space because it is a linear inversion and nothing in the arithmetic knows that prevalence lives in \([0, 1]\).

The cost of truncating at zero

The usual response is to report zero. That is defensible as a statement about the parameter and it changes the estimator, so the change should be measured rather than assumed harmless.

p_low <- c(0, 0.0025, 0.005, 0.01, 0.02)
n_tr_rep <- 4000
tr_tab <- data.frame(p = p_low)
tr_draws <- NULL

set.seed(8810)
for (k in seq_along(p_low)) {
  ap_k <- ap_of(p_low[k], se_true, sp_true)
  ap_d <- rbinom(n_tr_rep, n_survey, ap_k) / n_survey
  se_d <- rbinom(n_tr_rep, n_val_pos, se_true) / n_val_pos
  sp_d <- rbinom(n_tr_rep, n_val_neg, sp_true) / n_val_neg
  ph <- rg_of(ap_d, se_d, sp_d)
  sdv <- delta_sd(ap_d, se_d, sp_d, n_survey, n_val_pos, n_val_neg)
  pt <- pmax(ph, 0)
  lo <- ph - zc * sdv
  hi <- ph + zc * sdv
  tr_tab$raw_mean[k] <- mean(ph); tr_tab$trunc_mean[k] <- mean(pt)
  tr_tab$rmse_raw[k] <- sqrt(mean((ph - p_low[k])^2))
  tr_tab$rmse_trunc[k] <- sqrt(mean((pt - p_low[k])^2))
  tr_tab$cover_raw[k] <- mean(lo <= p_low[k] & hi >= p_low[k])
  tr_tab$cover_clip[k] <- mean(pmax(lo, 0) <= p_low[k] & pmax(hi, 0) >= p_low[k])
  tr_tab$width_raw[k] <- mean(hi - lo)
  tr_tab$width_clip[k] <- mean(pmax(hi, 0) - pmax(lo, 0))
  tr_tab$q90_trunc[k] <- unname(quantile(pt, 0.9))
  tr_tab$mc_se[k] <- sd(ph) / sqrt(n_tr_rep)
  tr_tab$at_design[k] <- mean(pt >= p_true)
  if (p_low[k] == 0) tr_draws <- ph
}
print(round(tr_tab[, 1:6], 5))
       p raw_mean trunc_mean rmse_raw rmse_trunc cover_raw
1 0.0000 -0.00032    0.00635  0.01640    0.01054   0.87275
2 0.0025  0.00288    0.00825  0.01668    0.01123   0.85675
3 0.0050  0.00491    0.00963  0.01681    0.01145   0.87425
4 0.0100  0.00960    0.01292  0.01717    0.01238   0.89650
5 0.0200  0.01974    0.02106  0.01721    0.01472   0.90925
print(round(tr_tab[, c(1, 7:12)], 5))
       p cover_clip width_raw width_clip q90_trunc   mc_se at_design
1 0.0000    0.87450   0.06269    0.03002   0.01958 0.00026   0.09000
2 0.0025    0.85675   0.06221    0.03259   0.02264 0.00026   0.14700
3 0.0050    0.87425   0.06347    0.03507   0.02551 0.00027   0.19600
4 0.0100    0.89650   0.06517    0.04007   0.02990 0.00027   0.29025
5 0.0200    0.90925   0.06687    0.04895   0.04123 0.00027   0.51975

Start with the point estimate in a population that genuinely has none of the virus. The untruncated estimator averages -0.0316 per cent, against a Monte Carlo standard error of 0.0259 percentage points, so it is sitting on zero. Truncating at zero takes the average of what gets reported to 0.6348 per cent. The ninetieth percentile of what it reports is 1.958 per cent, and in 9 per cent of these surveys the reported prevalence is at or above the 2 per cent the survey was designed to detect, in a population where the pathogen is absent.

The bias decays as the pathogen becomes commoner but not quickly. At a true prevalence of 0.5 per cent the truncated estimator averages 0.963 per cent, and at 2 per cent it averages 2.106 per cent, still 0.106 percentage points high.

A histogram on a warm off-white panel, roughly bell shaped with a long left tail, with corrected prevalence in per cent on the horizontal axis running from about minus seven to plus five. Bars to the left of zero are drawn in red and bars to the right in dark green, and the two groups are close to equal in area. Three vertical lines stand near the middle: a solid dark one at zero, a gold one about half a percentage point to its right, and a dark dashed one near two per cent. Three lines of small text in the upper left corner identify them as the truth, the mean after truncation and the ninetieth percentile after truncation.
Figure 4: Distribution of the Rogan-Gladen estimate over four thousand simulated surveys of four hundred and eighty animals in a population with no infection at all. The estimator is centred on the truth and roughly half its mass lies below zero. Truncating that mass to zero moves the average of what gets reported to the marked value, and one survey in ten reports at or above the dashed line.

Two things about truncation came out differently from what I expected before running it.

It improves mean squared error, and by a wide margin. At a true prevalence of zero the root mean squared error falls from 1.64 percentage points to 1.054, and it is lower at every prevalence in the table. That is not a surprise once stated: pushing an estimate towards a boundary the parameter cannot cross is shrinkage, and shrinkage buys variance at the cost of bias. Anyone choosing between the two estimators on squared error alone should truncate.

It does not damage interval coverage either, and cannot. Clipping the interval to \([\max(lo, 0), \max(hi, 0)]\) leaves coverage at 87.45 per cent against 87.28 per cent unclipped at zero prevalence, and identical to four decimal places at every positive prevalence tested. The reason is arithmetic rather than luck: if the true value is non-negative and the original interval covered it, no part of the interval below zero was doing any work.

What truncation is not free in is width. The clipped interval at zero prevalence averages 3.002 percentage points against 6.269 unclipped, so a reader sees an interval 52.1 per cent narrower with no gain in information. Put the two together and the truncated report is a point estimate biased upwards by 0.635 percentage points attached to an interval that looks twice as precise as the honest one. That combination is worse than either part, and it is the standard output.

Specificity sets the floor, not sample size

The design question a survey like this exists to answer is usually not “what is the prevalence” but “is the pathogen here at all”, a test of \(p = 0\). Cameron and Baldock (1998) built the standard freedom-from-disease sample size calculation for exactly this question. The version below asks something more demanding: how many animals are needed for the 95 per cent interval on the corrected prevalence to exclude zero, with probability 0.8, when the truth is 2 per cent. Under \(p = 0\) the expected apparent prevalence is \(1 - Sp\), and under the alternative it is \(Se \cdot p + (1 - Sp)(1 - p)\); the gap between them is \(p \cdot D\), the true prevalence shrunk by the Youden index. The variance that gap has to beat has two parts, one that shrinks with the survey and one that does not.

req_n <- function(p, se, sp, nneg, pow = 0.8) {
  ap1 <- ap_of(p, se, sp)
  sig_target <- p * (se + sp - 1) / (zc + qnorm(pow))
  budget <- sig_target^2 - sp * (1 - sp) / nneg
  ifelse(budget <= 0, NA_real_, ap1 * (1 - ap1) / budget)
}

min_neg <- function(p, se, sp, pow = 0.8) {
  sp * (1 - sp) / (p * (se + sp - 1) / (zc + qnorm(pow)))^2
}
pow_ceiling <- function(p, se, sp, nneg) {
  pnorm(p * (se + sp - 1) / sqrt(sp * (1 - sp) / nneg) - zc)
}

sp_show <- c(0.95, 0.96, 0.97, 0.98, 0.99, 0.995, 0.999)
des_tab <- data.frame(
  specificity = sp_show,
  n_if_known = req_n(p_true, se_true, sp_show, Inf),
  n_val_2000 = req_n(p_true, se_true, sp_show, 2000),
  n_val_500 = req_n(p_true, se_true, sp_show, 500),
  n_val_100 = req_n(p_true, se_true, sp_show, 100),
  min_negatives = min_neg(p_true, se_true, sp_show),
  ceiling_100 = pow_ceiling(p_true, se_true, sp_show, 100))
print(round(des_tab, 2))
  specificity n_if_known n_val_2000 n_val_500 n_val_100 min_negatives
1        0.95    1535.28    3615.33        NA        NA       1150.68
2        0.96    1298.81    2382.94        NA        NA        909.91
3        0.97    1067.76    1611.26        NA        NA        674.63
4        0.98     841.92    1082.63   7608.21        NA        444.67
5        0.99     621.15     697.86   1108.60        NA        219.85
6        1.00     512.60     542.23    656.02        NA        109.31
7        1.00     426.62     431.32    446.04    545.32         21.77
  ceiling_100
1        0.13
2        0.15
3        0.19
4        0.26
5        0.47
6        0.76
7        1.00
sp_break <- uniroot(function(s) min_neg(p_true, se_true, s) - n_val_neg,
                    c(0.98, 0.9999))$root
sp_for_480 <- uniroot(function(s) req_n(p_true, se_true, s, Inf) - n_survey,
                      c(0.98, 0.9999))$root
print(round(c(specificity_making_100_negatives_enough = sp_break,
              specificity_making_480_animals_enough = sp_for_480,
              min_negatives_at_design_spec = min_neg(p_true, se_true, sp_true),
              power_ceiling_at_design_spec =
                pow_ceiling(p_true, se_true, sp_true, n_val_neg)), 5))
specificity_making_100_negatives_enough   specificity_making_480_animals_enough 
                                0.99542                                 0.99651 
           min_negatives_at_design_spec            power_ceiling_at_design_spec 
                              444.67003                                 0.26389 

The first column is the classical answer: with the specificity known exactly, distinguishing a two per cent prevalence from zero needs 842 animals at a specificity of 0.98, 1535 at 0.95 and 427 at 0.999. Improving the test from 0.95 to 0.99 specificity does the work of 2.47 times the fieldwork. That alone is the design lesson people usually take away.

The rest of the table does not appear in a standard calculation. With the specificity estimated from a panel of 100 known negatives, the sample size at 0.98 specificity is not large. It does not exist. The validation variance does not shrink when animals are added, so once it alone exceeds what the design can tolerate, no survey size will do. The ceiling on power at 0.98 specificity with 100 validation negatives is 26.39 per cent, reached in the limit of infinitely many animals.

A line chart with specificity from 0.95 to 0.999 on the horizontal axis and animals needed on a logarithmic vertical axis from about four hundred to ten thousand. Four falling curves. A dark green curve labelled specificity known exactly is lowest, dropping from about fifteen hundred on the left to about four hundred and thirty on the right. A pale green curve for two thousand validation negatives lies just above it and converges onto it towards the right. A gold curve for five hundred validation negatives runs off the top of the panel near a specificity of 0.979 and does not exist at all to the left of that. A red curve for one hundred validation negatives appears only in the extreme right-hand corner, above a specificity of about 0.996. A horizontal dashed line marks four hundred and eighty animals.
Figure 5: Number of animals needed for a 95 per cent interval on the corrected prevalence to exclude zero with probability 0.8, when the truth is two per cent, against the specificity of the test. Each curve is a different size of validation panel of known negatives. A curve that stops has run into the ceiling: at that specificity and that validation size the target is unreachable at any survey size. The dashed line marks the four hundred and eighty animals in the worked example.

Read the figure as a set of walls. With 100 known negatives in the validation panel the design is impossible until the specificity passes 0.9954; with five hundred the wall moves left; with the specificity known exactly there is no wall at all and the curve is a smooth trade against sample size. Turning that round gives the number a survey planner needs: at the design specificity of 0.98, the validation panel needs at least 445 known negatives before any survey size can distinguish a two per cent prevalence from zero. The panel of 100 was short by a factor of about 4.4.

The closed form uses the variance under the alternative for both hypotheses, which makes it conservative, and the plug-in test is not correctly sized when the validation panel is small. Both are worth measuring rather than asserting.

sim_reject <- function(nn, nneg, p_at, reps = 40000, seed = 4400) {
  set.seed(seed)
  ap_d <- rbinom(reps, nn, ap_of(p_at, se_true, sp_true)) / nn
  se_d <- rbinom(reps, n_val_pos, se_true) / n_val_pos
  sp_d <- if (is.finite(nneg)) rbinom(reps, nneg, sp_true) / nneg else
    rep(sp_true, reps)
  nv <- if (is.finite(nneg)) nneg else 1e12
  ph <- rg_of(ap_d, se_d, sp_d)
  mean(ph - zc * delta_sd(ap_d, se_d, sp_d, nn, n_val_pos, nv) > 0)
}

n_formula <- ceiling(req_n(p_true, se_true, sp_true, Inf))
pow_grid <- c(650, 700, 750, 800, n_formula)
print(rbind(animals = pow_grid,
            power = round(vapply(pow_grid,
                                 function(nn) sim_reject(nn, Inf, p_true),
                                 numeric(1)), 4)))
            [,1]     [,2]     [,3]     [,4]     [,5]
animals 650.0000 700.0000 750.0000 800.0000 842.0000
power     0.6952   0.7528   0.8022   0.8384   0.8582
size_grid <- c(100, 250, 500, 1000, 2000)
print(rbind(validation_negatives = size_grid,
            size_at_p_zero = round(vapply(size_grid,
                                          function(nv) sim_reject(n_survey, nv, 0),
                                          numeric(1)), 4)))
                         [,1]    [,2]     [,3]     [,4]     [,5]
validation_negatives 100.0000 250.000 500.0000 1.00e+03 2.00e+03
size_at_p_zero         0.1334   0.047   0.0251 1.55e-02 1.07e-02
print(round(c(size_with_known_specificity = sim_reject(n_survey, Inf, 0)), 4))
size_with_known_specificity 
                     0.0091 

The formula asks for 842 animals and simulation puts the true requirement near 750, where power is 0.8022, so the closed form is conservative by roughly 12 per cent. Use it as a design number and expect to be slightly over-resourced, which is the right direction to be wrong in.

The size of the test is the more serious problem. At 480 animals and a validation panel of 100 negatives, the delta-method interval excludes zero in a genuinely uninfected population 13.33 per cent of the time against a nominal 2.5. The cause is the one already identified: when the validation panel records no false positives, the specificity term in the variance vanishes, the interval collapses, and its lower limit sits above zero. That failure rate is essentially the 13.35 per cent chance of a clean validation panel. With five hundred validation negatives the size falls to 2.51 per cent and with the specificity known to 0.9 per cent. A small validation study does not merely widen the answer; it manufactures detections of a pathogen that is not there.

The honest limit

Everything above treats the sensitivity and the specificity as fixed properties of the test, uncertain only through the size of the validation panel. That is the assumption the whole correction rests on and it is the weakest thing here.

Validation panels are assembled from animals whose status is known, which usually means clinically affected animals with high antibody titres on the positive side and animals from a region with no history of the pathogen on the negative side. The sensitivity measured that way is the sensitivity against strong positives, and the survey will contain early infections and recovering animals that the strip finds less often. Greiner and Gardner (2000) set out this spectrum problem and the validation design literature that goes with it. If field sensitivity is lower than the panel’s, the correction under-corrects; if field specificity is lower, every number in this post is optimistic. Neither direction can be checked from the survey data, because the survey has no gold standard in it. That is what makes it a survey.

Three further assumptions are load-bearing. Test errors are treated as independent between animals, so a bad reagent lot or one operator with a systematic reading habit would make false positives cluster and the effective validation panel smaller than one hundred, which makes every interval above too narrow rather than too wide. The reference assay is treated as a true gold standard; when it is not, the two numbers are relative sensitivity and relative specificity, and a latent class model with two imperfect tests is the honest formulation. And the survey is treated as a simple random sample, which hunter-submitted samples are not: Nusser, Clark, Otis and Huang (2008) show what convenience sampling does to a wildlife disease prevalence estimate, and that bias sits on top of everything measured here.

The delta method and the parametric bootstrap were used because they are what an ecologist would build from base R in an afternoon, and they share a boundary failure that neither can see. Reiczigel, Foldi and Ozsvari (2010) and Lang and Reiczigel (2014) give intervals for true prevalence that behave properly there, the second of them specifically for estimated sensitivity and specificity, and either is a better production choice than the code above.

Where to go next

The clean fix for the boundary is not truncation but a model that never leaves the parameter space. A prior on \(p\) supported on \([0, 1]\), with priors on \(Se\) and \(Sp\) carrying the validation counts, gives a posterior that is bounded by construction, propagates the validation uncertainty without a delta approximation, and degrades gracefully when the panel reports a perfect specificity, because a Beta posterior from 100 negatives and zero false positives still has a tail. The cost is that the answer near zero prevalence is then partly a statement about the prior, which is exactly the information the data did not have.

The other direction is dropping the gold standard. Two imperfect tests applied to the same animals across two populations with different prevalences identify all the parameters without any validation panel, which turns the problem from correction into estimation and removes the transfer assumption this post could not check.

References

Rogan WJ, Gladen B 1978 American Journal of Epidemiology 107(1):71-76 (10.1093/oxfordjournals.aje.a112510)

Wilson EB 1927 Journal of the American Statistical Association 22(158):209-212 (10.1080/01621459.1927.10502953)

Clopper CJ, Pearson ES 1934 Biometrika 26(4):404-413 (10.1093/biomet/26.4.404)

Cameron AR, Baldock FC 1998 Preventive Veterinary Medicine 34(1):1-17 (10.1016/S0167-5877(97)00081-0)

Greiner M, Gardner IA 2000 Preventive Veterinary Medicine 45(1-2):3-22 (10.1016/S0167-5877(00)00114-8)

Nusser SM, Clark WR, Otis DL, Huang L 2008 The Journal of Wildlife Management 72(1):52-60 (10.2193/2007-317)

Reiczigel J, Foldi J, Ozsvari L 2010 Epidemiology and Infection 138(11):1674-1678 (10.1017/S0950268810000385)

Lang Z, Reiczigel J 2014 Preventive Veterinary Medicine 113(1):13-22 (10.1016/j.prevetmed.2013.09.015)

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.