library(ggplot2)
theme_te <- function(bs = 12) theme_minimal(base_size = bs) + theme(
text = element_text(colour = "#2c3a31"),
plot.title = element_text(colour = "#16241d", face = "bold", size = rel(1.05)),
plot.subtitle = element_text(colour = "#5d6b61", size = rel(0.9)),
axis.title = element_text(colour = "#46604a"), axis.text = element_text(colour = "#46604a"),
panel.grid.major = element_line(colour = "#dad9ca", linewidth = 0.3),
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = "#f5f4ee", colour = NA),
panel.background = element_rect(fill = "#f5f4ee", colour = NA),
strip.text = element_text(colour = "#16241d", face = "bold"),
legend.position = "bottom", legend.title = element_blank())
forest <- "#275139"; brick <- "#b5534e"; ink <- "#16241d"; gold <- "#c9b458"
phi_t <- 0.78; psiAB_t <- 0.30; psiBA_t <- 0.30; p_t <- 0.65
k <- 9; nA0 <- 360; nB0 <- 340; N <- nA0 + nB0
init_state <- c(rep(1L, nA0), rep(2L, nB0))Checking multi-state models
The last three posts fitted multi-state and multi-event models and read parameters off them. None asked the prior question: does the model fit at all? This matters because a misspecified multi-state model rarely announces itself. The estimates come back at plausible values, the optimiser converges, and nothing looks wrong, even when a core assumption is violated. The most common violation is the Markov assumption itself. The standard Arnason-Schwarz model assumes the next state depends only on the current one, but animals often carry memory: an individual that just arrived somewhere may be more likely to return where it came from. Pradel, Wintrebert and Gimenez (2003) built the reference goodness-of-fit test for exactly this model, and pointed to the memory model of Brownie and others (1993) as the biologically plausible alternative when the test fails.
This post builds a parametric bootstrap goodness-of-fit test in base R, then shows it doing its job: passing data that really are Markov and flagging data that carry memory the fitted model ignores.
Two data-generating processes
The first process is a clean two-state Markov chain. The second adds memory: when an animal has just switched sites, it is more likely to switch back, a return tendency the Markov model has no way to represent. A single parameter mem controls the effect, with mem of zero recovering the Markov case.
sim_ms <- function(mem, p, seed) {
set.seed(seed)
y <- matrix(1L, N, k)
for (i in 1:N) {
s <- init_state[i]; sprev <- s; y[i, 1] <- if (s == 1L) 2L else 3L
for (t in 2:k) {
if (s == 3L) { y[i, t] <- 1L; next }
pAB <- psiAB_t; pBA <- psiBA_t
if (sprev != s && sprev != 3L) { # just switched: raise return
if (s == 2L && sprev == 1L) pBA <- min(1, psiBA_t + mem)
if (s == 1L && sprev == 2L) pAB <- min(1, psiAB_t + mem)
}
if (runif(1) >= phi_t) { sprev <- s; s <- 3L; y[i, t] <- 1L; next }
ns <- if (s == 1L) sample.int(2, 1, prob = c(1 - pAB, pAB))
else sample.int(2, 1, prob = c(pBA, 1 - pBA))
sprev <- s
s <- if (s == 1L) c(1L, 2L)[ns] else c(1L, 2L)[ns]
y[i, t] <- if (s == 1L) sample.int(3, 1, prob = c(1 - p, p, 0))
else sample.int(3, 1, prob = c(1 - p, 0, p))
}
}
y
}The model we fit is the standard Markov multi-state model from the first post, with state-specific detection.
Gam <- function(psiAB, psiBA, phi) matrix(c(
phi * (1 - psiAB), phi * psiAB, 1 - phi,
phi * psiBA, phi * (1 - psiBA), 1 - phi,
0, 0, 1), nrow = 3, byrow = TRUE)
nll_ms <- function(par, y) {
phi <- plogis(par[1]); psiAB <- plogis(par[2]); psiBA <- plogis(par[3])
pA <- plogis(par[4]); pB <- plogis(par[5]); G <- Gam(psiAB, psiBA, phi)
Pm <- list(c(1 - pA, 1 - pB, 1), c(pA, 0, 0), c(0, pB, 0)); ll <- 0
for (i in 1:N) {
a <- rep(0, 3); a[init_state[i]] <- 1; lg <- 0
for (t in 2:k) { a <- (a %*% G) * Pm[[y[i, t]]]; s <- sum(a)
if (s <= 0) return(1e10); a <- a / s; lg <- lg + log(s) }
ll <- ll + lg
}
-ll
}
start <- c(qlogis(0.7), qlogis(0.3), qlogis(0.3), qlogis(0.6), qlogis(0.6))
fit_ms <- function(y) plogis(optim(start, nll_ms, y = y, method = "BFGS",
control = list(maxit = 600))$par)
sim_markov <- function(th, seed) {
set.seed(seed); phi <- th[1]; psiAB <- th[2]; psiBA <- th[3]; pA <- th[4]; pB <- th[5]
G <- Gam(psiAB, psiBA, phi); y <- matrix(1L, N, k)
for (i in 1:N) {
s <- init_state[i]; y[i, 1] <- if (s == 1L) 2L else 3L
for (t in 2:k) { s <- sample.int(3, 1, prob = G[s, ])
if (s == 1L) y[i, t] <- sample.int(3, 1, prob = c(1 - pA, pA, 0))
else if (s == 2L) y[i, t] <- sample.int(3, 1, prob = c(1 - pB, 0, pB)) }
}
y
}A discrepancy that memory disturbs
A goodness-of-fit test needs a statistic that the assumption controls. Memory shows up as reversals, so we measure the reversal rate: among animals seen on three consecutive occasions where the first two states differ, how often does the third occasion return to the first state. Under a Markov model this is fixed by the transition probabilities; memory pushes it up.
Tstat <- function(y) {
rev <- 0; sw <- 0
for (i in 1:nrow(y)) {
obs <- which(y[i, ] != 1L)
if (length(obs) < 3) next
st <- ifelse(y[i, obs] == 2L, 1L, 2L)
for (j in 1:(length(obs) - 2)) {
if (obs[j + 1] == obs[j] + 1 && obs[j + 2] == obs[j] + 2) {
if (st[j] != st[j + 1]) { sw <- sw + 1; if (st[j + 2] == st[j]) rev <- rev + 1 }
}
}
}
if (sw == 0) return(NA); rev / sw
}
run_gof <- function(y, B = 200) {
th <- fit_ms(y); Tobs <- Tstat(y); Tboot <- numeric(B)
for (b in 1:B) Tboot[b] <- Tstat(sim_markov(th, seed = 9000 + b))
Tboot <- Tboot[!is.na(Tboot)]
list(Tobs = Tobs, Tboot = Tboot, pval = mean(Tboot >= Tobs), theta = th)
}The test fits the Markov model, then simulates many data sets from the fitted model, recomputing the statistic each time to build its distribution under the assumption that the model is correct. If the observed value sits deep in the tail, the assumption is doubtful.
yM <- sim_ms(0.0, p_t, 138) # well specified
gofM <- run_gof(yM, B = 200)
yMem <- sim_ms(0.22, p_t, 138) # memory in the data
gofMem <- run_gof(yMem, B = 200)
data.frame(
data = c("Markov", "Memory"),
phi = c(round(gofM$theta[1], 3), round(gofMem$theta[1], 3)),
psiAB = c(round(gofM$theta[2], 3), round(gofMem$theta[2], 3)),
T_obs = c(round(gofM$Tobs, 3), round(gofMem$Tobs, 3)),
boot_mean = c(round(mean(gofM$Tboot), 3), round(mean(gofMem$Tboot), 3)),
gof_p = c(round(gofM$pval, 3), round(gofMem$pval, 3))) data phi psiAB T_obs boot_mean gof_p
1 Markov 0.796 0.288 0.336 0.299 0.155
2 Memory 0.796 0.362 0.556 0.344 0.000
The parameter estimates give no warning. Both fits return survival near 0.8 and transitions in a believable range; read on their own they look fine. The goodness-of-fit test tells them apart. On the Markov data the observed reversal rate of 0.336 sits close to its bootstrap mean of 0.299, giving a p-value of 0.155: no evidence of misfit. On the memory data the observed reversal rate of 0.556 lies far above the bootstrap mean of 0.344, and no bootstrap sample reaches it, giving a p-value below 0.005: clear misfit.
bd <- rbind(
data.frame(case = "Well-specified (Markov data)", T = gofM$Tboot),
data.frame(case = "Misspecified (memory in data)", T = gofMem$Tboot))
bd$case <- factor(bd$case, levels = c("Well-specified (Markov data)", "Misspecified (memory in data)"))
obs <- data.frame(
case = factor(c("Well-specified (Markov data)", "Misspecified (memory in data)"), levels = levels(bd$case)),
Tobs = c(gofM$Tobs, gofMem$Tobs),
lab = c(paste0("observed = ", round(gofM$Tobs, 3), "\np = ", round(gofM$pval, 3)),
paste0("observed = ", round(gofMem$Tobs, 3), "\np < 0.005")))
ggplot(bd, aes(T)) +
geom_histogram(aes(y = after_stat(density)), bins = 26, fill = forest, alpha = 0.55, colour = NA) +
geom_vline(data = obs, aes(xintercept = Tobs), colour = brick, linewidth = 1.1) +
geom_text(data = obs, aes(x = Tobs, y = Inf, label = lab), colour = ink, size = 3.3, hjust = -0.06, vjust = 1.4) +
facet_wrap(~case) +
labs(title = "A goodness-of-fit test catches what estimates hide",
subtitle = "Bootstrap distribution of the reversal statistic under the fitted Markov model; red is the observed value",
x = "Reversal rate under the fitted model", y = "Density") +
theme_te() + coord_cartesian(xlim = c(0.2, 0.62))
Sensitivity to the size of the problem
A test that only fires at one memory strength would not be much use. Sweeping mem upward shows the observed statistic climbing steadily out of the Markov null band, so the test detects memory across a range and, at mem of zero, correctly stays inside the band.
mem_grid <- c(0, 0.05, 0.10, 0.15, 0.20, 0.25)
R_rep <- 8
Tsweep <- sapply(mem_grid, function(mm)
mean(sapply(1:R_rep, function(r) Tstat(sim_ms(mm, p_t, 1380 + r))), na.rm = TRUE))
null_lo <- as.numeric(quantile(gofM$Tboot, 0.025))
null_hi <- as.numeric(quantile(gofM$Tboot, 0.975))
data.frame(mem = mem_grid, T_obs = round(Tsweep, 3)) mem T_obs
1 0.00 0.328
2 0.05 0.389
3 0.10 0.434
4 0.15 0.484
5 0.20 0.488
6 0.25 0.532
With no memory the observed statistic is 0.328, inside the null band of 0.228 to 0.37. By a memory strength of 0.25 it has risen to 0.532, well outside it.
sw <- data.frame(mem = mem_grid, Tobs = Tsweep)
ggplot(sw, aes(mem, Tobs)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = null_lo, ymax = null_hi, fill = gold, alpha = 0.25) +
geom_hline(yintercept = mean(gofM$Tboot), linetype = "dashed", colour = "#7a6f2e", linewidth = 0.4) +
annotate("text", x = 0.252, y = null_hi, label = "Markov null (95%)", colour = "#7a6f2e", size = 3.5, hjust = 1, vjust = -0.5) +
geom_line(linewidth = 1, colour = brick) + geom_point(size = 2.8, colour = brick) +
labs(title = "Test sensitivity rises with the strength of memory",
subtitle = "Observed reversal rate leaves the Markov null band as second-order dependence grows",
x = "Strength of state memory", y = "Observed reversal rate") + theme_te()
What to remember
Plausible estimates are not evidence of fit. A multi-state model can absorb a violated assumption into slightly shifted parameters and give no outward sign, so fit has to be checked directly. A parametric bootstrap makes that check general: choose a statistic the assumption controls, simulate from the fitted model to get its null distribution, and see where the data fall. Here memory showed up as an excess of reversals, but the same recipe works for any discrepancy you can compute, from transience to overdispersion.
In practice you would not hand-roll every test. U-CARE and its R successor R2ucare implement the component tests of Pradel, Wintrebert and Gimenez (2003), which decompose overall fit into interpretable pieces, one sensitive to transience, another to exactly the memory effect shown here. When a test fails, the response is to enrich the model rather than ignore the warning: a memory model in the spirit of Brownie and others (1993), individual heterogeneity, or, if the misfit is mild overdispersion, a variance inflation factor to widen the intervals. A model worth trusting is one you have tried and failed to break.
This closes the group on multi-state and multi-event capture-recapture, from the basic model through transition estimation, uncertain states, and now fit.
References
Pradel, R., Wintrebert, C.M.A. & Gimenez, O. (2003). A proposal for a goodness-of-fit test to the Arnason-Schwarz multisite capture-recapture model. Biometrics, 59, 43-53. https://doi.org/10.1111/1541-0420.00006
Choquet, R., Lebreton, J.-D., Gimenez, O., Reboulet, A.-M. & Pradel, R. (2009). U-CARE: Utilities for performing goodness of fit tests and manipulating capture-recapture data. Ecography, 32, 1071-1074. https://doi.org/10.1111/j.1600-0587.2009.05968.x
Brownie, C., Hines, J.E., Nichols, J.D., Pollock, K.H. & Hestbeck, J.B. (1993). Capture-recapture studies for multiple strata including non-Markovian transitions. Biometrics, 49, 1173-1187. https://doi.org/10.2307/2532259
Lebreton, J.-D., Nichols, J.D., Barker, R.J., Pradel, R. & Spendelow, J.A. (2009). Modeling individual animal histories with multistate capture-recapture models. Advances in Ecological Research, 41, 87-173. https://doi.org/10.1016/S0065-2504(09)00403-6
Gimenez, O., Rossi, V., Choquet, R., Dehais, C., Doris, B., Varella, H., Vila, J.-P. & Pradel, R. (2007). State-space modelling of data on marked individuals. Ecological Modelling, 206, 431-438. https://doi.org/10.1016/j.ecolmodel.2007.03.040