Misread colour rings and apparent survival

R
capture-recapture
survival
colour ringing
simulation
ecology tutorial
Misread colour rings revive dead birds as resightings: in R, CJS survival bias grows with study length and a two-sighting filter fails when sightability varies.
Author

Tidy Ecology

Published

2026-09-23

A shorebird team has been colour-ringing godwits on the same meadows for ten years. Every bird carries a unique combination of coloured rings, and every spring volunteers with telescopes read those combinations off birds feeding on the mudflats, a few hundred metres away, often in wind and low light. Each reading is typed in and looked up in the ring register. If the combination is in the register, the sighting joins that bird’s capture history; if it is not, the record is thrown out as an obvious error. The survival analysis then runs a Cormack-Jolly-Seber (CJS) model on the joined histories.

The weak point is the join. A misread combination that happens to form another code the scheme has issued passes the register check, and it becomes a sighting of a bird that was not there. The register holds every bird ever ringed, including the ones that died years ago, so some of these sightings bring dead birds back to life for a season. This is known. Tucker and colleagues measured misread rates in a ten-year Red Knot flag dataset and showed by simulation that misreads produce spurious negative trends in survival over time, worst in long studies, and that dropping every flag reported only once in a sampling occasion reduced the bias and removed the spurious trend, at a cost in precision. Rakhimberdiev and colleagues report systematic positive biases in CJS survival from misread resightings and fix them with a model that splits each season into secondary resighting sessions and uses the repeated sightings within it. This post is a demonstration of their result in a constant-parameter CJS model, not a claim to it. What is measured here is narrower: how the bias grows with the number of seasons, what happens to the filter they evaluated when birds differ in how often they are seen, and whether a detection probability for the dead state, added to a two-class detection mixture in an ordinary CJS model, can do the filter’s job without secondary sessions.

Cormack-Jolly-Seber survival models in R builds the m-array likelihood reused below, and it says that apparent survival “is a lower bound on true survival”, because a dead bird and an emigrant look the same. That is right as long as every sighting is real. Once the resightings are joined to the ring register by a code that can be misread, some sightings belong to birds that are already dead, the bound fails, and the failure grows with every season the study runs. Joint live-dead models proves the downward version exactly: apparent survival is survival times fidelity. Misreads push the other way.

The same error appears on the site in a closed population. Identification errors in capture-recapture measures missed and false photo matches under model M0 and leaves out the case of a photograph filed under a different catalogued animal, because M0 uses only the number of entries and captures and that error barely moves it. In an open model it is the whole problem. The nearest method post is Transients and the single-capture rule in CJS, where a deletion rule biases survival and a model does better; the filter here looks like the opposite case, a rule that fixes survival, and the question is when it stops doing so. The repair used at the end is the event layer of Multi-event models for uncertain states applied to the dead state, combined with the two-class detection mixture that Capture heterogeneity: Mt, Mb and Mh in R fits in a closed population.

library(ggplot2)
library(patchwork)

te_paper  <- "#f5f4ee"
te_ink    <- "#16241d"
te_body   <- "#2c3a31"
te_forest <- "#275139"
te_rust   <- "#b5534e"
te_gold   <- "#c9b458"
te_line   <- "#dad9ca"

theme_datasheet <- function() {
  theme_minimal(base_size = 12) +
    theme(plot.background  = element_rect(fill = te_paper, colour = NA),
          panel.background = element_rect(fill = te_paper, colour = NA),
          panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
          panel.grid.minor = element_blank(),
          text             = element_text(colour = te_body),
          plot.title       = element_text(colour = te_ink, face = "bold"),
          plot.subtitle    = element_text(colour = te_body),
          axis.text        = element_text(colour = te_body))
}

A ring register that keeps the dead

Each simulated study rings 80 birds a season for the first K - 1 seasons and resights in seasons 2 to K. A bird survives each interval with probability 0.85, the same for every bird and every year. While it is alive it is read a Poisson number of times per season with mean 2. Each reading is misread with probability e, and half of all misreads (q = 0.5) form a combination that exists in the register; the other half are caught by the register check and discarded, so they do no harm. A misread that passes lands on a code drawn uniformly from every code issued so far, alive or dead. The season is scored as a detection when the joined readings for a code reach a threshold: one reading for the usual analysis, two for the filter. All of these constants are fixed before any run below.

phi_true <- 0.85
n_mark <- 80L
lam_mean <- 2
q_valid <- 0.5

sim_counts <- function(K, e, cv = 0, phi = phi_true, R = n_mark,
                       lam = lam_mean, q = q_valid) {
  N <- R * (K - 1)
  rel <- rep(seq_len(K - 1), each = R)
  last <- rel + rgeom(N, 1 - phi)
  lam_i <- if (cv > 0) rgamma(N, 1 / cv^2, scale = lam * cv^2) else rep(lam, N)
  cnt <- matrix(0L, N, K)
  cnt0 <- matrix(0L, N, K)
  fh <- data.frame(t = 2:K, n_false = 0, on_dead = 0, dead_codes = 0,
                   dead_seen = 0, n_alive = 0, issued = 0, live_seen1 = 0,
                   live_seen2 = 0, lam_alive = 0, n_reject = 0)
  for (tt in 2:K) {
    alive <- which(rel < tt & last >= tt)
    issued <- which(rel <= tt)
    n_read <- rpois(length(alive), lam_i[alive])
    n_mis <- rbinom(length(alive), n_read, e)
    cc <- integer(N)
    cc[alive] <- n_read - n_mis
    n_valid <- rbinom(1, sum(n_mis), q)
    hit <- integer(0)
    if (n_valid > 0) {
      hit <- issued[sample.int(length(issued), n_valid, replace = TRUE)]
      cc <- cc + tabulate(hit, nbins = N)
    }
    dead <- which(rel < tt & last < tt)
    fh[tt - 1, ] <- c(tt, n_valid, sum(last[hit] < tt), length(dead),
                      sum(cc[dead] >= 1), length(alive), length(issued),
                      sum(cc[alive] >= 1), sum(cc[alive] >= 2), sum(lam_i[alive]),
                      sum(n_mis) - n_valid)
    cnt[, tt] <- cc
    cnt0[alive, tt] <- n_read
  }
  cnt[cbind(seq_len(N), rel)] <- 99L
  cnt0[cbind(seq_len(N), rel)] <- 99L
  list(cnt = cnt, cnt0 = cnt0, fh = fh)
}

hist_of <- function(cnt, rule) (cnt >= rule) * 1L

The ringing occasion itself is coded as a certain detection, so a bird always enters its history at release. Every study is scored twice: cnt holds the readings as the register join delivers them, and cnt0 holds the same readings of the same birds as if every one had been read correctly. The no-misread controls below are therefore paired with the misread analyses bird for bird, and a difference between them is the effect of misreads alone. The fh table records, season by season, the truth that an analyst never sees: how many false hits there were, how many fell on dead codes, and how many dead codes were scored as seen.

The analysis side has two engines. The first is the constant survival, constant detection CJS model fitted by maximum likelihood to the m-array, with a Wald interval on the logit scale: the model of the CJS post, in the m-array form of Lebreton and colleagues. The second is a forward algorithm over individual histories, collapsed to unique histories with counts as weights, which allows two things the m-array cannot hold: a mixture of two detection classes, and a dead state that is itself “detected” with probability f.

marray_of <- function(H) {
  K <- ncol(H)
  pos <- which(H == 1L, arr.ind = TRUE)
  pos <- pos[order(pos[, 1], pos[, 2]), , drop = FALSE]
  same_bird <- c(pos[-1, 1] == pos[-nrow(pos), 1], FALSE)
  from <- pos[, 2]
  to <- c(pos[-1, 2], NA)
  to[!same_bird] <- K + 1L
  keep <- from < K
  m <- matrix(0, K - 1, K)
  m[] <- table(factor(from[keep], 1:(K - 1)), factor(to[keep] - 1L, 1:K))
  m
}

fit_marray <- function(m) {
  K1 <- nrow(m)
  ii <- row(matrix(0, K1, K1)); jj <- col(matrix(0, K1, K1))
  up <- jj >= ii
  gap <- (jj - ii + 1)[up]
  m_up <- m[, 1:K1][up]
  never <- m[, K1 + 1]
  nll <- function(th) {
    ph <- plogis(th[1]); pp <- plogis(th[2])
    pr <- matrix(0, K1, K1)
    pr[up] <- ph^gap * (1 - pp)^(gap - 1) * pp
    chi <- pmax(1 - rowSums(pr), 1e-300)
    -(sum(m_up * log(pr[up])) + sum(never * log(chi)))
  }
  o <- optim(c(1, 0), nll, method = "BFGS", hessian = TRUE)
  se <- sqrt(diag(solve(o$hessian)))
  c(phi = plogis(o$par[1]), p = plogis(o$par[2]),
    lo = plogis(o$par[1] - 1.96 * se[1]), hi = plogis(o$par[1] + 1.96 * se[1]))
}

collapse_hist <- function(H) {
  key <- apply(H, 1, paste, collapse = "")
  u <- !duplicated(key)
  list(H = H[u, , drop = FALSE], w = as.vector(table(key)[key[u]]))
}

lik_rows <- function(H, first, ph, pa, f) {
  a <- rep(1, nrow(H)); d <- rep(0, nrow(H))
  for (tt in 2:ncol(H)) {
    act <- first < tt
    y <- H[, tt]
    d <- ifelse(act, (d + a * (1 - ph)) * (y * f + (1 - y) * (1 - f)), d)
    a <- ifelse(act, a * ph * (y * pa + (1 - y) * (1 - pa)), a)
  }
  a + d
}

fit_hmm <- function(H, model, hess = FALSE) {
  cl <- collapse_hist(H)
  Hu <- cl$H; w <- cl$w
  first <- max.col(Hu, ties.method = "first")
  nll <- switch(model,
    one = function(th)
      -sum(w * log(lik_rows(Hu, first, plogis(th[1]), plogis(th[2]), 0) + 1e-300)),
    two = function(th) {
      ph <- plogis(th[1]); mw <- plogis(th[4])
      -sum(w * log(mw * lik_rows(Hu, first, ph, plogis(th[2]), 0) +
                   (1 - mw) * lik_rows(Hu, first, ph, plogis(th[3]), 0) + 1e-300))
    },
    twof = function(th) {
      ph <- plogis(th[1]); mw <- plogis(th[4]); f <- plogis(th[5])
      -sum(w * log(mw * lik_rows(Hu, first, ph, plogis(th[2]), f) +
                   (1 - mw) * lik_rows(Hu, first, ph, plogis(th[3]), f) + 1e-300))
    })
  init <- switch(model, one = c(1.5, 1.5), two = c(1.5, 1.5, -1, 0),
                  twof = c(1.5, 1.5, -1, 0, -4))
  o <- optim(init, nll, method = "BFGS", hessian = hess, control = list(maxit = 300))
  out <- c(phi = plogis(o$par[1]), conv = o$convergence)
  if (model == "twof") out <- c(out, f = plogis(o$par[5]))
  if (hess) {
    v <- tryCatch(diag(solve(o$hessian))[1], error = function(z) NA_real_)
    se <- if (is.finite(v) && v > 0) sqrt(v) else NA_real_
    out <- c(out, lo = plogis(o$par[1] - 1.96 * se), hi = plogis(o$par[1] + 1.96 * se))
  }
  out
}

set.seed(2909)
check_sim <- sim_counts(10, 0.03)
check_h <- hist_of(check_sim$cnt, 1)
check_marray <- fit_marray(marray_of(check_h))[["phi"]]
check_forward <- fit_hmm(check_h, "one")[["phi"]]

The two engines agree where they should. On one simulated ten-season study, the m-array fit gives an apparent survival of 0.85159 and the forward algorithm with one detection class and f fixed at zero gives 0.85158.

Apparent survival climbs with every season

The first block runs the constant CJS model on studies of 5, 10 and 15 seasons, with no misreads, with a misread rate of 3 per cent, and at 15 seasons also with 1 per cent. Every study is also scored under the two-sighting filter, so the next section compares the two analyses on the same simulated birds.

n_study_a <- 400L
cells_a <- data.frame(K = c(5, 10, 15, 15), e = c(0.03, 0.03, 0.03, 0.01))
set.seed(4417)
runs_a <- lapply(seq_len(nrow(cells_a)), function(g) {
  fits <- vector("list", n_study_a); truth <- vector("list", n_study_a)
  for (i in seq_len(n_study_a)) {
    s <- sim_counts(cells_a$K[g], cells_a$e[g])
    fits[[i]] <- c(all = fit_marray(marray_of(hist_of(s$cnt, 1))),
                   two = fit_marray(marray_of(hist_of(s$cnt, 2))),
                   clean = fit_marray(marray_of(hist_of(s$cnt0, 1))))
    truth[[i]] <- s$fh
  }
  list(fits = do.call(rbind, fits), truth = do.call(rbind, truth))
})
covers <- function(lo, hi) lo <= phi_true & hi >= phi_true
tab_a <- do.call(rbind, lapply(seq_along(runs_a), function(g) {
  r <- runs_a[[g]]$fits
  data.frame(K = cells_a$K[g], e = cells_a$e[g],
             phi = mean(r[, "all.phi"]), sd = sd(r[, "all.phi"]), p = mean(r[, "all.p"]),
             cover = mean(covers(r[, "all.lo"], r[, "all.hi"])),
             phi2 = mean(r[, "two.phi"]), sd2 = sd(r[, "two.phi"]), p2 = mean(r[, "two.p"]),
             cover2 = mean(covers(r[, "two.lo"], r[, "two.hi"])),
             phi0 = mean(r[, "clean.phi"]), sd0 = sd(r[, "clean.phi"]),
             cover0 = mean(covers(r[, "clean.lo"], r[, "clean.hi"])),
             shift = mean(r[, "all.phi"] - r[, "clean.phi"]),
             shift_se = sd(r[, "all.phi"] - r[, "clean.phi"]) / sqrt(n_study_a),
             shift2 = mean(r[, "two.phi"] - r[, "clean.phi"]),
             shift2_se = sd(r[, "two.phi"] - r[, "clean.phi"]) / sqrt(n_study_a),
             width = median(r[, "all.hi"] - r[, "all.lo"]))
}))
tab_a$bias <- tab_a$phi - phi_true
tab_a$bias2 <- tab_a$phi2 - phi_true
tab_a$mcse_phi <- tab_a$sd / sqrt(n_study_a)
tab_a$mcse_phi0 <- tab_a$sd0 / sqrt(n_study_a)
tab_a$mcse_cover <- sqrt(tab_a$cover * (1 - tab_a$cover) / n_study_a)
print(round(tab_a[, c("K", "e", "phi0", "cover0", "phi", "sd", "p", "cover",
                      "phi2", "sd2", "p2", "cover2")], 4))
   K    e   phi0 cover0    phi     sd      p  cover   phi2    sd2     p2 cover2
1  5 0.03 0.8518 0.9450 0.8599 0.0169 0.8497 0.9175 0.8531 0.0245 0.5820 0.9475
2 10 0.03 0.8500 0.9425 0.8610 0.0075 0.8322 0.6950 0.8502 0.0086 0.5808 0.9600
3 15 0.03 0.8498 0.9475 0.8637 0.0053 0.8118 0.2850 0.8498 0.0059 0.5815 0.9450
4 15 0.01 0.8503 0.9475 0.8552 0.0056 0.8455 0.8150 0.8503 0.0061 0.5899 0.9400
print(format(round(tab_a[, c("K", "e", "shift", "shift_se", "shift2", "shift2_se")], 5),
             scientific = FALSE), row.names = FALSE)
  K    e   shift shift_se   shift2 shift2_se
  5 0.03 0.00812  0.00027  0.00127   0.00082
 10 0.03 0.01101  0.00015  0.00017   0.00023
 15 0.03 0.01386  0.00011  0.00002   0.00011
 15 0.01 0.00488  0.00007 -0.00007   0.00011
row_a <- function(K, e) tab_a[tab_a$K == K & tab_a$e == e, ]
a5 <- row_a(5, 0.03); a10 <- row_a(10, 0.03); a15 <- row_a(15, 0.03)
a15low <- row_a(15, 0.01)
ctrl_a <- tab_a[tab_a$e == 0.03, ]

Start with the check on the simulator. Under a misread rate of 3 per cent a live bird’s joined readings are Poisson with mean lam * (1 - e), before the small number of false hits it receives, so the share of live birds seen at least once in a season should be 1 - exp(-lam * (1 - e)) and the share seen at least twice 1 - exp(-lam * (1 - e)) * (1 + lam * (1 - e)). Those formulas are arithmetic, not findings, and they give the numbers the simulator has to reproduce.

lam_eff <- lam_mean * (1 - 0.03)
p1_formula <- 1 - exp(-lam_eff)
p2_formula <- 1 - exp(-lam_eff) * (1 + lam_eff)
truth15 <- runs_a[[which(cells_a$K == 15 & cells_a$e == 0.03)]]$truth
by_season <- aggregate(cbind(n_false, on_dead, dead_codes, issued, live_seen1,
                             live_seen2, n_alive) ~ t, truth15, sum)
p1_sim <- sum(by_season$live_seen1) / sum(by_season$n_alive)
p2_sim <- sum(by_season$live_seen2) / sum(by_season$n_alive)
by_season$hit_dead <- by_season$on_dead / by_season$n_false
by_season$reg_dead <- by_season$dead_codes / by_season$issued
print(round(by_season[, c("t", "n_false", "hit_dead", "reg_dead")], 3))
    t n_false hit_dead reg_dead
1   2     855    0.081    0.075
2   3    1530    0.141    0.141
3   4    2023    0.201    0.204
4   5    2667    0.255    0.260
5   6    3081    0.318    0.310
6   7    3391    0.362    0.355
7   8    3761    0.384    0.394
8   9    3972    0.421    0.431
9  10    4232    0.457    0.465
10 11    4354    0.486    0.495
11 12    4534    0.523    0.523
12 13    4639    0.548    0.549
13 14    4878    0.569    0.573
14 15    5002    0.639    0.637
hit_dead_first <- by_season$hit_dead[1]
hit_dead_last <- by_season$hit_dead[nrow(by_season)]
false_per_season <- mean(truth15$n_false[truth15$t == 15])
reads_per_season <- lam_mean * mean(truth15$n_alive[truth15$t == 15])

The formulas give 0.856 and 0.578; over all live bird-seasons of the 15-season studies the simulation gives 0.858 and 0.582. The first sits a little above its formula because live birds also collect false hits.

The mechanism is in the truth table above. A passing misread picks its code uniformly from the register, so the share of false hits that land on a dead bird follows the share of the register that is dead, and that share rises every season. In season 2 of a 15-season study 8 per cent of false hits fall on dead codes; by season 15, when ringing has stopped and no new live codes join the register, it is 64 per cent, at a mean of 12.5 false hits per study in that season. The number of false hits is small against the expected 813 readings of live birds in the same season. What matters is where they go.

ggplot(by_season, aes(t)) +
  geom_line(aes(y = reg_dead), colour = te_forest, linewidth = 0.8) +
  geom_point(aes(y = hit_dead), colour = te_rust, size = 2.4) +
  annotate("text", x = 2, y = 0.62, label = "points: false hits on dead codes",
           colour = te_rust, hjust = 0, size = 3.8) +
  annotate("text", x = 2, y = 0.56, label = "line: dead share of the register",
           colour = te_forest, hjust = 0, size = 3.8) +
  scale_y_continuous(limits = c(0, 0.7), breaks = seq(0, 0.7, 0.1)) +
  scale_x_continuous(breaks = seq(2, 15, 1)) +
  labs(x = "Season", y = "Share",
       title = "The register fills with the dead") +
  theme_datasheet()
A line chart on warm off-white paper headed The register fills with the dead. The horizontal axis is the season from 2 to 15 and the vertical axis a share from 0 to 0.7. A dark green line for the dead share of the register rises from about 0.08 in season 2 through about 0.31 in season 6 and 0.47 in season 10 to about 0.57 in season 14, its slope easing, and then jumps to about 0.64 in season 15, when no new birds are ringed. Red points for the share of false hits that fall on dead codes sit on or just beside the line in every season. Text in the upper left labels the points and the line.
Figure 1: Share of false hits that fall on the code of a dead bird, by season, pooled over the 15-season studies at a misread rate of 3 per cent (points), against the share of the ring register that is dead in that season (line).

A false hit on a dead code is read by the CJS model as that bird still being alive. A single one extends the bird’s apparent life to the season of the hit, and the gap in between is absorbed as missed detections, so the model trades a little detection probability for survival. The measured result, with the resighting rate of 2 per live bird per season held fixed throughout:

Read correctly, the same birds give 0.8518, 0.8500 and 0.8498 at 5, 10 and 15 seasons, and the 95 per cent interval covers the true 0.85 in 94.5, 94.2 and 94.8 per cent of 400 studies. At a misread rate of 3 per cent, apparent survival reads 0.8599, 0.8610 and 0.8637. The part of that owed to misreads is the paired shift, the misread fit minus the correctly read fit of the same birds: +0.0081, +0.0110 and +0.0139, with standard errors of at most 0.0003, so it grows with every season. Against the true 0.85 the total bias is +0.0099, +0.0110 and +0.0137 (Monte Carlo standard errors of at most 0.0008); at 5 seasons that total includes an excess of +0.0018 (Monte Carlo standard error 0.0008) that the correctly read fits carry without any misreads. The detection estimate falls from 0.850 to 0.812, which is the other half of the trade. At a misread rate of 1 per cent and 15 seasons the paired shift is +0.0049 and coverage 81.5 per cent.

The biases are small, and that is exactly why they are dangerous. The sampling standard deviation of the estimate falls from 0.0169 at 5 seasons to 0.0075 at 10 and 0.0053 at 15, while the bias grows, so the interval covers the truth in 91.8, 69.5 and 28.5 per cent of studies (Monte Carlo standard errors up to 2.3 percentage points). The transients post already shows that an interval can be tight and wrong at one design; here the point is that the same study gets more wrong with every season of effort, because both the dead share of the register and the precision of the estimate increase with time. Coverage is driven jointly by the resighting rate, the misread rate and the study length (only the misread rate and the length were varied here), so these numbers belong to a resighting rate of 2; a better-watched scheme at the same misread rate is expected to lose coverage sooner.

lev_arm <- c("e = 0", "e = 0.01", "e = 0.03", "e = 0.03, two-sighting filter")
growth <- rbind(
  data.frame(K = ctrl_a$K, arm = lev_arm[1], phi = ctrl_a$phi0, cover = ctrl_a$cover0,
             g = which(tab_a$e == 0.03), filt = NA),
  data.frame(K = tab_a$K, arm = ifelse(tab_a$e == 0.01, lev_arm[2], lev_arm[3]),
             phi = tab_a$phi, cover = tab_a$cover, g = seq_len(nrow(tab_a)), filt = FALSE),
  data.frame(K = tab_a$K[tab_a$e == 0.03], arm = lev_arm[4],
             phi = tab_a$phi2[tab_a$e == 0.03], cover = tab_a$cover2[tab_a$e == 0.03],
             g = which(tab_a$e == 0.03), filt = TRUE))
growth$lo <- NA_real_; growth$hi <- NA_real_
for (i in seq_len(nrow(growth))) {
  col_phi <- if (is.na(growth$filt[i])) "clean.phi" else if (growth$filt[i]) "two.phi" else "all.phi"
  est <- runs_a[[growth$g[i]]]$fits[, col_phi]
  growth$lo[i] <- quantile(est, 0.025); growth$hi[i] <- quantile(est, 0.975)
}
growth$mcse <- sqrt(growth$cover * (1 - growth$cover) / n_study_a)
growth$arm <- factor(growth$arm, levels = lev_arm)
arm_cols <- setNames(c(te_ink, te_gold, te_rust, te_forest), lev_arm)
arm_shapes <- setNames(c(1, 16, 16, 17), lev_arm)
dodge <- position_dodge(width = 1.6)
p_phi <- ggplot(growth, aes(K, phi, colour = arm)) +
  geom_hline(yintercept = phi_true, linetype = 2, colour = te_body, linewidth = 0.3) +
  geom_errorbar(aes(ymin = lo, ymax = hi), width = 0, position = dodge, linewidth = 0.5) +
  geom_line(position = dodge, linewidth = 0.6) +
  geom_point(aes(shape = arm), position = dodge, size = 2.4, stroke = 1) +
  scale_colour_manual(values = arm_cols, name = NULL) +
  scale_shape_manual(values = arm_shapes, name = NULL) +
  scale_x_continuous(breaks = c(5, 10, 15)) +
  labs(x = "Seasons in the study", y = "Apparent survival",
       title = "Estimate") +
  theme_datasheet() + theme(legend.position = "bottom")
p_cov <- ggplot(growth, aes(K, cover, colour = arm)) +
  geom_hline(yintercept = 0.95, linetype = 2, colour = te_body, linewidth = 0.3) +
  geom_errorbar(aes(ymin = cover - 2 * mcse, ymax = pmin(1, cover + 2 * mcse)),
                width = 0, position = dodge, linewidth = 0.5) +
  geom_line(position = dodge, linewidth = 0.6) +
  geom_point(aes(shape = arm), position = dodge, size = 2.4, stroke = 1) +
  scale_colour_manual(values = arm_cols, name = NULL) +
  scale_shape_manual(values = arm_shapes, name = NULL) +
  scale_x_continuous(breaks = c(5, 10, 15)) +
  scale_y_continuous(limits = c(0, 1)) +
  labs(x = "Seasons in the study", y = "Coverage of the 95% interval",
       title = "Coverage") +
  theme_datasheet() + theme(legend.position = "bottom")
(p_phi | p_cov) +
  plot_layout(guides = "collect") +
  plot_annotation(theme = theme_datasheet()) &
  theme(legend.position = "bottom")
Two panels on warm off-white paper with a shared legend below; both have seasons in the study on the horizontal axis at 5, 10 and 15. In the left panel, headed Estimate, open black circles for no misreads and green triangles for three per cent misreads under the two-sighting filter sit on a dashed line at 0.85 at every length, with vertical bars for the central 95 per cent of estimates that shrink from about 0.82 to 0.88 at five seasons to about 0.84 to 0.86 at fifteen. Red points for three per cent misreads sit above the line at about 0.860, 0.861 and 0.864, and a single gold point for one per cent misreads at fifteen seasons sits at about 0.855. In the right panel, headed Coverage, the black circles and green triangles stay near a dashed line at 0.95, while the red line falls from about 0.92 at five seasons to about 0.70 at ten and 0.29 at fifteen; the gold point at fifteen seasons is at about 0.82.
Figure 2: Mean apparent survival (left, with the central 95 per cent of study estimates) and interval coverage (right, with plus or minus two Monte Carlo standard errors) against study length, for no misreads, 1 and 3 per cent misreads, and 3 per cent misreads under the two-sighting filter. Resighting rate 2 per live bird per season; 400 studies per point.

The two-sighting filter, when every bird is equally visible

The filter Tucker and colleagues evaluated counts a season for a bird only when its code was read at least twice. The logic is that a genuine bird is read repeatedly and a false hit is a one-off: two false hits rarely land on the same code in the same season, since there are only a handful of false hits spread over hundreds of codes. In the simulation where every bird has the same resighting rate, that logic holds.

b_sd_ratio_5 <- a5$sd2 / a5$sd
b_sd_ratio_15 <- a15$sd2 / a15$sd
print(round(tab_a[tab_a$e == 0.03, c("K", "phi2", "bias2", "sd2", "p2", "cover2")], 4))
   K   phi2   bias2    sd2     p2 cover2
1  5 0.8531  0.0031 0.0245 0.5820 0.9475
2 10 0.8502  0.0002 0.0086 0.5808 0.9600
3 15 0.8498 -0.0002 0.0059 0.5815 0.9450

On the same simulated studies, the filter returns 0.8531, 0.8502 and 0.8498 at 5, 10 and 15 seasons, and the interval covers the truth in 94.8, 96.0 and 94.5 per cent of studies. The detection probability drops to 0.581, near the at-least-twice share computed above, and the price is precision: the standard deviation of the estimate is 1.45 times that of the unfiltered analysis at 5 seasons and 1.12 times at 15. The cost falls on the short studies, where the bias was smallest anyway. At 5 seasons the filtered estimate and the correctly read one both sit a little above 0.85, by 0.0031 and 0.0018, and the paired difference between them, filtered minus correctly read, is +0.0013 (standard error 0.0008). Most of the excess is carried by the correctly read fits, so it is there without misreads, which points to the small-sample behaviour of the five-season estimator rather than to the filter. Under homogeneity, then, the filter is a good trade, and this is the regime in which it looks like a general fix.

Unequal sightability turns the filter against you

Real birds are not equally visible. Some feed close to the sea wall where every observer walks, some use a distant creek, some carry a combination with a colour that fades. The third block gives each bird its own resighting rate from a gamma distribution with the same mean of 2 and a coefficient of variation of 0.5 or 1.0, and fits five models to every study: the constant CJS on all readings, the same with the filter, a two-class detection mixture of the kind Pledger, Pollock and Norris set out for the CJS model, the mixture with the filter, and the mixture plus a detection probability f for the dead state. Each study is fitted twice, once with its readings as the register join delivers them at a misread rate of 3 per cent and once as if every reading had been correct, and those paired control fits carry half of the argument. The five-model comparison runs on 50 studies per level because the mixture fits are slow; the two constant-model fits at a coefficient of variation of 0.5 are cheap, and are repeated on 1000 fresh studies.

n_study_c <- 50L
cv_levels <- c(0.5, 1)
model_lab <- c(const_all = "constant p, all readings",
               const_two = "constant p, two-sighting filter",
               mix_all = "two-class p, all readings",
               mix_two = "two-class p, two-sighting filter",
               mix_dead = "two-class p + dead-state f")
five_fits <- function(cnt) {
  h1 <- hist_of(cnt, 1); h2 <- hist_of(cnt, 2)
  md <- fit_hmm(h1, "twof")
  c(const_all = fit_marray(marray_of(h1))[["phi"]],
    const_two = fit_marray(marray_of(h2))[["phi"]],
    mix_all = fit_hmm(h1, "two")[["phi"]],
    mix_two = fit_hmm(h2, "two")[["phi"]],
    mix_dead = md[["phi"]], f_dead = md[["f"]])
}
set.seed(6203)
runs_c <- lapply(cv_levels, function(cv) {
  pairs <- replicate(n_study_c, {
    s <- sim_counts(10, 0.03, cv = cv)
    list(clean = five_fits(s$cnt0), misread = five_fits(s$cnt),
         dead = c(sum(s$fh$dead_seen), sum(s$fh$dead_codes)))
  }, simplify = FALSE)
  dead <- rowSums(sapply(pairs, function(z) z$dead))
  list(clean = t(sapply(pairs, function(z) z$clean[names(model_lab)])),
       misread = t(sapply(pairs, function(z) z$misread[names(model_lab)])),
       f_clean = mean(sapply(pairs, function(z) z$clean[["f_dead"]])),
       f_misread = mean(sapply(pairs, function(z) z$misread[["f_dead"]])),
       f_real = dead[1] / dead[2])
})
tab_c <- do.call(rbind, lapply(seq_along(cv_levels), function(g) {
  rc <- runs_c[[g]]
  rbind(data.frame(cv = cv_levels[g], e = 0, model = names(model_lab),
                   phi = colMeans(rc$clean), mcse = apply(rc$clean, 2, sd) / sqrt(n_study_c)),
        data.frame(cv = cv_levels[g], e = 0.03, model = names(model_lab),
                   phi = colMeans(rc$misread), mcse = apply(rc$misread, 2, sd) / sqrt(n_study_c)))
}))
shift_c <- do.call(rbind, lapply(seq_along(cv_levels), function(g) {
  dd <- runs_c[[g]]$misread - runs_c[[g]]$clean
  data.frame(cv = cv_levels[g], model = names(model_lab), shift = colMeans(dd),
             shift_se = apply(dd, 2, sd) / sqrt(n_study_c))
}))
print(reshape(tab_c[, c("cv", "e", "model", "phi")], idvar = c("cv", "e"),
              timevar = "model", direction = "wide"), digits = 4, row.names = FALSE)
  cv    e phi.const_all phi.const_two phi.mix_all phi.mix_two phi.mix_dead
 0.5 0.00        0.8438        0.8224      0.8480      0.8424       0.8458
 0.5 0.03        0.8551        0.8223      0.8623      0.8425       0.8470
 1.0 0.00        0.8185        0.7632      0.8389      0.8355       0.8365
 1.0 0.03        0.8336        0.7637      0.8569      0.8367       0.8426
print(shift_c, digits = 3, row.names = FALSE)
  cv     model     shift shift_se
 0.5 const_all  0.011291 0.000457
 0.5 const_two -0.000125 0.000220
 0.5   mix_all  0.014251 0.000592
 0.5   mix_two  0.000186 0.000333
 0.5  mix_dead  0.001189 0.000533
 1.0 const_all  0.015073 0.000602
 1.0 const_two  0.000404 0.000367
 1.0   mix_all  0.017969 0.000874
 1.0   mix_two  0.001183 0.000554
 1.0  mix_dead  0.006106 0.000938
get_c <- function(cv, e, model) tab_c$phi[tab_c$cv == cv & tab_c$e == e & tab_c$model == model]
get_shift <- function(cv, model) shift_c$shift[shift_c$cv == cv & shift_c$model == model]
get_shift_se <- function(cv, model) shift_c$shift_se[shift_c$cv == cv & shift_c$model == model]
mcse_c_max <- max(tab_c$mcse)
f_cost <- sapply(runs_c, function(rc) mean(rc$clean[, "mix_dead"] - rc$clean[, "mix_all"]))
f_cost_se <- sapply(runs_c, function(rc) sd(rc$clean[, "mix_dead"] - rc$clean[, "mix_all"]) /
                      sqrt(n_study_c))
f_c <- data.frame(cv = cv_levels, f_cost = f_cost, f_cost_se = f_cost_se,
                  f_hat_clean = sapply(runs_c, `[[`, "f_clean"),
                  f_hat_misread = sapply(runs_c, `[[`, "f_misread"),
                  f_real = sapply(runs_c, `[[`, "f_real"))
print(f_c, digits = 3, row.names = FALSE)
  cv   f_cost f_cost_se f_hat_clean f_hat_misread f_real
 0.5 -0.00216  0.000407     0.00194        0.0150 0.0149
 1.0 -0.00243  0.000512     0.00161        0.0115 0.0145
n_study_big <- 1000L
set.seed(8156)
big_c <- t(replicate(n_study_big, {
  s <- sim_counts(10, 0.03, cv = 0.5)
  c(all0 = fit_marray(marray_of(hist_of(s$cnt0, 1)))[["phi"]],
    two0 = fit_marray(marray_of(hist_of(s$cnt0, 2)))[["phi"]],
    all3 = fit_marray(marray_of(hist_of(s$cnt, 1)))[["phi"]],
    two3 = fit_marray(marray_of(hist_of(s$cnt, 2)))[["phi"]])
}))
big_mean <- colMeans(big_c)
big_mcse <- apply(big_c, 2, sd) / sqrt(n_study_big)
big_rule_shift <- mean(big_c[, "two3"] - big_c[, "two0"])
big_rule_shift_se <- sd(big_c[, "two3"] - big_c[, "two0"]) / sqrt(n_study_big)
print(round(rbind(mean = big_mean, mcse = big_mcse), 4))
       all0   two0   all3   two3
mean 0.8453 0.8246 0.8567 0.8245
mcse 0.0003 0.0003 0.0003 0.0003
cv_p <- function(cv, rule, n_draw = 2e5) {
  lam_i <- rgamma(n_draw, 1 / cv^2, scale = lam_mean * cv^2)
  p_i <- if (rule == 1) 1 - exp(-lam_i) else 1 - exp(-lam_i) * (1 + lam_i)
  sd(p_i) / mean(p_i)
}
set.seed(118)
cvp_one <- cv_p(0.5, 1); cvp_two <- cv_p(0.5, 2)

In the five-model table the Monte Carlo standard errors of the means are at most 0.0023, and the paired shifts, misread fit minus correctly read fit on the same study, are known far more precisely than that.

Start with the constant model on 1000 studies at a coefficient of variation of 0.5. Read correctly, the birds give 0.8453: unequal sightability alone already pulls apparent survival down by 0.0047. With misreads the estimate is 0.8567, so the upward push of the false hits more than cancels that. The filter gives 0.8245, a bias of -0.0255 against +0.0067 without it: nearly four times larger and in the other direction (Monte Carlo standard errors 0.0003). The paired control says where it comes from. On the correctly read histories the filter gives 0.8246, and the paired shift from misreads under the filter is -0.0002 (standard error 0.0001). The filter does remove the misreads, to within 0.0002, even here. What it adds is a bias of its own, and that bias is there with no misreads at all.

The reason is in what the filter does to detection. A bird with resighting rate lam_i is detected with probability 1 - exp(-lam_i) under the usual scoring and 1 - exp(-lam_i) * (1 + lam_i) under the filter. For resighting rates with a coefficient of variation of 0.5, the per-bird detection probability has a coefficient of variation of 0.19 under the usual scoring and 0.43 under the filter. The filter spreads detection across birds, and a CJS model with one detection probability reads a bird that is persistently hard to see as a bird that died. That is the familiar downward bias of unmodelled heterogeneity in detection, and the filter more than doubles the heterogeneity it acts on.

A two-class detection mixture looks like the obvious answer to that, and on misread data it goes wrong in a new way. On the correctly read histories at a coefficient of variation of 0.5 it gives 0.8480 against the constant model’s 0.8438, so two classes take out part of the heterogeneity bias but not all of it. With misreads the mixture moves up by 0.0143, more than the constant model’s 0.0113: the low-detection class offers a home for dead birds that are “seen” now and then, and the model files them there as survivors that are hard to see. Adding the dead-state detection f to the mixture cuts the misread shift to 0.0012 (standard error 0.0005), and the fit reads 0.8470. Part of what remains against the truth is the price of f itself: on the correctly read histories, adding f moves the mixture from 0.8480 to 0.8458 (paired difference -0.0022, standard error 0.0004), because with no misreads at all f is still fitted at 0.0019 on average and absorbs some real late sightings of hard-to-see birds. The rest is heterogeneity that two classes miss.

At a coefficient of variation of 1.0 nothing here is clean, and the post does not call it a success. The mixture with f still moves up by 0.0061 with misreads, about a third of the plain mixture’s 0.0180, and reads 0.8426. On correctly read histories the plain mixture gives 0.8389 and adding f takes it to 0.8365 (paired difference -0.0024, standard error 0.0005); the plain mixture’s own gap from 0.85 shows how much heterogeneity two classes leave behind. The filtered constant model falls to 0.7637.

One row deserves a warning of its own. At a coefficient of variation of 1.0, the naive constant model is closer to the truth with misreads (0.8336) than without them (0.8185), because heterogeneity pulls the estimate down and misreads push it up. It is the open-population version of the cancelling pair in the identification errors post. A survival estimate that looks plausible can be two errors cancelling, and no amount of staring at the estimate will tell you which.

tab_c$model_f <- factor(model_lab[tab_c$model], levels = rev(model_lab))
tab_c$cv_f <- factor(sprintf("CV of resighting rate %.1f", tab_c$cv))
tab_c$e_f <- factor(ifelse(tab_c$e == 0, "no misreads", "3% misreads"),
                    levels = c("no misreads", "3% misreads"))
p_het <- ggplot(tab_c, aes(phi, model_f, colour = e_f)) +
  geom_vline(xintercept = phi_true, linetype = 2, colour = te_body, linewidth = 0.3) +
  geom_errorbar(aes(xmin = phi - 2 * mcse, xmax = phi + 2 * mcse), orientation = "y",
                width = 0, linewidth = 0.6,
                position = position_dodge(width = 0.5)) +
  geom_point(size = 2.6, position = position_dodge(width = 0.5)) +
  facet_wrap(~ cv_f, nrow = 1) +
  scale_x_continuous(breaks = seq(0.76, 0.86, 0.02)) +
  scale_colour_manual(values = c("no misreads" = te_body, "3% misreads" = te_rust),
                      name = NULL) +
  labs(x = "Mean apparent survival", y = NULL,
       title = "The filter's damage is there without misreads") +
  theme_datasheet() +
  theme(legend.position = "bottom",
        strip.text = element_text(colour = te_ink, face = "bold"))
p_het
Two panels on warm off-white paper headed The filter's damage is there without misreads, for a coefficient of variation in resighting rate of 0.5 on the left and 1.0 on the right. Each lists five models down the vertical axis, with mean apparent survival on the horizontal axis from 0.76 to 0.86 and a dashed line at 0.85, and each model has a dark point for correctly read histories and a red point for three per cent misreads, with short error bars. In the left panel the constant model on all readings moves from about 0.844 to 0.856; with the two-sighting filter both points sit together at about 0.822; the two-class mixture on all readings moves from about 0.848 to 0.862; the mixture with the filter has both points at about 0.842; the mixture with dead-state detection has both points close together near 0.846. In the right panel the constant model moves from about 0.818 to 0.834, the filtered constant model has both points at about 0.763, the mixture moves from about 0.839 to 0.857, the filtered mixture has both near 0.836, and the mixture with dead-state detection moves from about 0.837 to 0.843.
Figure 3: Mean apparent survival from five models fitted to the same ten-season studies, with resighting rates varying between birds (coefficient of variation 0.5 left, 1.0 right), each study scored as read correctly and with 3 per cent misreads. Bars are plus or minus two Monte Carlo standard errors over 50 studies; the dashed line is the true 0.85.

Modelling the false hit instead

The mixture with a dead-state detection probability is a small hidden Markov model. A bird is alive in one of two detection classes or dead; a live bird is detected with its class probability, and a dead bird whose code is still in the register is “detected” with probability f, the chance that at least one passing misread lands on it in a season. It needs no secondary sessions and no count of readings, only the binary season histories. The information that identifies f is the shape of the false record: a dead code’s false hits keep arriving at the same low rate for as long as the study runs, while a live bird’s detections, however sparse, stop when it dies, and while it lives they arrive at a class rate far above f. The fourth block asks whether the estimate from this model comes with an interval that covers, at a coefficient of variation of 0.5, 3 per cent misreads and ten seasons, and compares the fitted f with the realised rate at which dead codes were scored as seen.

n_study_d <- 100L
set.seed(7730)
fits_d <- vector("list", n_study_d); truth_d <- vector("list", n_study_d)
const_d <- vector("list", n_study_d)
for (i in seq_len(n_study_d)) {
  s <- sim_counts(10, 0.03, cv = 0.5)
  fits_d[[i]] <- fit_hmm(hist_of(s$cnt, 1), "twof", hess = TRUE)
  const_d[[i]] <- fit_marray(marray_of(hist_of(s$cnt, 1)))
  truth_d[[i]] <- s$fh
}
fits_d <- as.data.frame(do.call(rbind, fits_d))
const_d <- as.data.frame(do.call(rbind, const_d))
d_const_width <- median(const_d$hi - const_d$lo)
d_const_cover <- mean(covers(const_d$lo, const_d$hi))
ok_d <- is.finite(fits_d$lo) & is.finite(fits_d$hi)
n_fail_d <- sum(!ok_d)
d_phi <- mean(fits_d$phi)
d_mcse <- sd(fits_d$phi) / sqrt(n_study_d)
d_cover <- mean(covers(fits_d$lo[ok_d], fits_d$hi[ok_d]))
d_cover_mcse <- sqrt(d_cover * (1 - d_cover) / sum(ok_d))
d_width <- median(fits_d$hi[ok_d] - fits_d$lo[ok_d])
d_f <- mean(fits_d$f)
d_f_q <- quantile(fits_d$f, c(0.1, 0.9))
truth_d <- do.call(rbind, truth_d)
f_season <- aggregate(cbind(dead_seen, dead_codes, lam_alive, issued, n_reject) ~ t, truth_d, sum)
f_season$f_real <- f_season$dead_seen / f_season$dead_codes
f_season$f_expect <- 1 - exp(-0.03 * q_valid * f_season$lam_alive / f_season$issued)
f_season$f_reject <- 1 - exp(-f_season$n_reject * q_valid / (1 - q_valid) / f_season$issued)
f_pooled <- sum(f_season$dead_seen) / sum(f_season$dead_codes)
f_reject_pooled <- sum(f_season$f_reject * f_season$dead_codes) / sum(f_season$dead_codes)
print(round(f_season[, c("t", "dead_codes", "f_real", "f_expect", "f_reject")], 4))
   t dead_codes f_real f_expect f_reject
1  2       1199 0.0092   0.0127   0.0156
2  3       3448 0.0148   0.0156   0.0149
3  4       6614 0.0157   0.0162   0.0155
4  5      10383 0.0153   0.0161   0.0163
5  6      14850 0.0147   0.0156   0.0163
6  7      19818 0.0154   0.0150   0.0146
7  8      25245 0.0138   0.0144   0.0139
8  9      31058 0.0137   0.0137   0.0135
9 10      37309 0.0142   0.0144   0.0141
print(round(c(phi = d_phi, mcse = d_mcse, cover = d_cover, failed = n_fail_d,
              width = d_width, const_width = d_const_width, const_cover = d_const_cover,
              f_hat = d_f, f_pooled = f_pooled, f_reject = f_reject_pooled), 4))
        phi        mcse       cover      failed       width const_width 
     0.8483      0.0009      0.9300      0.0000      0.0357      0.0302 
const_cover       f_hat    f_pooled    f_reject 
     0.8900      0.0141      0.0143      0.0145 

Over 100 studies the model gives a mean apparent survival of 0.8483 (Monte Carlo standard error 0.0009), and its 95 per cent logit-Wald interval, from the numerical Hessian, covers 0.85 in 93 per cent of the 100 studies where the Hessian could be inverted (Monte Carlo standard error 2.6 percentage points; 0 failed). The median interval width is 0.036. The constant model fitted to the same studies gives a median width of 0.030 and covers 0.85 in 89 per cent of them, so the wider interval is the price of estimating a mixture and a false-detection rate from the same histories, and its coverage sits nearer to 95 per cent.

The fitted f averages 0.0141, with the central 80 per cent of studies between 0.0063 and 0.0233. The realised share of dead codes scored as seen, pooled over seasons and studies, is 0.0143. The model holds f constant, and the truth is not: the expected rate per dead code is e q lam N_alive / C, the misread flux of the live birds spread over every issued code, and over the nine resighting seasons the realised values run from 0.0092 to 0.0157, the expected ones from 0.0127 to 0.0162. Ringing continues in every season but the last here, so the live population and the register grow together and the ratio stays within that range, which is why a constant f is enough in this design; a scheme that stops ringing while it keeps resighting would see the ratio fall, and would need f to vary with time.

fits_d$rank <- rank(fits_d$phi, ties.method = "first")
fits_d$status <- ifelse(!ok_d, "no interval",
                        ifelse(covers(fits_d$lo, fits_d$hi), "covers 0.85", "misses 0.85"))
p_int <- ggplot(fits_d[ok_d, ], aes(y = rank)) +
  geom_vline(xintercept = phi_true, linetype = 2, colour = te_body, linewidth = 0.3) +
  geom_errorbar(aes(xmin = lo, xmax = hi, colour = status), orientation = "y",
                width = 0, linewidth = 0.45) +
  geom_point(aes(x = phi, colour = status), size = 0.9) +
  scale_colour_manual(values = c("covers 0.85" = te_forest, "misses 0.85" = te_rust),
                      name = NULL) +
  labs(x = "Apparent survival", y = "Study, sorted by estimate",
       title = "One interval per study") +
  theme_datasheet() + theme(legend.position = "bottom")
p_f <- ggplot(f_season, aes(t)) +
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = d_f_q[1], ymax = d_f_q[2],
           fill = te_gold, alpha = 0.3) +
  geom_hline(yintercept = d_f, linetype = 2, colour = te_ink, linewidth = 0.4) +
  geom_line(aes(y = f_expect), colour = te_forest, linewidth = 0.7) +
  geom_point(aes(y = f_real), colour = te_rust, size = 2.4) +
  scale_x_continuous(breaks = 2:10) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Season", y = "Dead codes scored as seen",
       title = "Fitted f against the truth") +
  theme_datasheet()
(p_int | p_f) + plot_annotation(theme = theme_datasheet())
Two panels on warm off-white paper. The left panel, headed One interval per study, stacks 100 horizontal intervals sorted by estimate, with apparent survival on the horizontal axis from about 0.81 to 0.88 and a dashed vertical line at 0.85; the estimates climb from about 0.83 at the bottom to about 0.867 at the top. Nearly all intervals are dark green and cross the dashed line; six red intervals at the bottom end just short of it and one red interval at the top starts just to its right. The right panel, headed Fitted f against the truth, has season from 2 to 10 on the horizontal axis and the share of dead codes scored as seen on the vertical axis from 0 to about 0.023. A pale gold band spans about 0.006 to 0.023, a dashed line marks the mean fitted f at about 0.014, red points for the realised share sit at about 0.009 in season 2 and between about 0.014 and 0.016 afterwards, and a green line for the expected share rises from about 0.013 to 0.016 and eases back to about 0.014.
Figure 4: Left: the 95 per cent intervals from the two-class model with dead-state detection for each of 100 studies (coefficient of variation 0.5, 3 per cent misreads, ten seasons), sorted by estimate, with the true 0.85 dashed. Right: the realised share of dead codes scored as seen by season (points) and its expectation from the misread flux (line), against the mean fitted f (dashed) with the central 80 per cent of fitted values shaded.

What to report

A CJS analysis built on resightings joined to a ring register should state how codes were checked against it and how many readings were rejected because they formed no issued code. That rejection count is the one piece of direct evidence about misreads that every scheme already has, and the harmful rate is the misread rate times the share of misreads that form a valid code, a share set by how densely the issued codes fill the space of possible combinations.

Report the study length beside any survival estimate from resightings. The bias measured here grows with the number of seasons, and a long-term series is where it is largest and where the interval is narrowest.

If a sighting filter is used, report the resighting rate distribution it was applied to, or at least the share of bird-seasons with exactly one reading, and fit the model with and without the filter. If the filtered estimate sits below the unfiltered one by more than misreads could explain, as it does here at a coefficient of variation of 0.5, the filter is acting on heterogeneity rather than on misreads.

If the data allow it, fit a model with a detection probability for the dead state and report the fitted f against the register rejection rate. The rejected readings estimate e (1 - q) times all readings, so the expected f in a season is about that season’s rejections times q / (1 - q), divided by the number of codes issued, with q roughly the share of possible combinations that has been issued. In the fourth block, where q is known to be 0.5, that rejection-based figure, pooled over dead codes as the realised rate is, comes to 0.0145 against a realised 0.0143. A scheme that double-reads a sample of birds estimates e directly. A near-zero f is reassuring; an f well above what the rejection count implies means something other than misreads is feeding the dead state, and on correctly read data f is not exactly zero either (the heterogeneity section above).

Honest limits

Every constant is fixed: 80 birds ringed a season, survival 0.85, a mean resighting rate of 2, and half of all misreads forming a valid code. The bias grows with the misread rate, the valid share and the resighting rate; only the first was varied here, giving a paired shift of +0.0049 at 1 per cent against +0.0139 at 3 per cent over 15 seasons, and none of the percentages above should be carried to another scheme without rerunning the code. In particular a share of valid codes other than 0.5 was not run, and alphanumeric flags, where most misreads form strings that were never issued, are likely to have a much smaller one.

Misreads here land uniformly on the register. Real misreads cluster: a combination is confused with its mirror image or with the one that differs by a faded colour, and some codes attract far more false hits than others. That would make f vary between dead birds, which the model here does not allow.

Survival and detection are constant in time. Tucker and colleagues and Rakhimberdiev and colleagues both report spurious declines in time-varying survival, a plausible consequence of the growing dead share of the register in a model that gives each year its own survival; this post fits only the constant model and does not measure the trend.

The coverage of the dead-state model was measured in one cell: a coefficient of variation of 0.5, ten seasons, 3 per cent misreads. It was not measured at 1.0, where the mean is already off and the fitted f falls to 0.0115 on misread data against a realised 0.0145 (at 0.5, in the same third-block studies, it is 0.0150 against 0.0149): the low-detection class and the dead state start to trade sightings. Nor was it measured at five seasons, where a dead code has few seasons in which to show its steady false-hit rate and f may be weakly identified. A mixture with more than two classes was not tried, and at a coefficient of variation of 1.0 it is the obvious next step.

The resighting counts are independent Poisson draws. Real readings of one bird on one day are correlated, because the same flock is scanned by several observers, and that makes a genuine bird read twice more likely than the Poisson says and a false hit read twice more likely too. How that changes the filter was not measured.

Finally, the model with f uses binary season histories only. The repair of Rakhimberdiev and colleagues uses the repeated readings within a season, which carry more information about which records are false, and when those data exist their model with secondary sessions is the stronger choice; the point here is that the ordinary season-level histories already contain enough to remove most of the misread shift at moderate heterogeneity, and about two thirds of it at a coefficient of variation of 1.0.

References

Tucker AM, McGowan CP, Robinson RA, Clark JA, Lyons JE, DeRose-Wilson A, du Feu R, Austin GE, Atkinson PW, Clark NA 2019 The Condor 121(1):duy017 (10.1093/condor/duy017)

Rakhimberdiev E, Karagicheva J, Saveliev A, Loonstra AHJ, Verhoeven MA, Hooijmeijer JCEW, Schaub M, Piersma T 2022 Methods in Ecology and Evolution 13(5):1106-1118 (10.1111/2041-210x.13825)

Pledger S, Pollock KH, Norris JL 2003 Biometrics 59(4):786-794 (10.1111/j.0006-341X.2003.00092.x)

Lebreton J-D, Burnham KP, Clobert J, Anderson DR 1992 Ecological Monographs 62(1):67-118 (10.2307/2937171)

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.