Day 4 · Lab

Maximum likelihood in the morning, Bayes in the afternoon

A double-feature day. We start with "which parameter values would have most plausibly produced my data?" and end with "given my data, what is the probability distribution over the parameters?" Two questions, two paradigms, one body of mathematics. We will fit models by grid search, by numerical optimization, and, for the first time this week, by Markov chain Monte Carlo with 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 morning session's Maximum likelihood problem set is worth 20 points and the afternoon session's Bayesian modeling problem set is worth 20 points, for 40 points across Day 4. Both live on the same page (the graded problem set); write up your answers in the R Markdown template for each session and submit them as two Canvas assignments.

Morning · 20 pts
Maximum likelihood problem set
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)
Afternoon · 20 pts
Bayesian modeling problem set 1
Practice Walk through the afternoon problems on this page (answers visible, in class)
Assessed Graded problem set (answers not shown, graded, submit on Canvas)
Hand-inafternoon_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.

Day 4 topic split: the morning problem set (problems 1–4) uses maximum likelihood only. The afternoon problem set (problems 5–10) uses Bayesian methods, introduced in the afternoon lecture.

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

vonbert.csvLength-at-age data for von Bertalanffy growth fitting (columns: sex, age, length).
whale.csvWhale blow counts, one row per whale (columns: sex, blows), used for the two-component Poisson mixture of males and females.
Logistic.csvPopulation size and per-capita growth rate (used for the r/K Bayesian fit).

R scripts

Logistic_BayesianGrowth_Nimble.RLogistic_BayesianGrowth_Nimble.R · Nimble re-implementation
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 ↓

Plain-language summary Day 4, the ideas in normal English A short, no-math explanation of what a likelihood really is, what maximum likelihood is actually doing, and how Bayes turns a prior plus data into a posterior. Read it before the lab to preview the ideas, or afterwards to consolidate what you did. Open the summary →
Helpful companions for today. Three interactive tools pair with this lab, the likelihood & MCMC explorer (which includes a 3D likelihood surface with its profile), the conjugate prior/posterior explorer, and the 2D MCMC sampler playground.

Morning · Why we care about likelihood

Every model you fit in this course is, secretly, a statement of the form $Y \sim f(\theta)$: data $Y$ are random draws from a distribution whose shape is controlled by parameters $\theta$. Once you write down $f$, the data become a function of $\theta$:

$L(\theta \mid y) \;=\; f(y \mid \theta)$

Same formula, same numbers. Only the interpretation flips. As a function of $y$ it is a probability (or probability density). As a function of $\theta$ with $y$ held fixed at the value you actually observed, it is the likelihood. The likelihood is not a probability over $\theta$. It is a measure of how compatible different $\theta$ values are with the data you have.

Maximum likelihood chooses the $\theta$ that makes the observed data as plausible as possible. Formally,

$\hat\theta_{\text{MLE}} \;=\; \arg\max_\theta L(\theta \mid y) \;=\; \arg\min_\theta \, -\!\log L(\theta \mid y)$.

The minus-log version is what your computer actually does. It minimizes negative log-likelihood (NLL) using a stable, additive scale.

Why log? Because likelihoods are products of many small probabilities. A single product of 500 terms each ~0.1 underflows to zero on a 64-bit machine. Sums of logs do not underflow. Every numerical likelihood routine in R works on $-\log L$, not $L$.

Read the full plain-language summary for Day 4 →. The same ideas are worked through slowly, with examples and no math.

Core concepts you will use all morning

ConceptWhat it meansWhere it shows up today
Joint likelihood under independence$L(\theta) = \prod_i f(y_i \mid \theta)$, so $-\log L = -\sum_i \log f(y_i \mid \theta)$Everywhere
Grid searchEnumerate $\theta$ on a grid, evaluate $L$ at each, pick the bestVonbert, whales
Numerical optimizationUse mle() / optim() to climb the likelihood surfaceAll non-trivial models
Score and HessianFirst and second derivatives of $\log L$; the Hessian gives standard errorsSEs returned by mle()
Likelihood ratio test (LRT)For nested models $M_0 \subset M_1$, $2\Delta\ell \sim \chi^2_{\Delta\,\text{df}}$ under $H_0$Vonbert with sex effect
AIC$\text{AIC} = -2\log L + 2k$; smaller is better; not a test, a rankingSame Vonbert comparison
Profile likelihood CIAll $\theta$ for which $\Delta\!-\!\log L \le 1.92$ (one parameter, 95%); $1.92 = \tfrac{1}{2}\chi^2_{1,0.95}$Red-light proportion, all examples

Walk-through 1 · Bernoulli warmup: proportion of successes

Cleanest possible example. We watch 50 cars approach an intersection on a yellow-then-red light; 30 run the red, 20 stop. Each car is an independent Bernoulli trial with unknown probability $p$. The data $y = (y_1, \dots, y_{50})$ are 0s and 1s, with $\sum y_i = 30$.

The likelihood is

$L(p) = \prod_{i=1}^{50} p^{y_i}(1-p)^{1-y_i} = p^{30}(1-p)^{20}$.

Take the log:

$\ell(p) = 30 \log p + 20 \log(1-p)$.

Differentiate, set to zero:

$\dfrac{d\ell}{dp} = \dfrac{30}{p} - \dfrac{20}{1-p} = 0 \;\;\Longrightarrow\;\; \hat p_{\text{MLE}} = \dfrac{30}{50} = 0.6$.

The proportion of successes is the MLE under Bernoulli/Binomial sampling. Same answer if we used a single Binomial$(n=50, p)$ likelihood. Only the binomial coefficient differs, and it doesn't depend on $p$.

R · plot the likelihood and the negative log-likelihood
p_grid <- seq(0.01, 0.99, by = 0.001)
L <- p_grid^30 * (1 - p_grid)^20
nLL <- -30 * log(p_grid) - 20 * log(1 - p_grid)

par(mfrow = c(1, 2))
plot(p_grid, L, type = "l", lwd = 2, xlab = "p", ylab = "Likelihood")
abline(v = 0.6, col = "red")
plot(p_grid, nLL, type = "l", lwd = 2, xlab = "p", ylab = "-log L")
abline(v = 0.6, col = "red")
The MLE under any Bernoulli/Binomial model is the sample proportion. This is the single most important MLE in applied statistics. Every logistic regression below is built on top of it.

Walk-through 2 · Grid search: Von Bertalanffy growth

The Von Bertalanffy growth function (VBGF) describes asymptotic growth in fishes:

$L_a = L_\infty \left(1 - e^{-\kappa a}\right) + \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, \sigma^2).$

where $a$ is age, $L_a$ is length at age $a$, $L_\infty$ is asymptotic maximum length, and $\kappa$ is the growth coefficient. Given $n$ aged individuals, the likelihood is

$L(L_\infty, \kappa, \sigma \mid \text{data}) = \prod_{i=1}^n \dfrac{1}{\sqrt{2\pi}\sigma}\exp\!\left[-\dfrac{(L_i - L_\infty(1 - e^{-\kappa a_i}))^2}{2\sigma^2}\right].$

The original course code holds $\sigma = 10$ and $L_\infty = 100$ fixed and grids over $\kappa$ to show how the likelihood changes with the parameter. Here is that grid search on a grid fine enough to locate the minimum ($\kappa$ from $0.05$ to $0.50$ in steps of $0.005$), complete and runnable.

Two things to hold on to before you run it. (i) This walk-through and the next one both use only the sex == 1 subgroup (50 fish), not the whole file. The vectors are therefore named age1 and len1, to keep them separate from the full-dataset Age/Len built in Walk-through 5. Re-use the wrong pair and you will silently fit the wrong data. (ii) The grid varies $\kappa$ alone, with $L_\infty$ and $\sigma$ frozen at the nominal values 100 and 10. Freezing $L_\infty$ is not innocent, and Walk-through 3 shows exactly what it costs.
R · grid search for Vonbert κ
# Load the Vonbert data (columns: sex, age, length) and keep the sex == 1 fish.
TheData <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "vonbert.csv"))
Sex1 <- TheData[TheData$sex == 1, ]
age1 <- Sex1$age; len1 <- Sex1$length

# negative log-likelihood as a function of (Linf, kappa, sigma)
nll_vbgf <- function(Linf, kappa, sigma) {
 pred <- Linf * (1 - exp(-kappa * age1))
 -sum(dnorm(len1, mean = pred, sd = sigma, log = TRUE))
}

# Grid search over kappa with Linf and sigma fixed (as in the original lab).
kappa_grid <- seq(0.05, 0.50, by = 0.005)
nll_vec <- sapply(kappa_grid, function(k) nll_vbgf(Linf = 100, kappa = k, sigma = 10))
plot(kappa_grid, nll_vec, type = "l", lwd = 2,
 xlab = expression(kappa), ylab = "Negative log-likelihood")
abline(v = kappa_grid[which.min(nll_vec)], col = "red")
A grid search is conceptually clean but does not scale. With 3 parameters at 50 grid points each you evaluate the likelihood $50^3 = 125{,}000$ times. With 10 parameters it is hopeless. That is exactly the problem numerical optimizers solve.

Walk-through 3 · Numerical optimization: mle() and optim()

The stats4::mle() wrapper around optim() takes a function of named parameters and a list of starting values. It returns the MLE, the Hessian-based standard errors, and lets you compute Wald or profile confidence intervals. We stay on the same sex == 1 subgroup as Walk-through 2, so the two results are directly comparable. The block below rebuilds age1/len1 from scratch so it also runs on its own.

R · MLE of the full 3-parameter Vonbert model
library(stats4)

TheData <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "vonbert.csv"))
Sex1 <- TheData[TheData$sex == 1, ]
age1 <- Sex1$age; len1 <- Sex1$length

nll_vbgf3 <- function(Linf, kappa, sigma) {
 pred <- Linf * (1 - exp(-kappa * age1))
 -sum(dnorm(len1, mean = pred, sd = sigma, log = TRUE))
}

fit <- mle(nll_vbgf3,
 start = list(Linf = 100, kappa = 0.2, sigma = 10),
 method = "L-BFGS-B",
 lower = c(10, 0.001, 0.01),
 upper = c(500, 2, 100))
summary(fit)
confint(fit) # profile-likelihood CIs (the right kind for nonlinear models)
-logLik(fit)[1] # minimum NLL at the MLE
Why this $\hat\kappa$ is not the grid’s $\kappa$, and why that is the whole point. Walk-through 2 searched a grid and reported $\kappa \approx 0.15$. This block, on the identical 50 fish, returns $\hat\kappa = 0.2591$. Nothing went wrong in either one. They are answers to two different questions.
  • The grid pinned $L_\infty$ at the round number $100$. But the sex-1 fish level off near $\hat L_\infty = 82.6$, so a curve forced up to an asymptote of 100 will overshoot the older fish, unless the only free knob, $\kappa$, is turned down to make the curve approach that too-high ceiling more slowly. $\kappa \approx 0.15$ is that compensation, not a growth coefficient.
  • Free all three and the model no longer needs the compensation: $\hat L_\infty = 82.60$, $\hat\kappa = 0.2591$, $\hat\sigma = 8.18$. The minimum NLL drops from $198.78$ on the grid to $176.05$, a gap of nearly 23 log-likelihood units, so the grid answer is not even close in likelihood terms.
$L_\infty$ and $\kappa$ are strongly correlated in the VBGF, and that is the general lesson: a “profile” taken with the other parameters frozen is not a profile at all. A real profile likelihood (Walk-through 5, and Exercise M2) re-optimizes every nuisance parameter at each grid point, which is exactly why it gives back $0.2591$ and the frozen grid does not.
Pick starting values in the same ballpark as the data. L-BFGS-B with sensible bounds protects against negative sigma and other nonsense. If summary() tells you the Hessian is singular, your model is unidentified or you're at a boundary. Diagnose, don't ignore.

Walk-through 4 · Two-component Poisson mixture (whales)

Bowhead whale blow counts come from a mix of males and females, and the two sexes blow at different average rates. Treat each whale's sex as an unobserved (latent) label and model the counts as a two-component mixture, a male component with rate $\lambda_1$ and a female component with rate $\lambda_2$. A whale is male with probability $\pi$ (the proportion of males in the population) and female with probability $1-\pi$. The goal is to estimate all three quantities, the male blow rate $\lambda_1$, the female blow rate $\lambda_2$, and the sex proportions $\pi$ (males) and $1-\pi$ (females). The likelihood for one count $y$ is a mixture:

$f(y \mid \lambda_1, \lambda_2, \pi) \;=\; \pi\, \text{Pois}(y; \lambda_1) + (1 - \pi)\, \text{Pois}(y; \lambda_2).$

Three parameters, no closed form. We minimize the joint negative log-likelihood with mle().

R · MLE for a 2-component Poisson mixture
TheData <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "whale.csv"))   # columns: sex (1/2, unused here), blows
blows <- TheData$blows

nll_mix <- function(lam1, lam2, p) {
 comp <- p * dpois(blows, lam1) + (1 - p) * dpois(blows, lam2)
 -sum(log(comp))
}

fit_mix <- mle(nll_mix,
 start = list(lam1 = 10, lam2 = 30, p = 0.4),
 method = "L-BFGS-B",
 lower = c(0.01, 0.01, 0.001),
 upper = c(Inf, Inf, 0.999))
summary(fit_mix)

Identifiability. A two-component mixture is invariant under relabeling: $(\lambda_1, \lambda_2, \pi) = (3, 25, 0.4)$ and $(25, 3, 0.6)$ are the same model, because which component we call "male" is arbitrary to the likelihood. Start with $\lambda_1 < \lambda_2$ and (often) constrain it; otherwise different chains/optimizers can land on relabeled solutions and your standard errors are nonsense.

Even with relabeling fixed, mixtures have local optima. Always run from several starting values and keep the best.

Walk-through 5 · Likelihood ratio tests & profile likelihood

LRT: do the two sex groups share a growth curve?

Fit two nested Vonbert models to the full dataset (sex 1 and sex 2 combined):

  • Model A (null, 3 parameters): one $L_\infty$, one $\kappa$, one $\sigma$, shared across sexes.
  • Model B (alternative, 5 parameters): sex-specific $L_\infty$ and $\kappa$, shared $\sigma$.

Likelihood-ratio statistic: $\Lambda = 2(\ell_B - \ell_A)$. Under $H_0$: $\Lambda \sim \chi^2_{\Delta\,\text{df} = 2}$.

R · LRT for sex-specific Vonbert
# Capitalised Sex/Age/Len = ALL 100 fish, both sexes. (Walk-throughs 2 and 3
# used lower-case age1/len1, the 50 fish with sex == 1. Do not mix them up.)
TheData <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "vonbert.csv"))
Sex <- TheData$sex; Age <- TheData$age; Len <- TheData$length

nll_A <- function(Linf, kappa, sigma) {
 pred <- Linf * (1 - exp(-kappa * Age))
 -sum(dnorm(Len, pred, sigma, log = TRUE))
}
nll_B <- function(Linf1, kappa1, Linf2, kappa2, sigma) {
 pred <- ifelse(Sex == 1,
 Linf1 * (1 - exp(-kappa1 * Age)),
 Linf2 * (1 - exp(-kappa2 * Age)))
 -sum(dnorm(Len, pred, sigma, log = TRUE))
}

fitA <- mle(nll_A, start = list(Linf = 100, kappa = 0.2, sigma = 10),
 method = "L-BFGS-B", lower = c(10, 0.001, 0.01))
fitB <- mle(nll_B, start = list(Linf1 = 100, kappa1 = 0.2, Linf2 = 100, kappa2 = 0.2, sigma = 10),
 method = "L-BFGS-B", lower = c(10, 0.001, 10, 0.001, 0.01))

LRT <- 2 * (-logLik(fitA)[1] - (-logLik(fitB)[1]))
pval <- pchisq(LRT, df = 2, lower.tail = FALSE)
c(LRT = LRT, p_value = pval)

# AIC ranking on the same models
AIC(fitA, fitB)

Profile likelihood confidence intervals

Most likelihood surfaces in ecology are not symmetric around the MLE, so Wald CIs (MLE ± 1.96·SE) lie. The profile likelihood CI is the set of parameter values for which

$\ell(\hat\theta) - \ell(\theta) \;\le\; \tfrac{1}{2}\chi^2_{1,\,0.95} = 1.92.$

i.e. all values whose deviance is within 1.92 NLL units of the minimum. confint() on an mle object gives exactly this. You can also draw it.

R · profile likelihood plot for red-light p̂
nll_p <- function(p) -(30 * log(p) + 20 * log(1 - p))
phat <- 0.6
best <- nll_p(phat)
pp <- seq(0.40, 0.80, length.out = 400)
plot(pp, nll_p(pp) - best, type = "l", lwd = 2,
 ylab = expression(-Delta * log~L), xlab = "p")
abline(h = 1.92, lty = 2, col = "red")
abline(v = phat, col = "grey")

Read the 95% CI off where the curve crosses the horizontal line at 1.92.

See the profile on a 3D likelihood surface. With one parameter, as here, the profile is simply the log-likelihood curve. With more parameters there is nothing to “just plot”, and the profile has to be built by re-fitting the others. The likelihood surface & profile explorer draws the Von Bertalanffy log-likelihood over $\kappa$ and $L_\infty$ as a hill you can turn. It traces the profile as a path along the ridge and sets it beside a slice that freezes $L_\infty$. Its profile interval for $\kappa$, 0.204 to 0.285, is the one confint() gives in Exercise M2, while the frozen slice gives an interval 26% narrower. Press Side view to see that the profile is the outline of the hill.

Bonus walk-through 6 · MLE for logistic regression, by hand vs glm()

This example shows that glm(y ~ x, family=binomial) is not magic. It is iteratively minimizing the same negative log-likelihood you can write down in five lines of R. Simulating data lets us check that glm() and your hand-rolled MLE agree to many decimal places.

Why this matters is that when you fit a non-standard model later (zero-inflated, occupancy, state-space), there is no canned glm(). You will write your own NLL exactly like this one and feed it to optim() or to Nimble. The Bernoulli kernel never changes.

Simulate $n=200$ presence/absence observations of a songbird detected as a function of forest cover $x$:

$\text{logit}\,\Pr(Y_i = 1) \;=\; \beta_0 + \beta_1 x_i, \qquad Y_i \sim \text{Bernoulli}(p_i).$
R · MLE for logistic regression two ways
library(stats4)
set.seed(123)
n <- 200
x <- rnorm(n)
beta0_true <- -0.5; beta1_true <- 1.2
p <- 1 / (1 + exp(-(beta0_true + beta1_true * x)))
y <- rbinom(n, size = 1, prob = p)

# --- 1. Hand-rolled MLE ----------------------------------------------
nll_logit <- function(beta0, beta1) {
 eta <- beta0 + beta1 * x
 mu <- 1 / (1 + exp(-eta)) # inverse-logit
 -sum(dbinom(y, size = 1, prob = mu, log = TRUE))
}
fit_mle <- mle(nll_logit, start = list(beta0 = 0, beta1 = 0))

# --- 2. The R one-liner ----------------------------------------------
fit_glm <- glm(y ~ x, family = binomial(link = "logit"))

# --- 3. Side-by-side -------------------------------------------------
cbind(MLE = coef(fit_mle), GLM = coef(fit_glm), truth = c(beta0_true, beta1_true))
# Standard errors:
cbind(MLE = sqrt(diag(vcov(fit_mle))),
 GLM = sqrt(diag(vcov(fit_glm))))

The estimates agree precisely. The standard errors agree because both methods invert the same observed Fisher information.

Generalize. Replace dbinom(..., log=TRUE) with dpois(..., log=TRUE), swap 1/(1+exp(-η)) for exp(η), and you've just written your own Poisson regression. Every link/distribution combination in glm() is a different choice of those two lines.

Bonus walk-through 7 · Zero-inflated Poisson (a non-standard mixture)

You count fledglings per nest. Some nests fail entirely (true structural zeros, such as nest abandoned or depredated). The successful nests produce a Poisson count of fledglings, including occasional Poisson zeros. The mixture has two sources of zeros and is called a zero-inflated Poisson (ZIP):

$\Pr(Y_i = 0) = \pi + (1 - \pi) e^{-\lambda}, \qquad \Pr(Y_i = k) = (1 - \pi) \dfrac{e^{-\lambda} \lambda^k}{k!}\ \text{for}\ k \ge 1.$

Here $\pi$ is the probability of structural zero and $\lambda$ is the Poisson mean among non-structural nests. Base R's glm() has no zero-inflated family, so ZIP is not a one-line glm(...) call, but ready-made ZIP fitters do exist: pscl::zeroinfl() is the standard one, and the VGAM and glmmTMB packages fit ZIP models too. We write the negative log-likelihood by hand anyway, because it is a single line and doing so is the transferable skill for the non-standard models that have no canned fitter:

R · ZIP simulated and fit by MLE
library(stats4)
set.seed(11)
n <- 300
pi_true <- 0.35; lam_true <- 2.5
z <- rbinom(n, 1, pi_true) # 1 = structural zero
y <- ifelse(z == 1, 0, rpois(n, lam_true))

# Plain Poisson MLE underestimates the mean and misses the zero inflation:
mean(y); var(y)
mean(y == 0); dpois(0, mean(y)) # observed zeros >> Poisson-predicted zeros

# ZIP negative log-likelihood:
nll_zip <- function(p, lambda) {
 if (p <= 0 || p >= 1 || lambda <= 0) return(1e10)
 prob_zero <- p + (1 - p) * exp(-lambda)
 ll_pos <- log(1 - p) + dpois(y, lambda, log = TRUE)
 ll <- ifelse(y == 0, log(prob_zero), ll_pos)
 -sum(ll)
}
fit_zip <- mle(nll_zip, start = list(p = 0.2, lambda = 1),
 method = "L-BFGS-B",
 lower = c(0.001, 0.001),
 upper = c(0.999, 50))
summary(fit_zip)
confint(fit_zip)
The point of this example is not the ZIP per se. It is that mixture likelihoods are an everyday tool. Once you can write any NLL function in R, you can fit any custom model. This is the bridge to Nimble later today, where the model code is literally a generative story written down.

Morning exercises

Four exercises on the likelihood half of the day. Work them with a partner; open each reveal only after you have tried. You are not expected to finish these during the session.

Practice Exercise M1 · MLE, Wald and profile intervals

Bernoulli MLE from scratch

You observe $y_1, \dots, y_n$ independent Bernoulli($p$) trials with $s = \sum y_i$ successes.

  1. Write the joint likelihood $L(p)$ as a function of $p$, $s$, and $n$. If you have seen the Binomial probability $\binom{n}{s} p^s (1-p)^{n-s}$, why can you leave the $\binom{n}{s}$ out?
  2. Take the log and the derivative, and show that the MLE is $\hat p = s/n$.
  3. Two new tools. The observed Fisher information is the curvature of the log-likelihood at its peak, $I(\hat p) = -\ell''(\hat p)$. A sharply peaked log-likelihood carries a lot of information about $p$; a flat one carries little. It turns directly into a standard error, $\text{SE} = 1/\sqrt{I(\hat p)}$, and the Wald 95% confidence interval is $\hat p \pm 1.96\,\text{SE}$. Take the second derivative of $\ell(p)$, show that $I(\hat p) = n / [\hat p(1-\hat p)]$, and so $\text{SE} = \sqrt{\hat p(1-\hat p)/n}$.
  4. For the red-light data, $s = 30$ and $n = 50$, compute $\hat p$, its SE, the Wald 95% CI, and the 95% profile-likelihood CI from confint() on an mle() fit (the 1.92 rule from Walk-through 5). How close are the two intervals?
  5. Now repeat for a rare event, $s = 1$ and then $s = 0$, both with $n = 20$. What goes wrong with the Wald interval? Why does the profile interval not have the same problem? In one or two sentences, say when a Wald interval can be trusted.
Reveal solution

(a) Where the likelihood comes from. One Bernoulli trial has probability $p^{y_i}(1-p)^{1-y_i}$, which is $p$ for a success and $1-p$ for a failure. The trials are independent, so the probability of the whole data set is the product of those terms, and the exponents add up to $s$ successes and $n-s$ failures:

$L(p) = \prod_{i=1}^{n} p^{y_i}(1-p)^{1-y_i} = p^s(1-p)^{n-s}.$

The Binomial coefficient $\binom{n}{s}$ counts the orderings in which $s$ successes could have occurred. It does not contain $p$, so it only multiplies the likelihood by a constant, and adds a constant to the log-likelihood. That constant cannot move the peak, cannot change the curvature, and cancels in every likelihood ratio, which is all we ever use. So it can be dropped; a likelihood is only ever needed up to a constant.

(b) $\ell(p) = s\log p + (n-s)\log(1-p)$, so $\ell'(p) = s/p - (n-s)/(1-p) = 0 \Rightarrow \hat p = s/n$.

(c) $\ell''(p) = -s/p^2 - (n-s)/(1-p)^2$. Substituting $s = n\hat p$ gives $-\ell''(\hat p) = n/\hat p + n/(1-\hat p) = n/[\hat p(1-\hat p)]$, so $\text{SE} = \sqrt{\hat p(1-\hat p)/n}$. More data means a sharper peak, more information, and a smaller SE, shrinking like $1/\sqrt{n}$.

(d) $\hat p = 0.6$ and SE $= 0.069$. Wald CI $(0.464, 0.736)$; profile CI $(0.462, 0.728)$. They nearly agree. With 50 trials and $\hat p$ well away from 0 and 1, the log-likelihood near its peak is close to a parabola, and a parabola is exactly the shape the Wald interval assumes.

(e) The comparison, from the code below:

  s  n  phat   Wald CI            profile CI
 30 50  0.60   (0.464, 0.736)     (0.462, 0.728)
  1 20  0.05   (-0.046, 0.146)    (0.003, 0.202)
  0 20  0.00   (0.000, 0.000)     (0.000, 0.092)

With one success the Wald interval runs below zero, an impossible probability, and it is symmetric even though the log-likelihood is lopsided against the boundary at 0. With no successes the curvature formula gives SE $= 0$, so the Wald interval collapses to a single point and claims perfect certainty from 20 trials. The profile interval follows the actual shape of the log-likelihood, keeping every value of $p$ within 1.92 log-likelihood units of the peak, so it stays inside $(0, 1)$ and becomes asymmetric exactly when the likelihood is. (mle() cannot profile at $\hat p = 0$, which is why the code reads the interval off a grid.)

When Wald works, and why you will still see it everywhere. A Wald interval needs only the MLE and the curvature at one point, so it is cheap, and it is the default almost everywhere. The standard errors, $z$ or $t$ values, and p-values in summary() for lm, glm and lmer fits are all Wald, and so is confint.default(). It is trustworthy when the log-likelihood is close to a parabola near its peak: plenty of data, an estimate well away from any boundary (such as a probability near 0 or 1, or a variance near 0), and a parameter on a scale where its likelihood is symmetric. That last condition is why GLMs report Wald statistics on the log or logit scale, where they behave well, rather than on the probability scale. Distrust Wald with small samples, rare events, estimates near a boundary, variance components, and nonlinear parameters like the Von Bertalanffy $\kappa$ in Exercise M2. There, use the profile interval (confint() on an mle or glm fit profiles) or, this afternoon, a Bayesian credible interval.

library(stats4)

# (d) red-light data: s = 30 successes in n = 50 trials
s <- 30; n <- 50
nll <- function(p) -(s * log(p) + (n - s) * log(1 - p))
fit <- mle(nll, start = list(p = 0.5), method = "L-BFGS-B", lower = 0.001, upper = 0.999)
phat <- coef(fit)
se   <- sqrt(diag(vcov(fit)))         # Wald SE from the curvature (inverse Hessian)
c(phat = phat, se = se)
c(lower = phat - 1.96 * se, upper = phat + 1.96 * se)   # Wald 95% CI
confint(fit)                                            # profile 95% CI

# (e) a rare event: compare both intervals as successes become scarce
wald_vs_profile <- function(s, n) {
  nll  <- function(p) -(s * log(p) + (n - s) * log(1 - p))
  phat <- s / n
  se   <- sqrt(phat * (1 - phat) / n)
  grid <- seq(0.0001, 0.9999, by = 0.0001)
  keep <- grid[nll(grid) - min(nll(grid)) <= 1.92]      # profile interval, read off a grid
  round(c(s = s, n = n, phat = phat,
          wald_lo = phat - 1.96 * se, wald_hi = phat + 1.96 * se,
          prof_lo = min(keep), prof_hi = max(keep)), 3)
}
rbind(wald_vs_profile(30, 50), wald_vs_profile(1, 20), wald_vs_profile(0, 20))
Practice Exercise M2 · Vonbert

Profile likelihood for κ

Using the full Vonbert dataset (both sexes pooled) and the three-parameter model, compute and plot the profile likelihood for $\kappa$. For each $\kappa$ in a grid, optimize over $L_\infty$ and $\sigma$; record the minimum negative log-likelihood. Plot $-\Delta\ell$ vs $\kappa$; mark the 95% CI at the 1.92 line.

Reveal solution
library(stats4)
TheData <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "vonbert.csv"))
Age <- TheData$age; Len <- TheData$length

profile_kappa <- function(k) {
 nll <- function(Linf, sigma) {
 pred <- Linf * (1 - exp(-k * Age))
 -sum(dnorm(Len, pred, sigma, log = TRUE))
 }
 fit <- mle(nll, start = list(Linf = 100, sigma = 10),
 method = "L-BFGS-B", lower = c(10, 0.01))
 -logLik(fit)[1]
}
grid <- seq(0.05, 0.45, by = 0.005)
prof <- sapply(grid, profile_kappa)
best <- min(prof)
plot(grid, prof - best, type = "l", lwd = 2, xlab = expression(kappa),
 ylab = expression(-Delta * log~L))
abline(h = 1.92, lty = 2, col = "red")

Read 95% CI off where the profile crosses 1.92. The CI is asymmetric. Wald would have lied.

Practice Exercise M3 · LRT + AIC

Sex-specific VBGF revisited

Use the LRT code from Walk-through 5. Report (a) the LRT statistic and p-value, (b) AIC for both models, (c) your conclusion. Then refit Model B with $L_\infty$ shared but $\kappa$ sex-specific (4 parameters) and run a second LRT against the same null. Comment on what you learn.

Reveal worked solution (actual R run)

Deterministic MLE fit (stats4::mle, L-BFGS-B) on the full data/vonbert.csv ($n = 100$, 50 per sex), model $L_a = L_\infty(1 - e^{-\kappa a})$, no seed needed.

(a) Full (Model B, 5 param) vs null (Model A, 3 param), 2 df: $\Lambda = 2(\ell_B - \ell_A) = 75.4$, $p \approx 4\times10^{-17}$: the null of a single shared growth curve is decisively rejected.

(b) AIC (lower is better): Model A (pooled, 3p) $= 798.0$; intermediate (shared $L_\infty$, sex-specific $\kappa$, 4p) $= 767.1$; Model B (full sex-specific $L_\infty$ and $\kappa$, 5p) $= \mathbf{726.6}$. The full model wins by ~40 AIC over the intermediate and ~71 over the null, far beyond the 2-unit rule of thumb. AIC and the LRT agree.

(c) Second LRT, intermediate (4p) vs null (3p), 1 df: $\Lambda = 32.9$, $p \approx 9.7\times10^{-9}$. A single sex-specific $\kappa$ already helps a lot, but it is not where the action is. Fitting Model B shows the sex difference is concentrated in asymptotic length $L_\infty$ ($\hat L_{\infty,1} \approx 82.6$ for sex 1 vs $\hat L_{\infty,2} \approx 102.5$ for sex 2) far more than in growth coefficient $\kappa$ ($0.259$ vs $0.284$). When the intermediate model is forced to share $L_\infty$, it can only compensate by splaying the two $\kappa$'s apart ($0.167$ for sex 1 vs $0.335$ for sex 2), and still lands ~40 AIC units short of the full model.

Conclusion: the sexes differ mainly in asymptotic size $L_\infty$ (sex 2 reaches a substantially larger maximum length; the file does not record which code is male and which is female, so report the result by code) and only modestly in $\kappa$; the fully sex-specific model is preferred by both criteria. Remember the division of labor. The LRT needs nesting and returns a p-value; AIC needs no nesting and returns a ranking.

Practice Exercise M4 · ZIP

Zero-inflated Poisson diagnostics

Using the ZIP walk-through code, simulate three datasets with $\pi = 0.35$ and $\lambda \in \{1, 2.5, 8\}$. For each:

  1. Fit a plain Poisson and a ZIP. Compare AIC.
  2. Compute and report the proportion of zeros under each fitted model and compare to the observed proportion.
  3. How does the AIC gap between the Poisson and ZIP fits change as $\lambda$ increases, and why?
Reveal solution

(a) and (b) Each data set uses the walk-through’s set.seed(11) and $n = 300$. The Poisson has one parameter and the ZIP has two, so AIC charges the ZIP 2 extra units:

              proportion of zeros           fitted lambda        ZIP             AIC
              observed  Poisson   ZIP       Poisson   ZIP        pi      Poisson     ZIP   Poisson - ZIP
lambda = 1      0.563    0.512   0.563       0.670   0.927     0.278      681.0    672.4        8.6
lambda = 2.5    0.357    0.190   0.357       1.663   2.335     0.288     1104.6   1023.0       81.6
lambda = 8      0.310    0.004   0.310       5.500   7.968     0.310     2214.3   1363.1      851.2

The ZIP wins on AIC in all three data sets and reproduces the observed proportion of zeros exactly, which an MLE with a free zero-inflation parameter is expected to do. The Poisson falls short on zeros, and it also underestimates $\lambda$, because the only way it can make more zeros is to lower the mean of every count.

(c) The gap grows with $\lambda$: 8.6 AIC units at $\lambda = 1$, 82 at $\lambda = 2.5$, and 851 at $\lambda = 8$. When $\lambda$ is small, the Poisson already produces many zeros ($e^{-1} = 0.37$ of counts at $\lambda = 1$), so extra structural zeros look much like a Poisson with a slightly smaller mean: here it predicts 51% zeros against 56% observed. The two likelihoods are hard to tell apart, and it takes a lot of data to detect the inflation. When $\lambda$ is large, a Poisson predicts almost no zeros ($e^{-8} \approx 0.0003$; 0.4% even at its lowered mean of 5.5), so 31% zeros are impossible to explain without the inflation, and the ZIP wins by hundreds of units.

library(stats4)

fit_both <- function(lam_true, pi_true = 0.35, n = 300) {
  set.seed(11)
  z <- rbinom(n, 1, pi_true)                     # 1 = structural zero
  y <- ifelse(z == 1, 0, rpois(n, lam_true))

  # (a) plain Poisson: one parameter
  nll_pois <- function(lambda) -sum(dpois(y, lambda, log = TRUE))
  fit_p <- mle(nll_pois, start = list(lambda = mean(y)),
               method = "L-BFGS-B", lower = 0.001, upper = 100)

  # (a) ZIP: two parameters, same NLL as the walk-through
  nll_zip <- function(p, lambda) {
    if (p <= 0 || p >= 1 || lambda <= 0) return(1e10)
    prob_zero <- p + (1 - p) * exp(-lambda)
    ll <- ifelse(y == 0, log(prob_zero), log(1 - p) + dpois(y, lambda, log = TRUE))
    -sum(ll)
  }
  fit_z <- mle(nll_zip, start = list(p = 0.2, lambda = 1),
               method = "L-BFGS-B", lower = c(0.001, 0.001), upper = c(0.999, 50))

  lam_p <- coef(fit_p)[["lambda"]]
  p_z <- coef(fit_z)[["p"]]; lam_z <- coef(fit_z)[["lambda"]]
  round(c(lambda_true = lam_true,
          obs_zeros   = mean(y == 0),
          pois_zeros  = dpois(0, lam_p),                   # (b) zeros predicted by Poisson
          zip_zeros   = p_z + (1 - p_z) * exp(-lam_z),     # (b) zeros predicted by ZIP
          pois_lambda = lam_p, zip_lambda = lam_z, zip_pi = p_z,
          AIC_pois = AIC(fit_p), AIC_zip = AIC(fit_z),
          dAIC = AIC(fit_p) - AIC(fit_z)), 3)
}
t(sapply(c(1, 2.5, 8), fit_both))
Practice (answers visible)

Afternoon practice problems

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

Open the graded afternoon problem set →    afternoon_lab_template.Rmd ↓

Afternoon · From likelihood to posterior

You spent the morning maximizing $L(\theta \mid y)$, finding the single $\theta$ that makes the data most plausible. Bayesian inference goes further. It gives you a distribution over $\theta$ that combines the data with what you knew (or assumed) before. Same likelihood, plus a prior, equals a posterior:

$\underbrace{p(\theta \mid y)}_{\text{posterior}} \;=\; \dfrac{\overbrace{p(y \mid \theta)}^{\text{likelihood}}\;\overbrace{p(\theta)}^{\text{prior}}}{\underbrace{p(y)}_{\text{marginal}}} \;\propto\; p(y\mid\theta)\, p(\theta).$

The marginal $p(y) = \int p(y\mid\theta) p(\theta)\,d\theta$ does not depend on $\theta$, so it acts only as a normalizing constant. For most realistic models it is impossible to compute in closed form, and that is exactly why MCMC exists. MCMC samples from the unnormalized product on the right, dodging the integral entirely.

Three shifts you make moving from frequentist to Bayesian:

  1. Parameters become random variables. $\theta$ has a distribution that expresses your uncertainty about it.
  2. Probabilities are about $\theta$, conditional on data. You can finally write $\Pr(r > 0.22 \mid \text{data})$ and mean it.
  3. You must specify a prior. Often weakly informative. Always justified. The prior is not optional baggage. It is part of the model.

Bayes' rule, worked out for a single coin

To anchor the algebra, fit a binomial model to a single observation: $y = 7$ successes in $n = 10$ trials. Use a flat prior $p \sim \text{Uniform}(0,1)$, which, note, is exactly a $\text{Beta}(1,1)$ distribution (a constant density on $[0,1]$):

$p(\theta \mid y) \propto \binom{10}{7}\theta^7(1-\theta)^3 \cdot 1 = \theta^7(1-\theta)^3.$

Recognize this kernel? It is a Beta(8, 4) density (up to a constant). The normalizing constant exists in closed form via the Beta function:

$p(\theta \mid y) = \dfrac{1}{B(8, 4)} \theta^{7}(1-\theta)^{3}, \qquad B(\alpha,\beta) = \dfrac{\Gamma(\alpha)\Gamma(\beta)}{\Gamma(\alpha+\beta)}.$

This is the magic of conjugacy: the prior family is chosen so that the posterior stays in the same family. The flat $\text{Uniform}(0,1)$ prior is a $\text{Beta}(1,1)$, so this really is a Beta prior + Binomial likelihood → Beta posterior, and the posterior $\text{Beta}(8,4) = \text{Beta}(1+7,\, 1+3)$ simply adds the 7 successes and 3 failures to the prior's two parameters.

R · plot the prior, the likelihood, and the posterior
theta <- seq(0.001, 0.999, length.out = 500)
prior <- dbeta(theta, 1, 1)
likelihood <- dbinom(7, size = 10, prob = theta)
posterior <- dbeta(theta, 8, 4)
plot(theta, posterior, type = "l", lwd = 2, col = "darkred",
 ylab = "density (or scaled likelihood)", xlab = expression(theta))
lines(theta, prior, lty = 2)
lines(theta, likelihood / max(likelihood) * max(posterior), col = "darkblue")
legend("topleft", lty = c(2, 1, 1), col = c("black", "darkblue", "darkred"),
 legend = c("Prior Beta(1,1)", "Likelihood (scaled)", "Posterior Beta(8,4)"), bty = "n")

Conjugacy 1 · Beta–Binomial in full

For $y \mid p \sim \text{Bin}(n, p)$ and $p \sim \text{Beta}(\alpha, \beta)$,

$p(p \mid y) \;=\; \text{Beta}(\alpha + y,\; \beta + n - y).$

The posterior just adds successes to $\alpha$ and failures to $\beta$.

The mean of a $\text{Beta}(\alpha, \beta)$ distribution shows why that is the right way to read it:

$\mathbb{E}[p] = \dfrac{\alpha}{\alpha + \beta}, \qquad \mathbb{E}[p \mid y] = \dfrac{\alpha + y}{\alpha + \beta + n}.$

The prior mean has exactly the form of a success proportion, $\alpha$ successes out of $\alpha + \beta$ trials. The posterior mean is the same proportion after the real data are added: $y$ more successes on top, and $n$ more trials underneath. So the prior behaves as if you had already watched $\alpha + \beta$ trials and seen $\alpha$ successes. The posterior mean is also a weighted average of the prior mean and the observed proportion $y/n$,

$\mathbb{E}[p \mid y] = \underbrace{\dfrac{\alpha+\beta}{\alpha+\beta+n}}_{\text{weight on prior}}\cdot\dfrac{\alpha}{\alpha+\beta} \;+\; \underbrace{\dfrac{n}{\alpha+\beta+n}}_{\text{weight on data}}\cdot\dfrac{y}{n},$

so the more real trials you have compared with $\alpha + \beta$, the more the data dominate.

Play with it. The interactive conjugate prior/posterior explorer lets you drag a Beta prior and binomial data (or a Gamma–Poisson rate, or a Normal–Normal mean) and watch the prior × likelihood → posterior update live.
Prior as pseudo-observations. A Beta($\alpha,\beta$) prior is exactly equivalent to having previously seen $\alpha$ "successes" and $\beta$ "failures" in an imaginary pre-experiment, so its strength, its pseudo-sample size, is $\alpha+\beta$ trials. Beta(1,1) is worth $1+1=2$ pseudo-trials, which is next to nothing beside any real dataset. Beta(50,50) means "I've already seen 100 trials with $p=0.5$." That is the prior's information content, measurable in trials.

Worked example: spotted-owl nest survival. You monitored 12 nests; 9 fledged. Choose three priors and see how each posterior moves.

R · three priors, one likelihood
y <- 9; n <- 12
theta <- seq(0.001, 0.999, length.out = 500)
priors <- list(
 flat = c(1, 1), # Uniform
 skeptical = c(2, 8), # prior centered at 0.2
 informative = c(30, 10) # tight, centered at 0.75
)
par(mfrow = c(1, 3))
# names(priors) returns the list's labels, c("flat", "skeptical", "informative"),
# so the loop runs once per prior with nm holding that prior's name.
for (nm in names(priors)) {
 # Double brackets pull out the element itself: priors[["flat"]] is the vector c(1, 1).
 # Single brackets, priors["flat"], would instead return a one-item list still wrapping it.
 ab <- priors[[nm]]                              # ab[1] = alpha, ab[2] = beta
 post <- c(ab[1] + y, ab[2] + n - y)          # add successes to alpha, failures to beta
 plot(theta, dbeta(theta, post[1], post[2]), type = "l", lwd = 2,
 main = paste(nm, ": Beta(", post[1], ", ", post[2], ")", sep = ""),
 xlab = expression(theta), ylab = "posterior density")
 lines(theta, dbeta(theta, ab[1], ab[2]), lty = 2, col = "grey")
 abline(v = y / n, col = "red")
}
par(mfrow = c(1, 1))

Conjugacy 2 · Gamma–Poisson

For $y_1, \dots, y_n \mid \lambda \sim \text{Pois}(\lambda)$ and $\lambda \sim \text{Gamma}(\alpha, \beta)$ (shape-rate),

$p(\lambda \mid y) = \text{Gamma}\!\left(\alpha + \sum y_i,\; \beta + n\right).$

Again, the prior plays the role of pseudo-observations: $\alpha$ events accumulated over $\beta$ units of effort. After observing $n$ new units and $\sum y_i$ events, you just add them in. Note that the bookkeeping differs from the Beta–Binomial case. Here the prior's weight is carried by $\beta$ alone, the pseudo-effort that your $n$ new units are added to, and not by $\alpha+\beta$. That is a claim about weight, not about influence: $\alpha$ still moves the answer, because it sets the prior mean $\alpha/\beta$. Writing the posterior mean as a weighted average separates the two jobs,

$\mathbb{E}[\lambda \mid y] = \dfrac{\alpha + \sum y_i}{\beta + n} = \underbrace{\dfrac{\beta}{\beta+n}}_{\text{weight on prior}}\cdot\dfrac{\alpha}{\beta} + \underbrace{\dfrac{n}{\beta+n}}_{\text{weight on data}}\cdot\bar{y}.$

So $\alpha$ decides what the prior claims and $\beta$ decides how loudly. Hold $\beta = 1$ and raise $\alpha$ from 2 to 200 in the tick example below. The prior's weight stays pinned at $1/11$, yet the posterior mean climbs from 2.45 to 20.45, an extravagant belief asserted with the authority of a single bird.

Worked example: a parasitologist counts $y = (3, 1, 4, 2, 0, 5, 2, 3, 1, 4)$ ticks on 10 birds (total 25 in 10 birds). Prior $\lambda \sim \text{Gamma}(2, 1)$ (weakly informative around mean 2 per bird).

R · Gamma–Poisson posterior
y <- c(3, 1, 4, 2, 0, 5, 2, 3, 1, 4); n <- length(y)
alpha0 <- 2; beta0 <- 1
alpha1 <- alpha0 + sum(y)
beta1 <- beta0 + n
c(post_mean = alpha1 / beta1,
 post_sd = sqrt(alpha1 / beta1^2),
 MLE = mean(y)) # compare to MLE

lam <- seq(0, 6, length.out = 400)
plot(lam, dgamma(lam, alpha1, beta1), type = "l", lwd = 2,
 xlab = expression(lambda), ylab = "posterior")
lines(lam, dgamma(lam, alpha0, beta0), lty = 2, col = "grey")
abline(v = mean(y), col = "red")

Why conjugacy is not enough, and what MCMC buys you

Conjugacy gives elegant closed forms for one-parameter problems. Real models have:

  • Nonlinear functions of parameters (logistic growth: $\mu = r - r/K \cdot x$ depends on the ratio $r/K$).
  • Many parameters interacting (random effects, hierarchical means and variances).
  • Awkward likelihoods (mixtures, occupancy, capture-recapture, state-space).

For these models the marginal $p(y) = \int p(y,\theta)\,d\theta$ is intractable. MCMC sidesteps the integral by generating dependent samples from the unnormalized posterior; the long-run frequency of those samples is the posterior itself. These are the samplers you will meet today and tomorrow:

AlgorithmIdeaWhen it is used
Metropolis–HastingsPropose a move, then accept or reject it using the ratio of posterior densitiesWorks for any posterior. Nimble’s default for continuous parameters that have no conjugate update is an adaptive random-walk Metropolis–Hastings sampler, which tunes its own proposal size during the run.
GibbsDraw each parameter directly from its distribution given all the others (its full conditional)Only possible when that conditional has a closed form, which in practice means a conjugate prior–likelihood pair like the two above. Nimble detects these automatically and uses a conjugate sampler for them.
Slice samplingSample uniformly from the region under the posterior density curve, stepping out until the region is coveredNimble’s default for discrete parameters without a small, fixed set of possible values, such as an unknown population size.
Hamiltonian Monte CarloUse the slope of the log-posterior to make long, informed movesThe default in Stan. Available in Nimble through the separate nimbleHMC package, but not used unless you ask for it.

So a single Nimble model can mix samplers: each parameter gets whichever one fits it. configureMCMC(model, print = TRUE) lists what Nimble chose.

The Metropolis algorithm in 30 lines of R

Same Beta–Binomial spotted-owl example, but pretending we don't know the closed form. The Metropolis recipe at each iteration:

  1. Propose $\theta^\star \sim \text{Normal}(\theta_{\text{current}}, s)$ (symmetric proposal).
  2. Compute the acceptance ratio $\alpha = \min\!\left(1, \dfrac{p(\theta^\star)\, L(\theta^\star \mid y)}{p(\theta_{\text{current}})\, L(\theta_{\text{current}} \mid y)}\right)$.
  3. Draw $u \sim \text{Uniform}(0,1)$. If $u < \alpha$, accept; otherwise stay.

We will write it twice. First exactly as the recipe above reads, with densities multiplied, ratio taken, and compared with a uniform draw. Then again on the log scale, which is how every real sampler is written, once you have seen why.

R · Metropolis, written exactly as the recipe reads
y <- 9; n <- 12; alpha0 <- 2; beta0 <- 2

# The posterior is proportional to prior x likelihood. That product is all
# Metropolis needs: the normalising constant cancels in the ratio below.
post <- function(p) {
  if (p <= 0 || p >= 1) return(0)     # p is a probability; outside (0,1) it is impossible
  dbeta(p, alpha0, beta0) * dbinom(y, size = n, prob = p)
}

set.seed(2)
niter <- 20000; chain <- numeric(niter); chain[1] <- 0.5
accept <- 0; s <- 0.35                # proposal SD

for (i in 2:niter) {
  prop  <- rnorm(1, mean = chain[i - 1], sd = s)   # 1. propose
  alpha <- post(prop) / post(chain[i - 1])         # 2. acceptance ratio
  if (runif(1) < alpha) {                          # 3. accept w.p. min(1, alpha)
    chain[i] <- prop; accept <- accept + 1
  } else chain[i] <- chain[i - 1]
}
cat("acceptance rate =", round(accept / niter, 3), "\n")   # 0.365

That runs, and its posterior mean of 0.686 sits right on the closed-form Beta(11, 5) mean of 0.688. Note there is no min(1, ...) anywhere. If alpha exceeds 1 then runif(1) < alpha is certain, so the comparison already is the minimum.

Why every real sampler does this in logs. The toy above is safe because the aggregated Binomial pmf is a single moderate number; dbinom(75000, 100000, 0.75) is still 0.0029. But real likelihoods are products over observations, and those collapse fast. Suppose that instead of 12 spotted-owl nests you had monitored many more, with the same 75% of them fledging (9 of 12 is 75%). Record each nest as its own row, 1 if it fledged and 0 if it failed, so the likelihood is the product of one Bernoulli probability per nest:

n <- 200                                               # then try 700 and 1400
y_nests <- rep(c(1, 0), times = c(0.75 * n, 0.25 * n))   # 1 = fledged, 0 = failed
prod(dbinom(y_nests, size = 1, prob = 0.75))
# n =  200  ->  1.4e-49
# n =  700  ->  1.1e-171
# n = 1400  ->  0            underflowed

The smallest positive number R can hold is about $2.2 \times 10^{-308}$. Past that the product is not merely small, it is exactly zero, so the acceptance ratio becomes 0/0, which is NaN, and the chain dies. Normal data underflows sooner still: 400 observations is enough, and Bonus walk-through 9 below fits 120 of them. So this is not a hypothetical.

Logs fix it because they turn those doomed products into sums that stay ordinary-sized. The $n = 1400$ product above is just $-787.3$ in logs. There are exactly three translations:

  • Multiplication becomes addition. $\log(\text{prior} \times \text{likelihood}) = \log\text{prior} + \log\text{likelihood}$, so dbeta(...) * dbinom(...) becomes dbeta(..., log = TRUE) + dbinom(..., log = TRUE). Use each density's own log = TRUE rather than wrapping the product in log(): R then works in logs throughout and never forms the doomed number at all.
  • The ratio becomes a difference. $\log(a/b) = \log a - \log b$, so post(prop) / post(cur) becomes log_post(prop) - log_post(cur). That quantity is now $\log\alpha$, not $\alpha$.
  • The comparison moves to the log scale too. We still want $u < \alpha$, and taking $\log$ of both sides preserves the inequality because $\log$ is increasing, giving $\log u < \log\alpha$, i.e. log(runif(1)) < log_alpha. Exponentiating back to compare on the original scale would re-create the underflow we just escaped.

One consequence worth naming is that impossible values now return -Inf instead of 0, because $\log 0 = -\infty$. It behaves correctly, since -Inf minus anything finite is -Inf, and log(runif(1)) < -Inf is never true, so such a proposal is always rejected.

R · the same sampler, on the log scale
log_post <- function(p) {
  if (p <= 0 || p >= 1) return(-Inf)        # log(0) = -Inf: impossible
  dbeta(p, alpha0, beta0, log = TRUE) +     # log(prior x likelihood)
  dbinom(y, size = n, prob = p, log = TRUE) #   = log prior + log likelihood
}

niter <- 20000; chain <- numeric(niter); chain[1] <- 0.5
accept <- 0; s <- 0.35
set.seed(2)
for (i in 2:niter) {
  prop      <- rnorm(1, mean = chain[i - 1], sd = s)
  log_alpha <- log_post(prop) - log_post(chain[i - 1])  # ratio -> difference
  if (log(runif(1)) < log_alpha) {                      # u < alpha, both logged
    chain[i] <- prop; accept <- accept + 1
  } else chain[i] <- chain[i - 1]
}

burn <- 2000
cat("acceptance rate =", round(accept / niter, 3), "\n")
hist(chain[-(1:burn)], breaks = 60, freq = FALSE, main = "Posterior of p",
     xlab = "p", col = "grey90", border = "white")
# Overlay the closed-form Beta(alpha0+y, beta0+n-y) posterior
curve(dbeta(x, alpha0 + y, beta0 + n - y), col = "red", lwd = 2, add = TRUE)

Run both. With the same seed they accept and reject at exactly the same steps and produce a bit-for-bit identical chain. The logs change nothing about the algorithm. They only change the arithmetic it can survive.

Tuning. An acceptance rate of about 25–40% is healthy for random-walk Metropolis in moderate dimensions. Too high → tiny proposals, slow mixing. Too low → wasted iterations. Real samplers (Nimble's default) adapt the proposal scale during burn-in for you.
Play with it. The 2D MCMC sampler playground runs this same random-walk Metropolis on a correlated two-parameter posterior and lets you compare it against Gibbs and Hamiltonian Monte Carlo, a good place to watch what the proposal SD does to the acceptance rate and to mixing.

The main event · logistic growth with Nimble

We now fit the ecological example the morning's lm() couldn't honestly answer, posterior distributions for the logistic-growth parameters $r$, $K$, and $\sigma$, plus derived quantities $K/2$ (population size at maximum growth) and $\text{MSY} = rK/4$ (theoretical maximum sustainable yield).

The model is the per-capita form of $dN/dt = rN(1 - N/K)$:

$\dfrac{1}{N}\dfrac{dN}{dt} \;=\; r - \dfrac{r}{K}\,N, \qquad y_i \sim \mathcal{N}\!\left(r - \dfrac{r}{K} x_i,\; \sigma^2\right).$
This is a textbook ecological model and is full of conceptual punchlines: $r$ is the per-capita rate at low density; $K$ is the carrying capacity; $K/2$ maximizes population-level growth $dN/dt$; $rK/4$ is the theoretical sustainable harvest. Bayesian fitting gives us full posterior distributions for all of these, with proper uncertainty propagation. Try doing that with lm()!

The complete Nimble workflow

R · packages + data
library(nimble); library(MCMCvis); library(HDInterval); library(coda)
Logistic <- read.csv(here::here("Day4_Likelihood_BayesI", "data", "Logistic.csv"), header = TRUE)
Logistic <- Logistic[order(Logistic$PopulationSize), ]
N_grid <- seq(0, 1500, by = 10); N_grid[1] <- 1
R · the Nimble model
logistic_code <- nimbleCode({
 # priors
 K ~ dunif(0, 4000)
 r ~ dunif(0, 2)
 sigma ~ dunif(0, 2)
 tau <- 1 / sigma^2 # precision = 1 / variance

 # likelihood
 for (i in 1:n) {
 mu[i] <- r - (r / K) * x[i]
 y[i] ~ dnorm(mu[i], tau)
 }

 # derived quantities (scalars -- monitoring these is free)
 N_at_maxdNdt <- K / 2
 MSY <- r * K / 4
})

Precision, not variance. In BUGS-family languages, including Nimble, dnorm() takes a mean and a precision, $\tau = 1/\sigma^2$, the reciprocal of the variance. Big $\tau$ means tightly clustered data, not widely spread. That is why the model above puts the prior on sigma, which is on the scale you can think about, and then derives tau from it. Hand dnorm() an SD by mistake and the model still runs, silently fitting a completely different amount of noise: for $\sigma = 2$ you would be claiming a precision of 2, which is an SD of $1/\sqrt{2} = 0.71$. The same convention applies to the $\tau$ in every Nimble model in this course and the next day.

What a monitor costs, and the one habit that saves you. The run below keeps $3 \times (25{,}000 - 5{,}000) = 60{,}000$ draws. Each monitored quantity is therefore stored as a column of 60,000 numbers. R keeps each number in 8 bytes, so a column is about 0.46 MB. That is nothing for a scalar like MSY, which is why you should put scalar derived quantities straight into the model code and monitor them.

It is not nothing for a vector. The obvious way to get the $dN/dt$ curve is to give the model the list of population sizes you want to plot, the 151 values in N_grid (1, 10, 20, …, 1500), as constants named N and nN, then add for (j in 1:nN) { dNdt[j] <- r * N[j] * (1 - N[j] / K) } to the model, and monitor dNdt. With those 151 population sizes, plus the 50 fitted mu[i], that is 206 columns, a 94.3 MB posterior object instead of a 2.3 MB one, slow to summarise and slow to save.

You do not have to choose between the curve and the memory. dNdt[j] and mu[i] are deterministic functions of $r$ and $K$, so evaluating them draw-by-draw in R on the saved $r$ and $K$ columns gives back exactly the same numbers Nimble would have stored, not an approximation, at zero storage cost. Rule of thumb. Monitor scalars in the model; for a long sequence of predictions, save the parameters and compute the predictions afterwards in R. Logistic_BayesianGrowth_Nimble.R does exactly this.

R · constants vs data, inits, MCMC
# Nimble takes the inputs to a model as two lists:
# constants : sizes, indices, fixed covariates (things that are not random variables)
# data : observed stochastic nodes (y[i] here)
constants <- list(
 n = nrow(Logistic),
 x = as.double(Logistic$PopulationSize)
) # N_grid stays in R -- it is a plotting grid, not part of the model
data <- list(y = as.double(Logistic$GrowthRate))

inits_list <- list(
 list(K = 1500, r = 0.20, sigma = 1.0),
 list(K = 1000, r = 0.15, sigma = 0.1),
 list(K = 900, r = 0.30, sigma = 0.01)
)

set.seed(10)
samples <- nimbleMCMC(
 code = logistic_code,
 constants = constants,
 data = data,
 inits = inits_list,
 monitors = c("K", "r", "sigma", "N_at_maxdNdt", "MSY"),
 nchains = 3,
 nburnin = 5000,
 niter = 25000,
 thin = 1,
 samplesAsCodaMCMC = TRUE
)
R · diagnostics, summaries, derived quantities
MCMCtrace(samples, params = c("K", "r", "sigma"), pdf = FALSE, Rhat = TRUE, n.eff = TRUE)
MCMCsummary(samples, params = c("K", "r", "sigma", "N_at_maxdNdt", "MSY"), round = 3)

print(object.size(samples), units = "MB") # ~2.3 Mb, not 94.3

# P(r > 0.22 | data)
r_chain <- as.numeric(MCMCchains(samples, params = "r"))
K_chain <- as.numeric(MCMCchains(samples, params = "K"))
mean(r_chain > 0.22)
R · rebuilding the dN/dt curve from the r and K draws
library(HDInterval)
N_grid <- seq(0, 1500, by = 10); N_grid[1] <- 1

# One grid point at a time, so the full 60,000 x 151 matrix never exists.
dNdt_summary <- sapply(N_grid, function(Nj) {
 d <- r_chain * Nj * (1 - Nj / K_chain)
 c(median = median(d), hdi(d, 0.95)) # median, lower, upper
})
med_dNdt <- dNdt_summary["median", ]
lo_dNdt <- dNdt_summary["lower", ]
hi_dNdt <- dNdt_summary["upper", ]

plot(N_grid, med_dNdt, type = "l", lwd = 2,
 ylim = range(med_dNdt, lo_dNdt, hi_dNdt),
 xlab = "Population size N", ylab = "Population growth rate dN/dt")
abline(h = 0, col = "grey")
lines(N_grid, lo_dNdt, lty = "dashed")
lines(N_grid, hi_dNdt, lty = "dashed")

Two kinds of credible interval: BCI vs HPDI

A 95% Bayesian credible interval (BCI, equal-tailed) cuts 2.5% off each tail of the posterior. A 95% highest posterior density interval (HPDI) is the shortest interval containing 95% of the posterior. By construction, every point inside has higher posterior density than every point outside.

  • For symmetric posteriors (a typical sample-size-large $r$ posterior here), BCI and HPDI are nearly identical.
  • For skewed posteriors (often $\sigma$, $K$ when the prior bound bites, ratios of parameters), HPDI is shorter and shifted toward the mode.
  • HPDI is the right interval when you want to say "the 95% most-credible values of $\theta$."
  • BCI is the right interval when you want a quantile-symmetric summary (e.g. for forecasts where the median matters).
R · BCI and HPDI for the fitted line
# mu[i] = r - (r/K)*x[i] is deterministic, so rebuild its 60,000 posterior
# draws from the saved r and K columns rather than monitoring 50 extra nodes.
mu_draws <- sapply(constants$x, function(xi) r_chain - (r_chain / K_chain) * xi)

BCI <- apply(mu_draws, 2, quantile, probs = c(.025, .50, .975)) # 3 x n
HPDI <- apply(mu_draws, 2, function(v) hdi(v, .95)) # 2 x n

plot(constants$x, data$y, pch = 19,
 xlab = "Population size N", ylab = "Per-capita growth (1/yr)")
lines(constants$x, BCI[2, ], lwd = 2)
lines(constants$x, BCI[1, ], lty = 2, col = "red")
lines(constants$x, BCI[3, ], lty = 2, col = "red")
lines(constants$x, HPDI[1, ], lty = 2, col = "blue")
lines(constants$x, HPDI[2, ], lty = 2, col = "blue")

Bonus walk-through 8 · Bayesian binomial estimation of survival

This example shows beta-binomial conjugacy from end to end, with the prior justification spelled out and a comparison between (i) the closed-form posterior, (ii) a Nimble fit, and (iii) Metropolis. All three give the same answer. They have to, because the model is the same.

Setting. You banded 25 juvenile Western Bluebirds in spring; 18 were resighted alive the following spring. The biological literature suggests annual juvenile survival in this species is "around 0.45 with a fair amount of variability." We encode this prior as Beta(9, 11), which has mean $9/20 = 0.45$ and an effective sample size of 20 trials.

R · closed-form posterior
y <- 18; n <- 25; alpha0 <- 9; beta0 <- 11
alpha1 <- alpha0 + y # 27
beta1 <- beta0 + n - y # 18
c(mean = alpha1 / (alpha1 + beta1),
 median = qbeta(0.5, alpha1, beta1),
 BCI_lo = qbeta(0.025, alpha1, beta1),
 BCI_hi = qbeta(0.975, alpha1, beta1))
R · same model in Nimble
surv_code <- nimbleCode({
 phi ~ dbeta(9, 11) # prior
 y ~ dbinom(phi, n) # likelihood
})

set.seed(7)
surv_samps <- nimbleMCMC(
 code = surv_code,
 constants = list(n = 25),
 data = list(y = 18),
 inits = list(list(phi = 0.5), list(phi = 0.3), list(phi = 0.7)),
 monitors = "phi",
 nchains = 3, nburnin = 2000, niter = 10000,
 samplesAsCodaMCMC = TRUE)
MCMCsummary(surv_samps, round = 3)

# P(phi > 0.5 | data)?
mean(MCMCchains(surv_samps, "phi") > 0.5)
# closed-form check:
1 - pbeta(0.5, alpha1, beta1)
A common student question: "If I had no prior information, would I get the MLE?" Almost, and the gap is worth understanding. A Beta(1,1) (Uniform) prior gives a posterior whose mode is exactly the MLE, $y/n = 0.72$. But the number MCMCsummary() prints is the posterior mean, and that is $19/27 = 0.704$, not 0.72. Students who compare the two and find them different have not made a mistake.

The reason is that Beta(1,1) is not “no information”. It is one pseudo-success and one pseudo-failure, a pseudo-sample size of 2. Add the data and the posterior is $\text{Beta}(1 + 18,\; 1 + 7) = \text{Beta}(19, 8)$. Now compare the formulas for the peak and the average of a $\text{Beta}(\alpha, \beta)$:

$\text{mode} = \dfrac{\alpha - 1}{\alpha + \beta - 2}, \qquad \text{mean} = \dfrac{\alpha}{\alpha + \beta}.$

The mode subtracts 1 from $\alpha$ and 2 from $\alpha + \beta$, which is exactly the one pseudo-success and two pseudo-trials that Beta(1,1) added. So they cancel at the peak: $\text{mode} = (19 - 1)/(27 - 2) = 18/25 = 0.72$, the MLE. The mean has no such subtraction, so the pseudo-observations stay in: $\text{mean} = 19/27 = 0.704$.

The weighted-average form of the posterior mean from Conjugacy 1 shows where the 0.704 comes from. Here $\alpha_0 = 1$ and $\beta_0 = 1$ are the prior’s parameters, and $y = 18$ survivors out of $n = 25$ birds are the data:

$\text{posterior mean} = \dfrac{\alpha_0 + y}{\alpha_0 + \beta_0 + n} = \underbrace{\dfrac{\alpha_0+\beta_0}{\alpha_0+\beta_0+n}}_{\text{weight on prior}} \times \underbrace{\dfrac{\alpha_0}{\alpha_0+\beta_0}}_{\text{prior mean}} \;+\; \underbrace{\dfrac{n}{\alpha_0+\beta_0+n}}_{\text{weight on data}} \times \underbrace{\dfrac{y}{n}}_{\text{MLE}}$
$= \dfrac{2}{27} \times \dfrac{1}{2} \;+\; \dfrac{25}{27} \times \dfrac{18}{25} \;=\; \dfrac{2}{27} \times 0.5 \;+\; \dfrac{25}{27} \times 0.72 \;=\; \dfrac{19}{27} = 0.704.$

The 2 in $2/27$ is the prior’s pseudo-sample size, the 25 in $25/27$ is the real sample size, and 27 is their total. The prior mean of Beta(1,1) is 0.5 and the MLE is 0.72. So the posterior mean is 0.72 pulled a little toward 0.5, with the prior getting 2 parts in 27 of the say. That pull fades as data accumulate: the gap between mean and MLE is 0.016 at $n = 25$, 0.004 at $n = 100$, and 0.0002 at $n = 2000$. If you want the Bayesian summary that reproduces the MLE, report the posterior mode; if you report the mean, as almost everyone does, expect it to sit a little toward the prior.

The prior we used here, Beta(9,11), shrinks the estimate much harder. Its pseudo-sample size is 20 against 25 real birds, so the prior gets $20/45 = 0.44$ of the weight, and the posterior mean is $0.44 \times 0.45 + 0.56 \times 0.72 = 0.60$. The larger the prior’s pseudo-sample size relative to your data, the more shrinkage you get.

Bonus walk-through 9 · Bayesian Normal regression, with posterior predictive checks

Body mass as a function of body length in Pacific giant salamanders. We will (i) fit the regression in Nimble, (ii) use the posterior to predict new salamanders, and (iii) use those predictions to check whether the model can produce data that look like the real data. This is the workflow you should use on Bayesian models.

R · simulate the data
library(nimble); library(MCMCvis)

set.seed(21)
n <- 120
length_mm <- runif(n, 80, 250)
true_int   <- -15      # grams
true_slope <- 0.35     # grams per mm
true_sigma <- 4        # grams of scatter around the line
mass_g <- true_int + true_slope * length_mm + rnorm(n, 0, true_sigma)
plot(length_mm, mass_g, pch = 19, xlab = "length (mm)", ylab = "mass (g)")
R · fit the regression in Nimble
reg_code <- nimbleCode({
  # priors, chosen on the scale of the data
  beta0 ~ dnorm(0, sd = 100)             # mean mass at length 0: very wide
  beta1 ~ dnorm(0, sd = 1)               # grams added per mm: a few tenths is plausible
  sigma ~ T(dnorm(0, sd = 20), 0, )      # scatter in grams, kept positive

  # likelihood
  for (i in 1:n) {
    mu[i] <- beta0 + beta1 * length_mm[i]
    mass[i] ~ dnorm(mu[i], sd = sigma)
  }
})

reg_samps <- nimbleMCMC(
  code = reg_code,
  constants = list(n = n, length_mm = length_mm),
  data = list(mass = mass_g),
  inits = list(list(beta0 = 0,   beta1 = 0,   sigma = 10),
               list(beta0 = -30, beta1 = 0.5, sigma = 5),
               list(beta0 = 10,  beta1 = 0.2, sigma = 15)),
  monitors = c("beta0", "beta1", "sigma"),
  nchains = 3, nburnin = 3000, niter = 15000,
  samplesAsCodaMCMC = TRUE)
MCMCsummary(reg_samps, round = 3)

Step 1 · The posterior predictive distribution of one new salamander

The posterior tells you which values of $\beta_0$, $\beta_1$ and $\sigma$ are plausible. It does not yet tell you what a new salamander will weigh. Two kinds of uncertainty go into that: uncertainty about where the line is, and the scatter of individual salamanders around the line. The posterior predictive distribution includes both, and the recipe is short. For each posterior draw, take that draw’s parameter values and simulate one new observation from the likelihood, $\text{Normal}(\beta_0 + \beta_1 x,\ \sigma)$.

R · predict a new 150 mm salamander
# One row per saved posterior draw; each row is one plausible set of parameter values
post <- MCMCchains(reg_samps, params = c("beta0", "beta1", "sigma"))
head(post)
S <- nrow(post)                        # 36,000 draws

# A new salamander that is 150 mm long
mu_150  <- post[, "beta0"] + post[, "beta1"] * 150        # average mass of 150 mm salamanders, one value per draw
new_150 <- rnorm(S, mean = mu_150, sd = post[, "sigma"])   # one simulated 150 mm salamander per draw

quantile(mu_150,  c(0.025, 0.5, 0.975))   # uncertainty about the AVERAGE 150 mm salamander
quantile(new_150, c(0.025, 0.5, 0.975))   # where ONE new 150 mm salamander is likely to fall

hist(new_150, breaks = 60, col = "grey85", border = "white",
     main = "Posterior predictive distribution, 150 mm", xlab = "mass (g)")
abline(v = quantile(mu_150, c(0.025, 0.975)), col = "blue", lwd = 2)    # interval for the average
abline(v = quantile(new_150, c(0.025, 0.975)), col = "red", lwd = 2)    # interval for one individual

The two intervals answer different questions. The interval for mu_150, about 36.4 to 38.0 g, is where the average 150 mm salamander sits; it is narrow because 120 salamanders pin the line down well. The interval for new_150, about 29.3 to 45.1 g, is where one new 150 mm salamander is likely to fall; it is much wider because it also includes the individual scatter $\sigma$. A manager asking “what will the next salamander we catch weigh?” needs the second.

Step 2 · Fake datasets from the posterior

Do the same thing for all 120 real lengths at once and you get a whole fake dataset: the data the model says you could have collected. Each posterior draw gives a different fake dataset.

R · one fake dataset, then 1,000 of them
# A whole fake dataset: one posterior draw, one simulated mass for each of the 120 real lengths
s <- 1
fake <- rnorm(n, mean = post[s, "beta0"] + post[s, "beta1"] * length_mm, sd = post[s, "sigma"])

par(mfrow = c(1, 2))
plot(length_mm, mass_g, pch = 19, main = "real data", xlab = "length (mm)", ylab = "mass (g)")
plot(length_mm, fake,   pch = 19, main = "one fake dataset", xlab = "length (mm)", ylab = "mass (g)")
par(mfrow = c(1, 1))

# Now 1,000 fake datasets, each from a different posterior draw (one per row)
nfake <- 1000
draws <- sample(S, nfake)
fake_data <- matrix(NA, nrow = nfake, ncol = n)
for (k in 1:nfake) {
  s <- draws[k]
  fake_data[k, ] <- rnorm(n, mean = post[s, "beta0"] + post[s, "beta1"] * length_mm,
                          sd = post[s, "sigma"])
}

Step 3 · The posterior predictive check

If the model is adequate, the real data should look like just another one of the fake datasets. To check that, pick a summary you care about, compute it on the real data and on every fake dataset, and see where the real value falls. Here the summary is the standard deviation of mass: can the model reproduce how spread out the real masses are?

R · compare the real data with the fake datasets
# Does the model reproduce the spread of the real masses?
real_sd <- sd(mass_g)                   # one number from the real data
fake_sd <- apply(fake_data, 1, sd)      # the same summary for each of the 1,000 fake datasets

hist(fake_sd, breaks = 40, col = "grey85", border = "white",
     main = "SD of mass in 1,000 fake datasets", xlab = "SD of mass (g)")
abline(v = real_sd, col = "red", lwd = 2)

mean(fake_sd >= real_sd)                # posterior predictive p-value

The real SD, 17.8 g, sits in the middle of the fake SDs, and about 53% of fake datasets are at least as spread out as the real one. Your exact numbers will differ slightly from run to run.

The recipe. (1) For each posterior draw, simulate data from the likelihood using that draw’s parameters. (2) Compute the same summary on the real data and on each fake dataset. (3) Ask how often the fake value is at least as large as the real one. That fraction is the posterior predictive p-value. Near 0.5 means the real data look like something the model would produce; near 0 or 1 means the model misses that feature of the data. This model passes because the data were simulated from it. On real data, choose summaries tied to what matters biologically, such as the largest counts, the number of zeros, or the spread, and look at the histogram as well as the number.

Afternoon exercises

Three exercises on the Bayesian half of the day: conjugacy and two Nimble fits. Work them with a partner; open each reveal only after you have tried. You are not expected to finish these during the session.

Practice Exercise A1 · Conjugacy

Beta–Binomial posterior arithmetic

You start with prior Beta(3, 7) for the probability of a snag containing a cavity-nesting bird (skeptical, with prior mean 0.30). You survey 40 snags and find 22 with a bird.

  1. Write the posterior in closed form.
  2. Compute the posterior mean, median, 95% BCI, and 95% HPDI in R.
  3. How would your answer change if the prior had been Beta(1, 1) (flat)?
Reveal

(a) Where Beta(25, 25) comes from, derive it, don't memorize it. Start from what the two lines of the model literally say. The prior is a Beta density, which as a function of $p$ (dropping the constant $1/B(\alpha,\beta)$) is

$p(p) \;\propto\; p^{\alpha-1}(1-p)^{\beta-1} \;=\; p^{2}(1-p)^{6} \qquad (\alpha=3,\ \beta=7),$

and the binomial likelihood, dropping the $\binom{n}{y}$ that doesn't contain $p$, is

$L(p) \;\propto\; p^{y}(1-p)^{n-y} \;=\; p^{22}(1-p)^{18}.$

Bayes' rule multiplies prior by likelihood, and multiplying powers of the same base just adds the exponents:

$p(p\mid y) \;\propto\; p^{\alpha-1}(1-p)^{\beta-1}\cdot p^{y}(1-p)^{n-y} \;=\; p^{\,\alpha+y-1}(1-p)^{\,\beta+n-y-1}.$

That last line is exactly the kernel of a Beta$(\alpha+y,\ \beta+n-y)$, the same functional form as the prior, which is what makes the Beta prior conjugate to the binomial likelihood. The move generalizes to every Beta–Binomial update, and it is the rule worth keeping: the $y$ successes land on the first parameter and the $n-y$ failures on the second. Plug in $\alpha=3,\ \beta=7,\ y=22,\ n-y=18$:

$p(p\mid y) = \text{Beta}(3+22,\; 7+18) = \text{Beta}(25,\,25).$

Sanity-check the pull. The prior mean was $3/10=0.30$ and the data proportion is $22/40=0.55$; the posterior mean is $25/50=0.50$, parked between the two, exactly where a compromise between a skeptical prior and the data should sit.

library(HDInterval)
post <- rbeta(1e5, 25, 25)
c(mean = mean(post), median = median(post),
 quantile(post, c(.025, .975)),
 hpdi = hdi(post, .95))

(c) Flat prior gives Beta(23, 19); posterior mean $\approx 23/42 = 0.548$, slightly higher and not pulled toward 0.3. With $n=40$ data, the prior matters but doesn't dominate.

Practice Exercise A2 · Nimble · core lab

Logistic growth in Nimble, with derived quantities

Fit the logistic-growth model (walk-through above). Then:

  1. Compute the posterior of $K/2$ and $MSY$. Report the median and 95% HPDI for each.
  2. Plot $dN/dt$ vs $N$ with median and 95% HPDI shading.
  3. Compute $\Pr(r > 0.22 \mid \text{data})$.
  4. Compute $\Pr(\text{MSY} > 50 \mid \text{data})$.
  5. Pick one parameter where BCI and HPDI noticeably differ. Plot both intervals on top of a histogram and explain in 2 sentences why they differ for that parameter.
Reveal worked solution (actual NIMBLE run)

Ran Logistic_BayesianGrowth_Nimble.R as shipped: data/Logistic.csv, flat priors ($K\sim U(0,4000)$, $r\sim U(0,2)$, $\sigma\sim U(0,2)$), set.seed(10), 3 chains, 25{,}000 iterations, 5{,}000 burn-in → 60{,}000 posterior draws. All $\hat R = 1.00$.

(a) Posterior median with 95% equal-tailed BCI and 95% HPDI (including the two primary parameters $r$ and $K$):

quantitymedian95% BCI (equal-tail)95% HPDI
$r$0.201(0.181, 0.220)(0.181, 0.219)
$K$1{,}234(1{,}131, 1{,}378)(1{,}122, 1{,}365)
$K/2$617(565, 689)(561, 683)
$\text{MSY}=rK/4$62.0(57.1, 67.1)(57.1, 67.0)
$\sigma$0.0284(0.0234, 0.0353)(0.0229, 0.0346)

(c) $\Pr(r > 0.22 \mid \text{data}) = 0.023$: the $r$ posterior is tight ($\text{sd} \approx 0.010$), so $0.22$ sits about two SDs above the median.

(d) $\Pr(\text{MSY} > 50 \mid \text{data}) = 1.00$, essentially certain, because the entire 95% MSY interval (~57 to 67) lies above 50.

(e) $\sigma$ is the parameter where BCI and HPDI noticeably separate. It is bounded below by 0 and mildly right-skewed, so its HPDI $(0.0229, 0.0346)$ sits a touch lower and is marginally shorter than the equal-tailed BCI $(0.0234, 0.0353)$: an equal-tail interval splits the 5% miss symmetrically by quantile, whereas the HPDI captures the densest 95% of mass, which for a right-skewed posterior shifts toward the lower bound. The gap is modest here because $\sigma$ is well-determined. It would be dramatic for a strongly skewed posterior ($K$ and $K/2$ show the same rightward skew). Derived-quantity plotting code ($dN/dt$ vs $N$ with median and HPDI band) is in Logistic_BayesianGrowth_Nimble.R.

Practice Exercise A3 · Nimble

Bayesian survival with three priors

Repeat the bluebird-survival walk-through using priors Beta(1,1), Beta(9,11), and Beta(50,50). For each prior:

  1. Report the posterior mean and 95% BCI.
  2. Compute $\Pr(\phi > 0.5 \mid \text{data})$.
  3. Write one sentence explaining how the prior "tug" trades off with the data, i.e. how the effective sample size of the prior compares to $n=25$.
Reveal

With $y=18$ successes in $n=25$, the data alone give $18/25 = 0.72$. Each prior pulls that toward its own mean, and "pulls" is not a metaphor here; it is arithmetic you can derive once and reuse.

Why the pull is a weighted average. Beta–Binomial conjugacy makes the posterior Beta$(\alpha+y,\ \beta+n-y)$ (successes onto $\alpha$, failures onto $\beta$), and a Beta$(a,b)$ has mean $a/(a+b)$. So the posterior mean is

$\mathbb{E}[\phi\mid y] = \dfrac{\alpha+y}{\alpha+\beta+n}.$

Split that one fraction into the part the prior contributes and the part the data contribute:

$\dfrac{\alpha+y}{\alpha+\beta+n} \;=\; \underbrace{\dfrac{\alpha+\beta}{\alpha+\beta+n}}_{w}\cdot\underbrace{\dfrac{\alpha}{\alpha+\beta}}_{\text{prior mean}} \;+\; \underbrace{\dfrac{n}{\alpha+\beta+n}}_{1-w}\cdot\underbrace{\dfrac{y}{n}}_{\text{data mean}}.$

The posterior mean is a weighted average of the prior mean and the data proportion, weighted by their sample sizes: the prior's pseudo-count $\alpha+\beta$ against the real $n$, with weight $w=(\alpha+\beta)/(\alpha+\beta+n)$ on the prior. That single identity produces every row below; compute $w$, and the number falls out with no further work:

  • Beta(1,1) (flat, pseudo-$n=2$, $w=2/27=0.07$) → Beta(19, 8), mean $0.07(0.5)+0.93(0.72)=$ 0.704, barely moved; the data dominate.
  • Beta(9,11) (mean 0.45, pseudo-$n=20$, $w=20/45=0.44$) → Beta(27, 18), mean $0.44(0.45)+0.56(0.72)=$ 0.600, pulled about halfway, because pseudo-$n \approx n$.
  • Beta(50,50) (mean 0.50, pseudo-$n=100$, $w=100/125=0.80$) → Beta(68, 57), mean $0.80(0.50)+0.20(0.72)=$ 0.544, yanked most of the way to 0.50, because the prior is worth four times the data.
  • Beta(50,100) (mean 0.33, pseudo-$n=150$, $w=150/175=0.86$) → Beta(68, 107), mean $0.86(0.33)+0.14(0.72)=$ 0.389: the posterior now sits closer to the prior than to the data. (This fourth prior is a bonus, past the three the exercise asks for; it is here to show what happens once the prior outweighs the data by 6:1.)

(a) & (b): the three numbers per prior. Posterior mean, 95% equal-tailed BCI, and $\Pr(\phi > 0.5 \mid y)$, all from the closed-form Beta posterior (and reproduced by the Nimble run below to three decimals):

PriorPosteriorPosterior mean95% BCI$\Pr(\phi > 0.5 \mid y)$
Beta(1, 1)Beta(19, 8)0.704(0.522, 0.857)0.986
Beta(9, 11)Beta(27, 18)0.600(0.455, 0.737)0.913
Beta(50, 50)Beta(68, 57)0.544(0.457, 0.630)0.838
Beta(50, 100) (bonus)Beta(68, 107)0.389(0.318, 0.462)0.002

Read the last column top to bottom. The evidence that survival exceeds one half falls from near-certain (0.986) to strong (0.913) to merely suggestive (0.838), on identical data. Nothing changed but the prior. Note also that the intervals shrink as the prior strengthens (width 0.335 → 0.282 → 0.174). A heavy prior does not merely move the posterior, it makes it look more precise than 25 birds can justify. That is the whole reason reviewers interrogate priors.

The Nimble version (the exercise is tagged Nimble, and conjugacy gives you the exact answer to check it against, which is exactly why this is a good first Nimble model):

R · Nimble, three priors, one loop
library(nimble); library(MCMCvis); library(coda)

surv_code <- nimbleCode({
 phi ~ dbeta(a, b) # a, b passed in as constants: one model, three priors
 y ~ dbinom(phi, n)
})

fit_prior <- function(a, b) {
 set.seed(4)
 out <- nimbleMCMC(surv_code,
 constants = list(n = 25, a = a, b = b),
 data = list(y = 18),
 inits = list(list(phi = 0.3), list(phi = 0.5), list(phi = 0.8)),
 monitors = "phi", nchains = 3, nburnin = 2000, niter = 22000,
 samplesAsCodaMCMC = TRUE)
 phi <- as.numeric(MCMCchains(out, "phi"))
 c(mean = mean(phi),
 quantile(phi, c(.025, .975)),
 p_gt_0.5 = mean(phi > 0.5),
 Rhat = MCMCsummary(out)$Rhat[1])
}

priors <- list(c(1,1), c(9,11), c(50,50), c(50,100))
round(t(sapply(priors, function(ab) fit_prior(ab[1], ab[2]))), 3)

# Exact check -- no MCMC needed, because Beta-Binomial is conjugate:
# posterior is Beta(a + 18, b + 7)
qbeta(c(.025, .975), 19, 8) # 0.5221 0.8567
pbeta(0.5, 19, 8, lower.tail = FALSE) # 0.9855

All chains give $\hat R = 1.00$, and the MCMC means/BCIs match the closed form to three decimals, which is the point of running a conjugate model in Nimble at all. It is the one case where you can audit the sampler against a known answer.

(c) The one-sentence answer. A Beta$(\alpha,\beta)$ prior carries an effective sample size of $\alpha+\beta$ pseudo-observations, so against $n=25$ real birds Beta(1,1) is worth 2 (weight $0.07$, the data decide), Beta(9,11) is worth 20 (weight $0.44$: a near-tie), and Beta(50,50) is worth 100 (weight $0.80$: the prior decides, four to one).

The lesson is that the prior matters most when its pseudo-sample size is large relative to $n$. Beta(50,50) is not a "weak" prior just because it is centred at one half, with $n=25$ it overwhelms the data.

Graded problem set →