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 20-point assignment is a separate problem set; write up your answers in the R Markdown template and submit on Canvas.

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 · practice answer key.

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 referenced in this lab's exercises lives in this folder. Click any link below to download.

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

Jags_logistic_rK_Lab1.RJags_logistic_rK_Lab1.R · original JAGS version of the logistic-growth example, for comparison
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, 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: minimize 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 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 \in \{0.1, 0.2, 0.3, 0.4\}$ to show how the likelihood changes with the parameter. Here is that grid search, complete and runnable.

R · grid search for Vonbert κ
# Load the Vonbert data (columns: sex, age, length). Keep sex==1.
TheData <- read.csv("data/vonbert.csv")
TheData <- TheData[TheData$sex == 1, ]
age <- TheData$age; len <- TheData$length

# negative log-likelihood as a function of (Linf, kappa, sigma)
nll_vbgf <- function(Linf, kappa, sigma) {
 pred <- Linf * (1 - exp(-kappa * age))
 -sum(dnorm(len, 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.

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

nll_vbgf3 <- function(Linf, kappa, sigma) {
 pred <- Linf * (1 - exp(-kappa * age))
 -sum(dnorm(len, 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
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("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 — 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 males and females 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
TheData <- read.csv("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.

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: 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: nest abandoned, 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.
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$.

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))
for (nm in names(priors)) {
 ab <- priors[[nm]]; post <- c(ab[1] + y, ab[2] + n - y)
 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 — not by $\alpha+\beta$. Each conjugate family has its own currency for prior strength; read it off the update rule rather than assuming one formula covers them all.

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. The two algorithms you will meet today and tomorrow:

AlgorithmIdeaWhen you'd use it
Metropolis(-Hastings)Propose, accept/reject by a posterior ratioAny density — pedagogical, slower for high-d
GibbsSample each parameter from its full conditionalConjugate sub-blocks (default in JAGS)
Adaptive block / slice / HMCWhat Nimble & Stan use by defaultProduction work

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.
R · homemade Metropolis for Beta–Binomial
y <- 9; n <- 12; alpha0 <- 2; beta0 <- 2

log_post <- function(p) {
 if (p <= 0 || p >= 1) return(-Inf)
 dbeta(p, alpha0, beta0, log = TRUE) +
 dbinom(y, size = n, prob = p, log = TRUE)
}

niter <- 20000; chain <- numeric(niter); chain[1] <- 0.5
accept <- 0; s <- 0.35 # proposal SD
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])
 if (log(runif(1)) < log_alpha) { 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)
Tuning: 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("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 # BUGS-style precision

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

 # derived quantities
 N_at_maxdNdt <- K / 2
 MSY <- r * K / 4

 for (j in 1:nN) {
 dNdt[j] <- r * N[j] * (1 - N[j] / K)
 }
})
R · constants vs data, inits, MCMC
# Nimble splits the JAGS "data" list into:
# constants : sizes, indices, fixed covariates, prediction grids
# data : observed stochastic nodes (y[i] here)
constants <- list(
 n = nrow(Logistic),
 x = as.double(Logistic$PopulationSize),
 N = N_grid,
 nN = length(N_grid)
)
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", "mu", "N_at_maxdNdt", "MSY", "dNdt"),
 nchains = 3,
 nburnin = 5000,
 niter = 25000,
 thin = 1,
 samplesAsCodaMCMC = TRUE
)
JAGS → Nimble translation checklist:
  • cat("model{...}", file="x.txt") + jags.model() + coda.samples()  →  nimbleCode({...}) + nimbleMCMC(code=, constants=, data=, inits=, monitors=, nchains=, nburnin=, niter=)
  • The body of the model (priors, likelihood, derived) translates verbatim: same dnorm, dunif, dbeta, dbinom, dpois; same precision parameterization for dnorm(mu, tau).
  • One JAGS data list splits into Nimble's constants (sizes/indices/fixed covariates) and data (observed RVs).
  • nimbleMCMC returns a list of MCMC matrices (or a coda mcmc.list with samplesAsCodaMCMC=TRUE). MCMCvis functions (MCMCsummary, MCMCtrace, MCMCchains) work unchanged on either.
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)

# P(r > 0.22 | data)
r_chain <- MCMCchains(samples, params = "r")
mean(r_chain > 0.22)

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
BCI <- MCMCpstr(samples, params = "mu",
 func = function(x) quantile(x, c(.025, .50, .975)))
HPDI <- MCMCpstr(samples, params = "mu",
 func = function(x) hdi(x, .95))

plot(constants$x, data$y, pch = 19,
 xlab = "Population size N", ylab = "Per-capita growth (1/yr)")
lines(constants$x, BCI$mu[, 2], lwd = 2)
lines(constants$x, BCI$mu[, 1], lty = 2, col = "red")
lines(constants$x, BCI$mu[, 3], lty = 2, col = "red")
lines(constants$x, HPDI$mu[, 1], lty = 2, col = "blue")
lines(constants$x, HPDI$mu[, 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?" Yes — Beta(1,1) (Uniform) prior gives posterior mode = MLE = $y/n = 0.72$. The prior we used here (Beta(9,11)) shrinks the estimate from 0.72 toward 0.45. The closer your prior pseudo-sample size to your actual data, the more shrinkage you get. With $n=25$ and prior "sample size" 20, you can see almost 50% shrinkage.

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) think carefully about priors on the scale of the data, (ii) fit the regression in Nimble, and (iii) do a posterior predictive check by simulating replicate datasets from the posterior and comparing them to the real one. This is the workflow you should use on every Bayesian model you fit.

R · simulate + priors
set.seed(21)
n <- 120
length_mm <- runif(n, 80, 250)
true_int <- -35
true_slope <- 0.32
true_sigma <- 6.0
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)")

# Prior reasoning:
# * intercept: at length=0 the mean mass should be plausibly small; N(0, 100^2) is mild.
# * slope: a mm of length should add at most a few grams; N(0, 1) is very wide on this scale.
# * sigma: residual SD must be positive and on the same scale as mass (grams).
# half-N(0, 20) keeps it positive with most mass below 50.
R · Nimble model with posterior predictive node
reg_code <- nimbleCode({
 # priors
 beta0 ~ dnorm(0, sd = 100)
 beta1 ~ dnorm(0, sd = 1)
 sigma ~ T(dnorm(0, sd = 20), 0, ) # half-normal via truncation

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

 # No discrepancy statistics in here. sd(mass) is a constant — putting it in the
 # model re-computes it every iteration and monitors a number that never moves.
 # We compute discrepancies in R below from mu and mass_rep, so we can try
 # several of them without refitting.
})

constants <- list(n = n, length_mm = length_mm)
data <- list(mass = mass_g)
inits <- list(
 list(beta0 = 0, beta1 = 0, sigma = 10),
 list(beta0 = -50, beta1 = 0.5, sigma = 5),
 list(beta0 = 20, beta1 = -0.1, sigma = 15))

reg_samps <- nimbleMCMC(
 code = reg_code,
 constants = constants,
 data = data,
 inits = inits,
 monitors = c("beta0", "beta1", "sigma", "mu", "mass_rep"),
 nchains = 3, nburnin = 3000, niter = 15000,
 samplesAsCodaMCMC = TRUE)
MCMCsummary(reg_samps, params = c("beta0", "beta1", "sigma"), round = 3)
R · posterior predictive check
mu_post  <- MCMCchains(reg_samps, "mu")        # fitted mass for each salamander
rep_post <- MCMCchains(reg_samps, "mass_rep")  # a fake dataset, one per draw

# Two ways to summarise "how far are the points from the line?":
#   SSR          = the total squared distance
#   biggest miss = the single worst salamander
# Compute each on the real data and on the fake data, at every posterior draw.
S <- nrow(mu_post)
obs_ssr <- numeric(S)
rep_ssr <- numeric(S)
obs_miss <- numeric(S)
rep_miss <- numeric(S)

for (s in 1:S) {
 e_obs <- mass_g - mu_post[s, ] # residuals, real data
 e_rep <- rep_post[s, ] - mu_post[s, ] # residuals, fake data

 obs_ssr[s] <- sum(e_obs^2)
 rep_ssr[s] <- sum(e_rep^2)
 obs_miss[s] <- max(abs(e_obs))
 rep_miss[s] <- max(abs(e_rep))
}

# Bayesian p-value: how often is the fake data as bad as the real data?
mean(rep_ssr >= obs_ssr) # sum of squared residuals
mean(rep_miss >= obs_miss) # biggest miss

# Where does the real dataset sit among the fake ones?
hist(rep_miss, xlab = "biggest miss in a fake dataset (g)", main = "")
abline(v = mean(obs_miss), col = "red", lwd = 2) # real data: inside the pile, or off in the tail?

# Visual PPC: overlay 100 replicate datasets on the data
mass_rep_chain <- MCMCchains(reg_samps, "mass_rep")
plot(length_mm, mass_g, pch = 19, xlab = "length (mm)", ylab = "mass (g)")
for (i in sample(nrow(mass_rep_chain), 100)) {
 points(length_mm, mass_rep_chain[i, ], col = rgb(0, 0, 0, 0.04), pch = 19, cex = 0.5)
}
points(length_mm, mass_g, pch = 19, col = "red")
The recipe is always the same three steps: (1) simulate fake datasets from the posterior, (2) compute the same summary on the fake data and the real data, (3) ask how often the fake is at least as extreme as the real. That fraction is the Bayesian p-value. Near 0.5 means the real data look like something your model would produce. Near 0 or 1 is a red flag. Two different summaries of the same fit can give you two different p-values, so the summary you pick is part of the check — and look at the plot as well as the number.

Exercises

Eight numbered exercises spanning the day. Work them with a partner; check the answer key only after you've drafted your own solution. Difficulty pills indicate rough time commitment.

Exercise 1 · 15 min · MLE by hand

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$.
  2. Take the log and the derivative; show that the MLE is $\hat p = s/n$.
  3. Take the second derivative of $\ell(p)$ at $\hat p$. Show that the Fisher information is $I(\hat p) = n / [\hat p(1-\hat p)]$ and hence the Wald SE is $\sqrt{\hat p(1-\hat p)/n}$.
  4. Compute the MLE, its Wald SE, and a 95% profile-likelihood CI for the red-light data: $s = 30, n = 50$. (Use mle() for the profile.)
Reveal solution

(a) $L(p) = p^s(1-p)^{n-s}$. (b) $\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$, so $-\ell''(\hat p) = n/[\hat p(1-\hat p)]$.

(d) $\hat p = 0.6$, Wald SE $= \sqrt{0.6 \cdot 0.4/50} \approx 0.069$. Profile CI from confint(mle(...)) ≈ (0.462, 0.728).

library(stats4)
nll <- function(p) -(30 * log(p) + 20 * log(1 - p))
fit <- mle(nll, start = list(p = 0.5),
 method = "L-BFGS-B", lower = 0.001, upper = 0.999)
summary(fit); confint(fit)
Exercise 2 · 25 min · 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("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.

Exercise 3 · 20 min · LRT + AIC

Sex-specific VBGF revisited

Use the LRT code from the walk-through above. 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 102.5$ vs $\hat L_{\infty,2} \approx 82.6$) far more than in growth coefficient $\kappa$ ($0.284$ vs $0.259$). When the intermediate model is forced to share $L_\infty$, it can only compensate by splaying the two $\kappa$'s apart ($0.335$ vs $0.167$) — and still lands ~40 AIC units short of the full model.

Conclusion: the sexes differ mainly in asymptotic size $L_\infty$ (one sex reaches a substantially larger maximum length) 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.

Exercise 4 · 25 min · 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. Why is the gap between Poisson and ZIP largest at small $\lambda$?
Reveal

(c) When $\lambda$ is small, the Poisson already produces many zeros, so the extra zero inflation looks like "Poisson with smaller mean." The ZIP and Poisson likelihoods become hard to distinguish, and you need a lot of data to detect the inflation. When $\lambda$ is large, the Poisson predicts essentially no zeros, so any zero inflation jumps out.

Exercise 5 · 15 min · Conjugacy

Beta–Binomial posterior arithmetic

You start with prior Beta(3, 7) for the probability of a snag containing a cavity-nesting bird (skeptical: 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)$ — 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.

Exercise 6 · 30 min · Metropolis

Roll your own Metropolis for Gamma–Poisson

Tick count data (the Gamma–Poisson walk-through). Implement Metropolis on a single parameter $\lambda > 0$:

  1. Use $\log \lambda$ as the unconstrained variable (propose Normal jumps in log-space). Why is this a good idea?
  2. Run 30,000 iterations. Plot the trace, the running mean, and a histogram with the closed-form Gamma posterior overlaid.
  3. Report your acceptance rate and tune the proposal SD to ~30%.
Reveal

(a) Working in $\log\lambda$ makes the proposal Normal symmetric in the unconstrained space and automatically respects $\lambda > 0$; otherwise you need a rejection step for proposals $\le 0$.

There is one bookkeeping price for that reparameterization, and it is the + log_lam line in the log-posterior — the Jacobian. When you sample $u=\log\lambda$ rather than $\lambda$, the change of variables stretches the density by the factor $\left|\dfrac{d\lambda}{du}\right| = \dfrac{d\,e^{u}}{du} = e^{u} = \lambda$, so the target density expressed in $u$ is $p(\lambda)\,L(\lambda)\cdot\lambda$. Take logs and that trailing factor becomes an additive term:

$\log \tilde p(u) \;=\; \log p(\lambda) + \log L(\lambda) + \log\lambda, \qquad \lambda = e^{u}.$

Drop the $+\log\lambda$ and the chain converges to $p(\lambda)L(\lambda)/\lambda$ — the wrong distribution. It survives into the acceptance ratio while the proposal density does not: because the random-walk proposal is symmetric in $u$ (a Normal step is as likely up as down), the proposal terms cancel in $\alpha = \min(1,\, \tilde p(u^\star)/\tilde p(u_{\text{current}}))$, leaving the Jacobian as the only correction you owe for the switch to log-space.

y <- c(3,1,4,2,0,5,2,3,1,4)
log_post <- function(log_lam) {
 lam <- exp(log_lam)
 dgamma(lam, 2, 1, log = TRUE) + sum(dpois(y, lam, log = TRUE)) + log_lam # Jacobian
}
ll <- numeric(3e4); ll[1] <- log(2); acc <- 0; s <- 0.30
for (i in 2:length(ll)) {
 prop <- rnorm(1, ll[i - 1], s)
 if (log(runif(1)) < log_post(prop) - log_post(ll[i - 1])) { ll[i] <- prop; acc <- acc + 1 } else ll[i] <- ll[i - 1]
}
lam <- exp(ll[-(1:3000)])
hist(lam, breaks = 60, freq = FALSE)
curve(dgamma(x, 2 + sum(y), 1 + length(y)), add = TRUE, col = "red", lwd = 2)
acc / length(ll)
Exercise 7 · 45 min · Nimble · core lab

Logistic growth in Nimble — 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: 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.

Exercise 8 · 30 min · 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: 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.

The lesson: 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.

Open full answer key → Graded problem set →