From "I fit one Bayesian model" to hierarchical, latent-state, management-relevant inference
Today we plug everything together: priors on link scales, multilevel structure, latent states, derived management quantities, and proper convergence checks — all in NIMBLE.
What's in this lab and what to hand in
This page is your in-class practice problem set — worked examples with visible answers. Your graded 20-point assignment is a separate problem set; write up your answers in the R Markdown template and submit on Canvas.
Need help with R Markdown? See the R Markdown tutorial. Other docs for this day: plain-language summary · practice answer key.
Data files & R scripts
Everything referenced in this lab's exercises and problem sets lives in this folder. Click any link below to download.
Datasets
ants.csvAnt species richness in 44 New England bogs (Gotelli & Ellison); covariates: forest, latitude, elevation.N2OEmission.csvN2O emissions from agricultural plots, multiple fertilizers across sites.IslandsLizards.csvLizard occupancy (0/1) vs perimeter:area ratio for island habitat patches.R scripts
ant.priors.Rant.priors.R · original Nimble script for the priors sensitivity walkthroughlizzard.Rlizzard.R · original JAGS model for the island lizards example (for comparison)Lizards_Nimble.RLizards_Nimble.R · Nimble re-implementationSpawnerRecruit_Nimble.RSpawnerRecruit_Nimble.R · Beverton-Holt + derived management quantitiesMultilevel_Nimble.RMultilevel_Nimble.R · 5-model pooling spectrum on the N2O emission dataDynamicOccupancy_Nimble.RDynamicOccupancy_Nimble.R · multi-year occupancy with latent zNMixture_Nimble.RNMixture_Nimble.R · four nested N-mixture models + Bayesian p-valueTry it yourself
Helpful companions for today. The Day 4 MCMC explorer and conjugate prior/posterior explorer build the intuition behind today's models.Where Day 4 left us, and where Day 5 takes us
Yesterday (Day 4) you wrote your first Bayesian models. Each was a single-level model with a likelihood, priors, and a small handful of parameters — the Bayesian analog of the GLMs from Day 2. You learned how MCMC explores a posterior and how to read MCMCsummary output.
Today is where Bayes earns its keep. The real-world ecological problems that motivated us to learn this machinery rarely have just one level of variation, and they rarely have clean, fully-observed data. They have:
- Multiple levels of grouping — sites within regions within years, fish within streams within basins. Multilevel models share information across groups in a principled way.
- Latent states — the quantity you actually care about (true presence, true abundance, true survival) is never directly observed; what you have is a noisy observation of it.
- Derived management quantities — nobody manages a fishery by Beverton-Holt's $a$ and $b$. They manage by $S_\text{msy}$ and $H_\text{msy}$, which are nonlinear functions of $a$ and $b$. Bayes gives you their full posterior for free.
Key idea
Today's curriculum is six tightly connected sections. Each follows the same structure: biological motivation → the math → the NIMBLE code → one or two exercises to make it stick.
Watch out
Throughout the day we will use NIMBLE, not JAGS. The body of the model code is essentially identical (same dnorm precision parameterisation, same dbern, dpois, logit()/ilogit() assignments). The wrapper changes:
- JAGS'
cat("model{...}", file="x.txt")becomesmyCode <- nimbleCode({ ... }). - JAGS' single
data=list splits into two:constants=(sizes, indices, fixed covariate vectors — anything that never appears on the LHS of~) anddata=(the observed random variables). - JAGS' three-line
jags.model() / update() / coda.samples()becomes a singlenimbleMCMC()call.
Set samplesAsCodaMCMC = TRUE so coda functions like gelman.diag() and effectiveSize() work directly.
Plain-language overview
Plain-language summary
A hierarchical model is a model where parameters themselves come from a prior distribution governed by other parameters that you also estimate. The classic case is: each site has its own intercept $\alpha_j$, and all the $\alpha_j$ come from a Normal distribution with mean $\mu_\alpha$ and SD $\sigma_\alpha$. The result — called shrinkage — is that sites with very few data points get pulled toward the overall mean, while sites with lots of data are barely budged. That's exactly what a good ecologist would do by hand, and the math does it automatically.
A latent-state model separates two layers: the process (the true biological state of the system, which we never directly see) and the observation (what our imperfect survey actually records). Dynamic-occupancy and N-mixture models are the two canonical examples: both estimate true occupancy/abundance while explicitly modeling that we don't always detect what's there.
A derived quantity is any function of the model's parameters that you want a posterior for. $S_\text{msy}$ in fisheries, equilibrium occupancy in metapopulation work, marginal effects in any GLM — all are derived quantities. Bayes gives you their full posterior just by computing them inside the model.
Read the full plain-language summary for Day 5 → — the same ideas worked through slowly, with examples and no math.
A. Priors on the link scale — the ant richness example
Biological setup
The ants.csv data from Gotelli & Ellison contains ant species richness measured in 44 New England bogs, with three covariates: forest (bog vs. forest habitat), latitude, and elevation. The natural model is Poisson regression with a log link:
Biological intuition
If you fit this in glm() with a vague prior, you get the maximum likelihood estimates we covered on Day 2. Bayesian fitting should reproduce them — unless the priors accidentally encode strong information you didn't intend.
The trap: vague priors on a link scale
A common rookie move is to put a "vague" prior on every coefficient — in NIMBLE, dnorm(0, tau = 1/10000), which is $\beta \sim \mathcal{N}(0,\ \sigma^2 = 10000)$, i.e. a standard deviation of $\sigma = 100$. On the log link, a coefficient $\beta_j = 7$ corresponds to a $e^7 \approx 1096$-fold multiplicative effect on $\lambda$ — a 1000x change in richness from a one-unit change in latitude. The prior says that's plausible. It is not.
Worse, when one of the predictors (here latitude, on raw scale ~41°-45°) has a large mean, $\beta_j \cdot x_{ij}$ multiplies and shifts so wildly during MCMC that $\lambda_i$ overflows the floating-point range. The sampler stalls or wanders into nonsense.
The fix: scale your predictors
Standardising each predictor (subtract mean, divide by SD) puts every $x_{ij}$ in roughly $[-3, 3]$, so a vague $\beta$ prior no longer implies astronomical multiplicative effects. The frequentist and Bayesian fits then agree closely, and chains converge fast.
library(nimble); library(MCMCvis)
ants <- read.csv("data/ants.csv")
# Scale predictors -- THIS IS WHAT FIXES THINGS
ants$forest <- as.numeric(scale(ants$forest))
ants$latitude <- as.numeric(scale(ants$latitude))
ants$elevation <- as.numeric(scale(ants$elevation))
antCode <- nimbleCode({
for (i in 1:n) {
y[i] ~ dpois(lam[i])
log(lam[i]) <- b0 + b1 * forest[i] + b2 * latitude[i] + b3 * elevation[i]
}
b0 ~ dnorm(0, tau) # tau = 1/sigma^2 (NIMBLE = JAGS parameterisation)
b1 ~ dnorm(0, tau)
b2 ~ dnorm(0, tau)
b3 ~ dnorm(0, tau)
})
# constants= : nothing here ever appears on the LHS of a `~`
constants <- list(n = nrow(ants), forest = ants$forest,
latitude = ants$latitude, elevation = ants$elevation,
tau = 1/10)
# data= : the observed random variable, and nothing else
data <- list(y = ants$richness)
samples <- nimbleMCMC(antCode, constants = constants, data = data,
monitors = c("b0","b1","b2","b3"),
nchains = 3, niter = 10000, nburnin = 500,
samplesAsCodaMCMC = TRUE)
MCMCsummary(samples)
Watch out
Apply the constants/data rule to this model. Look back at the rule above: constants= holds anything that never appears on the LHS of a ~; data= holds the observed random variables. Here y[i] ~ dpois(lam[i]) is the only stochastic statement with an observed quantity on its left, so y is the only thing in data=. The covariates, n, and the prior's tau are all constants.
You will see a shortcut in older scripts: pass the whole data frame as a constant and index the response out of it, like this —
constants = list(ants = as.matrix(ants), n = nrow(ants), forest = ants$forest, ...)
# ...and in the model code:
ants[i,1] ~ dpois(lam[i])
# ...and no data= argument at all
NIMBLE does rescue this — it notices the node on the LHS of a ~ and reclassifies it as data for you, so the model runs and gives the right answer — but it is not silent about it. nimbleModel prints:
[Note] Using 'ants' (given within 'constants') as data.
[Warning] dimensions specified are larger than model specification
for variable `ants`.
Learn to read that Note: it is NIMBLE telling you your constants/data split was wrong and that it patched it up for you. Notes and Warnings scroll past easily, but this one is a real diagnostic — noticing it is the skill. (The Warning is a second symptom — you handed over a whole 44×4 matrix when the model only ever uses column 1.) Don't copy the pattern. It puts an observed random variable in constants=, which contradicts the rule you just learned, and it breaks the moment you want to do anything with the response: posterior predictive replicates, missing values, or a simulation from the fitted model all need y to be a proper data node. The shipped ant.priors.R uses the correct split — the form above is shown only so you recognise it in the wild.
Watch out
Why tau = 1/10 and not tau = 1/10000? Remember NIMBLE's dnorm takes a precision, so tau = 1/10 means $\sigma^2 = 10$, i.e. $\sigma = \sqrt{10} \approx 3.2$. On the log link that corresponds to roughly a $e^{\pm 6}$ range of multiplicative effects — still very wide, but not crazy. The truly "vague" tau = 1/10000 ($\sigma^2 = 10000$, $\sigma = 100$) is informative in the wrong direction: it puts huge prior weight on impossible parameter values.
Key idea
How to choose and specify a prior — a short checklist. A prior is a deliberate modeling choice, not a nuisance to zero out. Every model in today's lab follows these same rules.
- Think on the scale the parameter lives on. A prior that looks "vague" on a link scale (log, logit) can be violently informative on the natural scale (a rate, a probability) — you just saw a "flat" log-scale prior imply 1000-fold effects. Always ask what the prior implies for the quantity you actually care about, ideally by simulating from it: a prior predictive check draws parameters from the prior, pushes them through the model, and looks at the fake data they produce.
- Default to weakly-informative, not "flat." On standardized predictors, $\mathcal{N}(0,\ \sigma \approx 1\text{–}2)$ (note: $\sigma$, not $\sigma^2$) for logit/log coefficients rules out physically impossible effects while letting the data dominate. The genuinely flat priors ($\mathcal{N}(0,\ \sigma^2 = 10000)$, i.e. $\sigma = 100$) are the ones that misbehave.
- Standardize predictors first. It puts every coefficient on a common, interpretable scale, so one prior width is sensible for all of them — and it fixes the overflow and mixing problems shown above.
- Match the prior's support to the parameter. Use
dunif(0,1)ordbetafor probabilities, a positive-only prior (dunif(0,·)or half-normal) for standard deviations, and a Normal on the unconstrained link scale for regression coefficients. - Use an informative prior only when you genuinely have prior information — a published survival rate, a physical bound — and state it explicitly. Reserve it for real knowledge, not convenience.
What does a "vague" log-scale prior actually look like?
You're putting a $\mathcal{N}(0,\ \sigma = 100)$ prior (that is, $\sigma^2 = 10000$; in NIMBLE, dnorm(0, tau = 1/10000)) on the slope of a log-link Poisson regression. Latitude in the ants data ranges from 41.97° to 45.00° on the raw scale.
- Draw $10^5$ values of $\beta_2$ from $\mathcal{N}(0,\ \sigma = 100)$. For each draw, compute the implied multiplicative effect $e^{\beta_2 \cdot 3}$ over the latitudinal range (3 degrees). Plot the distribution of $\log_{10}$ of that effect.
- What fraction of the prior puts $|\log_{10}(\text{effect})| > 6$ (a million-fold change)?
- Now standardise latitude. Repeat (a) using $\beta_2 \cdot 1$ (a one-SD change in scaled latitude). Compare.
Reveal worked solution
set.seed(1)
beta <- rnorm(1e5, 0, 100)
eff <- exp(beta * 3) # raw scale: 3 deg latitude
hist(log10(eff), breaks=200, xlab="log10(effect)")
mean(abs(log10(eff)) > 6) # 0.963
eff2 <- exp(beta * 1) # scaled scale: 1 SD
mean(abs(log10(eff2)) > 6) # still 0.890 (!) -- prior is too wide
With this seed, 96.3% of the prior implies more than a million-fold effect over the raw 3° latitudinal range, and standardising only drops that to 89.0% — still almost the whole prior.
Where the analytic 0.9633 and 0.8901 come from — and the general rule. Don't take the simulation's word for it; the fraction is a one-line normal-tail calculation, and the same line handles any prior width and any predictor range. Start from what the prior literally says: $\beta_2 \sim \mathcal{N}(0,\ \sigma_\beta = 100)$. Over a predictor change of $\Delta x$, a log-scale coefficient produces a multiplicative effect $e^{\beta_2 \Delta x}$ (the same power-law move as Day 2's back-transform), so
A "million-fold" change means $|\log_{10}(\text{effect})| > 6$. Solve that inequality for the coefficient and the whole question becomes a statement about $\beta_2$ alone:
Now standardise the coefficient. Because $\beta_2/\sigma_\beta \sim \mathcal{N}(0,1)$, divide that threshold by $\sigma_\beta$ to read it as a $z$-score, and the prior mass is just a normal two-tail:
That is the reusable result: the prior mass beyond a million-fold effect depends only on the single product $\sigma_\beta\,\Delta x$ — prior SD times predictor range. Widen the prior or stretch the range and the same formula answers it. Only now do the numbers enter. Raw latitude spans $\Delta x = 3$ degrees, so $z = 6\ln 10/(100\cdot 3) = 4.605/300 = 0.0461$ and $2\,\Phi(-0.0461) = 0.9633$. Standardising collapses the range to $\Delta x = 1$ SD, tripling the threshold on $|\beta_2|$: $z = 6\ln 10/100 = 0.138$, and the fraction still only falls to $2\,\Phi(-0.138) = 0.8901$. Standardising helped a little, but nowhere near enough — and it is worth being clear about why, because this is the whole point of the exercise. Both moves you could make act through the same product $\sigma_\beta\,\Delta x$: standardising the predictor shrinks the range $\Delta x$, and tightening the prior shrinks the SD $\sigma_\beta$. What differs is the leverage. Standardising latitude shrinks $\Delta x$ from 3° to 1 SD — a factor of 3 — which only triples the $z$-score threshold, from $0.046$ to $0.138$. On a $\mathcal{N}(0,1)$ both of those numbers sit almost on top of the mean, where the bell curve is flat, so tripling the threshold barely moves the tail mass ($0.963 \to 0.890$). To buy the protection that $\sigma_\beta = 2$ gives, you would have to shrink $\Delta x$ by a factor of about 50 — which no amount of rescaling a real predictor can do. That is the sense in which the cure is a sane prior, not standardising: at $\sigma_\beta = 2$ the scaled $z$ jumps to $6\ln 10/2 = 6.9$, and $2\,\Phi(-6.9) \approx 5\times10^{-12}$ — effectively no prior mass on million-fold effects at all.
One more point, because it is a common source of confusion when you reach Exercise 2: standardising and priors solve two different problems, and you usually want both. Standardising centres the predictor and fixes the sampler — in Exercise 2 the raw-latitude chains stall because latitude near 42° is nearly fused to the intercept, and centring breaks that. A sane prior fixes the prior-predictive — the absurd tails you just computed. They are not substitutes for each other. And a too-wide prior only actually distorts the posterior when the data are weak: with the 44 bogs in Exercise 2 the likelihood swamps even the $\sigma = 100$ prior, so the scaled fit lands in the right place regardless (that is exactly what Exercise 2(b) and 2(c) show). Tight priors earn their keep when data are scarce — which, in ecology, is often.
The takeaway: a $\mathcal{N}(0,\ \sigma = 100)$ prior on a log-scale coefficient is almost always far too wide. Use $\mathcal{N}(0,\ \sigma \approx 2)$ or $\mathcal{N}(0,\ \sigma \approx 3)$ for standardised predictors. Tight, weakly informative priors are what most of the modern Bayesian regression literature now recommends.
Fit the ant richness model twice
Using ants.csv and ant.priors.R as a starting point, fit the model:
- With RAW predictors and $\mathcal{N}(0, 100)$ prior on each $\beta$. How long do the chains take to mix? What does
MCMCtraceshow? What doesgelman.diaggive for R-hat? - With SCALED predictors and the same prior. Compare R-hat, effective sample size, and the posterior medians of (b1, b2, b3) to the frequentist
glm()output. - Now keep the scaled predictors but tighten the prior to $\mathcal{N}(0, \sigma=2)$ (i.e.
dnorm(0, 1/4)). Does the posterior change meaningfully? Why or why not?
Reveal worked solution (actual NIMBLE run)
Numbers below are from an actual run (set.seed(10), 3 chains, niter=10000, nburnin=500): posterior mean [95% CI] with R-hat and effective sample size from MCMCsummary. Coefficients are b1=forest, b2=latitude, b3=elevation.
(a) Raw predictors, dnorm(0, tau=1/10000) (i.e. $\sigma=100$). The intercept and latitude are badly non-identified because raw latitude ($\approx$ 42°) is far from 0 and strongly correlated with the intercept, so the posterior is a long narrow ridge the sampler cannot traverse. Even with the dispersed glm()-based inits from ant.priors.R, b0 and b2 come back with R-hat in the tens (roughly 7–30, and it jumps around from run to run — itself a sign of non-convergence) and only n.eff ≈ 12–17 effective draws; their posterior means are unstable and disagree with glm() (which gives intercept 11.94, latitude −0.24). Forest (b1 ≈ 0.64, R-hat 1.00) happens to mix, but the fit is broken. If you instead leave the inits at their defaults the raw chains stall outright, with NaN log densities. Longer chains help only slowly; the real fix is scaling — and note that scaling, not the choice of inits, is what does it.
(b) Scaled predictors, same vague prior. Everything converges (R-hat = 1.00, n.eff ≈ 5000–6000) and the posterior means match glm() to two decimals:
| Coefficient | Posterior mean [95% CI] | R-hat | glm() |
|---|---|---|---|
| b0 (intercept) | 1.84 [1.71, 1.96] | 1.00 | 1.85 |
| b1 (forest) | 0.32 [0.21, 0.45] | 1.00 | 0.32 |
| b2 (latitude) | −0.25 [−0.39, −0.13] | 1.00 | −0.25 |
| b3 (elevation) | −0.18 [−0.30, −0.07] | 1.00 | −0.18 |
(c) Scaled predictors, tight prior dnorm(0, 1/4) (i.e. $\sigma=2$). The posterior is essentially unchanged: b0 1.84 [1.71, 1.96], b1 0.32 [0.20, 0.44], b2 −0.25 [−0.39, −0.13], b3 −0.19 [−0.31, −0.07]. With 44 bogs the data dominate both priors, so tightening $\sigma$ from 100 to 2 moves nothing. Tight priors matter most when the data are weak.
B. Bayesian logistic regression — the island lizard example
Biological setup
You have presence/absence of a lizard species on $n$ islands, plus each island's perimeter:area ratio (a proxy for habitat edge per unit interior). Logistic regression on the logit scale:
The full NIMBLE template
library(nimble); library(MCMCvis); library(coda)
liz <- read.csv("data/IslandsLizards.csv")
x.scaled <- as.numeric(scale(liz$perimeterAreaRatio))
x.grid <- seq(-2, 2, by = 0.01)
lizardCode <- nimbleCode({
a ~ dnorm(0, 1e-6) # vague (intentionally bad)
b ~ dnorm(0, 1e-6)
for (i in 1:n) {
logit(p[i]) <- a + b * x[i]
y[i] ~ dbern(p[i])
}
for (j in 1:ngrid) {
p.out[j] <- ilogit(a + b * x.grid[j]) # prediction
}
})
constants <- list(n=nrow(liz), ngrid=length(x.grid),
x=x.scaled, x.grid=x.grid)
data <- list(y = as.integer(liz$presence))
inits <- list(list(a=0,b=0), list(a=1,b=-1), list(a=-1,b=0.5))
samples <- nimbleMCMC(lizardCode, constants=constants, data=data,
inits=inits, monitors=c("a","b","p.out"),
nchains=3, nburnin=1000, niter=6000,
samplesAsCodaMCMC=TRUE)
MCMCsummary(samples, params=c("a","b"))
gelman.diag(samples[, c("a","b")])
heidel.diag(samples[, c("a","b")])
Show the full probability model (likelihood + priors) and its DAG
The nimbleCode above is the model, but it helps to see it written out as a likelihood times priors — every Bayesian model has exactly this shape.
Likelihood (how the data arise):
Priors (what we believe about the parameters before seeing data): the excerpt above uses NIMBLE's precision parameterisation, dnorm(0, 1e-6), i.e. precision $\tau = 10^{-6}$, i.e.
The posterior NIMBLE samples is proportional to the product of these: $p(a,b \mid y) \propto \big[\prod_i \text{Bernoulli}(y_i \mid p_i)\big]\,\mathcal{N}(a \mid 0,10^6)\,\mathcal{N}(b \mid 0,10^6)$. That prior is the intentionally bad one, and the rest of this section is about why: on the logit scale it looks flat, but pushed through the inverse-logit it is anything but. The weakly-informative alternative developed below replaces it with $a, b \sim \mathcal{N}(0,\ \sigma^2 = 2.71)$, i.e. dnorm(0, 1/2.71), which keeps each coefficient in a biologically sane range on standardized $x$ and makes the implied prior on $p_i$ nearly flat.
The same model as a DAG (directed acyclic graph). Arrows run from causes to effects; each node is a quantity, and the graph is exactly the factorization above — the parents of a node are the things that appear on the right of its ~ or <-.
Read it as: the parameters $a,b$ and the island's edge ratio $x_i$ together determine the island's probability of being occupied, $p_i$ (a deterministic node — no randomness of its own), which in turn generates the observed presence/absence $y_i$. The plate means that inner structure repeats independently for every island; $a$ and $b$ sit outside the plate because they are shared by all islands. Note there is no detection layer anywhere in this model: $y_i$ is taken as the true presence/absence, and $p_i$ is the probability the species occurs on island $i$, not the probability you would detect it if it did. Sections E and F add exactly that missing layer.
Init gotchas
Watch out
If you initialise a = 10, b = -10, then for some islands $\text{logit}^{-1}(a + bx) \approx 0$. If $y_i = 1$ at that island, the Bernoulli likelihood at start is $\log(0) = -\infty$ and the sampler refuses to start. The safe pattern is initial values close to 0. The lab script uses $\{0, 1, -1\}$ for both $a$ and $b$.
The tighter-prior fix
Recall the link-scale problem from section A. The same issue appears in logistic regression: a vague dnorm(0, 1e-6) on $a$ and $b$ does not imply a flat prior on the probability $p_i$. Pushed through the inverse-logit, that "vague" normal piles almost all of its mass out at $p_i = 0$ and $p_i = 1$ — the implied prior on $p_i$ is U-shaped (bimodal at the boundaries), not flat.
Watch out
Is that bimodal prior actually a problem? It depends on where the likelihood sits. A prior that spikes at 0 and 1 can still be effectively uninformative and harmless — as long as the likelihood has essentially no weight near $p=0$ or $p=1$. If the data pin $p$ somewhere in the interior (say a species detected at roughly a third of sites), the posterior is driven almost entirely by the likelihood; the prior's boundary spikes sit in a region the likelihood has already ruled out, so they never come into play, and the posterior is indistinguishable from what a flat prior would give.
But if the likelihood does place weight near a boundary — a rare species detected at almost no sites, or a near-ubiquitous one detected at nearly all — then multiplying the likelihood by a prior that itself spikes at that boundary drags the posterior into the corner and makes it very informative and distorted in exactly the region you cared about (overconfident that $p$ is essentially 0 or 1). Because you rarely know in advance whether a boundary is in play, a genuinely flat (or gently weakly-informative) prior on $p$ is the safer default.
The practical fix is a moderate prior on the logit-scale coefficients:
The value $2.71$ (i.e. $\sigma \approx 1.65$) is chosen for one concrete reason: it is very close to the variance that makes the implied prior on $p_i = \text{ilogit}(a)$ come out nearly uniform on $[0,1]$. Push draws through the inverse-logit and check it yourself:
set.seed(1)
for (v in c(1, 2.71, 4, 10, 1e6)) {
p <- plogis(rnorm(2e6, 0, sqrt(v)))
cat(v, sd(p), mean(p < 0.05 | p > 0.95), "\n")
}
# Uniform(0,1) reference: sd = 0.289, P(tail) = 0.10
At $\sigma^2 = 2.71$ the implied prior on $p$ has SD 0.285 and puts 7.4% of its mass in the outer tails ($p<0.05$ or $p>0.95$) — against 0.289 and 10% for a true Uniform(0,1). At $\sigma^2 = 1$ it is too concentrated in the middle (SD 0.208, only 0.3% in the tails); at $\sigma^2 = 10$ it is already piling up at the boundaries (35% in the tails); and at the "vague" $\sigma^2 = 10^6$ essentially all of the prior mass (99.8%) sits in those tails. That last number is the U-shape in Panel A below, quantified.
Watch out
A naming caution. You will sometimes see $\mathcal{N}(0, 2.71)$ called "the Gelman default." Don't repeat that label — it is a misattribution. Gelman et al. (2008) recommend a Cauchy(0, 2.5) prior for coefficients of standardised predictors in logistic regression: heavier-tailed than a Normal, deliberately, so it regularises the bulk while still letting a genuinely large effect escape. $\mathcal{N}(0, 2.71)$ is a different, perfectly defensible weakly-informative choice, but it earns its keep from the near-uniform implied prior on $p$ shown above, not from that paper. The statistics in this section are unaffected; only the name was wrong.
Lizards: run the model, diagnose, predict
Run the script Lizards_Nimble.R end to end. Then:
- Report
gelman.diagandheidel.diagfor $a$ and $b$. Do all three chains agree? - What is the posterior probability that the slope $b$ is negative? (i.e., higher perimeter:area ⇒ lower occupancy)
- Compute and report the posterior median and 95% CI for $\text{diff} = P(\text{presence} \mid \text{PA}=20) - P(\text{presence} \mid \text{PA}=10)$.
- Compare the marginal posterior of $b$ under
dnorm(0, 1e-6)versusdnorm(0, 1/2.71). With this sample size, does the prior matter much?
Reveal worked solution (actual NIMBLE run)
Numbers below are from an actual run. Lizards_Nimble.R sets no seed, so we fixed set.seed(2025); both the vague and the tight model use 3 chains, niter=6000, nburnin=1000 on the $n=19$ islands.
(a) Convergence. For the vague-prior model gelman.diag returns R-hat = 1.00 for both $a$ and $b$ (upper C.I. 1.01) and heidel.diag passes the stationarity test for both ($p=0.62$ for $a$, $0.84$ for $b$). The tight-prior model behaves the same (R-hat = 1.00, stationarity passes); its only blemish is that the halfwidth precision test on $a$ flags, purely because $a$'s posterior mean sits almost exactly at 0. All three chains agree — the model converges.
(b) Sign of the slope. The slope is negative with near-certainty: $P(b<0)$ = 1.00 under the vague prior and 0.9995 under the tight prior. Higher perimeter:area ⇒ lower occupancy.
(c) Management difference. Using the tight-prior model, predicted occupancy falls from $y_{10}$ = 0.73 [0.47, 0.91] at PA = 10 to $y_{20}$ = 0.41 [0.17, 0.68] at PA = 20, so diff $= P(\text{PA}=20)-P(\text{PA}=10)$ has posterior median −0.30, 95% CI [−0.54, −0.10] — credibly negative (the interval excludes 0).
(d) Does the prior matter? Here it does. With only 19 islands and near-separation (small-PA islands are almost all occupied, large-PA almost all empty), the vague dnorm(0, 1e-6) lets the logit slope run large and diffuse: $b$ mean −4.97, 95% CI [−10.2, −1.5]. The weakly-informative dnorm(0, 1/2.71) regularises it to $b$ mean −2.44, 95% CI [−4.5, −0.8] — roughly half the magnitude and half the width. Both priors agree on the sign and the biological conclusion, but the marginal posterior of $b$ itself is much tighter and less extreme under the weakly-informative prior. This is exactly the small-$n$ regime, foreshadowed by the U-shaped-prior figure above, where the prior does real work rather than washing out.
C. Hierarchical / multilevel models — the N2O example
Biological setup
Soil nitrous-oxide ($\text{N}_2\text{O}$) emissions are measured at multiple sites across the world. Each site has multiple measurements of (nitrogen input, emission). Different sites have wildly different soil organic carbon, climate, and microbial communities — so we expect site-level structure.
The pooling spectrum
You face a fundamental choice on how to handle the site grouping:
| Approach | Math | What it assumes | When to use |
|---|---|---|---|
| Complete pooling | $\mu_i = \alpha + \beta x_i$ | All sites identical | Almost never — ignores grouping |
| No pooling | $\mu_i = \alpha_{j[i]} + \beta x_i$, each $\alpha_j$ independent | Sites totally unrelated | When you have abundant data per site |
| Partial pooling | $\alpha_j \sim \mathcal{N}(\mu_\alpha, \sigma_\alpha^2)$ | Sites are exchangeable draws from a population | Default choice for grouped data |
Key idea
Partial pooling produces shrinkage: each site's estimated $\alpha_j$ is pulled toward the global $\mu_\alpha$ by an amount inversely proportional to that site's data quantity and quality. Sites with one noisy observation get shrunk a lot; sites with 50 clean observations barely move. This is exactly the inference an expert ecologist would do by hand.
Key idea
Shrinkage by hand. The posterior mean of a random intercept is a precision-weighted average of the site's own data and the global mean:
$$\hat\alpha_j = \frac{n_j\bar y_j/\sigma^2 + \mu_\alpha/\sigma_\alpha^2}{n_j/\sigma^2 + 1/\sigma_\alpha^2}$$
Take $\sigma=2$, $\mu_\alpha=0$, $\sigma_\alpha=3$, and two sites with the same observed mean $\bar y = 5$:
- Site A ($n=100$): $\hat\alpha_A = \dfrac{100/4 \cdot 5 + 1/9 \cdot 0}{100/4 + 1/9} = \dfrac{125}{25.11} \approx 4.98$ — pulled almost none.
- Site B ($n=4$): $\hat\alpha_B = \dfrac{4/4 \cdot 5 + 1/9 \cdot 0}{4/4 + 1/9} = \dfrac{5}{1.111} \approx 4.50$ — pulled 0.5 toward 0.
Same observed mean, different estimates — because Site B's $\bar y$ is less precise. That is the entire mechanism of hierarchical models, and the formula generalises to almost every random-intercept model you will fit.
NIMBLE template: partial-pooling random intercept
mod3 <- nimbleCode({
for (j in 1:n.sites) {
alpha[j] ~ dnorm(mu_alpha, tau_alpha)
}
mu_alpha ~ dnorm(0, 1/10000)
sigma_alpha ~ dunif(0, 100)
tau_alpha <- 1 / sigma_alpha^2
beta ~ dnorm(0, 1/10000)
sigma ~ dunif(0, 100)
tau <- 1 / sigma^2
for (i in 1:n) {
mu[i] <- alpha[group[i]] + beta * x[i]
y[i] ~ dnorm(mu[i], tau)
}
})
Adding a group-level covariate — the same model, two ways to write it
So far the random intercept only told us that sites differ (each $\alpha_j$ is a draw from $\mathcal{N}(\mu_\alpha, \sigma_\alpha^2)$). A natural next step is to add a group-level covariate $w_j$ — a predictor with one value per site, not one per observation (e.g. log-soil-carbon at site $j$) — to explain some of that site-to-site variation. A common way to write this is to regress the site intercept on $w_j$:
This is sometimes sold as a bold new "regression on the random intercepts." It is worth seeing that it is not a new kind of model — only a different way to write a group-level predictor. Substitute $\alpha_j = \kappa + \eta\, w_j + u_j$ with $u_j \sim \mathcal{N}(0, \sigma_\alpha^2)$, and it collapses to an ordinary random-intercept regression that simply carries $w_j$ as another term in the linear predictor:
The two forms have the same likelihood, the same parameters $(\kappa,\eta,\beta,\sigma_\alpha,\sigma)$, and the same predictions. Whether you place $\eta\, w_j$ "up in the hierarchy" (on the intercept's mean) or "down in the base model" (as an ordinary fixed-effect term) is a notational choice, not a modeling one: the mean-zero random intercept soaks up whatever site variation $w_j$ leaves behind, either way. The hierarchical notation is handy mainly because it generalizes cleanly to several grouping levels — but for a single group-level covariate it is exactly a fixed effect.
Key idea
What the covariate actually buys you. The real contrast is against a random intercept with no group covariate, and each point below holds identically in either of the two writings above:
- It explains why groups differ, not just that they do. A covariate-free random intercept absorbs site-to-site variation into a black-box $\sigma_\alpha$. The term $\eta\, w_j$ turns part of that variation into a testable statement — "sites with higher soil carbon emit more N2O on average" — with $\eta$ carrying a posterior and credible interval. (Because $w_j$ has one value per site, $\eta$ is informed by the number of sites, not the number of observations, so it can stay uncertain even when data are plentiful.)
- It sharpens shrinkage. Data-poor sites are still pooled — but now toward the value their own covariate predicts, $\kappa + \eta\, w_j$, rather than toward one global mean. Better targets, tighter site estimates.
- It predicts brand-new, unsampled sites. Knowing a new site's covariate $w_\text{new}$, its expected baseline is $\kappa + \eta\, w_\text{new}$ (with a spread of $\sigma_\alpha$ for the unknown site deviation). A covariate-free random-intercept model can only offer the grand mean $\mu_\alpha$ for an unseen site. Crucially, this prediction is identical whether $w_j$ sat in the hierarchy or in the base linear predictor — it is having the covariate that lets you predict a new site, not where you wrote it.
Build and compare all five N2O models
Open Multilevel_Nimble.R and walk through all five models in order. For each:
- Report posterior median + 95% CI for $\beta$ (the N-input slope on log scale).
- For models 2 and 3, plot the per-site $\alpha_j$ estimates from no-pooling vs. partial-pooling on the same axes. Where are the largest shrinkages?
- For model 4 (covariate on random intercept), is the posterior of $\eta$ (the effect of SOC on baseline emissions) credibly different from 0?
- For model 5 (random slope by fertilizer), which fertilizer has the largest absolute effect of N input on emissions?
Reveal worked solution (actual NIMBLE run)
From an actual run of Multilevel_Nimble.R (set.seed(10); 563 measurements across 107 sites and 10 fertilizer types; 3 chains, niter=10000, nburnin=1000). Every $\beta$ has R-hat = 1.00.
Model 4's site-level covariate is $w_j = \log\!\big(c_j / (100 - c_j)\big)$, the logit of site $j$'s mean soil organic carbon percentage $c_j$ (the carbon column of the data), entered on top of the within-site N-input slope $\beta$.
(a) The N-input slope $\beta$ is positive and stable across the whole pooling spectrum (posterior median [95% CI]):
| Model | $\beta$ median | 95% CI |
|---|---|---|
| 1 — complete pooling | 0.91 | [0.70, 1.12] |
| 2 — no pooling | 0.85 | [0.65, 1.05] |
| 3 — partial pooling | 0.88 | [0.69, 1.06] |
| 4 — + covariate on intercept | 0.87 | [0.69, 1.05] |
| 5 — random slope ($\mu_\beta$) | 0.89 | [0.54, 1.31] |
Pooling barely moves the average slope; it mostly tightens the per-site intercepts. (Model 5 reports the mean slope $\mu_\beta$; individual fertilizers spread around it — see (d).)
(b) Shrinkage is largest for the data-poor sites. Comparing per-site $\alpha_j$ medians from Model 2 (no pooling) to Model 3 (partial pooling, grand mean $\mu_\alpha=0.18$), the sites that move most toward the mean are all single-measurement sites: e.g. site 51 ($n_j=1$) shifts 0.72, site 39 ($n_j=1$) 0.63, site 42 ($n_j=1$) 0.61. Averaged over all sites, $|\Delta\alpha_j|$ = 0.46 for $n_j=1$ sites versus only 0.06 for sites with $n_j\ge10$, and the correlation between $n_j$ and $|\Delta\alpha_j|$ is $-0.49$: more data ⇒ less shrinkage, exactly as the hierarchical prior intends.
(c) $\eta$ is credibly positive — higher-carbon sites emit more N2O. Posterior median $\eta = 0.30$, 95% CI [0.01, 0.59], with $\Pr(\eta > 0) = 0.98$. The interval clears zero, though only just: a site's soil organic carbon carries real, if modest, explanatory power for its baseline emission on top of the within-site N-input slope $\beta$ — consistent with more labile carbon fuelling denitrification. Notice $\eta$ is informed by the number of sites (107), not the 563 observations, which is why it stays fairly uncertain even with plenty of data. Note also that $\eta$ and the group-level intercept $\kappa$ are correlated and mix slowly, so the default niter=10000 leaves R-hat $\approx 1.04$ for $\eta$; the numbers above come from a longer run (3 chains, 60k iterations, thin 5, R-hat = 1.00). Centering $w$ achieves the same mixing for far less compute.
(d) Fertilizer with the steepest N-input response (Model 5). Per-fertilizer slopes range from 0.60 (fertilizer N) up to 1.41 for UM (fert.index 5; 95% CI [0.76, 2.33]) — a urea-containing type. The other urea/mixed types (U 0.94, unknown 1.02, AM 0.98, UA 0.90) are also among the steepest, while the plain mineral-N type is shallowest. So urea-based fertilizers do show the steepest emission response, consistent with the agronomic literature. (At these settings $\sigma_\beta$ and the per-fertilizer slopes all reached R-hat $\le$ 1.01, so the default 10k iterations sufficed.)
D. Spawner-recruit & derived management quantities
Biological setup
For a salmon population, you have time series of spawners $S_t$ and recruits $R_t$ for $t = 1, \dots, T$ years. The Beverton-Holt model:
where $a$ = productivity at low spawners, $b$ = asymptotic recruitment. Equivalently on the log scale:
Why fisheries managers love Bayes
The quantities that drive policy are derived from $(a, b)$:
- Equilibrium population size: $N_\text{eq} = b(a-1)/a$
- Spawners for max sustained yield: $S_\text{msy} = b\sqrt{1/a} - b/a$
- Harvest rate at MSY: $H_\text{msy} = 1 - \sqrt{1/a}$
In a maximum likelihood fit you'd plug in point estimates and either accept zero uncertainty in the management quantities or bootstrap them. In Bayes, you compute them inside the model and the MCMC delivers their full joint posterior — correctly propagating the correlated uncertainty in $a$ and $b$.
BHcode <- nimbleCode({
# Likelihood
for (t in 1:Nyears) {
log.mu[t] <- log(a) + log(S[t]) - log(1 + (a / b) * S[t])
log.R[t] ~ dnorm(log.mu[t], tau)
}
# Priors
a ~ dunif(1, 10)
b ~ dunif(1000, 10000)
sigma ~ dunif(0, 1)
tau <- 1 / (sigma * sigma)
# Derived management quantities -- automatic posterior!
Neq <- b * (a - 1) / a
Smsy <- b * sqrt(1 / a) - b / a
Hmsy <- 1 - sqrt(1 / a)
})
Biological intuition
Notice that we put the derived-quantity lines inside the model block. NIMBLE tracks them as deterministic nodes; at every MCMC iteration their values are computed from the current $(a, b)$ and stored. Posterior summaries of $S_\text{msy}$ and $H_\text{msy}$ come from MCMCsummary with no extra work and with all the parameter correlations properly accounted for.
Watch out
Look hard at a ~ dunif(1, 10). This is today's whole thesis, caught in the act. It looks like a harmless "wide, uninformative" prior. It is not. Both of its bounds silently make management decisions for you, before the data say anything.
The lower bound $a > 1$ makes collapse a priori impossible. The equilibrium is
which is the solution of $R(S) = S$ — set $\frac{aS}{1 + (a/b)S} = S$, cancel $S$, and rearrange. Read it: $N_\text{eq} > 0$ for every $a > 1$, and $N_\text{eq} = 0$ exactly at $a = 1$. Since the prior assigns zero density below $a=1$, the model cannot entertain a stock that fails to replace itself. If the real question on the table is "is this population collapsing?", this prior has already answered "no" — and no amount of data can overturn it. That is an informative prior wearing a uniform's clothing.
The upper bound $a < 10$ caps the harvest rate. Since $H_\text{msy} = 1 - \sqrt{1/a}$ is strictly increasing in $a$, the prior imposes a hard ceiling
and the posterior is visibly pressed against it: in the Exercise 5 run, $a$'s 97.5th percentile is 9.25 against a ceiling of 10, and the upper end of the $H_\text{msy}$ 95% CI, 0.671, sits at 98% of the 0.684 bound. That is what a truncated posterior looks like. The reported interval is not "what the data say" — it is "what the data say, clipped by a bound nobody defended."
We are keeping the prior anyway — deliberately, so you can see this. In a real assessment you would either justify the bounds from biology (a productivity above 10 recruits/spawner may genuinely be impossible for this species — then say so, and cite it), or replace them with something that does not cliff-edge: e.g. a lognormal on $a$, or $\log a \sim \mathcal{N}(0, 1)$, which is weakly informative but leaves both collapse and high productivity reachable. The rule from section A applies exactly as it did on the log link: always ask what your prior implies for the quantity you actually care about — here $N_\text{eq}$ and $H_\text{msy}$, not $a$.
Spawner-recruit: fit, predict, derive
Open SpawnerRecruit_Nimble.R. The script simulates 20 years of data from a known truth ($a=5, b=5000, \sigma=0.2$), then fits the model.
- Run the script. Report posterior median + 95% CI for $a, b, \sigma, N_\text{eq}, S_\text{msy}, H_\text{msy}$.
- Plot the posterior of $H_\text{msy}$ as a histogram. What is the 5th percentile? In management language, the harvest rate you'd allow if you wanted to be 95% confident you weren't overfishing.
- Increase
Nyearsfrom 20 to 100, regenerate data, refit. How much does the posterior of $H_\text{msy}$ tighten? Is the gain linear in $N$?
Reveal worked solution (actual NIMBLE run)
From an actual run of SpawnerRecruit_Nimble.R (set.seed(2025); 3 chains, niter=10000, nburnin=1000).
Where the truth values $N_\text{eq}=4000$, $S_\text{msy}=1236$, $H_\text{msy}=0.553$ come from. These are not three separate facts to look up — each is one of the reference-point formulas above with the simulation truth $(a,b)=(5,5000)$ dropped in, and doing that arithmetic by hand is exactly how you sanity-check any assessment's output. Equilibrium is where recruits exactly replace spawners:
The harvest rate depends on the productivity $a$ alone — the stock's scale $b$ never enters, because $\sqrt{1/a}$ is the fraction of recruits that must escape to replace themselves:
and $S_\text{msy}$ carries that same $\sqrt{1/a}$ but rescaled by the capacity $b$:
The general reading is worth keeping: $a$ (productivity) alone fixes the harvest rate, while $b$ (capacity) sets the scale — so $H_\text{msy}$ is a pure function of $a$, and both $N_\text{eq}$ and $S_\text{msy}$ ride linearly on $b$. That is why the $H_\text{msy}$ posterior below inherits its shape straight from the posterior of $a$, and why the prior ceiling $a<10$ translates directly into the $H_\text{msy}$ ceiling of $1-\sqrt{1/10}=0.684$ flagged earlier. The MCMC simply runs this same arithmetic at every draw, so the correlated uncertainty in $(a,b)$ flows automatically into a full joint posterior for all three management quantities.
(a) Posterior medians and 95% CIs (Nyears = 20). Every CI contains the truth (R-hat = 1.00–1.01):
| Quantity | Median | 95% CI | Truth |
|---|---|---|---|
| $a$ | 5.32 | [3.22, 9.25] | 5 |
| $b$ | 4977 | [3927, 7151] | 5000 |
| $\sigma$ | 0.245 | [0.181, 0.351] | 0.2 |
| $N_\text{eq}$ | 4044 | [3393, 5099] | 4000 |
| $S_\text{msy}$ | 1216 | [889, 1770] | 1236 |
| $H_\text{msy}$ | 0.566 | [0.443, 0.671] | 0.553 |
(b) Conservative harvest rate. The 5th percentile of the $H_\text{msy}$ posterior is 0.46 — the harvest rate you could allow while staying 95% confident you are not exceeding the true $H_\text{msy}$. Note how much lower this risk-based number sits than the median 0.57.
(c) More data, tighter posterior. Regenerating from the same truth with Nyears = 100 shrinks the $H_\text{msy}$ 95% CI from width 0.228 (20 yr: [0.44, 0.67]) to 0.069 (100 yr: [0.53, 0.60]) — a factor of 3.3×. That is actually a bit more than the $\sqrt{100/20}=\sqrt5\approx2.24$ the pure $\sqrt N$ rule predicts: at only 20 years the Beverton-Holt curvature is weakly identified (note $a$'s posterior runs to 9.25, up against its prior ceiling of 10), so the 20-year interval is inflated; 100 years both add information and pull the posterior into its well-identified, roughly-Gaussian regime, so it tightens faster than $\sqrt N$ alone. The qualitative lesson holds — more data buy tighter management quantities, on the order of $\sqrt N$.
E. Latent state models — dynamic occupancy
Biological setup
You survey $N_\text{sites}$ sites in $N_\text{years}$ years with $N_\text{reps}$ replicate visits per site-year. You want to estimate the true occupancy of each site each year ($z[i,t] \in \{0, 1\}$), allowing for imperfect detection ($p < 1$).
Key idea
The model has two layers:
$\phi$ = site persistence (probability an occupied site stays occupied), $\gamma$ = site colonization (probability an empty site becomes occupied). Both are estimated per year transition.
The z-init trick (read carefully)
Watch out
If you ever observed $y[i, j, t] = 1$, then $z[i, t]$ must equal 1, because $P(y=1 \mid z=0) = 0$. Random initial values for $z$ will sometimes put $z = 0$ where a $y = 1$ was observed — this gives a log-density of $-\infty$ and the MCMC won't start. The fix: initialise the entire z matrix at 1. The sampler will quickly learn from the data where the actual 0s belong.
DynOccCode <- nimbleCode({
# Process
for (i in 1:Nsites) {
z[i, 1] ~ dbern(psi)
for (t in 2:Nyears) {
muZ[i,t] <- z[i,t-1]*phi[t-1] + (1-z[i,t-1])*gamma[t-1]
z[i,t] ~ dbern(muZ[i,t])
}
}
# Observation
for (t in 1:Nyears) for (i in 1:Nsites) for (j in 1:Nreps) {
Py[i,j,t] <- z[i,t] * p
y[i,j,t] ~ dbern(Py[i,j,t])
}
# Priors
psi ~ dunif(0,1); p ~ dunif(0,1)
for (t in 1:(Nyears-1)) {
phi[t] ~ dunif(0,1)
gamma[t] ~ dunif(0,1)
}
})
zst <- matrix(1L, Nsites, Nyears) # THE init trick
Derived quantities: turning raw transitions into metapopulation summaries
The raw parameters $\phi_t$ (persistence) and $\gamma_t$ (colonization) are hard to read one at a time. As in the fisheries model, the biologically meaningful numbers are derived from them — and because we compute them inside the model, MCMC returns a full posterior for each. This block is already in DynamicOccupancy_Nimble.R; the names below are exactly the ones the script monitors:
# Derived quantities -- each gets a full posterior for free
psi.t[1] <- psi
for (t in 2:Nyears) {
psi.t[t] <- psi.t[t-1]*phi[t-1] + (1-psi.t[t-1])*gamma[t-1] # expected occupancy
lambda[t-1] <- psi.t[t] / psi.t[t-1] # finite rate of change
turn[t-1] <- (gamma[t-1]*(1-psi.t[t-1])) /
(gamma[t-1]*(1-psi.t[t-1]) + phi[t-1]*psi.t[t-1]) # newly-colonized share
eq[t-1] <- gamma[t-1] / (gamma[t-1] + (1-phi[t-1])) # equilibrium occupancy
}
Watch out
Two indexing points worth pausing on. All three derived quantities describe a transition, not a year, so they are indexed [t-1] and run 1…Nyears-1 — the same index as phi and gamma. Writing one of them as eq[t] inside a for (t in 2:Nyears) loop would silently never assign eq[1], and monitoring it would hand you a column of NAs.
Note also that the turnover node is called turn, not tau. In NIMBLE and JAGS tau conventionally denotes a precision ($1/\sigma^2$) — that is exactly how it is used in the ant and N2O models earlier in this lab. Reusing the name for turnover invites a real mistake.
Key idea
Each derived quantity answers a different management question:
- Expected occupancy $\psi_t$ — the fraction of sites occupied in year $t$, carried forward by $\psi_t = \psi_{t-1}\phi_{t-1} + (1-\psi_{t-1})\gamma_{t-1}$. This is the metapopulation's state: how widespread is the species right now?
- Finite rate of change $\lambda_t = \psi_{t+1}/\psi_t$ — year-over-year growth of occupancy. $\lambda > 1$ means the range is expanding, $\lambda < 1$ contracting; it is the occupancy analogue of a population growth rate.
- Turnover $\tau_t = \dfrac{\gamma_t(1-\psi_t)}{\gamma_t(1-\psi_t) + \phi_t\psi_t}$ — of the sites occupied next year, what fraction were empty this year (newly colonized)? High turnover means constant local extinction-and-recolonization churn even when total occupancy looks steady — a very different conservation picture from a static, stable set of occupied patches.
- Equilibrium occupancy $\hat\psi_\text{eq} = \dfrac{\gamma}{\gamma + (1-\phi)}$ — where occupancy would settle if this year's rates held forever. Comparing $\hat\psi_\text{eq}$ to the current $\psi_t$ shows which way, and how far, the system is heading.
From the same run as Exercise 6 (30 sites, 8 years; R-hat = 1.00 for every derived quantity except $\lambda_3$, which is 1.01), the posterior medians tell a story the raw $\phi,\gamma$ cannot:
| Transition | $\psi_t \rightarrow \psi_{t+1}$ | lambda $\lambda_t$ | turn $\tau_t$ | eq $\hat\psi_{\text{eq}}$ |
|---|---|---|---|---|
| 1 → 2 | 0.33 → 0.37 | 1.15 | 0.85 | 0.37 |
| 2 → 3 | 0.37 → 0.18 | 0.49 | 0.47 | 0.15 |
| 3 → 4 | 0.18 → 0.43 | 2.36 | 0.79 | 0.46 |
| 4 → 5 | 0.43 → 0.31 | 0.72 | 0.81 | 0.34 |
| 5 → 6 | 0.31 → 0.41 | 1.31 | 0.94 | 0.38 |
| 6 → 7 | 0.41 → 0.43 | 1.06 | 0.79 | 0.43 |
| 7 → 8 | 0.43 → 0.40 | 0.93 | 0.52 | 0.40 |
The year-3 crash ($\lambda \approx 0.5$, occupancy roughly halved) and the year-4 rebound ($\lambda \approx 2.4$) jump straight out of the lambda column, and turnover stays high throughout (0.5–0.94) — so this is a churny metapopulation, sites blinking on and off, not a stable core of occupied patches. None of that is visible in the raw per-year $\phi_t,\gamma_t$.
Dynamic occupancy: simulate and recover
Open DynamicOccupancy_Nimble.R and run end-to-end.
- Plot posterior medians of $\phi_t$ vs. their simulated truth values. How close are they?
- Repeat for $\gamma_t$.
- Repeat for the year-specific expected occupancy $\psi_t$.
- What happens if you (mistakenly) omit the z-init trick — replace
zstwithmatrix(rbinom(Nsites*Nyears, 1, 0.3), Nsites, Nyears)? - Posterior intervals on individual $\phi_t$ are wide. Why? What would you do in a real analysis to tighten them?
Reveal worked solution (actual NIMBLE run)
Numbers below are from actually running the script (set.seed(2025): 30 sites, 8 years, 3 reps, true $p=0.6$, baseline occupancy $\approx 0.37$; 3 chains, 20000 iter, 10000 burn-in, thin 5). Across all 45 monitored parameters, R-hat is 1.00 everywhere except $\lambda_3$, which is 1.01 — convergence is not the problem here.
(a–c) The two quantities informed by all the data are recovered well: detection $p$ = 0.63 [0.55, 0.70] (truth 0.60) and baseline occupancy $\psi$ = 0.33 [0.18, 0.51] (truth 0.37). Year-specific persistence and colonization scatter around the 1:1 line but with very wide 95% CIs — e.g. $\phi_3$ = 0.52 [0.16, 0.89], $\phi_5$ = 0.08 [0.00, 0.36], $\gamma_1$ = 0.47 [0.26, 0.70] (its truth 0.47, recovered almost exactly; others land anywhere in their wide interval). The derived yearly occupancy $\psi_t$ tracks the true trajectory, including the dip in year 3 (true 0.13, posterior 0.18 [0.08, 0.34]).
(d) Replacing the all-ones zst with random 0/1 inits triggers an "initial values produce a density/likelihood of 0" error (a $-\infty$ log-prob), because any site-year where a randomly-drawn $z=0$ collides with an observed $y=1$ is logically impossible.
(e) Each year-transition $\phi_t,\gamma_t$ is informed by only ~30 sites across a single two-year step, so the CIs are wide even though R-hat is essentially 1.00 throughout — Bayes is being honest about a genuinely small sample, not failing to converge. The standard fixes: put a hierarchical prior on the transitions, e.g. $\text{logit}(\phi_t) \sim \mathcal{N}(\mu_\phi, \sigma_\phi^2)$ so years borrow strength, and/or add a covariate model for $\phi$ and $\gamma$.
F. Latent state models — N-mixture
Plain-language summary
What is an N-mixture model, and why do we need it? Suppose you want to know how many animals live at a site — salamanders in a plot, birds at a point, fish in a reach — but the animals are not individually marked and you cannot count every one. A single count is always an undercount: you never detect everything present, and the fraction you miss changes with weather, observer, habitat, and season. So a raw count confounds two very different things — how many animals are actually there (abundance, controlled by $\lambda$) and what fraction you happened to see (detection, $p$). Comparing raw counts across sites can therefore tell you more about detectability than about biology.
Royle's (2004) insight is that repeated visits break that confounding. If you count the same site several times over a short window during which the population is effectively constant ("closed"), then the variation among the repeated counts at a site carries information about detection, while the overall level of the counts carries information about abundance. With replication the model can estimate detection and abundance separately — something a plain GLM on raw counts cannot do.
It is the abundance cousin of the occupancy model. Occupancy asks "is the species present (0/1), correcting for missed detections?"; the N-mixture model asks "how many are present, correcting for missed detections?" Both are latent-state models: the quantity you care about (true presence, true abundance) is never observed directly, only through imperfect counts.
Biological setup
You visit $N_\text{sites}$ sites, revisiting each $N_\text{reps}$ times, and record a count $y[i, j]$ on visit $j$ to site $i$. The model has two layers. In the process layer, the true abundance $N_i$ at each site is a latent (unobserved) non-negative integer drawn from a Poisson with mean $\lambda$. In the observation layer, each of the $N_i$ individuals present is detected independently with probability $p$, so a visit yields a Binomial count out of $N_i$. The repeated visits to a site all share the same latent $N_i$, and that shared structure is what lets the model tease detection apart from abundance:
Watch out
Assumptions — check these before trusting $\hat\lambda$. The N-mixture model can separate abundance from detection only if a few conditions hold:
- Population closure. $N_i$ is constant across the replicate visits — no births, deaths, or movement in or out during the survey window. Space the visits closely enough in time that this is credible.
- No double counting. Within a visit each individual is counted at most once, and detections are independent. If the same animal is counted twice — a bird singing from several perches, a mobile animal re-entering the plot, overlapping observers double-recording — the counts are inflated and the extra visit-to-visit variance masquerades as imperfect detection: the model "explains" it by pushing $p$ down and $\hat\lambda$ up, biasing abundance high. This is the assumption most often broken in the field, so design the count protocol to prevent it.
- Homogeneous detection. Every individual present shares the same $p$ (or a $p$ fully captured by covariates). Unmodelled detection heterogeneity biases abundance, usually upward.
- Correct abundance distribution. The Poisson $N_i$ assumes mean $=$ variance; real counts are often clumpier (overdispersed). Catching exactly that is what the model and the Bayesian p-value below are for, with the Poisson-lognormal or negative-binomial as the fix.
- Independence among sites given their covariates.
Four nested models
- Basic: constant $\lambda$, constant $p$.
- Covariate on abundance: $\log \lambda_i = \beta_0 + \beta_1 \cdot \text{env}_i$.
- Covariate on both: abundance and detection get their own linear predictors.
- Overdispersion + Bayesian p-value: generate data with $N \sim \text{NB}$ but fit with the wrong Poisson model. Use posterior predictive simulation to detect the misspecification.
Watch out
In NIMBLE (and JAGS) the binomial is dbinom(prob, size) — the argument order is the opposite of R's rbinom(n, size, prob). A frequent silent bug. Also, initialise the latent abundance vector with N = apply(y, 1, max) so the binomial constraint $N_i \ge \max_j y_{i,j}$ is satisfied at the start.
Bayesian p-value as a goodness-of-fit check
This is likely the first time you have met a Bayesian p-value, so let's build it up slowly. The question it answers is simple: could data like mine plausibly have come from this fitted model? If the model is right, fake datasets simulated from it should look statistically like the real one. If the real data are systematically weirder than anything the model can produce, the model is missing something.
Step 1 — pick a discrepancy statistic $T(y, \theta)$. Choose a single number that captures a feature you care about — here, a chi-square-type sum $\sum_i (y_i - \mathbb{E}[y_i \mid \theta])^2 / \mathbb{E}[y_i \mid \theta]$, which grows large when the data are more variable than the model expects. Note it depends on the parameters $\theta$ as well as the data, because the expected value it compares against is itself a function of $\theta$. (In the code below the same idea appears as a Freeman-Tukey statistic, $\sum_i (\sqrt{y_i} - \sqrt{\mathbb{E}[y_i \mid \theta]})^2$ — same logic, better-behaved for small counts.)
Step 2 — build a reference distribution from the model itself. At each MCMC iteration $m$ you hold a full set of parameter draws $\theta^{(m)}$. Use them to simulate a fresh "replicate" dataset $y^\text{rep}_m$ of the same size — a dataset the fitted model considers perfectly ordinary — and compute the statistic on it, $T(y^\text{rep}_m, \theta^{(m)})$. Compute the statistic on the real data at the same draw, $T(y, \theta^{(m)})$. Repeating over all $M$ iterations gives paired values of $T$ across the posterior.
Step 3 — ask where the real data land in that distribution. The Bayesian p-value is the fraction of iterations at which the replicate's statistic exceeds the observed one:
Reading the notation: $\mathbb{1}[\cdot]$ is an indicator that equals 1 when the bracketed comparison is true and 0 when it is false, so the sum counts how many of the $M$ iterations put the replicate above the data, and dividing by $M$ turns that count into a proportion.
Watch out
Which thing carries the $m$? The superscript/subscript $m$ is on $\theta^{(m)}$ and on $y^\text{rep}_m$ — never on $y$. Your observed data are a fixed dataset; they do not change from one MCMC iteration to the next, so writing "$T(y_m)$" is meaningless. What changes across iterations is (i) the parameter draw $\theta^{(m)}$ and (ii) the fresh replicate $y^\text{rep}_m$ simulated from it. Both sides of the comparison are evaluated at the same $\theta^{(m)}$, and that is the pairing that makes this a posterior predictive check rather than a plug-in one: the discrepancy is averaged over the posterior of $\theta$ instead of being computed once at a point estimate.
Reading the number. If the model fits, the real data are a typical draw, so roughly half the replicates land above it and half below: $p_B \approx 0.5$. A value near 0 means the observed statistic is larger than almost every replicate (the data are far more variable — or more clustered — than the model can generate); a value near 1 means the opposite. Either extreme is evidence of misfit, and, unlike a bare point estimate, it tells you which feature (whatever $T$ measured) the model gets wrong. Values from about 0.1 to 0.9 are unremarkable.
N-mixture: build up from basic to overdispersed
Open NMixture_Nimble.R and run all four models.
- Model 1 (basic): recover $\lambda$ and $p$ from 20-site / 3-rep simulated data. How close are the posteriors to truth?
- Model 2 (covariate on abundance): is the posterior of $\beta_1$ credibly different from 0?
- Model 3 (covariate on both): does adding the detection covariate change inference on the abundance covariate?
- Model 4 (overdispersion): what is the Bayesian p-value? What does it say about the Poisson assumption?
Reveal worked solution (actual NIMBLE run)
From an actual run of NMixture_Nimble.R (set.seed(2025); 3 chains, niter=10000, nburnin=1000, thin 5). All R-hat = 1.00–1.02.
(a) Basic model (20 sites, 3 reps; truth $\lambda=6,\ p=0.8$). Recovered cleanly: $\lambda$ = 6.24 [5.06, 7.78] and $p$ = 0.80 [0.67, 0.87]. With only 20 sites the intervals are wide, but both comfortably cover truth.
(b) Covariate on abundance (truth $\beta_1=1$). $\beta_1$ = 1.00 [0.94, 1.07] — credibly positive and bang on the simulated value; $\beta_0$ = 2.99 [2.91, 3.07].
(c) Covariate on both. Adding the detection covariate ($a_0$ = 0.92 [0.70, 1.11], $a_1$ = 1.00 [0.91, 1.09], both recovering truth 1) leaves the abundance-covariate inference essentially unchanged: $\beta_1$ moves only from 1.00 to 1.04 [0.97, 1.10]. Because these data are simulated clean, modeling detection heterogeneity here neither biases nor much tightens $\beta_1$; in messier data, ignoring detection structure is what would distort it.
(d) Overdispersion + Bayesian p-value. The 200-site data were generated from a negative binomial but fit with a Poisson N-mixture. The discrepancy sits almost entirely on one side — observed $T(\text{data})$ median 367 vs replicated $T(y^\text{rep})$ median 332 — giving a Bayesian p-value of 0.008, far from 0.5. The Poisson model cannot absorb the extra variance, so it is decisively rejected; the fix is a Poisson-lognormal or negative-binomial N-mixture (Exercise 8). Note $\lambda$ = 5.81 [5.46, 6.17] still looks confident and reasonable — the GOF check is what exposes the misfit the point estimate alone would hide.
Add a site-level random effect to the N-mixture model
Modify Nmix4Code to add a Poisson-lognormal structure:
- Write the modified
nimbleCodeblock. Remember to add a prior on $\sigma_\epsilon$ (e.g.dunif(0, 5)). - Re-fit using the overdispersed simulated data from Model 4.
- Compute the Bayesian p-value again. Is it closer to 0.5 now?
- What is the posterior of $\sigma_\epsilon$? Does it capture the overdispersion that the basic Poisson missed?
Reveal sketch
The random effect is the new part, but keep the whole goodness-of-fit apparatus from Nmix4Code — the replicate y.pred, the expected value e, the two Freeman-Tukey residual arrays, and the two fit.* sums — or part (c) has nothing to compute. Note that e[i,j] now uses the site-specific lambda[i]:
Nmix5Code <- nimbleCode({
for (i in 1:Nsites) {
eps[i] ~ dnorm(0, 1/sig.eps^2)
log(lambda[i]) <- b0 + eps[i]
N[i] ~ dpois(lambda[i])
for (j in 1:Nreps) {
y[i,j] ~ dbinom(prob = p, size = N[i])
y.pred[i,j] ~ dbinom(prob = p, size = N[i]) # posterior replicate
e[i,j] <- p * lambda[i] # expected count under the model
resid[i,j] <- pow(pow(y[i,j], 0.5) - pow(e[i,j], 0.5), 2)
resid.pred[i,j] <- pow(pow(y.pred[i,j], 0.5) - pow(e[i,j], 0.5), 2)
}
}
fit.data <- sum(resid[1:Nsites, 1:Nreps]) # T(y, theta)
fit.pred <- sum(resid.pred[1:Nsites, 1:Nreps]) # T(y.rep, theta)
b0 ~ dunif(-5, 5)
p ~ dbeta(1, 1)
sig.eps ~ dunif(0, 5)
})
Monitor c("b0", "p", "sig.eps", "fit.data", "fit.pred"), then read the p-value off exactly as in Model 4:
post <- as.matrix(samples5)
mean(post[, "fit.pred"] > post[, "fit.data"])
After fitting, the Bayesian p-value moves much closer to 0.5 and the posterior of $\sigma_\epsilon$ is bounded away from 0.
Wrap up & reading
Key idea
What you can now do.
- Translate any JAGS model into NIMBLE, splitting
data=intoconstants=anddata=. - Diagnose link-scale prior problems and fix them with predictor scaling and weakly informative priors.
- Build multilevel models that share information across groups, with optional group-level covariates.
- Define derived quantities inside the model and let MCMC propagate uncertainty into them.
- Write latent-state models (dynamic occupancy and N-mixture) and apply the init tricks that make them actually run.
- Check goodness of fit with Bayesian p-values.
Suggested reading
- Hobbs & Hooten (2015), Bayesian Models: A Statistical Primer for Ecologists, Princeton.
- Kéry & Schaub (2012), Bayesian Population Analysis using WinBUGS, Academic Press.
- Gelman, Hill & Vehtari (2020), Regression and Other Stories, Cambridge — chapters 22-23 on hierarchical models.
- de Valpine et al. (2017), "Programming with models: writing statistical algorithms for general model structures with NIMBLE," JCGS.
- Royle & Dorazio (2008), Hierarchical Modeling and Inference in Ecology, Academic Press.
If you want to keep going
The graded problem set gives you ten more hierarchical, latent-state, and derived-quantity problems in NIMBLE, fit on the same real datasets in data/. Work through it, then try the same modelling patterns on your own study system's data.