Day 5 · Lab

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 work is a separate problem set. The Bayesian modeling problem set is worth 20 points, the only graded assignment on Day 5. Write up your answers in the R Markdown template and submit on Canvas.

Morning · 20 pts
Bayesian modeling problem set 2
Practice Walk through the morning problems on this page (answers visible, in class)
Assessed Graded problem set (answers not shown, graded, submit on Canvas)
Hand-inmorning_lab_template.Rmd (knit to HTML and upload to Canvas)

Need help with R Markdown? See the R Markdown tutorial. Other docs for this day: plain-language summary.

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 walkthrough
lizzard.Rlizzard.R · original JAGS model for the island lizards example (for comparison)
Lizards_Nimble.RLizards_Nimble.R · Nimble re-implementation
SpawnerRecruit_Nimble.RSpawnerRecruit_Nimble.R · Beverton-Holt + derived management quantities
Multilevel_Nimble.RMultilevel_Nimble.R · 5-model pooling spectrum on the N2O emission data
DynamicOccupancy_Nimble.RDynamicOccupancy_Nimble.R · multi-year occupancy with latent z
NMixture_Nimble.RNMixture_Nimble.R · four nested N-mixture models + Bayesian p-value
Plain-language summary Day 5, the ideas in normal English A short, no-math explanation of hierarchical models, latent states, and derived quantities: what each is for, and why ecologists reach for them. Read it before the lab to preview the ideas, or afterwards to consolidate what you did. Open the summary →
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.

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.

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") becomes myCode <- nimbleCode({ ... }).
  • JAGS' single data= list splits into two: constants= (sizes, indices, fixed covariate vectors, that is anything that never appears on the LHS of ~) and data= (the observed random variables).
  • JAGS' three-line jags.model() / update() / coda.samples() becomes a single nimbleMCMC() call.

Set samplesAsCodaMCMC = TRUE so coda functions like gelman.diag() and effectiveSize() work directly.

Plain-language overview

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 that 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 are all 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 are worked through slowly, with examples and no math.

Practice (answers visible)

Morning practice problems

The exercises below are walk-through practice. Your graded morning assignment uses a separate, novel problem set. Open the graded problem set below and write your answers in morning_lab_template.Rmd.

Open the graded morning problem set →    morning_lab_template.Rmd ↓

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:

$y_i \sim \text{Poisson}(\lambda_i), \quad \log \lambda_i = \beta_0 + \beta_1 \cdot \text{forest}_i + \beta_2 \cdot \text{latitude}_i + \beta_3 \cdot \text{elevation}_i$

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.

R · Poisson regression in NIMBLE (template ant.priors.R)
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/10000) # the same vague prior Part I used -- see the callout below
# 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)

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. It passes 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, and 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, since 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.

Why does this block keep tau = 1/10000? Because here the prior is the one thing that must not change. The shipped ant.priors.R fits this model twice, unscaled predictors in Part I, scaled predictors in Part II, holding the prior and the inits fixed at tau = 1/10000 both times, so that scaling is the only difference between the two runs. Change the prior at the same time and you can no longer attribute the improvement to scaling, which is the whole point of the demonstration.

That does not make tau = 1/10000 a good default. NIMBLE's dnorm takes a precision, so tau = 1/10 would mean $\sigma^2 = 10$, i.e. $\sigma = \sqrt{10} \approx 3.2$, on the log link roughly an $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. Exercise M2(c) is where you swap it out, and it shows that once the predictors are scaled and you have 44 bogs, the posterior barely notices.

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) or dbeta for 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.
Practice Exercise M1 · prior intuition

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.

  1. 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.
  2. What fraction of the prior puts $|\log_{10}(\text{effect})| > 6$ (a million-fold change)?
  3. 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

$\log_{10}(\text{effect}) = \dfrac{\beta_2\,\Delta x}{\ln 10}.$

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:

$|\log_{10}(\text{effect})| > 6 \quad\Longleftrightarrow\quad |\beta_2| > \dfrac{6\ln 10}{\Delta x}.$

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:

$\Pr\!\big(|\log_{10}(\text{effect})| > 6\big) = 2\,\Phi\!\left(-\dfrac{6\ln 10}{\sigma_\beta\,\Delta x}\right).$

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 M2. Standardising and priors solve two different problems, and you usually want both. Standardising centres the predictor and fixes the sampler. In Exercise M2 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 M2 the likelihood swamps even the $\sigma = 100$ prior, so the scaled fit lands in the right place regardless (that is exactly what Exercise M2(b) and 2(c) show). Tight priors earn their keep when data are scarce, which, in ecology, is often.

The takeaway is that 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.

Practice Exercise M2 · R

Fit the ant richness model twice

Using ants.csv and ant.priors.R as a starting point, fit the model:

  1. With RAW predictors and $\mathcal{N}(0, 100)$ prior on each $\beta$. How long do the chains take to mix? What does MCMCtrace show? What does gelman.diag give for R-hat?
  2. 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.
  3. 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 badly unconverged, and how badly is itself unstable, which is the point. Over ten different seeds at these settings, R-hat for b0 and b2 ranged from about 1.8 to 17 (median ≈ 4.6) and the effective sample size from 6 to 19 draws (median ≈ 10) out of 28,500 saved iterations. Do not memorise a single number here. The honest summary is "R-hat between roughly 2 and 20, n.eff in the single or low double digits, different every run." Their posterior means are correspondingly unstable. The intercept came back anywhere from 10.0 to 27.0 across those ten seeds, and disagree with glm() (which gives intercept 11.94, latitude −0.24). b3 (elevation) is the milder casualty, R-hat 1.01–2.3 depending on the seed. 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:

CoefficientPosterior mean [95% CI]R-hatglm()
b0 (intercept)1.84 [1.71, 1.96]1.001.85
b1 (forest)0.32 [0.21, 0.45]1.000.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, with 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:

$y_i \sim \text{Bernoulli}(p_i), \quad \text{logit}\,p_i = a + b \cdot x_i$
0 0.5 1 0 20 40 60 perimeter : area ratio P(presence) p = 0.5 near ratio 16 fitted logistic curve present (y=1) absent (y=0)
The 19 islands (teal): lizards are mostly present on low-edge islands (small perimeter:area) and absent on high-edge islands, with real scatter in between. The fitted logistic curve (orange) is the model's estimate of $P(\text{presence})$ as edge ratio increases. It falls from near 1 to near 0 and passes $p=0.5$ at a ratio of about 16. This is the data and fit behind Exercise M3.

The full NIMBLE template

R · Lizards_Nimble.R (excerpt)
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, because every Bayesian model has exactly this shape.

Likelihood (how the data arise):

$y_i \sim \text{Bernoulli}(p_i), \qquad \text{logit}\,p_i = a + b\,x_i, \qquad i = 1,\dots,n$

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.

$a \sim \mathcal{N}(0,\ \sigma^2 = 10^{6}), \qquad b \sim \mathcal{N}(0,\ \sigma^2 = 10^{6}) \qquad (\sigma = 1000)$

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 <-.

islands i = 1 … n a b x​i p​i y​i observed deterministic parameter (has a prior)

Read it as follows. 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, with 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

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.

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:

$a, b \sim \mathcal{N}(0, \sigma^2 = 2.71) \quad\Leftrightarrow\quad \texttt{dnorm(0, 1/2.71)}$

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:

R · what does each logit-scale prior imply for $p$?
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.

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.

How a vague logit-scale prior interacts with the likelihood Three panels on a 0-to-1 probability axis. Panel A: a vague normal prior on the logit becomes a U-shaped (bimodal) prior on p, spiking at 0 and 1. Panel B: when the likelihood has its weight in the middle of the interval, the posterior essentially equals the likelihood and the prior is harmless. Panel C: when the likelihood has weight near a boundary, multiplying by the boundary-spiking prior pulls the posterior hard toward 0, distorting it. Vague prior (U-shaped on p) Likelihood (data) Posterior A. The prior by itself 0 0.5 1 spikes at 0 and 1 B. Likelihood in the middle 0 0.5 1 harmless: posterior ≈ likelihood C. Likelihood near a boundary 0 0.5 1 distorted: posterior pushed to 0
A "vague" $\mathcal{N}(0,\text{large})$ prior on the logit becomes U-shaped on the probability $p$ (A). When the likelihood sits in the interior, the posterior is essentially the likelihood and the prior does no harm (B). But when the likelihood has weight near a boundary, multiplying by the boundary-spiking prior drags the posterior into the corner and makes it overconfident (C), which is why a flat prior on $p$ is the safer default.
Practice Exercise M3 · R · convergence

Lizards: run the model and read the diagnostics properly

Run the script Lizards_Nimble.R end to end. This exercise is about the diagnostics. The biological inference from this model (the sign of the slope, the occupancy difference between two islands, and how much work the prior is doing with only 19 islands) is Problem 5 and Problem 6, where you write the model yourself.

  1. Report gelman.diag and heidel.diag for $a$ and $b$ from the first (vague-prior) fit. Do all three chains agree?
  2. Given an mcmc.list, heidel.diag returns one table per chain rather than one pooled table, so you get three sets of p-values, not one. Look at all three. What would you have reported from chain 1 alone, and why is reading only the first chain a habit worth breaking?
  3. Now fit the second model in the script, the one with dnorm(0, 1/2.71). Its stationarity test passes but its halfwidth (precision) test flags on $a$ in all three chains. Only one of those two results is telling you something about convergence. Work out which, using this hint. Look at where $a$'s posterior mean sits, and ask what a test of relative precision does when the quantity it is measuring is near zero.
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 fits 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 all three chains pass the stationarity test for both parameters, with p-values of 0.62, 0.23, 0.18 for $a$ and 0.84, 0.40, 0.48 for $b$. So yes, the chains agree and the model has converged.

(b) Three tables, not one. Chain 1 alone would have you reporting 0.62 and 0.84, comfortably the most reassuring pair of the nine numbers, and the honest summary is the whole spread, 0.18 to 0.62 for $a$. Nothing here is failing; the point is that a stationarity p-value is not an estimate of anything. It means only "no evidence against stationarity" whenever it exceeds 0.05, so it carries real sampling variation from chain to chain, and quoting the best of three makes the agreement look tighter than it is. The habit generalises. Any diagnostic reported per chain should be read across all of them, because the one that fails is the one you need to see.

(c) A flag that is not a failure. The tight-prior model converges just as well (R-hat = 1.00; stationarity p-values 0.83/0.19/0.89 for $a$ and 0.93/0.77/0.69 for $b$). The halfwidth test flags anyway, and the reason is arithmetic rather than sampling. That test asks whether the Monte Carlo error of the posterior mean is small relative to the mean itself. Roughly, is the half-width of the mean's confidence interval under some fraction of $|\hat\mu|$. Under dnorm(0, 1/2.71) the posterior mean of $a$ sits almost exactly at zero, so that denominator is near zero and the criterion is close to unsatisfiable no matter how long you run the chain. Stationarity is the diagnostic that answers "has it converged?"; halfwidth answers the different question "have I run it long enough for the precision I want?", and it is unreliable whenever the parameter is centred on zero. The right response is to ignore it here, not to add iterations.

The general lesson. A diagnostic flag is a prompt to work out what the test measures, not an instruction. Two of the three results on this page look alarming at first glance and neither one indicates a problem.

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:

ApproachMathWhat it assumesWhen to use
Complete pooling$\mu_i = \alpha + \beta x_i$All sites identicalAlmost never, because it ignores grouping
No pooling$\mu_i = \alpha_{j[i]} + \beta x_i$, each $\alpha_j$ independentSites totally unrelatedWhen you have abundant data per site
Partial pooling$\alpha_j \sim \mathcal{N}(\mu_\alpha, \sigma_\alpha^2)$Sites are exchangeable draws from a populationDefault choice for grouped data

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.

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

R · partial pooling on alpha
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 rather than 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$:

$y_i = \alpha_{j[i]} + \beta x_i + \varepsilon_i, \qquad \alpha_j \sim \mathcal{N}\!\left(\kappa + \eta\, w_j,\ \sigma_\alpha^2\right).$

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:

$y_i = \underbrace{\kappa + \eta\, w_{j[i]} + \beta\, x_i}_{\text{fixed part (now including the group-level covariate)}} + \underbrace{u_{j[i]}}_{\text{mean-0 random intercept}} + \varepsilon_i.$

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.

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.
Practice Exercise M4 · R · core lab

Walk the pooling spectrum: the three N2O models

Open Multilevel_Nimble.R and walk through models 1–3 in order: complete pooling, no pooling, partial pooling. That progression is the point of the morning, and this exercise stops there. Models 4 and 5 in the same script (a covariate on the random intercept, and a slope that varies by fertilizer type) are what Problem 4 asks you to build for yourself; leave them alone for now.

  1. For each of the three, report the posterior median + 95% CI for $\beta$ (the N-input slope on the log scale). How much does the choice of pooling move the average slope?
  2. Plot the per-site $\alpha_j$ estimates from no-pooling (model 2) against partial-pooling (model 3) on the same axes, with the 1:1 line. Where are the largest shrinkages, and what do those sites have in common?
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). R-hat is 1.00 for every $\beta$ in models 1–3.

(a) The N-input slope $\beta$ is positive and barely moves across the pooling spectrum (posterior median [95% CI]):

Model$\beta$ median95% CI
1, complete pooling0.91[0.70, 1.12]
2, no pooling0.85[0.65, 1.05]
3, partial pooling0.88[0.69, 1.06]

This is worth pausing on, because it is the usual result and it is not the one students expect. Pooling is not a knob that changes your headline slope; it changes the per-site intercepts, and it changes how much you should believe each one. The average within-site response to nitrogen input is about the same however you handle the sites.

(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 biggest movers are dominated by the data-poorest sites, though not exclusively. The top five are site 51 ($n_j=1$) shifting 0.72, site 76 ($n_j=2$) 0.64, site 39 ($n_j=1$) 0.63, site 21 ($n_j=3$) 0.62, and site 42 ($n_j=1$) 0.61. Shrinkage depends on how far a site's own estimate sits from the mean as well as on how little data it has, so a site with two or three measurements that lands far out (site 76's no-pooling $\alpha$ is 2.64, against a grand mean of 0.18) can be pulled harder than a one-measurement site that was already near the middle. The tendency is what matters, and in the aggregate it is unmistakable. 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.

Where this goes next. Partial pooling pulls a data-poor site toward the grand mean because the grand mean is the only other information the model has about it. Problem 4 asks what happens when you give the model something better to pull it toward.

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:

$R_t = \dfrac{a \cdot S_t}{1 + (a/b) \cdot S_t} \cdot e^{\varepsilon_t}, \quad \varepsilon_t \sim \mathcal{N}(0, \sigma^2)$

where $a$ = productivity at low spawners, $b$ = asymptotic recruitment. Equivalently on the log scale:

$\log R_t \sim \mathcal{N}\left(\log a + \log S_t - \log\!\left(1 + \tfrac{a}{b} S_t\right),\ \sigma^2\right)$

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$.

2000 4000 6000 0 2000 4000 6000 Spawners S (this year) Recruits R (next year) replacement R = S fitted B-H curve N​eq ≈ 4040 equilibrium: R = S S​msy MSY max surplus
The fitted Beverton-Holt curve (orange) through the 20 years of data (teal), with the 1:1 replacement line $R=S$ (blue dashed) where each spawner produces exactly one recruit. $N_\text{eq}$ is where the curve crosses the replacement line, where recruits exactly replace spawners, so an unfished population settles there. The vertical gap between the curve and the replacement line is the surplus, recruits above replacement, the part you can harvest sustainably. That gap is largest at $S_\text{msy}$, and its height is the maximum sustainable yield. $H_\text{msy}=1-\sqrt{1/a}\approx 0.57$ is that maximum surplus expressed as a fraction of the recruits, the harvest rate that takes MSY. Because $(a,b)$ carry a joint posterior, so does every one of these lines and points.
R · Beverton-Holt model body (NIMBLE)
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)
})

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.

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

$N_\text{eq} = \dfrac{b(a-1)}{a}$

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

$H_\text{msy} < 1 - \sqrt{1/10} = 0.684$

and the posterior is visibly pressed against it. In the Exercise M5 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$.

Practice Exercise M5 · R

Spawner-recruit: fit the curve, then ask what it can support

Open SpawnerRecruit_Nimble.R. The script simulates 20 years of data from a known truth ($a=5$, $b=5000$, $\sigma=0.2$) and fits the Beverton-Holt curve. Fit it and interrogate the parameters here. The management quantities everyone actually manages by ($N_\text{eq}$, $S_\text{msy}$, $H_\text{msy}$) are nonlinear functions of $a$ and $b$, and building them as deterministic nodes so their full posteriors come for free is Problem 7. Get the fit right first; everything the problem set does rests on it.

  1. Run the script. Report the posterior median and 95% CI for $a$, $b$ and $\sigma$. Does every interval contain the truth?
  2. $a$ is the productivity parameter, and it is the one every reference point hinges on. How well do 20 years pin it down? Compare its 95% interval against its own prior, $a \sim U(1, 10)$, and say what you notice about the upper end.
  3. Increase Nyears from 20 to 100, regenerate the data, refit. By what factor does $a$'s 95% interval tighten? Compare that against the $\sqrt{N}$ rule of thumb ($\sqrt{100/20}\approx 2.24$) and account for any discrepancy.
Reveal worked solution (actual NIMBLE run)

From an actual run of SpawnerRecruit_Nimble.R (set.seed(2025); 3 chains, niter=10000, nburnin=1000).

(a) Posterior medians and 95% CIs (Nyears = 20). Every interval contains the truth (R-hat = 1.00–1.01):

QuantityMedian95% CITruth
$a$5.32[3.22, 9.25]5
$b$4977[3927, 7151]5000
$\sigma$0.245[0.181, 0.351]0.2

(b) $a$ is barely identified by 20 years. Its interval, [3.22, 9.25], covers almost two-thirds of the prior's entire range and its upper end runs very nearly into the prior ceiling of 10. That is the tell. When a posterior presses against a bound you chose, the bound is part of the answer. Productivity is the hardest thing to estimate from a short stock-recruit series, because $a$ is the slope of the curve at the origin and 20 years rarely include many observations at low spawner abundance, the very region that identifies it. Everything built on $a$ inherits that, which is worth remembering when Problem 7 turns $a$ into a harvest rate.

(c) More data, tighter posterior, faster than $\sqrt{N}$. Regenerating from the same truth with Nyears = 100 shrinks $a$'s 95% interval from [3.17, 9.19] (width 6.02) to [4.63, 6.34] (width 1.71), a factor of 3.5×. That beats the $\sqrt{100/20}\approx 2.24$ the $\sqrt N$ rule predicts, and (b) explains why. At 20 years the posterior is inflated by that collision with the prior ceiling, so it is wider than a well-identified posterior would be. The extra data do two jobs at once. They add information and pull the parameter into the regime where it is properly identified and roughly Gaussian, so the interval tightens faster than counting observations alone would suggest. $b$ behaves the same way (width 3269 → 878, a factor of 3.7), while $\sigma$, which was well identified from the start, tightens by 3.3×. The qualitative lesson is the safe one: more data buy tighter management quantities, on the order of $\sqrt N$ once you are out of the badly-identified regime.

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$).

The model has two layers:

$z[i, t] \sim \text{Bernoulli}(z[i, t-1] \phi_{t-1} + (1 - z[i, t-1]) \gamma_{t-1})$   (process)
$y[i, j, t] \sim \text{Bernoulli}(z[i, t] \cdot p)$   (observation)

$\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)

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, and that site-year's observation gets an initial log-density of $-\infty$. The fix: initialise the entire z matrix at 1, which is always consistent with every observed 1. The sampler will quickly learn from the data where the actual 0s belong.

What actually happens if you skip it depends on the engine. In JAGS the impossible start is fatal. jags.model() stops with Error in node y[1,1,1]: Node inconsistent with parents and no chain is built. NIMBLE 1.4.2 is more forgiving. It prints one warning: logProb of data node y[i, j, t]: logProb is -Inf. line per offending observation (dozens of them for this dataset), then builds and runs the MCMC anyway, and its binary samplers flip those $z$ to 1 almost immediately, so the posterior comes back essentially the same as with the all-ones start. So in NIMBLE the trick is defensive practice, not a hard requirement. It keeps the console clean so a genuine initialisation problem is visible, it keeps the model portable to JAGS/BUGS, and it protects you in the models where recovery is not automatic (a latent state that is continuous, or sampled by a block/Metropolis sampler that cannot step out of a $-\infty$ region, can stay stuck there for the whole run).

R · dynamic occupancy in NIMBLE (excerpt)
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:

R · derived quantities, inside DynOccCode
 # 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
 }

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$), which 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.

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 M6 (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 → 20.33 → 0.371.150.850.37
2 → 30.37 → 0.180.490.470.15
3 → 40.18 → 0.432.360.790.46
4 → 50.43 → 0.310.720.810.34
5 → 60.31 → 0.411.310.940.38
6 → 70.41 → 0.431.060.790.43
7 → 80.43 → 0.400.930.520.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$.

Practice Exercise M6 · R · latent states

Dynamic occupancy: simulate and recover

Open DynamicOccupancy_Nimble.R and run end-to-end.

  1. Plot posterior medians of $\phi_t$ vs. their simulated truth values. How close are they?
  2. Repeat for $\gamma_t$.
  3. Repeat for the year-specific expected occupancy $\psi_t$.
  4. What happens if you (mistakenly) omit the z-init trick, replacing zst with matrix(rbinom(Nsites*Nyears, 1, 0.3), Nsites, Nyears)?
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 does not stop NIMBLE 1.4.2. Every site-year where a randomly-drawn $z=0$ collides with an observed $y=1$ is logically impossible, so each such observation starts at a $-\infty$ log-density, and NIMBLE says so, printing one warning: logProb of data node y[i, j, t]: logProb is -Inf. line per offending observation before it runs. It then runs to completion, and because $z$ is binary the samplers move those cells to 1 on essentially the first sweep. With a random 0/1 start the posterior comes back the same as with zst (detection $p$ median 0.63 either way). Run the identical model in JAGS and it is fatal: jags.model() aborts with Error in node y[1,1,1]: Node inconsistent with parents. The lesson to take from (d) is therefore not "it crashes" but "read the warnings". A wall of $-\infty$ log-probabilities means your initial values contradict your data; you cannot rely on the sampler rescuing you in a model with continuous latent states, and the all-ones zst makes the whole class of problem disappear.

Notice what you are left with. The intervals on the individual $\phi_t$ and $\gamma_t$ are very wide, and R-hat says that is not a convergence problem. The model is being honest about a genuinely small sample. What to do about it is Problem 10, where you measure two different fixes and find that one of them can fool you.

F. Latent state models: N-mixture

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:

$N_i \sim \text{Poisson}(\lambda), \quad y[i, j] \sim \text{Binomial}(N_i, p)$

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, with 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

  1. Basic: constant $\lambda$, constant $p$.
  2. Covariate on abundance: $\log \lambda_i = \beta_0 + \beta_1 \cdot \text{env}_i$.
  3. Covariate on both: abundance and detection get their own linear predictors.
  4. 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.

In NIMBLE (and JAGS) the binomial is dbinom(prob, size), and 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:

$p_B = \dfrac{1}{M}\sum_{m=1}^M \mathbb{1}\!\left[ T(y^\text{rep}_m,\, \theta^{(m)}) > T(y,\, \theta^{(m)}) \right]$

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.

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.

Practice Exercise M7 · R

N-mixture: build up from basic to overdispersed

Open NMixture_Nimble.R and run all four models.

  1. Model 1 (basic): recover $\lambda$ and $p$ from 20-site / 3-rep simulated data. How close are the posteriors to truth?
  2. Model 2 (covariate on abundance): is the posterior of $\beta_1$ credibly different from 0?
  3. Model 3 (covariate on both): does adding the detection covariate change inference on the abundance covariate?
  4. 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 M8). 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.

Practice Exercise M8 · R · synthesis

Add a site-level random effect to the N-mixture model

Modify Nmix4Code to add a Poisson-lognormal structure:

$\log \lambda_i = \beta_0 + \epsilon_i, \quad \epsilon_i \sim \mathcal{N}(0, \sigma_\epsilon^2)$
  1. Write the modified nimbleCode block. Remember to add a prior on $\sigma_\epsilon$ (e.g. dunif(0, 5)).
  2. Re-fit using the overdispersed simulated data from Model 4.
  3. Compute the Bayesian p-value again. Is it closer to 0.5 now?
  4. 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, $\sigma_\epsilon$ comes back clearly bounded away from 0 (median 0.47, 95% CI [0.39, 0.57]) and the Bayesian p-value climbs from 0.008 to about 0.17: a big improvement, and inside the unremarkable 0.1–0.9 band, but not all the way to 0.5. It should not be, because the lognormal random effect is not the negative binomial the data actually came from, so it soaks up most of the extra variance and leaves a little behind. Two practical notes. Give this run more iterations than the script's default (the numbers above are 3 chains, 60000 iterations, 20000 burn-in, thin 10: sig.eps mixes slowly), and expect a wall of problem initializing stochastic node y.pred[i, j]: logProb is -Inf warnings at startup, which are about the posterior-predictive replicates being initialised at values impossible under the current N[i] and clear themselves within the first sweeps.

Wrap up & reading

What you can now do.

  1. Translate any JAGS model into NIMBLE, splitting data= into constants= and data=.
  2. Diagnose link-scale prior problems and fix them with predictor scaling and weakly informative priors.
  3. Build multilevel models that share information across groups, with optional group-level covariates.
  4. Define derived quantities inside the model and let MCMC propagate uncertainty into them.
  5. Write latent-state models (dynamic occupancy and N-mixture) and apply the init tricks that make them actually run.
  6. 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.

Graded problem set →