Parasite burdens truncated by host death

R
parasitology
count data
selection bias
ecology tutorial
Hosts carrying the heaviest worm burdens die before the survey reaches them. Simulating that truncation in R and measuring what it does to the mean and to k.
Author

Tidy Ecology

Published

2026-08-04

Two hundred and forty wood mice, trapped at eight woodland sites over three autumns, dissected one at a time, with the small intestine opened under a scope and every adult nematode counted. The number that goes into the spreadsheet is one integer per mouse. About a quarter of the mice carry nothing at all. Most of the rest carry a handful. Four or five carry more worms between them than the bottom two hundred put together.

That shape is the oldest quantitative fact in parasitology. Crofton (1971) set it out as the defining property of parasitism: hosts do not share their parasites evenly, and the distribution of burdens across a host population is overdispersed by a wide margin relative to a Poisson. The negative binomial with a small aggregation parameter \(k\) is the standard description, and Shaw and Dobson (1995) found small values of \(k\) across a large collection of published wildlife datasets. Shaw, Grenfell and Dobson (1998) went further and asked which aggregated family fits best; the negative binomial won often enough to remain the default.

The difficulty is in the same sentence as the finding. A mouse carrying two hundred worms is a mouse in trouble. Heavy burdens damage the gut wall, cost the host protein it cannot spare, slow it down, and make it easier to catch. Whatever kills it, the mouse is dead before the trap line goes out, and the count it would have contributed never reaches the spreadsheet. The survey does not sample the burden distribution. It samples the burden distribution of the animals that survived it, which is the same distribution with its upper tail thinned or removed. Crofton (1971) named this in the second of his two papers that year and built a model around it, and Anderson and Gordon (1982) worked out what parasite-induced mortality does to the shape of the observed distribution. Lester (1984) went through the methods a fisheries parasitologist could use to detect the same thing in a wild fish population, and was blunt about how little any of them settle.

This post measures the damage and then measures the repair, and the second measurement is the one that matters. It sits next to four posts and repeats none of them. Poisson and negative binomial GLMs in R fits the negative binomial whose two parameters are the subject here, and takes both of them at face value; this post shows that under truncation one of them is badly wrong and the other is nearly right, which is not the pattern anybody expects. Zero-inflated and hurdle models for ecological counts is the mirror image: extra mass at zero against missing mass in the tail, and both are read off the same dispersion statistic, so the two failures are hard to tell apart from a fitted model alone. Collider bias and selection is the same mechanism written in causal notation, because surviving to be trapped is a common effect of burden and of everything else about the mouse, and conditioning on it is conditioning on a collider. And the abundance-occupancy relationship is the other post on this site that reads the negative binomial \(k\) as a statement about aggregation rather than as a nuisance parameter.

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"))
}

The population, and the part of it that dies

The generating model is a negative binomial with a mean of twelve worms per host and an aggregation parameter of four tenths. Both are ordinary values for a gut nematode in a small mammal. The lethal level is set at sixty worms to begin with: every host above it is dead before the survey, every host below it is available to be trapped. That is a caricature, and the smooth version comes two sections later, but it makes the arithmetic legible.

mu_true <- 12
k_true <- 0.4
lethal <- 60
n_host <- 240

x_grid <- 0:4000
p_true <- dnbinom(x_grid, size = k_true, mu = mu_true)

frac_hosts_lost <- sum(p_true[x_grid >= lethal])
frac_worms_lost <- sum(x_grid * p_true * (x_grid >= lethal)) / mu_true
vmr_true <- 1 + mu_true / k_true
prev_true <- 1 - dnbinom(0, size = k_true, mu = mu_true)

print(round(c(mean_burden = mu_true, k = k_true,
              variance = mu_true + mu_true^2 / k_true,
              variance_to_mean = vmr_true,
              prevalence = prev_true,
              pmf_sums_to = sum(p_true)), 5))
     mean_burden                k         variance variance_to_mean 
         12.0000           0.4000         372.0000          31.0000 
      prevalence      pmf_sums_to 
          0.7468           1.0000 
print(round(c(lethal_level = lethal,
              hosts_at_or_above = frac_hosts_lost,
              worms_at_or_above = frac_worms_lost,
              hosts_in_survey = n_host), 5))
     lethal_level hosts_at_or_above worms_at_or_above   hosts_in_survey 
         60.00000           0.03461           0.24625         240.00000 

The population prevalence is 74.68 per cent, so a quarter of the mice are uninfected and the survey would report that correctly. The variance is 372 against a mean of 12, a variance-to-mean ratio of 31, which is what aggregation means in one number.

The two figures that matter are in the second block. The lethal level removes 3.46 per cent of the hosts and 24.63 per cent of the worms. That gap is the whole problem in one line. Losing three hosts in a hundred sounds like nothing. Losing a quarter of the worms is not nothing, and the worms are what the survey exists to count.

Two stacked panels sharing a horizontal axis of worms per host running from zero to one hundred and fifty. The upper panel is a bar chart of expected hosts that starts at about sixty bars high at zero and falls away steeply, so that past thirty the bars are barely off the axis. The lower panel is a bar chart of expected worms carried; it rises steeply from zero to a broad peak near ten worms and then declines slowly, still well above the axis at one hundred and fifty. A dashed vertical line at sixty crosses both panels and everything to its right is shaded pale red.
Figure 1: The burden distribution of the whole host population, shown twice. The upper panel is the expected number of hosts at each burden out of two hundred and forty, on a square-root scale so that the tail is visible at all. The lower panel is the expected number of worms those hosts carry. The hosts pile up at zero; the worms live out in the tail, in the shaded region beyond the lethal level, where there are almost no hosts to be found.

Fitting the negative binomial the survey actually has

The likelihood is worth writing out once rather than calling. For a burden \(x\) with mean \(\mu\) and aggregation parameter \(k\), the negative binomial probability is

\[ P(X = x) \;=\; \frac{\Gamma(x + k)}{\Gamma(k)\,x!} \left(\frac{k}{k + \mu}\right)^{\!k} \left(\frac{\mu}{k + \mu}\right)^{\!x} \]

and the log likelihood is the sum of the logarithm of that over the hosts. Everything that follows in this post is a modification of the same three lines, so it pays to see them once.

nb_loglik <- function(x, mu, kk) {
  sum(lgamma(x + kk) - lgamma(kk) - lgamma(x + 1) +
        kk * log(kk / (kk + mu)) + x * log(mu / (kk + mu)))
}

fit_nb <- function(x) {
  opt <- optim(c(log(mean(x) + 0.5), 0),
               function(par) -nb_loglik(x, exp(par[1]), exp(par[2])),
               control = list(reltol = 1e-12, maxit = 5000))
  c(mu = exp(opt$par[1]), k = exp(opt$par[2]), loglik = -opt$value)
}

One survey first. The trapping draws two hundred and forty hosts from the population, the ones at or above the lethal level are removed, and the ordinary negative binomial is fitted to what is left. MASS::glm.nb fits the same likelihood by iterated weighted least squares and is used here only to confirm that the hand-written version has not gone astray.

set.seed(20260804)
burden_all <- rnbinom(n_host, size = k_true, mu = mu_true)
burden_obs <- burden_all[burden_all < lethal]

fit_all <- fit_nb(burden_all)
fit_obs <- fit_nb(burden_obs)
check <- MASS::glm.nb(burden_obs ~ 1)

print(round(c(hosts_dissected = length(burden_obs),
              hosts_lost = n_host - length(burden_obs),
              largest_burden_seen = max(burden_obs),
              zeros = sum(burden_obs == 0),
              sample_mean = mean(burden_obs)), 4))
    hosts_dissected          hosts_lost largest_burden_seen               zeros 
            230.000              10.000              56.000              57.000 
        sample_mean 
             10.187 
print(round(rbind(complete_population = fit_all,
                  survivors_only = fit_obs), 5))
                          mu       k    loglik
complete_population 13.80003 0.40399 -824.8045
survivors_only      10.18694 0.46364 -742.8377
print(round(c(glm_nb_mu = unname(exp(coef(check))),
              glm_nb_k = check$theta,
              hand_written_mu = unname(fit_obs["mu"]),
              hand_written_k = unname(fit_obs["k"])), 5))
      glm_nb_mu        glm_nb_k hand_written_mu  hand_written_k 
       10.18696         0.46364        10.18694         0.46364 

The two fits of the same likelihood agree to five decimal places, so the hand-written version is doing what it should. On this survey the lethal level removed 10 of the 240 hosts and the largest burden anybody saw was 56 worms. The complete sample would have given a mean of 13.8 and a \(k\) of 0.404. The survivors give a mean of 10.187 and a \(k\) of 0.4636.

One survey settles nothing. The next block runs eight hundred of them, and adds a second, more believable removal rule alongside the hard cut: survival declines smoothly with burden, following a logistic curve with a half-survival point at forty-five worms and a scale of twelve, so a mouse with twenty worms almost always lives, one with forty-five is a coin flip, and one with eighty rarely makes it.

x50 <- 45
s_mort <- 12
host_survival <- function(x) 1 / (1 + exp((x - x50) / s_mort))

print(round(c(survival_at_10 = host_survival(10),
              survival_at_20 = host_survival(20),
              survival_at_45 = host_survival(45),
              survival_at_80 = host_survival(80),
              survival_at_150 = host_survival(150)), 5))
 survival_at_10  survival_at_20  survival_at_45  survival_at_80 survival_at_150 
        0.94866         0.88927         0.50000         0.05134         0.00016 
print(round(c(overall_survival = sum(p_true * host_survival(x_grid)),
              mean_of_survivors =
                sum(x_grid * p_true * host_survival(x_grid)) /
                sum(p_true * host_survival(x_grid))), 5))
 overall_survival mean_of_survivors 
          0.88982           7.94803 

Under that curve 88.98 per cent of the hosts are still alive at trapping time, which is a heavier loss than the hard cut inflicts, and the mean burden among the survivors is 7.948 worms against a population mean of 12.

n_rep <- 800
top_fifth <- function(x) {
  srt <- sort(x, decreasing = TRUE)
  sum(srt[seq_len(ceiling(0.2 * length(x)))]) / sum(x)
}
gini_index <- function(x) {
  srt <- sort(x)
  nn <- length(srt)
  sum((2 * seq_len(nn) - nn - 1) * srt) / (nn^2 * mean(srt))
}
moment_k <- function(x) mean(x)^2 / (var(x) - mean(x))

summarise_sample <- function(x) {
  fitted_nb <- fit_nb(x)
  pois_ll <- sum(dpois(x, mean(x), log = TRUE))
  c(mu = unname(fitted_nb["mu"]), k = unname(fitted_nb["k"]),
    k_mom = moment_k(x), vmr = var(x) / mean(x),
    top = top_fifth(x), gini = gini_index(x),
    lrt = 2 * (unname(fitted_nb["loglik"]) - pois_ll), n = length(x))
}

set.seed(4242)
rep_out <- vapply(seq_len(n_rep), function(b) {
  x <- rnbinom(n_host, size = k_true, mu = mu_true)
  hard <- x[x < lethal]
  soft <- x[runif(n_host) < host_survival(x)]
  c(complete = summarise_sample(x),
    cut = summarise_sample(hard),
    smooth = summarise_sample(soft))
}, numeric(24))
rep_out <- as.data.frame(t(rep_out))

col_of <- function(which_set, stat) rep_out[[paste0(which_set, ".", stat)]]
avg <- function(which_set, stat) mean(col_of(which_set, stat))

avg_row <- function(key) vapply(c("mu", "k", "k_mom", "vmr", "n"),
                                function(s) avg(key, s), numeric(1))
print(round(rbind(complete = avg_row("complete"), hard_cut = avg_row("cut"),
                  smooth_mortality = avg_row("smooth")), 4))
                      mu      k  k_mom     vmr        n
complete         11.9690 0.4045 0.4157 30.5648 240.0000
hard_cut          9.3742 0.4507 0.5895 17.0006 231.8025
smooth_mortality  7.9602 0.4532 0.5570 15.4789 213.7038
mu_shortfall_cut <- 100 * (1 - avg("cut", "mu") / mu_true)
mu_shortfall_soft <- 100 * (1 - avg("smooth", "mu") / mu_true)
k_infl_ml_cut <- avg("cut", "k") / avg("complete", "k")
k_infl_mom_cut <- avg("cut", "k_mom") / avg("complete", "k_mom")
k_infl_ml_soft <- avg("smooth", "k") / avg("complete", "k")
k_infl_mom_soft <- avg("smooth", "k_mom") / avg("complete", "k_mom")
sd_mu_cut <- sd(col_of("cut", "mu"))

print(round(c(mean_shortfall_pct_hard = mu_shortfall_cut,
              mean_shortfall_pct_smooth = mu_shortfall_soft,
              bias_in_sd_units = (mu_true - avg("cut", "mu")) / sd_mu_cut,
              sd_of_mu_hat = sd_mu_cut), 4))
  mean_shortfall_pct_hard mean_shortfall_pct_smooth          bias_in_sd_units 
                  21.8819                   33.6650                    3.2791 
             sd_of_mu_hat 
                   0.8008 
print(round(c(k_inflation_ml_hard = k_infl_ml_cut,
              k_inflation_moments_hard = k_infl_mom_cut,
              k_inflation_ml_smooth = k_infl_ml_soft,
              k_inflation_moments_smooth = k_infl_mom_soft), 4))
       k_inflation_ml_hard   k_inflation_moments_hard 
                    1.1142                     1.4183 
     k_inflation_ml_smooth k_inflation_moments_smooth 
                    1.1205                     1.3400 

Over 800 replicates of a 240 host survey, the mean burden comes out 21.88 per cent short under the hard cut and 33.67 per cent short under the smooth mortality curve. The shortfall under the hard cut is 3.28 standard deviations of the estimator, so a single survey has essentially no chance of noticing it.

The aggregation parameter is the surprise, and it went the opposite way to what I expected before running it. The maximum likelihood \(k\) inflates by a factor of 1.114 under the hard cut and 1.12 under smooth mortality: eleven or twelve per cent, not the doubling that the loss of a quarter of the worms suggests. The method-of-moments \(k\), computed from the sample mean and variance and still the estimator behind a great many published aggregation figures, inflates by 1.418 and 1.34. The two estimators of the same parameter disagree by nearly a third on the same data, and only one of them is telling the reader that anything has happened.

The reason is in the likelihood. With \(k\) below one the negative binomial’s shape is set almost entirely by the crowd at the bottom: the height of the zero class relative to the one class relative to the two class. Truncation does not touch any of them. It removes a handful of observations from a region the likelihood was already treating as nearly weightless, and the fitted \(k\) barely moves, while the fitted \(\mu\) moves a long way because the sample mean moved a long way. The moment estimator has no such protection; it is a function of the variance, and the variance is exactly what the missing hosts were carrying.

Two side by side panels of density curves on warm off-white paper. The left panel, labelled fitted mean burden, has a dark green curve centred on twelve at the dashed line, a red curve well to its left centred near nine and a half, and a gold curve further left again centred near eight. The right panel, labelled fitted aggregation parameter, has the same three curves but they overlap heavily: the green one is centred on four tenths at the dashed line and the red and gold ones sit only slightly to its right, near forty-five hundredths, with all three of similar width.
Figure 2: Sampling distributions of the fitted mean and the fitted aggregation parameter over eight hundred simulated surveys of two hundred and forty hosts, for the complete population, for a hard lethal level at sixty worms, and for a smooth survival curve. Dashed lines mark the truth. The mean is displaced badly; the maximum likelihood aggregation parameter is displaced hardly at all.

Aggregation is the finding, and truncation attacks it unevenly

In most other count problems the mean is the answer and the dispersion is a nuisance. In parasitology the dispersion is the answer. Aggregation is what determines whether transmission is sustained by a small subset of hosts, what a mass treatment programme is worth, and whether the parasite regulates the host population at all. Woolhouse and colleagues (1997) put the working version of it in one sentence: for a wide range of infections a fifth of the hosts account for about four fifths of the transmission, and control effort aimed at that fifth buys far more than effort spread evenly. If truncation attacks the aggregation summaries, it attacks the conclusion rather than a detail of it.

Wilson, Grenfell and Shaw (1996) went through the summaries in use and their behaviour. Three of them appear below: the variance-to-mean ratio, the Gini coefficient of the burdens, and the share of the total burden held by the most infected fifth of hosts. The fourth entry is the test that decides whether an aggregated model is needed at all, a likelihood ratio comparison of the fitted negative binomial against a Poisson with the same mean.

agg_tab <- rbind(
  complete = c(vmr = avg("complete", "vmr"), gini = avg("complete", "gini"),
               top_fifth = avg("complete", "top"), lrt = avg("complete", "lrt")),
  hard_cut = c(avg("cut", "vmr"), avg("cut", "gini"),
               avg("cut", "top"), avg("cut", "lrt")),
  smooth_mortality = c(avg("smooth", "vmr"), avg("smooth", "gini"),
                       avg("smooth", "top"), avg("smooth", "lrt")))
print(round(agg_tab, 4))
                     vmr   gini top_fifth      lrt
complete         30.5648 0.6868    0.7010 4214.992
hard_cut         17.0006 0.6533    0.6642 2561.595
smooth_mortality 15.4789 0.6584    0.6681 1994.309
rel_change <- round(100 * (agg_tab[2:3, 1:3] / rep(agg_tab[1, 1:3], each = 2) - 1), 3)
print(rel_change)
                     vmr   gini top_fifth
hard_cut         -44.378 -4.882    -5.253
smooth_mortality -49.357 -4.142    -4.696
lrt_crit <- qchisq(0.95, 1)
print(round(c(chisq_threshold = lrt_crit,
              reject_poisson_complete = mean(col_of("complete", "lrt") > lrt_crit),
              reject_poisson_hard = mean(col_of("cut", "lrt") > lrt_crit),
              reject_poisson_smooth = mean(col_of("smooth", "lrt") > lrt_crit),
              smallest_statistic_hard = min(col_of("cut", "lrt")),
              smallest_statistic_smooth = min(col_of("smooth", "lrt"))), 4))
          chisq_threshold   reject_poisson_complete       reject_poisson_hard 
                   3.8415                    1.0000                    1.0000 
    reject_poisson_smooth   smallest_statistic_hard smallest_statistic_smooth 
                   1.0000                 1653.9234                 1263.4391 

The variance-to-mean ratio falls from 30.56 to 17 under the hard cut, a loss of 44.38 per cent of itself, and to 15.48 under smooth mortality. That is the collapse the intuition predicts. The other two summaries do not follow it. The share of the burden held by the top fifth of hosts goes from 0.701 to 0.6642, a fall of 5.25 per cent, and the Gini coefficient falls by 4.88 per cent. A survey that has lost a quarter of its worms still reports that the most infected fifth of hosts carry about two thirds of the burden, because removing the top of the distribution removes it from the numerator and the denominator at once.

The Poisson comparison does not react at all. The likelihood ratio statistic averages 2561.6 on the truncated samples against a threshold of 3.84, and the smallest value seen across 800 replicates was 1653.9. The Poisson is rejected in every replicate of every version. I expected this one to fail sometimes and it never did, at any sample size a real survey would have.

That combination is what lets the problem survive review. A truncated survey still says, correctly, that burdens are aggregated, still reports a top-fifth share close to the truth, and still reports an aggregation parameter only slightly too large. What it gets badly wrong is the mean, and through the mean everything derived from it: the total worm population, the burden of the animals that were not caught, and the variance the fitted model implies, which here is 204.4 against a true 372.

How the damage scales with the severity of the mortality can be had exactly rather than by simulation. The truncated distribution is known in closed form for any lethal level, so each summary follows from it directly, and the value the maximum likelihood fit converges to is the pair minimising the cross entropy between that distribution and the fitted negative binomial. That is the probability limit of the estimator, with no Monte Carlo noise in it.

nb_projection <- function(prob) {
  opt <- optim(c(log(sum(x_grid * prob) + 0.5), 0), function(par) {
    ld <- dnbinom(x_grid, size = exp(par[2]), mu = exp(par[1]), log = TRUE)
    -sum(prob * ifelse(is.finite(ld), ld, -1e6))
  }, control = list(reltol = 1e-14, maxit = 8000))
  c(mu = exp(opt$par[1]), k = exp(opt$par[2]), cross_entropy = -opt$value)
}

top_fifth_exact <- function(prob) {
  tail_p <- rev(cumsum(rev(prob)))
  j <- which(tail_p <= 0.2)[1]
  q <- x_grid[j]
  (sum(x_grid[x_grid >= q] * prob[x_grid >= q]) +
     (0.2 - tail_p[j]) * (q - 1)) / sum(x_grid * prob)
}

summarise_pmf <- function(prob) {
  proj <- nb_projection(prob)
  m <- sum(x_grid * prob)
  v <- sum((x_grid - m)^2 * prob)
  pois_ent <- sum(prob * ifelse(is.finite(dpois(x_grid, m, log = TRUE)),
                                dpois(x_grid, m, log = TRUE), -1e6))
  c(mean = m, vmr = v / m, k_ml = unname(proj["k"]), k_mom = m^2 / (v - m),
    top_fifth = top_fifth_exact(prob),
    deviance_per_host = 2 * (unname(proj["cross_entropy"]) - pois_ent))
}

lethal_grid <- c(400, 200, 120, 90, 60, 45, 30, 20)
sweep_tab <- t(vapply(lethal_grid, function(L) {
  prob <- p_true * (x_grid < L)
  c(lethal_level = L, hosts_lost = 1 - sum(prob),
    summarise_pmf(prob / sum(prob)))
}, numeric(8)))
sweep_full <- c(lethal_level = NA, hosts_lost = 0, summarise_pmf(p_true))
print(round(rbind(no_mortality = sweep_full, sweep_tab), 4))
             lethal_level hosts_lost    mean     vmr   k_ml  k_mom top_fifth
no_mortality           NA     0.0000 12.0000 31.0000 0.4000 0.4000    0.7043
                      400     0.0000 11.9999 30.9975 0.4000 0.4000    0.7043
                      200     0.0002 11.9582 30.3465 0.4009 0.4075    0.7035
                      120     0.0035 11.5293 26.6126 0.4089 0.4501    0.6958
                       90     0.0107 10.8703 22.8800 0.4205 0.4968    0.6852
                       60     0.0346  9.3692 17.0642 0.4465 0.5832    0.6633
                       45     0.0642  8.0468 13.3369 0.4711 0.6523    0.6458
                       30     0.1239  6.1364  9.1531 0.5130 0.7527    0.6235
                       20     0.1999  4.4378  6.1853 0.5646 0.8558    0.6071
             deviance_per_host
no_mortality           17.7651
                       17.7647
                       17.5952
                       16.2113
                       14.4459
                       11.1164
                        8.6556
                        5.6312
                        3.3674
smooth_row <- summarise_pmf(p_true * host_survival(x_grid) /
                              sum(p_true * host_survival(x_grid)))
print(round(smooth_row, 4))
             mean               vmr              k_ml             k_mom 
           7.9480           15.5124            0.4492            0.5477 
        top_fifth deviance_per_host 
           0.6664            9.3614 

At the lethal level used throughout, losing 3.46 per cent of the hosts costs 21.92 per cent of the mean, 44.95 per cent of the variance-to-mean ratio and 5.83 per cent of the top-fifth share, while inflating the maximum likelihood \(k\) by 11.63 per cent and the moment \(k\) by 45.81 per cent. Push the lethal level down to twenty worms, which removes a fifth of all hosts, and the mean has lost 63.02 per cent while the top-fifth share has lost 13.8 per cent. The ordering never changes over the whole sweep.

The last column explains why the Poisson test is useless here. It is the expected likelihood ratio statistic contributed by one host, so the whole survey’s statistic is that number times the sample size. At the lethal level of sixty it is 11.116, and even at a lethal level of twenty it is 3.367. A survey of two hosts would clear the 3.84 threshold on average. No amount of truncation short of removing almost everything makes an aggregated sample look Poisson, because the zeros are still there and the zeros are most of the evidence.

Five curves on a panel whose horizontal axis is the percentage of hosts removed, from zero to twenty, and whose vertical axis is the value relative to the complete population, from about two tenths to about two and a fifth. A gold curve for the moment aggregation parameter rises steeply to about two and a fifth; a pale green curve for the maximum likelihood aggregation parameter rises gently to about one and two fifths. A red curve for the variance-to-mean ratio falls steeply to about two tenths of its starting value, a dark green curve for the mean falls less steeply to about four tenths, and a near-black curve for the top-fifth share falls barely at all, staying above eight tenths across the whole panel. A horizontal line marks one and a dashed vertical line stands at three and a half per cent.
Figure 3: Each aggregation summary relative to its value in the complete population, against the percentage of hosts removed by parasite-induced mortality. All quantities are computed exactly from the truncated distribution rather than simulated. The variance-to-mean ratio falls fastest, the mean follows it, and the top-fifth share is almost flat. The vertical marker is the lethal level of sixty worms used throughout the post.

The right-truncated likelihood

If the lethal level is known, the fix is a renormalisation. The survivors are drawn from the negative binomial conditioned on being below the cut, so their density is the negative binomial density divided by the probability of falling below it:

\[ P(X = x \mid X < c) \;=\; \frac{f(x;\, \mu,\, k)}{F(c - 1;\, \mu,\, k)}, \qquad x = 0, 1, \ldots, c - 1 \]

which in R is dnbinom(x, size = k, mu = mu) / pnbinom(cut - 1, size = k, mu = mu). On the log scale it costs one extra term: subtract the sample size times the log of the cumulative probability. pnbinom has a log.p argument, which is safer than taking the logarithm of a small number afterwards.

rt_nb_loglik <- function(x, mu, kk, cut_at) {
  nb_loglik(x, mu, kk) -
    length(x) * pnbinom(cut_at - 1, size = kk, mu = mu, log.p = TRUE)
}

fit_rt_nb <- function(x, cut_at) {
  opt <- optim(c(log(mean(x) + 0.5), 0),
               function(par) -rt_nb_loglik(x, exp(par[1]), exp(par[2]), cut_at),
               control = list(reltol = 1e-12, maxit = 5000))
  c(mu = exp(opt$par[1]), k = exp(opt$par[2]), loglik = -opt$value)
}

fit_rt_one <- fit_rt_nb(burden_obs, lethal)
print(round(rbind(naive = fit_obs, right_truncated = fit_rt_one), 5))
                      mu       k    loglik
naive           10.18694 0.46364 -742.8377
right_truncated 13.91650 0.40696 -736.4343

On the single survey from earlier the naive fit gave a mean of 10.187 and the right-truncated fit gives 13.917, against a truth of 12. Five hundred replicates say whether that was luck.

n_rep_rt <- 500
set.seed(90210)
rt_out <- vapply(seq_len(n_rep_rt), function(b) {
  x <- rnbinom(n_host, size = k_true, mu = mu_true)
  hard <- x[x < lethal]
  soft <- x[runif(n_host) < host_survival(x)]
  c(right = fit_rt_nb(hard, lethal)[c("mu", "k")],
    wrong = fit_rt_nb(hard, 90)[c("mu", "k")],
    naive = fit_nb(hard)[c("mu", "k")],
    smooth = fit_rt_nb(soft, lethal)[c("mu", "k")])
}, numeric(8))
rt_out <- as.data.frame(t(rt_out))

rt_summary <- rbind(
  correct_cut = c(mean(rt_out$right.mu), sd(rt_out$right.mu),
                  mean(rt_out$right.k), sd(rt_out$right.k)),
  cut_assumed_90 = c(mean(rt_out$wrong.mu), sd(rt_out$wrong.mu),
                     mean(rt_out$wrong.k), sd(rt_out$wrong.k)),
  no_correction = c(mean(rt_out$naive.mu), sd(rt_out$naive.mu),
                    mean(rt_out$naive.k), sd(rt_out$naive.k)),
  smooth_mortality = c(mean(rt_out$smooth.mu), sd(rt_out$smooth.mu),
                       mean(rt_out$smooth.k), sd(rt_out$smooth.k)))
colnames(rt_summary) <- c("mean_mu", "sd_mu", "mean_k", "sd_k")
print(round(rt_summary, 4))
                 mean_mu  sd_mu mean_k   sd_k
correct_cut      12.4267 2.6855 0.4047 0.0430
cut_assumed_90    9.8412 1.0348 0.4395 0.0427
no_correction     9.3845 0.8284 0.4516 0.0417
smooth_mortality  9.1641 1.4637 0.4253 0.0481
print(round(c(bias_correct_cut = rt_summary[1, 1] - mu_true,
              bias_naive = rt_summary[3, 1] - mu_true,
              sd_ratio = rt_summary[1, 2] / rt_summary[3, 2]), 4))
bias_correct_cut       bias_naive         sd_ratio 
          0.4267          -2.6155           3.2419 

With the lethal level known the estimator works. Over 500 replicates it returns a mean of 12.427 against a truth of 12 and a \(k\) of 0.4047 against 0.4, where the uncorrected fit returns 9.384. The bias is gone.

The price is the last number in the block. The standard deviation of the corrected mean is 2.685 against 0.828 for the uncorrected one, a factor of 3.242. That is the usual exchange rate for a bias correction, and it is not the reason to be careful with this estimator. The reason is in the next section.

The lethal level the data cannot see

The correction above was handed the right answer. In a real survey nobody knows the lethal level, and there is a temptation to estimate it from the data along with everything else. That does not work, and the way it fails is instructive.

cut_alt <- 120
cut_seq <- sort(unique(c(max(burden_obs) + 1, seq(lethal, 260, by = 2))))
prof <- t(vapply(cut_seq, function(cc) {
  f <- fit_rt_nb(burden_obs, cc)
  c(cut_at = cc, mu = unname(f["mu"]), k = unname(f["k"]),
    loglik = unname(f["loglik"]))
}, numeric(4)))
prof <- as.data.frame(prof)
prof$delta <- prof$loglik - max(prof$loglik)

at_cut <- function(cc) prof[which(prof$cut_at == cc)[1], ]
print(round(rbind(at_cut(max(burden_obs) + 1), at_cut(lethal), at_cut(80),
                  at_cut(cut_alt), at_cut(200), at_cut(260)), 4))
    cut_at      mu      k    loglik   delta
1       57 15.0820 0.3985 -735.0432  0.0000
2       60 13.9165 0.4070 -736.4343 -1.3912
12      80 11.1624 0.4404 -740.9190 -5.8758
32     120 10.3259 0.4593 -742.6064 -7.5632
72     200 10.1915 0.4635 -742.8328 -7.7896
102    260 10.1873 0.4636 -742.8374 -7.7942
print(round(c(best_cut_by_likelihood = prof$cut_at[which.max(prof$loglik)],
              largest_burden_seen = max(burden_obs),
              loglik_span = max(prof$loglik) - min(prof$loglik),
              mu_at_true_cut = at_cut(lethal)$mu,
              mu_at_alternative_cut = at_cut(cut_alt)$mu,
              mu_span = max(prof$mu) - min(prof$mu)), 4))
best_cut_by_likelihood    largest_burden_seen            loglik_span 
               57.0000                56.0000                 7.7942 
        mu_at_true_cut  mu_at_alternative_cut                mu_span 
               13.9165                10.3259                 4.8947 

The profile has no interior maximum. It is monotone decreasing in the assumed lethal level, so the likelihood always prefers the smallest cut compatible with the data, which is one more than the largest burden observed. That is not an estimate of a biological quantity. It is a statement about how many hosts were dissected: trap more mice, see a bigger maximum, and the “estimate” moves up. Anderson and Gordon (1982) made the same point about trying to read the mortality out of the shape of the observed distribution.

The size of the effect is the second half of it. Across assumed lethal levels from 57 to 260 worms, the whole log likelihood moves by 7.79 while the estimated population mean moves by 4.89 worms per host. Assuming the true value of 60 gives 13.917; assuming 120 gives 10.326, a drop of 25.8 per cent for a change in an input nobody measured. In the five hundred replicates above, assuming a lethal level of ninety when the truth was sixty gave a mean of 9.841, which is 17.99 per cent short of the truth: about 2.09 standard deviations of an estimator whose standard deviation is 1.035.

The last row of the recovery table is the honest case. Under the smooth survival curve there is no lethal level at all, and applying the right-truncated fit with the cut set to sixty returns a mean of 9.164, still 23.63 per cent short. The correction removed part of the bias and left the rest, and nothing in its output distinguishes that from success.

Two stacked panels sharing a horizontal axis of assumed lethal level running from about sixty to two hundred and sixty worms. The upper panel shows a dark green curve of the log likelihood relative to its maximum: it starts at zero on the left, falls steeply over the first forty worms and flattens onto a floor near minus eight for the rest of the panel. The lower panel shows a red curve of the fitted mean burden falling from about fifteen at the left to about ten and levelling off, with a horizontal dashed line at twelve that it crosses early. A dashed vertical line at sixty marks the true lethal level in both panels.
Figure 4: Profile log likelihood and fitted population mean against the assumed lethal level, for the single simulated survey. The likelihood has no interior maximum: it is highest at the smallest cut the data allow, which is one worm more than the largest burden observed, and it decays smoothly from there. The estimated mean falls by about a third across the same range.

A second aggregated family, a different missing tail

The right-truncated fit recovers the population mean because the negative binomial carries the missing tail. The data below the cut pin down \(\mu\) and \(k\), and the assumed family then supplies everything above it. Nothing else could: there are no observations up there. The question is how much of the answer that assumption is doing, and the way to find out is to put a different but comparably plausible aggregated family through the same machinery.

The Poisson-lognormal is the natural competitor. Each host has a latent expected burden drawn from a lognormal, and the realised count is Poisson around it. Bulmer (1974) worked out how to fit it, and Shaw, Grenfell and Dobson (1998) found it competitive with the negative binomial across wildlife datasets. It has two parameters like the negative binomial, it produces the same kind of long-tailed aggregated shape, and it has no closed form, so the probability of a count has to be integrated over the latent variable.

Writing the latent variable as \(\log\lambda = m + s Z\) with \(Z\) standard normal turns the probability of a count into an expectation over \(Z\), and the obvious tool is Gauss-Hermite quadrature. Its nodes and weights come from the eigen-decomposition of the Jacobi matrix of the Hermite polynomials, which is a dozen lines of base R. The alternative is a fine grid in \(Z\) with normal weights, which is cruder and, as the comparison below shows, the one to use here.

gauss_hermite <- function(m) {
  idx <- seq_len(m - 1)
  jac <- matrix(0, m, m)
  jac[cbind(idx, idx + 1)] <- sqrt(idx / 2)
  jac[cbind(idx + 1, idx)] <- sqrt(idx / 2)
  eig <- eigen(jac, symmetric = TRUE)
  ord <- order(eig$values)
  list(node = sqrt(2) * eig$values[ord],
       weight = (eig$vectors[1, ord])^2)
}

gh <- gauss_hermite(120)
z_grid <- seq(-8, 8, length.out = 2001)
z_wt <- dnorm(z_grid) * (z_grid[2] - z_grid[1])

mix_pmf <- function(xv, lmean, lsd, node, wt) {
  lam <- exp(lmean + lsd * node)
  as.vector(outer(xv, lam, function(a, b) dpois(a, b)) %*% wt)
}
pln_pmf <- function(xv, lmean, lsd) mix_pmf(xv, lmean, lsd, z_grid, z_wt)

probe <- c(0, 5, 20, 50, 100, 200)
by_hermite <- mix_pmf(probe, 1.86, 2.25, gh$node, gh$weight)
by_grid <- pln_pmf(probe, 1.86, 2.25)
by_integrate <- vapply(probe, function(xi)
  integrate(function(z) dpois(xi, exp(1.86 + 2.25 * z)) * dnorm(z),
            -8, 8, rel.tol = 1e-10)$value, numeric(1))
grid_err <- max(abs(by_grid[-6] / by_integrate[-6] - 1))
hermite_err <- max(abs(by_hermite[-6] / by_integrate[-6] - 1))
print(signif(rbind(count = probe, gauss_hermite_120 = by_hermite,
                   fine_grid = by_grid, adaptive_integrate = by_integrate), 4))
                    [,1]    [,2]      [,3]      [,4]      [,5]      [,6]
count              0.000 5.00000 20.000000 50.000000 1.000e+02 2.000e+02
gauss_hermite_120  0.172 0.03426  0.007656  0.001372 5.684e-04 2.891e-04
fine_grid          0.172 0.03432  0.007818  0.002348 8.450e-04 2.765e-04
adaptive_integrate 0.172 0.03432  0.007818  0.002348 8.450e-04 1.599e-14
print(round(c(grid_weights_sum_to = sum(z_wt), worst_grid_error = grid_err,
              worst_hermite_error = hermite_err), 8))
grid_weights_sum_to    worst_grid_error worst_hermite_error 
          1.0000000           0.0000000           0.4158891 

The fine grid and integrate agree to eight figures everywhere except the last column, where integrate is the one that has failed: its adaptive subdivision misses a peak that has become very narrow. The Gauss-Hermite rule is wrong by up to 41.6 per cent over the counts probed, and no warning is issued. The reason is that the Poisson kernel, seen as a function of \(\log\lambda\), has width about \(1/\sqrt{x}\), so it narrows as the count grows until it falls between the quadrature nodes; a rule with a fixed number of nodes eventually aliases. This is the sort of thing that produces a plausible fitted curve and a wrong answer, so the grid rule is used from here on.

The right-truncated Poisson-lognormal likelihood is then the same construction as before: the log of the probability at each observed count, minus the sample size times the log of the probability of falling below the cut. Evaluating it on the distinct observed counts with their multiplicities is several times faster and identical. Because the lognormal’s tail runs past the end of any grid, every population quantity below uses the closed-form mean exp(lmean + lsd^2 / 2).

rt_pln_nll <- function(par, tab_x, tab_n, cut_at) {
  px <- pln_pmf(tab_x, par[1], exp(par[2]))
  pc <- sum(pln_pmf(0:(cut_at - 1), par[1], exp(par[2])))
  if (any(px <= 0) || pc <= 0) return(1e10)
  -(sum(tab_n * log(px)) - sum(tab_n) * log(pc))
}

fit_rt_pln <- function(x, cut_at) {
  tally <- table(x)
  tab_x <- as.integer(names(tally))
  tab_n <- as.vector(tally)
  opt <- optim(c(log(mean(x) + 0.5), log(1.5)), rt_pln_nll,
               tab_x = tab_x, tab_n = tab_n, cut_at = cut_at,
               control = list(reltol = 1e-12, maxit = 5000))
  lsd <- exp(opt$par[2])
  c(lmean = opt$par[1], lsd = lsd,
    mu = exp(opt$par[1] + lsd^2 / 2), loglik = -opt$value)
}

fit_pln_one <- fit_rt_pln(burden_obs, lethal)
print(round(fit_pln_one, 4))
    lmean       lsd        mu    loglik 
   2.3781    2.8284  588.6816 -737.3321 
nb_tail <- 1 - pnbinom(lethal - 1, size = fit_rt_one["k"], mu = fit_rt_one["mu"])
pln_tail <- 1 - sum(pln_pmf(0:(lethal - 1), fit_pln_one["lmean"],
                            fit_pln_one["lsd"]))
share_above <- function(prob_low, exact_mean) {
  1 - sum((0:(lethal - 1)) * prob_low) / exact_mean
}
nb_share <- unname(share_above(dnbinom(0:(lethal - 1), size = fit_rt_one["k"],
                                       mu = fit_rt_one["mu"]), fit_rt_one["mu"]))
pln_share <- unname(share_above(pln_pmf(0:(lethal - 1), fit_pln_one["lmean"],
                                        fit_pln_one["lsd"]), fit_pln_one["mu"]))
aic_gap <- 2 * (unname(fit_rt_one["loglik"]) - unname(fit_pln_one["loglik"]))

print(round(c(aic_advantage_of_nb = aic_gap,
              nb_population_mean = unname(fit_rt_one["mu"]),
              pln_population_mean = unname(fit_pln_one["mu"]),
              true_population_mean = mu_true), 4))
 aic_advantage_of_nb   nb_population_mean  pln_population_mean 
              1.7955              13.9165             588.6816 
true_population_mean 
             12.0000 
print(round(c(nb_prob_above_lethal = nb_tail,
              pln_prob_above_lethal = pln_tail,
              true_prob_above_lethal = frac_hosts_lost,
              nb_share_of_worms_above = nb_share,
              pln_share_of_worms_above = pln_share,
              true_share_of_worms_above = frac_worms_lost), 4))
     nb_prob_above_lethal     pln_prob_above_lethal    true_prob_above_lethal 
                   0.0475                    0.2732                    0.0346 
  nb_share_of_worms_above  pln_share_of_worms_above true_share_of_worms_above 
                   0.3028                    0.9871                    0.2463 

Both models were fitted to the same 230 counts, all of them below 60 worms, with the same number of parameters and the same assumed lethal level. The negative binomial says the population mean is 13.92 worms per host. The Poisson-lognormal says 588.7. The truth is 12, so the negative binomial is right, but only because it is the family the data came from, which is information no field survey has.

The two answers about the missing tail are further apart still. The negative binomial puts 4.75 per cent of hosts above the lethal level and 30.3 per cent of the worms; the Poisson-lognormal puts 27.32 per cent of hosts and 98.7 per cent of the worms there. The true values are 3.46 and 24.6. One model says under a third of the worm population is out of reach; the other says almost all of it is.

The last thing to check is whether the data can tell them apart. AIC prefers the negative binomial by 1.8, which is nothing: a difference of that size is the usual threshold for calling two models indistinguishable. A goodness-of-fit test on the observed range says the same, and is the fairer question anyway, because a table of observed against expected frequencies is what a field worker would actually inspect.

bin_edges <- c(0, 1, 2, 3, 5, 8, 12, 18, 26, 38, lethal)
observed <- as.vector(table(cut(burden_obs, breaks = bin_edges,
                                right = FALSE, labels = FALSE)))

expected_from <- function(prob_vec) {
  prob_vec <- prob_vec / sum(prob_vec)
  vapply(seq_len(length(bin_edges) - 1), function(i)
    sum(prob_vec[(bin_edges[i] + 1):bin_edges[i + 1]]), numeric(1)) *
    length(burden_obs)
}
exp_nb <- expected_from(dnbinom(0:(lethal - 1), size = fit_rt_one["k"],
                                mu = fit_rt_one["mu"]))
exp_pln <- expected_from(pln_pmf(0:(lethal - 1), fit_pln_one["lmean"],
                                 fit_pln_one["lsd"]))

chisq_of <- function(ex) {
  stat <- sum((observed - ex)^2 / ex)
  dfree <- length(observed) - 1 - 2
  c(statistic = stat, df = dfree, p = pchisq(stat, dfree, lower.tail = FALSE))
}
print(rbind(bin_start = bin_edges[-length(bin_edges)],
            observed = observed, expected_nb = round(exp_nb, 2),
            expected_pln = round(exp_pln, 2)))
              [,1]  [,2]  [,3]  [,4]  [,5]  [,6]  [,7]  [,8]  [,9] [,10]
bin_start     0.00  1.00  2.00  3.00  5.00  8.00 12.00 18.00 26.00 38.00
observed     57.00 17.00 20.00 25.00 25.00 17.00 22.00 14.00 18.00 15.00
expected_nb  56.69 22.42 15.32 21.83 22.46 20.75 21.10 18.26 16.49 14.68
expected_pln 53.78 26.39 17.29 22.99 21.79 18.86 18.56 16.31 16.05 17.98
print(round(rbind(negative_binomial = chisq_of(exp_nb),
                  poisson_lognormal = chisq_of(exp_pln)), 4))
                  statistic df      p
negative_binomial    5.3417  7 0.6183
poisson_lognormal    6.4878  7 0.4841

Neither model is rejected. The negative binomial gives a chi-square p value of 0.618 and the Poisson-lognormal 0.484, so the table most parasitology papers print would pass both. Two families that describe the visible part of the data equally well disagree by a factor of 42 about the quantity the survey was run to estimate.

Within the Poisson-lognormal alone the position is no better. Profiling the likelihood over the spread of the latent distribution and reading off the implied population mean shows how little the observed range constrains it.

lsd_seq <- seq(1.6, 4.6, by = 0.05)
pln_tally <- table(burden_obs)
pln_prof <- t(vapply(lsd_seq, function(s_try) {
  opt <- optimize(function(m_try)
    rt_pln_nll(c(m_try, log(s_try)), as.integer(names(pln_tally)),
               as.vector(pln_tally), lethal), c(-2, 7))
  c(lsd = s_try, lmean = opt$minimum, loglik = -opt$objective,
    pop_mean = exp(opt$minimum + s_try^2 / 2))
}, numeric(4)))
pln_prof <- as.data.frame(pln_prof)
pln_prof$delta <- pln_prof$loglik - max(pln_prof$loglik)

within2 <- pln_prof[pln_prof$delta > -2, ]
print(round(pln_prof[seq(1, nrow(pln_prof), by = 6), ], 4))
   lsd  lmean    loglik     pop_mean    delta
1  1.6 1.5274 -747.8643      16.5663 -10.5312
7  1.9 1.6219 -741.5522      30.7800  -4.2191
13 2.2 1.7922 -738.7479      67.5023  -1.4148
19 2.5 2.0344 -737.6250     174.0532  -0.2920
25 2.8 2.3452 -737.3338     525.9587  -0.0008
31 3.1 2.7221 -737.4565    1857.7338  -0.1234
37 3.4 3.1629 -737.7795    7652.9875  -0.4465
43 3.7 3.6657 -738.1910   36705.2959  -0.8580
49 4.0 4.2291 -738.6314  204662.5805  -1.2984
55 4.3 4.8519 -739.0694 1325031.5939  -1.7364
61 4.6 5.5331 -739.4890 9950316.8839  -2.1560
print(c(support_lsd_low = min(within2$lsd), support_lsd_high = max(within2$lsd),
        support_mean_low = round(min(within2$pop_mean), 1),
        support_mean_high = formatC(max(within2$pop_mean),
                                    format = "e", digits = 2)))
  support_lsd_low  support_lsd_high  support_mean_low support_mean_high 
           "2.15"            "4.45"            "58.6"        "3.56e+06" 

Every Poisson-lognormal in that set is within two log-likelihood units of the best one, which is the conventional boundary for “the data do not distinguish these”. Across the set the implied population mean runs from 58.6 worms per host to 3.56e+06. The observed counts, all of them below 60, place essentially no upper bound on the burden of the hosts that are not there.

A line chart with a logarithmic vertical axis of probability running from one hundred-thousandth to one, and a horizontal axis of worms per host from zero to two hundred. Black dots for the observed relative frequencies fall away from about a fifth at zero and stop just before sixty, where a dashed vertical line stands; everything right of that line is shaded pale red and labelled no data beyond here. A dark green curve and a red curve are indistinguishable across the left of the panel, running along the dots, then separate at the line: the dark green one bends downward steadily to the bottom right corner while the red one flattens out and finishes about forty times higher.
Figure 5: The two fitted models against the observed burdens, on a logarithmic probability scale. To the left of the lethal level, where the data are, the two curves lie on each other and on the observed frequencies. To the right of it, in the shaded region where the survey has nothing, they part and the gap widens to more than an order of magnitude. Both were fitted to the same counts with the same assumed lethal level.

What the field can supply and the likelihood cannot

Two field measures put real information where the likelihood has none, and it is worth knowing what each is worth.

The first is to stop treating the mortality as a cliff. If an experimental infection has calibrated the survival curve, so that the probability of a host with burden \(x\) being alive at trapping time is a known function \(S(x)\), then the survivors follow the survival-weighted distribution \(f(x)S(x)\) divided by its sum, and that is a likelihood like any other. It has no cut in it, which is the point: nothing about it pretends there is a burden above which hosts vanish.

mu_ceiling <- 400

fit_weighted <- function(x, x50_assumed, s_assumed) {
  w_grid <- 1 / (1 + exp((x_grid - x50_assumed) / s_assumed))
  w_obs <- 1 / (1 + exp((x - x50_assumed) / s_assumed))
  opt <- optim(c(log(mean(x) + 0.5), 0), function(par) {
    mu <- exp(par[1])
    kk <- exp(par[2])
    den <- sum(dnbinom(x_grid, size = kk, mu = mu) * w_grid)
    -(nb_loglik(x, mu, kk) + sum(log(w_obs)) - length(x) * log(den))
  }, method = "L-BFGS-B", lower = c(log(0.5), log(0.02)),
  upper = c(log(mu_ceiling), log(50)))
  c(mu = exp(opt$par[1]), k = exp(opt$par[2]))
}

n_rep_w <- 200
set.seed(5150)
w_out <- vapply(seq_len(n_rep_w), function(b) {
  x <- rnbinom(n_host, size = k_true, mu = mu_true)
  soft <- x[runif(n_host) < host_survival(x)]
  c(right = fit_weighted(soft, x50, s_mort),
    gentle = fit_weighted(soft, 1.4 * x50, s_mort),
    harsh = fit_weighted(soft, 0.7 * x50, s_mort))
}, numeric(6))
w_out <- as.data.frame(t(w_out))

w_row <- function(v) c(median = median(v), mean = mean(v), sd = sd(v),
                       ceiling_pct = 100 * mean(v > 0.99 * mu_ceiling))
print(round(rbind(calibration_correct = w_row(w_out$right.mu),
                  half_point_40pct_high = w_row(w_out$gentle.mu),
                  half_point_30pct_low = w_row(w_out$harsh.mu)), 3))
                      median   mean     sd ceiling_pct
calibration_correct   12.213 12.459  2.696         0.0
half_point_40pct_high  9.317  9.300  1.202         0.0
half_point_30pct_low  22.082 32.268 49.305         1.5

With the survival curve known the weighted fit recovers a mean of 12.459 against a truth of 12, on data where the naive fit returned 7.96. Get the half-survival point wrong in the forgiving direction, forty per cent too high, and it returns 9.3, which is 22.5 per cent short.

The other direction is worse than wrong, it is unbounded. With the half-survival point thirty per cent too low the median estimate is 22.08, nearly double the truth, and in 1.5 per cent of replicates the estimate walks to the ceiling of 400 worms that the optimiser was given. That is not a convergence failure. Assuming mortality is harsher than it is means claiming the observed hosts are improbable survivors, and the model’s only way of explaining them is to push the underlying mean up. Where it stops is set by the analyst’s bounds rather than by the data.

The second measure is to go and collect the missing observations. Carcasses are hard to find and biased in their own ways, but even a few of them turn the problem from extrapolation into interpolation. The likelihood is two pieces sharing one pair of parameters: the trapped hosts are right-truncated at the lethal level, the carcasses are left-truncated at it, and no assumption is needed about their relative sampling rates.

two_piece_fit <- function(alive, dead, cut_at) {
  opt <- optim(c(log(mean(c(alive, dead)) + 0.5), 0), function(par) {
    mu <- exp(par[1])
    kk <- exp(par[2])
    lo <- pnbinom(cut_at - 1, size = kk, mu = mu, log.p = TRUE)
    hi <- pnbinom(cut_at - 1, size = kk, mu = mu, log.p = TRUE,
                  lower.tail = FALSE)
    ll <- nb_loglik(alive, mu, kk) - length(alive) * lo
    if (length(dead)) ll <- ll + nb_loglik(dead, mu, kk) - length(dead) * hi
    -ll
  }, control = list(reltol = 1e-12, maxit = 5000))
  c(mu = exp(opt$par[1]), k = exp(opt$par[2]))
}

draw_carcasses <- function(m) {
  found <- numeric(0)
  while (length(found) < m) {
    pool <- rnbinom(500, size = k_true, mu = mu_true)
    found <- c(found, pool[pool >= lethal])
  }
  found[seq_len(m)]
}

n_rep_c <- 400
carcass_design <- list(c(n_host, 0), c(n_host, 10), c(n_host, 25),
                       c(n_host, 60), c(2 * n_host, 0))
carcass_tab <- t(vapply(carcass_design, function(des) {
  set.seed(31337)
  vals <- vapply(seq_len(n_rep_c), function(b) {
    x <- rnbinom(des[1], size = k_true, mu = mu_true)
    two_piece_fit(x[x < lethal],
                  if (des[2] > 0) draw_carcasses(des[2]) else numeric(0), lethal)
  }, numeric(2))
  c(hosts = des[1], carcasses = des[2], mean_mu = mean(vals[1, ]),
    sd_mu = sd(vals[1, ]), mean_k = mean(vals[2, ]))
}, numeric(5)))
print(round(carcass_tab, 4))
     hosts carcasses mean_mu  sd_mu mean_k
[1,]   240         0 12.3101 2.6346 0.4039
[2,]   240        10 12.1672 1.8998 0.4061
[3,]   240        25 12.1764 1.6777 0.4013
[4,]   240        60 12.0663 1.2989 0.4027
[5,]   480         0 12.1085 1.6008 0.4046
print(round(c(sd_drop_10_carcasses =
                100 * (1 - carcass_tab[2, "sd_mu"] / carcass_tab[1, "sd_mu"]),
              sd_drop_60_carcasses =
                100 * (1 - carcass_tab[4, "sd_mu"] / carcass_tab[1, "sd_mu"]),
              sd_drop_doubling_the_survey =
                100 * (1 - carcass_tab[5, "sd_mu"] / carcass_tab[1, "sd_mu"])), 3))
       sd_drop_10_carcasses.sd_mu        sd_drop_60_carcasses.sd_mu 
                           27.890                            50.699 
sd_drop_doubling_the_survey.sd_mu 
                           39.237 

Ten carcasses added to 240 trapped hosts cut the standard deviation of the estimated mean from 2.635 to 1.9, a drop of 27.89 per cent. Sixty carcasses take it to 1.299. The last row is the comparison worth having: doubling the number of hosts dissected, from 240 to 480, buys 39.24 per cent, so twenty-five carcasses were worth about as much as another 240 dissections, and cost a great deal less. They are also the only observations in the study that carry any information at all about the region the estimate depends on.

The honest limit

The estimand here is a property of animals that are not in the population being sampled. The mean burden of the whole host population, including the mice that died of their worms, is not a quantity the survey has partial information about and could get at with a better model. The survey has no information about it. Everything above the lethal level in every fit in this post came out of an assumed distributional family, not out of a measurement.

That is why the right-truncated fit works so well in the recovery section and means so little on its own. It recovered the mean to 12.427 because the data were generated from a negative binomial and it was told they were, and because it was handed the lethal level. Take either away and it moves: the wrong lethal level gave 9.841, and the Poisson-lognormal, told the right lethal level and fitting the observed range so well that a chi-square test returns 0.484, gave 588.7. The spread across assumptions that the data cannot rank is far larger than the sampling standard deviation of 2.685 that any of those fits would report.

So the correct use of these estimators is a sensitivity analysis, not a recovery. Fit the right-truncated model over the range of lethal levels the biology allows, fit it under two or three aggregated families, and report the envelope. If the envelope is narrow the conclusion is safe; if it spans a factor of 42 between two families, and runs from 58.6 to 3.56e+06 within one of them, as it does here, then the honest report is that the population mean burden was not estimated. Lester (1984) reached the same conclusion by a different route forty years ago, listing methods that are indicative rather than conclusive and saying so in the title of each section.

Three things follow for a field programme. Sample carcasses as well as live hosts, even badly and in small numbers, because twenty-five of them bought as much precision as doubling the trapping effort. Calibrate the survival curve with an experimental infection if the system allows one, and err on the forgiving side, because assuming mortality is harsher than it is sends the estimate to whatever ceiling the optimiser was given rather than to a wrong but finite number. And if neither is possible, report the estimate for what it is: the mean burden of hosts alive at the time of sampling, which the ordinary fit estimates without bias, and which is not the population burden distribution.

A closing note on what did not break. The qualitative statement that burdens are aggregated survived everything done to it here: the Poisson was rejected in 100 per cent of truncated replicates, the top-fifth share fell by 5.25 per cent, and the maximum likelihood \(k\) rose by 11.42 per cent. A paper whose claim is “this parasite is strongly aggregated” is not in trouble. A paper whose claim involves the mean burden, the total parasite population, the variance, or a moment-based aggregation index is in trouble by amounts between 22 and 44 per cent, and nothing in its output says so.

Where to go next

The mechanism has a name outside parasitology. Conditioning on survival is conditioning on a collider, and collider bias and selection works through the same structure where the outcome is a correlation rather than a distribution parameter. The counterpart failure at the other end of the distribution, where the sample has too many zeros rather than too few large values, is in zero-inflated and hurdle models; reading the two together is the quickest way to see that a dispersion statistic on its own cannot say which end of the distribution has been tampered with.

If the survey has covariates, so that the question is how burden varies with host age, sex or site rather than what the marginal distribution looks like, the right-truncated likelihood above extends by replacing \(\mu\) with \(\exp(X\beta)\) and letting each host have its own truncation probability. The regression version of the untruncated fit is in Poisson and negative binomial GLMs in R. Everything in this post about the lethal level being unidentified carries over unchanged, and the extra parameters make it worse rather than better.

References

Crofton HD 1971 Parasitology 62(2):179-193 (10.1017/S0031182000071420)

Crofton HD 1971 Parasitology 63(3):343-364 (10.1017/S0031182000079890)

Bulmer MG 1974 Biometrics 30(1):101-110 (10.2307/2529621)

Anderson RM, Gordon DM 1982 Parasitology 85(2):373-398 (10.1017/S0031182000055347)

Lester RJG 1984 Helgolander Meeresuntersuchungen 37(1):53-64 (10.1007/BF01989295)

Shaw DJ, Dobson AP 1995 Parasitology 111(S1):S111-S133 (10.1017/S0031182000075855)

Wilson K, Grenfell BT, Shaw DJ 1996 Functional Ecology 10(5):592-601 (10.2307/2390169)

Woolhouse MEJ, Dye C, Etard JF, Smith T, Charlwood JD, Garnett GP, Hagan P, Hii JLK, Ndhlovu PD, Quinnell RJ, Watts CH, Chandiwana SK, Anderson RM 1997 Proceedings of the National Academy of Sciences 94(1):338-342 (10.1073/pnas.94.1.338)

Shaw DJ, Grenfell BT, Dobson AP 1998 Parasitology 117(6):597-610 (10.1017/S0031182098003448)

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.