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 below is included in the course R project, organized by day. Open FW536.Rproj and the code on this page finds the data with here::here(). Click any link to download a single file.

Datasets

ants.csvAnt species richness at 22 New England sites, sampled once in a bog and once in the adjacent forest (44 rows; Gotelli & Ellison); covariates: forest (1 = forest, 0 = bog), latitude, elevation.
N2OEmission.csv563 N2O emission measurements from 107 fertilized agricultural sites, with fertilizer amount and type and soil carbon (columns explained in section C).
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.

Before the models: did it converge, and is the model any good?

Every model today ends with two separate questions, and they need different tools. Did the chains converge? asks whether the MCMC output is a trustworthy picture of the posterior. Is the model any good? asks whether that posterior describes the data. A model can pass the first and fail the second: a perfectly converged sampler can faithfully describe a wrong model.

1. Did the chains converge? R-hat

Start several chains from different values. If they have all found the same posterior, the spread between chains should match the spread within a chain. R-hat (the Gelman–Rubin statistic) is the ratio of the two, roughly. An R-hat of 1.42 says the chains still disagree and you need to run longer; an R-hat of 1.00 says the chains are interchangeable.

  • gelman.diag(samples) reports it. Aim for R-hat below 1.05 for every parameter; values up to about 1.1 are generally considered acceptable. Above that, run the chains longer.
  • R-hat is only meaningful if the chains started from genuinely different places. Identical starting values can report 1.00 on a model that has not converged.

2. An optional extra check: Heidelberger–Welch

R-hat and the traceplots are the routine checks. If you want a second opinion on whether a chain has settled and run long enough, heidel.diag(samples) runs two tests on each chain separately. You do not need it for every model; it is most useful when a traceplot looks borderline.

  1. Does the chain settle down? (the stationarity test) It compares the start of the chain with the end. If they look like samples from the same distribution, the chain has stopped wandering. A failure means it is still drifting: discard more as burn-in, or run longer. The test doubles as an automatic burn-in finder: if the whole chain fails, it retests without the first tenth, then without the first fifth, and so on up to half. Where it first passes is your burn-in.
  2. Is the mean pinned down? (the halfwidth test) How much would the reported posterior mean move if you re-ran the sampler with a new seed? That wobble is the Monte Carlo error. If it is larger than a tenth of the mean, run longer. (This test misbehaves when the mean is near zero, because a tenth of almost nothing is almost nothing; Exercise M3 shows that case.)

Passing is necessary, not sufficient. Convergence diagnostics such as R-hat can only detect failure. They can never prove the sampler found the whole posterior. Clean diagnostics say the chains are trustworthy; posterior predictive checks say whether the model is.

3. Is the model any good? Posterior predictive checks

If the model is any good, data simulated from it should look like the data you actually collected. The recipe has three steps.

  1. Draw parameters. Take one posterior draw $\theta^{(s)}$: one plausible version of the world.
  2. Simulate data. Run the likelihood forward with those parameters to get a fake dataset $y^\text{rep}$ of the same size as the real one.
  3. Compare. Does the observed $y$ sit comfortably among the simulated datasets?

Repeat across hundreds of draws and you get a whole distribution of datasets the model finds believable. A systematic mismatch, such as too many zeros, too much spread, or missing extremes, means the model is missing something real.

4. Bayesian p-values

To make the comparison concrete, summarize each dataset with one discrepancy number $T$, then ask how often the simulated $T$ is at least as extreme as the real one. A value near 0.5 says the simulated data look like the real data; a value near 0 or 1 says the model cannot reproduce that feature. This is not a hypothesis test. It is a diagnostic that tells you which feature fails, so choose $T$ to target what matters for your question: the number of zeros, the variance, the extremes. Section F builds one step by step for the N-mixture model, and Problem 8 has you run one yourself.

Where these appear today. Exercise M3 reads gelman.diag on the lizard model, and tries the optional heidel.diag to show how its output is read, including a halfwidth flag that is not a real failure. Exercises M7 and M8 and Problem 8 use posterior predictive checks and Bayesian p-values to catch, and then fix, an N-mixture model with the wrong abundance distribution.

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 records ant species richness, the number of ant species found, at 22 sites across New England. At each site ants were sampled twice, once in a bog and once in the forest next to it, so the file has 44 rows, one per habitat per site. The three covariates are forest (1 for the forest sample, 0 for the bog sample), latitude (degrees north), and elevation (m). 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(here::here("Day5_BayesII", "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 samples, 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 96% and 89% come from, one small step at a time. You can get both numbers without simulating. It takes one idea about logarithms and one about the bell curve.

Step 0 · What the two functions are doing. Two functions appear, and each has a plain meaning.

  • $e^{x}$ (in R, exp(x)) turns a number on the log scale into a multiplier. With a log link, a slope $\beta$ means "multiply the expected count by $e^{\beta}$ for each unit of $x$". So $e^{0.5 \times 3} = 4.5$ means richness is 4.5 times higher across the 3 degrees.
  • $\log_{10}$ just counts zeros. $\log_{10}(1{,}000{,}000) = 6$ because a million is 1 followed by 6 zeros, and $\log_{10}(0.000001) = -6$. So "$|\log_{10}(\text{effect})| > 6$" is shorthand for "the effect is more than a million times bigger, or more than a million times smaller".

Step 1 · Turn "a million-fold effect" into a question about $\beta$. Over the 3-degree range the effect is $e^{3\beta}$. When is that bigger than a million? The natural log, log() in R, is the function that undoes exp(), so ask what power of $e$ gives a million:

$\log(1{,}000{,}000) = 13.8, \qquad\text{so}\qquad e^{3\beta} > 1{,}000{,}000 \ \text{ exactly when } \ 3\beta > 13.8.$

Divide both sides by 3: the effect passes a million when $\beta > 4.6$. Check it: exp(3 * 4.6) is about 985,000, just short of a million. The same argument on the other side says the effect shrinks by more than a million when $\beta < -4.6$. So the whole question becomes: how often does the prior draw a slope more than 4.6 away from zero?

Step 2 · Compare 4.6 with the width of the prior. The prior is a bell curve centred at 0 with standard deviation 100. Most of its draws land somewhere between about $-200$ and $+200$. The "safe" band, $-4.6$ to $+4.6$, is a thin sliver in the middle of that: 4.6 is less than one-twentieth of one standard deviation (4.6 ÷ 100 = 0.046). Only about 3.7% of the prior falls inside the sliver, so about 96.3% falls outside it. In R, the share of a Normal curve beyond a cut-off is 2 * pnorm(-cutoff / SD):

log(1e6) / 3                   # 4.6, the slope that gives a million-fold effect over 3 degrees
2 * pnorm(-4.6 / 100)         # 0.963, the share of the prior beyond it

Step 3 · Standardise latitude and repeat. Now one unit of $x$ is one standard deviation of latitude, so the effect of a one-unit step is $e^{\beta}$, with no factor of 3. The cut-off becomes $\beta > 13.8$ instead of 4.6. That is three times further out, but it is still small next to a prior SD of 100 (13.8 ÷ 100 = 0.14), so the sliver is still thin: about 11% inside, 89% outside.

2 * pnorm(-13.8 / 100)        # 0.890

Step 4 · Now shrink the prior instead. Keep latitude standardised, so the cut-off is still 13.8, but use a prior SD of 2. Now 13.8 is almost 7 standard deviations from the centre (13.8 ÷ 2 = 6.9), far out in the tail where the bell curve is essentially zero:

2 * pnorm(-13.8 / 2)          # about 5 in a trillion

Under this prior half of all draws imply less than a 3.9-fold change per standard deviation of latitude, and 95% imply less than about 52-fold. Those are effects a biologist can believe.

SettingA million-fold effect needs $|\beta|$ abovePrior SDShare of the prior beyond that
Raw latitude, 3-degree range4.610096%
Standardised latitude, 1 SD13.810089%
Standardised latitude, 1 SD13.82about 0

What the table says. Standardising the predictor moved the cut-off further out, but only by a factor of 3, and 13.8 is still tiny next to 100, so almost nothing changed (96% to 89%). Shrinking the prior from 100 to 2 moved the cut-off from a fraction of one SD to seven SDs, and the absurd effects vanished. The cure for absurd prior effects is a sensible prior, not standardising. The general rule behind every row: compare the slope that would give an absurd effect with the prior's standard deviation. If that slope is well inside one SD, the prior is claiming absurd effects are likely.

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

R · code for this solution
library(nimble); library(MCMCvis); library(coda)
set.seed(10)
ants <- read.csv(here::here("Day5_BayesII", "data", "ants.csv"))

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); b1 ~ dnorm(0, tau)
  b2 ~ dnorm(0, tau); b3 ~ dnorm(0, tau)
})

# Fit the model to a data frame with a given prior precision tau.
# Inits start chain 1 at the glm() estimates and jitter the other two.
fit_ants <- function(dat, tau) {
  start <- coef(glm(richness ~ forest + latitude + elevation, data = dat, family = poisson))
  names(start) <- c("b0", "b1", "b2", "b3")
  inits <- list(as.list(start), as.list(start + runif(4, -1, 1)), as.list(start + runif(4, -1, 1)))
  nimbleMCMC(antCode,
             constants = list(n = nrow(dat), forest = dat$forest, latitude = dat$latitude,
                              elevation = dat$elevation, tau = tau),
             data = list(y = dat$richness), inits = inits,
             monitors = c("b0", "b1", "b2", "b3"),
             nchains = 3, niter = 10000, nburnin = 500, samplesAsCodaMCMC = TRUE)
}

# (a) raw predictors, sigma = 100 (tau = 1/10000)
raw <- fit_ants(ants, tau = 1/10000)
MCMCsummary(raw, round = 2)          # look at the Rhat and n.eff columns
MCMCtrace(raw, pdf = FALSE)          # b0 and b2 wander instead of overlapping
gelman.diag(raw)

# (b) scaled predictors, same prior
ants_s <- ants
for (v in c("forest", "latitude", "elevation")) ants_s[[v]] <- as.numeric(scale(ants[[v]]))
scaled <- fit_ants(ants_s, tau = 1/10000)
MCMCsummary(scaled, round = 2)
round(coef(glm(richness ~ forest + latitude + elevation, data = ants_s, family = poisson)), 2)

# (c) scaled predictors, tight prior sigma = 2 (tau = 1/4)
tight <- fit_ants(ants_s, tau = 1/4)
MCMCsummary(tight, round = 2)

(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 about 20 draws (median ≈ 10) out of 28,500 saved iterations. The code above, at set.seed(10), gives R-hat 3.4 and n.eff 21 for both. 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.00–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 samples 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(here::here("Day5_BayesII", "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

The line logit(p[i]) <- a + b * x[i] means $p_i = \text{logit}^{-1}(a + b x_i)$, and y[i] ~ dbern(p[i]) means NIMBLE scores each island by $\log p_i$ (present) or $\log(1-p_i)$ (absent). Both steps happen behind the scenes. If you initialise far from 0, say a = 50, b = -50, an island where the lizard is absent, such as island 9 ($x = -0.38$), gets $a + bx = 69$, and $\text{logit}^{-1}(69)$ is exactly 1 in floating point. That island then scores $\log(1-1) = \log(0) = -\infty$, and so do four other absent islands. NIMBLE does not stop: it prints logProb of data node y[i]: logProb is -Inf for each of them and runs, but the chain cannot move and stays at its starting values for the whole run. A moderate start like a = 10, b = -10 still works on these data. 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 and the difference in occupancy probability between two islands) is Problem 5, 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.

R · code for this solution (sources the script, then reads the diagnostics)
set.seed(2025)
source(here::here("Day5_BayesII", "Lizards_Nimble.R"))
library(coda)
# (a) the vague-prior fit
gelman.diag(samples1[, c("a", "b")])
heidel.diag(samples1[, c("a", "b")])

# (b) heidel.diag returns one table per chain; line up the stationarity p-values
sapply(heidel.diag(samples1[, c("a", "b")]), function(h) h[, "pvalue"])

# (c) the tight-prior fit: stationarity passes, halfwidth flags on a
heidel.diag(samples2[, c("a", "b")])
MCMCsummary(samples2, params = c("a", "b"), round = 3)   # a's posterior mean is near 0

(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.41/0.78/0.35 for $a$ and 0.36/0.24/0.22 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$ is close to zero, about −0.20 against a posterior SD of 0.58, so that denominator is small. Each chain's halfwidth is about 0.036, roughly 18% of $|\hat\mu|$, over the default 10% threshold, and because the target itself is near zero the criterion stays hard to satisfy 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

Nitrous oxide ($\text{N}_2\text{O}$) is a potent greenhouse gas, and fertilized farm soils are one of its largest sources. Adding nitrogen fertilizer generally raises emissions, but by how much differs from place to place. N2OEmission.csv compiles 563 emission measurements from 107 sites around the world. Each row is one measurement: a fertilizer of some type, applied at some rate at one site, and the $\text{N}_2\text{O}$ emission that followed. Sites differ in soil organic carbon, climate, and microbial communities, so we expect measurements from the same site to resemble each other, which is site-level structure.

ColumnWhat it isHow the models use it
emissionThe N2O emission measured after the fertilizer was applied.The response, as $y_i = \log(\text{emission})$.
n.inputThe amount of nitrogen fertilizer applied.The predictor, as $x_i = \log(\text{n.input})$ minus its mean.
group, group.indexThe site (field or study location) the measurement came from. group is the ID from the original compilation; group.index renumbers the same 107 sites 1–107.group.index is $j$, the site index, so row $i$ uses intercept $\alpha_{j[i]}$.
fertilizer, fert.indexThe type of fertilizer applied, as a letter code (A, AM, AN, M, N, U, UA, UAN, UM, unknown). fert.index numbers the same ten types 1–10. Type is recorded per measurement, and 47 sites tested more than one type.fert.index is $k$, the fertilizer index. It is used only when the N-input slope is allowed to differ by fertilizer, $\beta_{k[i]}$.
carbonSoil organic carbon, as a percentage of soil mass. Most sites report one value; 36 report slightly different values across their measurements.Averaged within each site to give one value $c_j$ per site, then transformed to the site-level covariate $w_j = \log\big(c_j/(100-c_j)\big)$.
repsA replicate count carried over from the source compilation. It is constant within a site.Not used in any model here.

The file does not record units. That matters less than it sounds: because both emission and N input are logged, the slope $\beta$ is unit-free. It is the proportional change in emission for a proportional change in N input, so $\beta = 1$ means doubling the fertilizer doubles the emission. The sites are very unevenly sampled, from 1 measurement (8 sites) to 25, which is exactly the situation where the choice of pooling matters.

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({
 # one intercept per SITE: j runs 1, 2, ..., 107
 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

 # one row of data at a time: i runs 1, 2, ..., 563
 for (i in 1:n) {
 # group[i] is the site number of row i, so alpha[group[i]] is
 # that row's own site intercept (see the note below)
 mu[i] <- alpha[group[i]] + beta * x[i]
 y[i] ~ dnorm(mu[i], tau)
 }
})

Reading alpha[group[i]]: the notation that makes a random effect work. Two different counters are running in that model, and keeping them straight is the whole trick.

  • $i$ counts rows of data, 1 to 563. Each row is one measurement, with its own $y_i$ and $x_i$.
  • $j$ counts sites, 1 to 107. Each site has one intercept, alpha[j]. There are 107 of them, not 563.

The two are linked by group, an ordinary integer vector of length 563 that you pass in as a constant: group[i] is the site number that row $i$ belongs to. So alpha[group[i]] reads from the inside out: take row $i$, look up its site, then use that site's intercept. If group[5] is 10, then row 5's mean uses alpha[10]. Every row from site 10 reuses that same parameter, which is exactly what "these measurements share a site" means, and is what lets 563 rows be described by 107 intercepts. In the notation used in the equations above, alpha[group[i]] is $\alpha_{j[i]}$.

Nothing exotic is happening: this is R's ordinary vector indexing, the same as alpha[c(1, 1, 2, 5, ...)], written inside a model. Two practical requirements follow.

  • The index must be consecutive integers starting at 1. alpha is a vector of length n.sites, so an index of 161 would look for a 161st element that does not exist. This is why the data file carries group.index (1–107) alongside the original site IDs, and why you would run as.numeric(factor(...)) on any real ID column before using it here.
  • The index belongs in constants, not data. NIMBLE uses it to build the model's graph, deciding which rows connect to which parameter, before any sampling starts. It is not a random variable.

The same pattern indexes anything else that varies by group. In the fertilizer model later, beta[fertilizer[i]] picks the slope belonging to row $i$'s fertilizer type, and a model can use both at once: alpha[group[i]] + beta[fertilizer[i]] * x[i].

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, to explain some of that site-to-site variation. In the N2O data, $w_j$ is built from the carbon column: average it within site $j$ to get $c_j$ (percent soil organic carbon), then take its logit, $w_j = \log\big(c_j/(100-c_j)\big)$. The logit suits a percentage, which is bounded between 0 and 100, and it spreads out the many low-carbon sites (site means range from 0.02% to 31%). A common way to write the model 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).$

Read the second part as a small regression with one data point per site. Its response is the site intercept $\alpha_j$ (a site's typical log-emission at average N input), and its predictor is the site's soil carbon $w_j$:

  • $\kappa$ (kappa) is that regression's intercept: the expected $\alpha_j$ for a site with $w_j = 0$. If you centre $w$, that is a site with average soil carbon.
  • $\eta$ (eta) is its slope: how much a site's typical log-emission changes per one-unit increase in $w_j$. $\eta > 0$ means carbon-rich soils emit more.
  • $\sigma_\alpha$ is the scatter of the site intercepts around that line: the site-to-site variation that soil carbon does not explain.

Without the covariate, $\kappa + \eta\, w_j$ is replaced by the single mean $\mu_\alpha$, so $\kappa$ plays the role $\mu_\alpha$ played before. 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.

R · code for this solution
# Run models 1-3 of Multilevel_Nimble.R first (everything above the MODEL 4 header),
# so samples1, samples2, samples3, group and nsites exist.

# (a) the N-input slope beta under each pooling choice: median and 95% CI
cols <- c("50%", "2.5%", "97.5%")
rbind(complete = MCMCsummary(samples1, params = "beta")[, cols],
      none     = MCMCsummary(samples2, params = "beta")[, cols],
      partial  = MCMCsummary(samples3, params = "beta")[, cols])

# (b) shrinkage: no-pooling vs partial-pooling site intercepts
a.no   <- MCMCpstr(samples2, params = "alpha", func = median)$alpha
a.part <- MCMCpstr(samples3, params = "alpha", func = median)$alpha
plot(a.no, a.part, pch = 19, xlab = "alpha[j], no pooling", ylab = "alpha[j], partial pooling")
abline(0, 1, col = "grey")

n_j   <- as.vector(table(group))      # measurements per site (group.index 1..107)
shift <- abs(a.no - a.part)           # how far each site moved
shrink <- data.frame(site = 1:nsites, n_j, no_pool = a.no, partial = a.part, shift)
head(shrink[order(-shrink$shift), ], 5)                 # the biggest movers
tapply(shift, cut(n_j, c(0, 1, 9, Inf), labels = c("1", "2-9", "10+")), mean)
cor(n_j, shift)
MCMCsummary(samples3, params = "mu_alpha", round = 2)

(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$. Here $t$ labels a brood, the fish spawned in one season, and $R_t$ counts the recruits those spawners produce. Those recruits do not show up the following year: salmon spend several years at sea before returning, so the recruits from brood $t$ come back over the next few years. That is why the model is written per time step (per brood, or generation) rather than per calendar year, and why the figure below says "this time step" and "next time step". 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 time step) Recruits R (next time step) 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 broods of data (teal), each pairing a brood's spawners with the recruits they produced, 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 6. 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).

R · code for this solution (sources the script, then refits at 20 and 100 years)
source(here::here("Day5_BayesII", "SpawnerRecruit_Nimble.R"))
# (a) medians and 95% CIs against the truth (a = 5, b = 5000, sigma = 0.2)
MCMCsummary(samples, params = c("a", "b", "sigma"), round = 3)

# (b) how much of the prior's range, U(1, 10), does a's 95% interval cover?
ci_a <- quantile(as.matrix(samples)[, "a"], c(0.025, 0.975))
ci_a
diff(ci_a) / (10 - 1)

# (c) simulate and fit a fresh dataset of any length, same truth and priors
fit_bh <- function(Nyears) {
  S <- rlnorm(Nyears, meanlog = 7.5, sdlog = 0.8)
  R <- (a.true * S) / (1 + (a.true / b.true) * S) * exp(rnorm(Nyears, 0, sigma.true))
  nimbleMCMC(BHcode, constants = list(Nyears = Nyears, S = S), data = list(log.R = log(R)),
             inits = inits, monitors = c("a", "b", "sigma"),
             nchains = 3, niter = 10000, nburnin = 1000, samplesAsCodaMCMC = TRUE)
}
width <- function(s, par) diff(quantile(as.matrix(s)[, par], c(0.025, 0.975)))

set.seed(2025); fit20  <- fit_bh(20)
set.seed(2025); fit100 <- fit_bh(100)
MCMCsummary(fit20,  params = c("a", "b", "sigma"), round = 3)
MCMCsummary(fit100, params = c("a", "b", "sigma"), round = 3)
sapply(c("a", "b", "sigma"), function(par) unname(width(fit20, par) / width(fit100, par)))
sqrt(100 / 20)

(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 6 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.22, 9.25] (width 6.03) to [4.58, 6.28] (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 3224 → 893, a factor of 3.6), while $\sigma$, which was well identified from the start, tightens by 3.0×. 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

Occupancy with a time series. A single-season occupancy model asks where a species is now. Survey the same sites year after year and you can ask how the pattern changes: which occupied sites stay occupied (persistence, the complement of local extinction) and which empty sites are colonized. Those two rates are what a dynamic occupancy model estimates, and they are the quantities that separate a stable range from one that is expanding, contracting, or simply churning.

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.

This structure scales a long way beyond the two layers here. Long-term spotted owl monitoring, for example, is analysed with integrated multistate dynamic occupancy models, which combine several data sources and let a site be occupied by a pair, by a single bird, or by a competitor such as the barred owl, with transitions among those states. The ideas are the ones below: a latent state that changes between years, an observation layer with imperfect detection, and transition probabilities as the parameters of interest.

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.

R · code for this solution (sources the script, then compares to truth and refits with random z starts)
source(here::here("Day5_BayesII", "DynamicOccupancy_Nimble.R"))
cols <- c("2.5%", "50%", "97.5%")
# (a)-(c) posterior medians and 95% CIs beside the simulated truth
MCMCsummary(samples, params = c("p", "psi"), round = 2)
cbind(truth = round(phi.true, 2),   MCMCsummary(samples, params = "phi",   round = 2)[, cols])
cbind(truth = round(gamma.true, 2), MCMCsummary(samples, params = "gamma", round = 2)[, cols])
cbind(truth = round(colMeans(z), 2), MCMCsummary(samples, params = "psi.t", round = 2)[, cols])

# (d) refit with random 0/1 starting values for z instead of the all-ones zst
bad.init <- function() {
  i <- init.fun()
  i$z <- matrix(rbinom(Nsites * Nyears, 1, 0.3), Nsites, Nyears)
  i
}
samples.bad <- nimbleMCMC(DynOccCode, constants = constants, data = data,
                          inits = list(bad.init(), bad.init(), bad.init()),
                          monitors = c("p", "psi", "phi", "gamma"),
                          nchains = 3, niter = 20000, nburnin = 10000, thin = 5,
                          samplesAsCodaMCMC = TRUE)
MCMCsummary(samples.bad, params = c("p", "psi"), round = 2)

(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 9, 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$. Dividing each squared difference by the expected count puts every observation on a comparable scale, because for counts the variance is roughly the mean: a miss of 3 when 2 were expected counts for more than a miss of 3 when 30 were expected. NMixture_Nimble.R computes exactly this sum, as fit.data for the real counts and fit.pred for the replicates.

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.

The discrepancy statistic in Model 4, and why it is built that way. Model 4 adds three lines that are easy to skim past:

e[i, j]          <- p * lambda                          # expected count on visit j to site i
resid[i, j]      <- pow(y[i, j]      - e[i, j], 2) / e[i, j]
resid.pred[i, j] <- pow(y.pred[i, j] - e[i, j], 2) / e[i, j]

Summed over all counts, that is

$T(y, \theta) \;=\; \sum_{i}\sum_{j} \dfrac{\big(y_{ij} - \mathbb{E}[y_{ij}]\big)^2}{\mathbb{E}[y_{ij}]}, \qquad \mathbb{E}[y_{ij}] = p\,\lambda,$

a squared error divided by the expected count, where $p$ is the per-individual detection probability and $\lambda$ the mean abundance per site, so $p\lambda$ is the number of animals you expect to see on a visit. The reason that denominator is the right one is that here the expected count is also the variance. If abundance is $N_i \sim \text{Poisson}(\lambda)$ and each animal is seen independently with probability $p$, then thinning a Poisson leaves a Poisson: $y_{ij} \sim \text{Poisson}(\lambda p)$, whose mean and variance are both $\lambda p$. So each term is

$\dfrac{(\text{observed} - \text{expected})^2}{\text{variance the model predicts}} \;=\; (\text{standardized residual})^2.$

Three things follow, and they are what make this a useful general-purpose statistic.

  • It puts every count on the same footing. Being 3 animals off matters far more at a site expected to hold 2 than at one expected to hold 30, and counts are naturally more variable where they are larger. Dividing by the variance builds that in, so no single busy site dominates the total. A raw sum of squared errors would be driven almost entirely by the largest sites.
  • It has a built-in yardstick. If the model is right, each standardized residual has an average squared value of about 1, so $T$ should land near the number of counts: here 200 sites × 3 visits = 600. A total of around 1200 says the data are about twice as far from the model's predictions as the model itself can generate. (This is the same arithmetic as the residual deviance / df check from Day 3, and as a Pearson chi-square in a GLM.)
  • It is sensitive to the failure we care about. Extra-Poisson variation in abundance inflates exactly this quantity, which is why it catches a negative-binomial world fitted with a Poisson model.

One detail to notice in the code. The expected count is $\mathbb{E}[y_{ij}] = p\lambda$, the detection probability times the mean abundance, so it is built from the two unknowns being estimated, and it changes from one MCMC draw to the next. (Both are model parameters; neither is a count of parameters, and nothing here is a degrees-of-freedom correction.) The statistic $T$ therefore has to be recomputed at every iteration, using that iteration's $\lambda$ and $p$: once for the real counts, giving fit.data, and once for the replicate dataset simulated at those same values, giving fit.pred. Comparing the pair at the same draw is what makes this a posterior predictive check rather than a plug-in one, and the Bayesian p-value is the fraction of iterations where the replicate's $T$ exceeds the data's.

  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): report the median of fit.data and of fit.pred, and compare them with the 600 counts in the dataset. Then compute the Bayesian p-value. What does it say about the Poisson assumption, and in which direction does the model fail?
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.

R · code for this solution (sources the script, then summarizes all four models)
source(here::here("Day5_BayesII", "NMixture_Nimble.R"))
# (a)-(c) the three fits, against the simulated truth
MCMCsummary(samples1, round = 2)   # truth: lambda = 6, p = 0.8
MCMCsummary(samples2, round = 2)   # truth: b0 = 3, b1 = 1, p = 0.8
MCMCsummary(samples3, round = 2)   # truth: b0 = 3, b1 = 1, a0 = 1, a1 = 1

# (d) the Bayesian p-value, and the picture behind it
MCMCsummary(samples4, params = c("lambda", "p"), round = 2)
post <- as.matrix(samples4)
mean(post[, "fit.pred"] > post[, "fit.data"])
apply(post[, c("fit.data", "fit.pred")], 2, median)
plot(post[, "fit.data"], post[, "fit.pred"], pch = ".",
     xlab = "T(observed data)", ylab = "T(replicate data)")
abline(0, 1, col = "red")          # a good fit scatters evenly around this line

(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.06].

(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. With the chi-square-type sum as the discrepancy, the real data sit consistently further from the model's expected counts than the replicates do (observed $T(\text{data})$ median 1247 vs replicated $T(y^\text{rep})$ median 1163), giving a Bayesian p-value of 0.025, far from 0.5. Both numbers are worth reading against the yardstick from the box above: with 600 counts, a model that fitted would give $T \approx 600$. The replicates come in at about twice that, because the fitted $\lambda$ and $p$ are themselves distorted by the misfit, and the real data are higher still. The direction is the one that matters: the observed counts are more variable than the model can produce, never less. The Poisson model cannot absorb the extra variance, so it is rejected; the fix is a Poisson-lognormal or negative-binomial N-mixture (Exercise M8). Note $\lambda$ = 5.81 [5.46, 6.16] 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

Why we are doing this. Model 4 in Exercise M7 failed its goodness-of-fit check: the counts were about twice as far from the model's predictions as the model can generate ($T \approx 1247$ against a yardstick of 600, Bayesian p-value 0.025), and in the direction of too much variation. The culprit is the assumption $N_i \sim \text{Poisson}(\lambda)$ with one $\lambda$ for every site. A Poisson forces the variance of abundance to equal its mean, which says every site is the same kind of place and differs from the others only by chance. Real sites are not like that: habitat quality, food, predators and history make some sites genuinely rich and others genuinely poor, in ways we did not measure. That unmeasured site-to-site variation piles up as extra variance the Poisson has nowhere to put. So we give it somewhere to go.

The fix. Let each site have its own expected abundance, $\lambda_i$, scattered around a typical value on the log scale:

$N_i \sim \text{Poisson}(\lambda_i), \qquad \log \lambda_i = \beta_0 + \epsilon_i, \qquad \epsilon_i \sim \mathcal{N}(0, \sigma_\epsilon^2)$

This is a Normal random effect on the link function: the log link turns the linear predictor into $\lambda_i$, and the random effect $\epsilon_i$ is added on that log scale. Because $\log \lambda_i$ is Normal, $\lambda_i$ itself is lognormal, and a Poisson whose mean is drawn from a lognormal distribution is called a Poisson-lognormal mixture. It is the same device as the observation-level random effect in Day 3 Problem 5 and Day 5 Problem 1, applied here to the latent abundance at each site rather than directly to the counts.

What it buys you: extra variance, in a controlled amount. Two quantities are needed to see how much.

  • $m$, the average abundance per site. Each site now has its own expected abundance $\lambda_i$; $m$ is simply the average of those across all sites, $m = \mathbb{E}[\lambda_i]$. It is not a new parameter. It is determined by the two parameters you do estimate, $\beta_0$ and $\sigma_\epsilon$:
    $m = e^{\beta_0 + \sigma_\epsilon^2/2}.$
    Where the $\sigma_\epsilon^2/2$ comes from: $e^{\beta_0}$ is the abundance at a typical site, the one with $\epsilon_i = 0$, which is the median of the $\lambda_i$. But a lognormal has a long right tail, a few very rich sites, and they pull the average above the median. The $+\sigma_\epsilon^2/2$ is exactly that pull; with no site-to-site variation ($\sigma_\epsilon = 0$) the mean and median coincide. Like any function of parameters, you can get $m$'s full posterior by adding m <- exp(b0 + sig.eps^2 / 2) to the model and monitoring it.
  • $\text{Var}(N_i)$, how much abundance varies from site to site. It has two sources, and they add:
    $\text{Var}(N_i) \;=\; \underbrace{m}_{\text{Poisson chance within a site}} \;+\; \underbrace{m^2\big(e^{\sigma_\epsilon^2} - 1\big)}_{\text{real differences among sites}}.$
    The first term is what a Poisson already had: even two sites with the same $\lambda$ end up with different numbers of animals by chance, and for a Poisson that chance variance equals the mean, so it averages to $m$ across sites. The second term is new. It is the variance of the $\lambda_i$ themselves, how much sites genuinely differ in their expected abundance, and for a lognormal that works out to $m^2(e^{\sigma_\epsilon^2} - 1)$. (Adding "variance within groups, averaged" to "variance among the group means" is the same bookkeeping as partitioning variance in a mixed model: total = within + among.)

Check the two extremes. With $\sigma_\epsilon = 0$, $e^{0} - 1 = 0$, the second term vanishes, every site shares one $\lambda$, and you are back to Model 4's Poisson with variance $m$. As $\sigma_\epsilon$ grows, the second term grows and abundance becomes more variable than a Poisson allows, which is exactly what the Model 4 check said the data needed. So $\sigma_\epsilon$ is itself the answer to "how much do sites really differ?" For example, $\beta_0 = 1.68$ and $\sigma_\epsilon = 0.47$ give $m = e^{1.68 + 0.47^2/2} \approx 6.0$ (the typical site, $e^{1.68}$, holds about 5.4) and $\text{Var}(N_i) \approx 6.0 + 6.0^2(e^{0.47^2} - 1) \approx 6.0 + 8.9 = 14.9$, two and a half times what a Poisson with the same mean would allow. The data in Model 4 were in fact generated from a negative binomial, which is a Poisson-gamma mixture (the site means are gamma-distributed instead of lognormal). The two do the same job; the lognormal version is used here because a Normal random effect on the log scale is the same building block as in the GLMMs from Day 3, so it extends easily to covariates and more grouping levels.

Modify Nmix4Code to add this structure.

  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$? Using the variance formula above, how much more variable is abundance under the fitted model than under a Poisson with the same mean? 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 chi-square 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(y[i,j] - e[i,j], 2) / e[i,j] # chi-square contribution
 resid.pred[i,j] <- pow(y.pred[i,j] - e[i,j], 2) / e[i,j]
 }
 }
 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.025 to about 0.36 (real-data $T$ median 608, replicates 597): comfortably inside the unremarkable 0.1–0.9 band. It need not land exactly on 0.5, because the lognormal random effect is not the negative binomial the data actually came from, but it absorbs the extra variance well enough that the real counts no longer look unusual. Note too that the real-data $T$ has dropped from about 1247 to 608, right on the yardstick of 600 counts. For part (d), the fitted $\beta_0 = 1.68$ and $\sigma_\epsilon = 0.47$ give a mean abundance of $m = e^{\beta_0 + \sigma_\epsilon^2/2} \approx 6.0$ and a variance of $6.0 + 6.0^2\,(e^{0.47^2} - 1) \approx 6.0 + 8.9 = 14.9$, about two and a half times the 6.0 a Poisson with the same mean allows. The negative binomial that generated the data ($\theta = 3$) has variance $6 + 6^2/3 = 18$, so the lognormal random effect recovers most, though not all, of the real extra variation, which is also why the p-value settles at 0.36 rather than 0.5. 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 →