Applied Informatics and Computational Economics Lab
5 July 2026
Required Packages
library(tidyverse) # data wrangling and ggplot2library(coda) # MCMC diagnostics: effectiveSize(), gelman.diag()library(MASS) # mvrnorm() for multivariate normal draws# Production Bayesian workflow (used alongside the hand-coded samplers):library(rstan) # Stan: HMC / NUTS via Hamiltonian Monte Carlolibrary(brms) # regression-style front-end to Stanlibrary(bsvars) # Bayesian structural VARs# Model checking and comparison (Part IX):library(loo) # WAIC and PSIS-LOO from a log-likelihood matrixlibrary(bayesplot) # pp_check() and posterior graphicslibrary(bridgesampling) # bridge-sampling marginal likelihoodslibrary(mvtnorm) # dmvnorm() for Chib's posterior ordinate# Extensions (Part X):library(BMS) # Bayesian model averaging over the whole model spacelibrary(quantreg) # classical quantile regression, as a reference point
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom scipy import stats, linalg# Production Bayesian workflow (used alongside the hand-coded samplers):import pymc as pm # PyMC: NUTS samplerimport arviz as az # posterior diagnostics and plots
* Native Bayesian engine — no ssc installs required:* bayesmh general Metropolis-Hastings / Gibbs* bayes: regress Bayesian linear regression* bayes: mixed Bayesian multilevel / hierarchical models* bayesstats summary, bayesgraph posterior summaries and diagnostics* Model checking and comparison (Part IX):* bayespredict, bayesstats ppvalues posterior predictive checks* bayesstats ic DIC and Laplace-Metropolis log ML* bayestest model posterior model probabilities* Extensions (Part X):* bmaregress Bayesian model averaging by MC3* bayesmh, likelihood(llf()) user-written log densities
About This Deck
Part I: Foundations — Bayes’ rule, the posterior, and why the normalising constant forces us to compute
Part II: MCMC — Metropolis–Hastings and Gibbs sampling, from detailed balance to a working linear-model sampler
Part III: HMC / NUTS — Hamiltonian dynamics that beat random walks in high dimensions
Part IV: Priors — elicitation, weakly-informative defaults, prior-predictive checks and sensitivity analysis
Part V: State-space filtering — the Kalman filter and the particle filter for latent states
Part VI: Bayesian VARs — the Minnesota prior, conjugate posteriors and impulse responses with credible bands
Part VII: Hierarchical panel models — partial pooling and shrinkage via Gibbs
Part VIII: DSGE estimation — the Kalman likelihood plus Metropolis–Hastings, and where HMC enters
Part IX: Model checking and comparison — posterior predictive checks, Bayes factors, marginal likelihoods, WAIC and PSIS-LOO
Part X: Extensions — variational inference, Bayesian model averaging, quantile regression and sequential Monte Carlo
Every sampler here is hand-coded so the mechanics are visible — then cross-checked against the production tools: rstan/brms (Stan NUTS), bsvars, PyMC and Stata’s bayesmh. The script bayesian-computation-data.R generates every shared dataset; R, Python and Stata read the same CSV.
Literature Review
Gelman, A., Carlin, J. B., Stern, H. S., Dunson, D. B., Vehtari, A., & Rubin, D. B. (2013). Bayesian Data Analysis, 3rd ed. CRC Press. Book page
Koop, G. (2003). Bayesian Econometrics. Wiley. Publisher page
Geweke, J. (2005). Contemporary Bayesian Econometrics and Statistics. Wiley. DOI: 10.1002/0471744735
Chib, S., & Greenberg, E. (1995). Understanding the Metropolis–Hastings algorithm. The American Statistician, 49(4), 327–335. DOI: 10.1080/00031305.1995.10476177
Gelfand, A. E., & Smith, A. F. M. (1990). Sampling-based approaches to calculating marginal densities. JASA, 85(410), 398–409. DOI: 10.1080/01621459.1990.10476213
Herbst, E. P., & Schorfheide, F. (2016). Bayesian Estimation of DSGE Models. Princeton University Press. Publisher page
Koop, G., & Korobilis, D. (2010). Bayesian multivariate time series methods for empirical macroeconomics. Foundations and Trends in Econometrics, 3(4), 267–358. DOI: 10.1561/0800000013
Part I: Foundations
Bayes’ rule, the posterior, and why we must compute
Posterior mean (\(g(\theta)=\theta\)), variance, tail probability, prediction — all are integrals
For all but a handful of conjugate models these integrals have no closed form
In a DSGE model \(\theta\) can have 30–40 dimensions; a grid with 20 points per axis is \(20^{40}\) cells — hopeless
\[
\boxed{\text{The entire field of Bayesian computation exists to approximate these integrals by simulation}}
\]
Draw from the posterior, then average. If \(\theta^{(1)},\dots,\theta^{(S)} \sim p(\theta\mid y)\) then \(\frac1S\sum_s g(\theta^{(s)}) \to \mathbb{E}[g(\theta)\mid y]\)
MCMC (Parts II, VII, VIII): Metropolis–Hastings, Gibbs — a Markov chain whose stationary distribution is the posterior
HMC / NUTS (Part III): MCMC guided by gradients, the engine inside Stan and PyMC
Sequential Monte Carlo (Part V): particle filters for latent states
Exploit structure: conjugate updates (Part I), the Kalman filter (Part V), Normal–inverse-Wishart VAR posteriors (Part VI)
The art is matching the algorithm to the geometry of the problem.
Conjugacy makes the posterior another Beta — prior and posterior share a family:
\[
\theta \mid k \;\sim\; \text{Beta}(a_0 + k,\; b_0 + n - k)
\]
With \(a_0=b_0=2\), \(n=50\), \(k=34\): posterior \(\text{Beta}(36, 18)\), mean \(36/54 = 0.667\). We can simulate it and confirm the closed form — a sanity check before we trust a sampler with no closed form.
Code
a0 <-2; b0 <-2; n <-50; k <-34ap <- a0 + k; bp <- b0 + n - k # posterior Beta(36, 18)set.seed(14159)draws <-rbeta(100000, ap, bp)cat(sprintf("Posterior mean : simulated = %.4f exact = %.4f\n",mean(draws), ap / (ap + bp)))cat(sprintf("95%% credible : [%.3f, %.3f] (exact quantiles)\n",qbeta(0.025, ap, bp), qbeta(0.975, ap, bp)))
Posterior mean : simulated = 0.6665 exact = 0.6667
95% credible : [0.537, 0.785] (exact quantiles)
Code
import numpy as npfrom scipy import statsa0, b0, n, k =2, 2, 50, 34ap, bp = a0 + k, b0 + n - krng = np.random.default_rng(14159)draws = rng.beta(ap, bp, size=100000)print(f"Posterior mean : simulated = {draws.mean():.4f} exact = {ap/(ap+bp):.4f}")
Posterior mean : simulated = 0.6666 exact = 0.6667
The error shrinks as \(\mathcal{O}(S^{-1/2})\) — independent of the dimension of \(\theta\), which is why the method survives in high dimensions where quadrature dies.
We check it on a case with a known answer: \(\mathbb{E}[e^X]\) for \(X\sim\mathcal{N}(0,1)\) equals \(e^{1/2} \approx 1.6487\).
Code
set.seed(14159)truth <-exp(0.5) # E[exp(X)] for X ~ N(0,1)for (S inc(100, 1000, 10000, 100000)) { x <-rnorm(S); g <-exp(x)cat(sprintf("S = %6d : estimate = %.4f se = %.4f |error| = %.4f\n", S, mean(g), sd(g) /sqrt(S), abs(mean(g) - truth)))}cat(sprintf("truth exp(1/2) = %.4f\n", truth))
S = 100 : estimate = 1.4796 se = 0.1498 |error| = 0.1692
S = 1000 : estimate = 1.5407 se = 0.0623 |error| = 0.1081
S = 10000 : estimate = 1.6651 se = 0.0228 |error| = 0.0164
S = 100000 : estimate = 1.6483 se = 0.0069 |error| = 0.0004
truth exp(1/2) = 1.6487
Ten times more draws cuts the error by about three -- the 1/sqrt(S) rate.
Code
import numpy as nprng = np.random.default_rng(14159)truth = np.exp(0.5)lines = []for S in (100, 1000, 10000, 100000): g = np.exp(rng.standard_normal(S)) lines.append(f"S = {S:6d} : estimate = {g.mean():.4f} "f"se = {g.std(ddof=1)/np.sqrt(S):.4f} |error| = {abs(g.mean()-truth):.4f}")lines.append(f"truth exp(1/2) = {truth:.4f}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
S = 100 : estimate = 1.3622 se = 0.1375 |error| = 0.2865
S = 1000 : estimate = 1.5809 se = 0.0571 |error| = 0.0678
S = 10000 : estimate = 1.6489 se = 0.0221 |error| = 0.0001
S = 100000 : estimate = 1.6485 se = 0.0068 |error| = 0.0003
truth exp(1/2) = 1.6487
273
Code
setlinesize 255clearsetseed 14159foreach S in 100 1000 10000 100000 {quietlysetobs`S'quietlygeneratedouble g = exp(rnormal())quietlysummarize gdisplayastext"S = " %6.0f `S'" : estimate = " %6.4f r(mean) " se = " %6.4f r(sd)/sqrt(`S')clear}displayastext"truth exp(1/2) = " %6.4f exp(0.5)
S = 100 : estimate = 1.5545 se = 0.1958
S = 1000 : estimate = 1.5987 se = 0.0681
S = 10000 : estimate = 1.6470 se = 0.0220
S = 100000 : estimate = 1.6553 se = 0.0068
truth exp(1/2) = 1.6487
The estimator has finite variance only if the weights do. If \(q\) has thinner tails than \(p\), then \(w = p/q\) is unbounded: a handful of draws carry all the weight and the effective sample size collapses.
The rule is proposal tails at least as fat as the target. We estimate \(\mathbb{E}[X^2] = 3\) for a Student-\(t_3\) target from two proposals — a Normal (too thin) and a Cauchy (fat enough) — and repeat the whole exercise 40 times to expose the variance.
Code
set.seed(14159)S <-20000; R <-40est_n <-numeric(R); est_c <-numeric(R)for (r in1:R) { x <-rnorm(S) # thin-tailed proposal w <-dt(x, 3) /dnorm(x) est_n[r] <-sum(w * x^2) /sum(w) x <-rcauchy(S) # fat-tailed proposal w <-dt(x, 3) /dcauchy(x) est_c[r] <-sum(w * x^2) /sum(w)}cat(sprintf("normal proposal : mean %.3f sd %.3f range [%.3f, %.3f]\n",mean(est_n), sd(est_n), min(est_n), max(est_n)))cat(sprintf("cauchy proposal : mean %.3f sd %.3f range [%.3f, %.3f]\n",mean(est_c), sd(est_c), min(est_c), max(est_c)))cat("truth E[X^2] = 3.000\n")
normal proposal : mean 1.795 sd 0.352 range [1.456, 3.286] median ESS 7701
cauchy proposal : mean 2.991 sd 0.031 range [2.917, 3.064] median ESS 17333
truth E[X^2] = 3.000
The Normal proposal never reaches the tails where X^2 has its mass:
biased low, and ten times the spread across replications.
Code
import numpy as npfrom scipy import statsrng = np.random.default_rng(14159)S, R =20000, 40est = {"normal": [], "cauchy": []}for r inrange(R): x = rng.standard_normal(S) # thin-tailed proposal w = stats.t.pdf(x, 3) / stats.norm.pdf(x) est["normal"].append(np.sum(w * x**2) / np.sum(w)) x = stats.cauchy.rvs(size=S, random_state=rng) # fat-tailed proposal w = stats.t.pdf(x, 3) / stats.cauchy.pdf(x) est["cauchy"].append(np.sum(w * x**2) / np.sum(w))lines = []for nm in ("normal", "cauchy"): e = np.array(est[nm]) lines.append(f"{nm} proposal : mean {e.mean():.3f} sd {e.std(ddof=1):.3f} "f"range [{e.min():.3f}, {e.max():.3f}]")lines.append("truth E[X^2] = 3.000")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
normal proposal : mean 1.782 sd 0.422 range [1.499, 4.061]
cauchy proposal : mean 3.008 sd 0.036 range [2.911, 3.071]
truth E[X^2] = 3.000
143
We cannot draw from \(p(\theta\mid y)\) directly, but we can build a Markov chain whose stationary distribution is exactly it. Crucially, the intractable constant \(p(y)\)cancels in every ratio.
Given the current state \(\theta\), propose \(\theta^\star \sim q(\theta^\star \mid \theta)\) and accept it with probability
Detailed balance \(\Rightarrow\)\(p(\theta\mid y)\) is a stationary distribution of the chain
Irreducibility + aperiodicity \(\Rightarrow\) the chain converges to it from any start
Early draws are contaminated by the start: discard a burn-in
Consecutive draws are correlated, so \(S\) MCMC draws are worth fewer than \(S\) independent ones — measured by effective sample size (ESS)
The proposal scale is the one knob that matters:
Too small → almost every proposal accepted, but the chain crawls; huge autocorrelation
Too large → almost every proposal rejected; the chain sticks
Rule of thumb (Roberts–Gelman–Gilks): aim for an acceptance rate near \(0.234\) in high dimensions, \(\approx 0.44\) in one dimension
Always accept on log densities
Likelihoods underflow to zero in double precision within a few hundred observations. Never form \(p(y\mid\theta^\star)/p(y\mid\theta)\) directly. Instead compute \(\log p(y\mid\theta) + \log p(\theta)\) and accept when \[
\log u \;<\; \big[\log p^\star + \log \pi^\star\big] - \big[\log p + \log \pi\big], \qquad u\sim U(0,1).
\] Every sampler in this deck follows this rule.
with \(n = 120\), \(\beta_0 = 1.0\), \(\beta_1 = 2.0\), \(\sigma = 1.5\). The script bayesian-computation-data.R writes the data to ../data/bayes-linreg.csv; R, Python and Stata all read that same file.
Code
# This block lives in bayesian-computation-data.R, run once before rendering:# Rscript bayesian-computation-data.Rset.seed(14159)n <-120; b0 <-1.0; b1 <-2.0; sigma <-1.5x <-rnorm(n, mean =0, sd =1)y <- b0 + b1 * x +rnorm(n, sd = sigma)df <-data.frame(x = x, y = y)write.csv(df, "../data/bayes-linreg.csv", row.names =FALSE)
Code
d <-read.csv("../data/bayes-linreg.csv")print(coef(lm(y ~ x, data = d))) # OLS anchor for later comparison
Read ../data/bayes-linreg.csv : 120 rows
(Intercept) x
0.7866 2.1417
Code
import pandas as pdimport statsmodels.api as smd = pd.read_csv("../data/bayes-linreg.csv") # reads R's CSVX = sm.add_constant(d["x"])print(sm.OLS(d["y"], X).fit().params.round(4))
const 0.7866
x 2.1417
dtype: float64
Code
import delimited "../data/bayes-linreg.csv", clearquietlydestring_all, replaceregressy x
(encoding automatically selected: ISO-8859-1)
(2 vars, 120 obs)
Source | SS df MS Number of obs = 120
-------------+---------------------------------- F(1, 118) = 206.32
Model | 524.217008 1 524.217008 Prob > F = 0.0000
Residual | 299.814279 118 2.54079897 R-squared = 0.6362
-------------+---------------------------------- Adj R-squared = 0.6331
Total | 824.031287 119 6.92463266 Root MSE = 1.594
------------------------------------------------------------------------------
y | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
x | 2.141672 .1491017 14.36 0.000 1.84641 2.436934
_cons | .78664 .1460022 5.39 0.000 .4975158 1.075764
------------------------------------------------------------------------------
When each full conditional is a known distribution, we can always accept — Gibbs is Metropolis–Hastings with acceptance probability one. Cycle through blocks, each drawn given the current value of the others:
A posterior mean is worthless if the chain has not mixed. Three standard checks:
Trace plots: a well-mixed chain looks like a “fat hairy caterpillar” with no trends or sticking
Autocorrelation and effective sample size: \(\text{ESS} = S / (1 + 2\sum_{k\ge1}\rho_k)\) — the number of independent draws your correlated chain is worth
\(\hat R\) (Gelman–Rubin): run several chains from dispersed starts; \(\hat R\) compares between-chain and within-chain variance and should be \(< 1.01\)
Gibbs on this conjugate model produces near-independent draws (ESS close to the number of iterations)
Random-walk MH produces correlated draws — the ACF decays slowly, ESS is a fraction of the raw count
That gap is exactly what Hamiltonian Monte Carlo (Part III) is built to close
Always report ESS and \(\hat R\) next to every posterior number — they are the MCMC analogue of a standard error
Turn ESS into a Monte Carlo error bar
The Monte Carlo standard error of a posterior mean is \[
\text{MCSE} \;=\; \frac{\widehat{\text{sd}}(\theta)}{\sqrt{\text{ESS}}}.
\] Report it so readers know how many significant digits of your posterior mean are actually resolved. Rule of thumb: sample until MCSE is an order of magnitude smaller than the posterior sd you care about — ESS \(\gtrsim 100\) for a mean, and considerably more for tail quantiles such as a 97.5% credible bound.
Gibbs needs every full conditional in closed form. Change one prior and that breaks. Put a half-Cauchy prior on the error scale — the modern default for scale parameters, because it is flat near zero and heavy-tailed:
Now \(p(\sigma \mid \beta, y)\) is not an inverse-gamma. The fix is not to abandon Gibbs but to replace the offending block with a Metropolis step:
\(\beta \mid \sigma^2, y\) — still Normal, draw it exactly
\(\sigma \mid \beta, y\) — no closed form, take one Metropolis step
The result is a valid Markov chain with the right stationary distribution: each block leaves \(p(\theta\mid y)\) invariant, so their composition does too. We sample \(\ell = \log\sigma\) so the proposal is unconstrained, which adds the Jacobian \(\sigma\):
setlinesize 255quietly import delimited "../data/bayes-linreg.csv", clearquietlydestring_all, replace* block() puts sigma in its own Metropolis block: this is Metropolis-within-Gibbs.* logdensity() supplies the half-Cauchy prior Stata has no built-innamefor.bayesmh y x, likelihood(normal({sig}^2)) prior({y:x _cons}, normal(0, 100)) prior({sig}, logdensity(cond({sig}>0, log(2/(_pi*(1+{sig}^2))), -1e10))) block({sig}) initial({y:x} 2 {y:_cons} 1 {sig} 1.5) rseed(14159) nomodelsummary mcmcsize(8000) burnin(2000)
Burn-in ...
Simulation ...
Bayesian normal regression MCMC iterations = 10,000
Random-walk Metropolis–Hastings sampling Burn-in = 2,000
MCMC sample size = 8,000
Number of obs = 120
Acceptance rate = .3207
Efficiency: min = .08577
avg = .145
Log marginal-likelihood = -236.74711 max = .2223
------------------------------------------------------------------------------
| Equal-tailed
| Mean Std. dev. MCSE Median [95% cred. interval]
-------------+----------------------------------------------------------------
y |
x | 2.140352 .1506602 .005752 2.137209 1.839801 2.451746
_cons | .7824409 .1420346 .004455 .7821245 .4886489 1.055775
-------------+----------------------------------------------------------------
sig | 1.596801 .1034445 .002453 1.59298 1.410188 1.811357
------------------------------------------------------------------------------
Note: Adaptation continues during simulation.
The probit likelihood has no conjugate prior — but it is a censored linear model in disguise. Reintroduce the latent utility that generated the binary outcome:
The variance is fixed at 1 — the probit scale is not identified — so the \(\beta\) block is the ordinary conjugate regression draw. The same device extends to ordered probit and to Tobit, where \(z\) is censored rather than dichotomised.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.set.seed(14159)n <-600; b0 <--0.3; b1 <-0.8; b2 <--0.5x1 <-rnorm(n); x2 <-rnorm(n)ystar <- b0 + b1 * x1 + b2 * x2 +rnorm(n)y <-as.numeric(ystar >0)write.csv(data.frame(y = y, x1 = x1, x2 = x2),"../data/bayes-probit.csv", row.names =FALSE)
Code
p <-read.csv("../data/bayes-probit.csv")X <-cbind(1, p$x1, p$x2); y <- p$y; n <-nrow(X)Vb <-solve(diag(1/100, 3) +crossprod(X)) # fixed: latent variance is 1set.seed(14159)S <-6000; b <-c(0, 0, 0); keep <-matrix(NA, S, 3)for (t in1:S) { mu <-as.vector(X %*% b) lo <-ifelse(y ==1, pnorm(-mu), 0) # truncated normal by inverse CDF hi <-ifelse(y ==1, 1, pnorm(-mu)) z <- mu +qnorm(runif(n, lo, hi)) b <-as.vector(mvrnorm(1, Vb %*%crossprod(X, z), Vb)) keep[t, ] <- b}kb <- keep[1001:S, ]cat(sprintf("b0 = %6.3f b1 = %6.3f b2 = %6.3f\n",mean(kb[, 1]), mean(kb[, 2]), mean(kb[, 3])))
The hand-coded sampler is not a toy: every production tool below targets the same posterior, and all four agree to two decimals. What differs is the machinery — data augmentation with Gibbs, NUTS on the marginal likelihood, or adaptive Metropolis–Hastings.
brms writes Stan code and runs NUTS on the probit likelihood directly, without latent \(z\)
PyMC does the same through its own NUTS implementation
Stata’s bayes: prefix uses adaptive Metropolis–Hastings, again with no augmentation
Agreement across three different algorithms is the strongest evidence that the hand-coded chain has converged to the right target.
Code
library(brms)p <-read.csv("../data/bayes-probit.csv")fit <-brm(y ~ x1 + x2, data = p, family =bernoulli(link ="probit"),prior =prior(normal(0, 10), class = b),chains =2, iter =2000, warmup =1000, seed =14159, refresh =0)fixef(fit)
brms -- NUTS on the probit likelihood (no latent z):
A random walk explores by diffusion — it takes \(O(d^2)\) steps to cross a \(d\)-dimensional posterior. HMC instead rolls a ball across the landscape, using the gradient to make long, informed moves. Introduce a momentum \(r\) and define energy
Simulating Hamilton’s equations conserves \(H\), so a proposal far away is still accepted with high probability. The gradient \(\nabla_\theta \log p(\theta\mid y)\) is what makes this possible.
Hamilton’s equations are discretised by the leapfrog scheme, which is reversible and volume-preserving — both required for a valid MCMC proposal:
Discretisation error is corrected by this Metropolis step, so HMC is exact, not approximate.
Two knobs — step size \(\epsilon\) and path length \(L\) — are hard to tune by hand
The No-U-Turn Sampler (Hoffman & Gelman 2014) picks \(L\) automatically by running the trajectory until it doubles back, and adapts \(\epsilon\) during warm-up
NUTS is the default engine of Stan, PyMC and brms
HMC needs a differentiable log posterior; discrete parameters must be marginalised out
Payoff: near-independent draws even in hundreds of dimensions — decisive for DSGE and large hierarchical models
We sample the same linear-model posterior as Part II, now with hand-coded HMC (\(\epsilon = 0.015\), \(L = 25\)), and compare effective sample size against random-walk Metropolis. The gradient of \(U(\theta) = -\log p(\theta\mid y)\) is analytic:
The prize is effective sample size per iteration: HMC turns correlated draws into nearly independent ones.
Do not be alarmed when the reported ESS exceeds the number of draws. With \(\text{ESS} = S/(1 + 2\sum_k \rho_k)\), successive HMC draws can be negatively autocorrelated, so \(\sum_k \rho_k < 0\) and the ratio rises above \(S\) — antithetic behaviour that beats independent sampling, not a bug.
Code
d <-read.csv("../data/bayes-linreg.csv")X <-cbind(1, d$x); y <- d$y; n <-nrow(X)U <-function(q) { # potential = -log posterior b <- q[1:2]; l <- q[3]; s2 <-exp(2* l); r <- y - X %*% b-(-n * l -sum(r^2) / (2* s2) +sum(dnorm(b, 0, 10, log =TRUE)) +dnorm(l, 0, 5, log =TRUE))}grad_U <-function(q) { b <- q[1:2]; l <- q[3]; s2 <-exp(2* l); r <- y - X %*% b gb <--as.vector(crossprod(X, r)) / s2 + b /100 gl <- n -sum(r^2) / s2 + l /25c(gb, gl)}hmc_step <-function(q, eps, L) { r0 <-rnorm(length(q)); q0 <- q; r <- r0 r <- r -0.5* eps *grad_U(q)for (i in1:L) { q <- q + eps * r; if (i < L) r <- r - eps *grad_U(q) } r <- r -0.5* eps *grad_U(q) H0 <-U(q0) +0.5*sum(r0^2); H1 <-U(q) +0.5*sum(r^2)if (log(runif(1)) < H0 - H1) list(q = q, acc =1) elselist(q = q0, acc =0)}set.seed(14159)S <-3000; chain <-matrix(NA, S, 3); q <-c(0, 0, 0); acc <-0for (t in1:S) { out <-hmc_step(q, 0.015, 25); q <- out$q; acc <- acc + out$acc; chain[t, ] <- q }keep <- chain[501:S, ]cat(sprintf("HMC acceptance = %.2f ESS(b1) = %.0f of %d draws\n", acc / S, coda::effectiveSize(keep[, 2]), nrow(keep)))
HMC : acceptance = 1.00 posterior mean b1 = 2.141
HMC ESS(b1) = 19780 of 2500 draws
RW-MH ESS(b1) = 443 of 4000 draws -> HMC is far more efficient per draw
Code
import numpy as np, pandas as pdd = pd.read_csv("../data/bayes-linreg.csv")X = np.column_stack([np.ones(len(d)), d["x"].values]); y = d["y"].values; n =len(y)def U(q): b, l = q[:2], q[2]; s2 = np.exp(2* l); r = y - X @ breturn-(-n * l - r @ r / (2* s2) - (b @ b) /200- l**2/50)def grad_U(q): b, l = q[:2], q[2]; s2 = np.exp(2* l); r = y - X @ b gb =-(X.T @ r) / s2 + b /100 gl = n - r @ r / s2 + l /25return np.append(gb, gl)def hmc_step(q, eps, L, rng): r0 = rng.standard_normal(len(q)); q0 = q.copy(); r = r0.copy() r -=0.5* eps * grad_U(q)for i inrange(L): q = q + eps * rif i < L -1: r -= eps * grad_U(q) r -=0.5* eps * grad_U(q) H0 = U(q0) +0.5* r0 @ r0; H1 = U(q) +0.5* r @ rreturn (q, 1) if np.log(rng.uniform()) < H0 - H1 else (q0, 0)rng = np.random.default_rng(14159)S =3000; chain = np.empty((S, 3)); q = np.zeros(3); acc =0for t inrange(S): q, a = hmc_step(q, 0.015, 25, rng); acc += a; chain[t] = qkeep = chain[500:]print(f"HMC acceptance = {acc/S:.2f} posterior mean b1 = {keep[:,1].mean():.3f}")
HMC acceptance = 1.00 posterior mean b1 = 2.141
Code
library(rstan)d <-read.csv("../data/bayes-linreg.csv")sc <-"data { int<lower=0> N; vector[N] x; vector[N] y; }parameters { real b0; real b1; real<lower=0> sigma; }model { b0 ~ normal(0, 10); b1 ~ normal(0, 10); sigma ~ normal(0, 5); y ~ normal(b0 + b1 * x, sigma); }"# Stan runs adaptive HMC (NUTS): it tunes step size and path length for us.# iter = 2000 => 1000 warmup + 1000 kept draws per chain (2000 total).fit <-stan(model_code = sc, data =list(N =nrow(d), x = d$x, y = d$y),chains =2, iter =2000, warmup =1000, seed =14159, refresh =0)print(fit, pars =c("b0", "b1", "sigma")) # mean, n_eff and Rhat
Stan NUTS — 2 chains x 1000 post-warmup draws (2000 total):
We hand-code the samplers to see the mechanics; for real models we hand the same posterior to Stan (rstan, brms) or PyMC, which implement NUTS — adaptive Hamiltonian Monte Carlo.
Pros: near-independent draws, scales to hundreds of parameters, gradients built by automatic differentiation, and \(\hat R\)/ESS/divergence diagnostics reported for free
Cons: parameters must be continuous and differentiable (discrete ones marginalised out); Stan compiles C++ once (~30–60 s); stiff geometries (funnels) need tuning
Why right here: the linear, hierarchical and DSGE posteriors are smooth and continuous — exactly HMC’s home ground. Conjugate Gibbs also works but does not generalise beyond conjugacy, whereas NUTS handles any differentiable likelihood
rstan: stan_model(model_code=) compiles the C++ once; then sampling(sm, data=, chains=, iter=, warmup=, seed=). Read results from summary(fit)$summary (posterior mean, n_eff, Rhat)
brms: brm(formula, data, family=, prior=, chains=, iter=) writes and compiles the Stan program for you — inspect it with make_stancode(), set priors via prior(), list them with get_prior()
PyMC: declare the model in a with pm.Model(): block, then pm.sample(draws, tune=, chains=, target_accept=); summarise with arviz.summary()
All three read R’s shared CSV — no binary interchange formats
chains — run 2–4 from dispersed starts so \(\hat R\) can compare them
warmup / tune — adaptation draws, discarded; only iter − warmup are kept
adapt_delta (Stan/brms) / target_accept (PyMC) — target acceptance, default 0.8; raise to 0.95–0.99 to kill divergences, at the price of smaller steps
max_treedepth — cap on the NUTS trajectory doubling (default 10); hitting it flags a hard posterior
seed / random_seed, cores — reproducibility and one chain per core
Divergences are not optional to read
A divergent transition means the leapfrog integrator blew up where the posterior is sharply curved — those draws are biased and must not be ignored. First raise adapt_delta / target_accept toward 0.99 (smaller steps). If they persist, the geometry is the problem: reparameterise — e.g. the non-centred form \[
\alpha_j = \mu + \tau\, z_j, \qquad z_j \sim \mathcal{N}(0, 1)
\] for hierarchical funnels. Stan and PyMC report the divergence count right next to \(\hat R\) and ESS — check it every run.
NUTS extends HMC by growing the leapfrog trajectory until it doubles back — so you never hand-set the path length \(L\) of Part III
Warmup runs dual averaging (Nesterov) to tune the step size \(\epsilon\) to the target acceptance, and estimates a diagonal mass matrix (the metric) from the warmup draws
Gradients \(\nabla\log p(\theta\mid y)\) come from reverse-mode automatic differentiation — Stan Math in rstan/brms, PyTensor in PyMC — never derived by hand
Point modes use quasi-Newton L-BFGS: rstan::optimizing() for the MAP, pm.find_MAP() (SciPy L-BFGS-B); fast approximate posteriors use ADVI variational inference
Initialisation: Stan draws random unconstrained starts in \((-2, 2)\); PyMC uses jitter+adapt_diag
Hierarchical models hide a hard geometry. Neal’s funnel is the distilled version:
\[
v \sim \mathcal{N}(0, 3^2), \qquad x_j \mid v \sim \mathcal{N}\big(0,\, e^{v/2}\big), \quad j = 1,\dots,9
\]
The conditional scale of \(x\) depends on \(v\), so the joint density is a funnel: wide and flat at large \(v\), pinched into a narrow neck as \(v \to -\infty\). No single HMC step size works — a step tuned for the mouth overshoots the neck, a step tuned for the neck crawls through the mouth.
The symptom is divergent transitions: the leapfrog integrator becomes unstable, energy is not conserved, and the sampler reports the failure rather than hiding it.
Rewrite the model so the parameters are a priori independent and the dependence is moved into a deterministic transformation:
This is the non-centred parameterisation. The sampler now explores \((v, z)\), whose joint density is a spherical Gaussian — no neck, one step size fits everywhere. The quantity of interest \(x_j\) is recovered afterwards by the transformation, so nothing is lost.
Watch three diagnostics, not one: divergences, ESS, and whether the marginal \(\text{sd}(v)\) recovers its known value of 3.
Code
library(rstan)sc_centred <-"parameters { real v; vector[9] x; }model { v ~ normal(0, 3); x ~ normal(0, exp(v/2)); }"sc_noncentred <-"parameters { real v; vector[9] z; }transformed parameters { vector[9] x = exp(v/2) * z; }model { v ~ normal(0, 3); z ~ std_normal(); }"for (nm inc("centred", "non-centred")) { code <-if (nm =="centred") sc_centred else sc_noncentred fit <-stan(model_code = code, chains =2, iter =2000, warmup =1000,seed =14159, refresh =0) sp <-get_sampler_params(fit, inc_warmup =FALSE) div <-sum(sapply(sp, function(z) sum(z[, "divergent__"])))cat(sprintf("%-12s divergences = %4d ESS(v) = %6.0f sd(v) = %.2f\n", nm, div, summary(fit, pars ="v")$summary[, "n_eff"],sd(extract(fit, "v")$v)))}
It is tempting to take “always use non-centred” from the funnel slide. That is wrong, and the reason is instructive.
The funnel geometry appears only when the likelihood is weak relative to the hierarchical prior — when each group has few observations, so \(\alpha_j\) is pinned by \(\tau\) rather than by its own data. Then the prior dependence dominates and the non-centred form wins.
When groups are informative, the likelihood breaks the prior dependence by itself. The centred parameterisation is then the better-conditioned one, and the non-centred rewrite actually introduces correlation between \(\tau\) and \(z\).
We fit the Part VII panel both ways, at \(n_j = 8\) observations per group and again thinned to \(n_j = 2\).
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pd, pymc as pm, arviz as azfull = pd.read_csv("../data/bayes-panel.csv")thin = full.groupby("group").head(2).reset_index(drop=True) # weak-likelihood versiondef run(d, centred): g = d["group"].values -1; J =int(d["group"].max())with pm.Model(): mu = pm.Normal("mu", 0, 10); tau = pm.HalfNormal("tau", 10) a = (pm.Normal("a", mu, tau, shape=J) if centredelse pm.Deterministic("a", mu + tau * pm.Normal("z", 0, 1, shape=J))) beta = pm.Normal("beta", 0, 10); sig = pm.HalfNormal("sig", 10) pm.Normal("y", a[g] + beta * d["x"].values, sig, observed=d["y"].values)return pm.sample(1000, tune=1000, chains=2, random_seed=14159, progressbar=False, target_accept=0.8)lines = []for lbl, d in (("n_j = 8 (informative)", full), ("n_j = 2 (weak)", thin)):for nm, c in (("centred", True), ("non-centred", False)): i = run(d, c) lines.append(f"{lbl:22}{nm:12} divergences = {int(i.sample_stats['diverging'].sum()):3d}"f" ESS(tau) = {float(az.ess(i, var_names=['tau'])['tau']):6.0f}"f" tau = {float(i.posterior['tau'].mean()):.2f}")lines.append("With 8 observations per group the centred form is far more efficient;")lines.append("thinned to 2, it starts diverging and the non-centred form takes over.")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
n_j = 8 (informative) centred divergences = 0 ESS(tau) = 2892 tau = 1.06
n_j = 8 (informative) non-centred divergences = 0 ESS(tau) = 435 tau = 1.06
n_j = 2 (weak) centred divergences = 7 ESS(tau) = 90 tau = 0.79
n_j = 2 (weak) non-centred divergences = 2 ESS(tau) = 349 tau = 0.74
With 8 observations per group the centred form is far more efficient;
thinned to 2, it starts diverging and the non-centred form takes over.
489
Code
setlinesize 255quietly import delimited "../data/bayes-panel.csv", clearquietlydestring_all, replace* Stata samples thismodel with adaptive Metropolis-Hastings, not HMC, so it* reports efficiency rather than divergences, and exposes no centring switch.bayes, rseed(14159) nomodelsummary: mixed y x || group:
note: Gibbs sampling is used for regression coefficients and variance components.
Burn-in 2500 aaaaaaaaa1000aaaaaaaaa2000aaaaa done
Simulation 10000 .........1000.........2000.........3000.........4000.........5000.........6000.........7000.........8000.........9000.........10000 done
Multilevel structure
------------------------------------------------------------------------------
group
{U0}: random intercepts
------------------------------------------------------------------------------
Bayesian multilevel regression MCMC iterations = 12,500
Metropolis–Hastings and Gibbs sampling Burn-in = 2,500
MCMC sample size = 10,000
Group variable: group Number of groups = 20
Obs per group:
min = 8
avg = 8.0
max = 8
Number of obs = 160
Acceptance rate = .8222
Efficiency: min = .01487
avg = .3186
Log marginal-likelihood max = .5829
------------------------------------------------------------------------------
| Equal-tailed
| Mean Std. dev. MCSE Median [95% cred. interval]
-------------+----------------------------------------------------------------
y |
x | .8645687 .087844 .001151 .8638244 .6913786 1.037076
_cons | 1.042984 .2534011 .02078 1.040071 .5464203 1.553284
-------------+----------------------------------------------------------------
group |
U0:sigma2 | 1.104238 .4509203 .011831 1.016022 .5092272 2.257215
-------------+----------------------------------------------------------------
e.y |
sigma2 | 1.045972 .1283635 .001761 1.03391 .8258246 1.332342
------------------------------------------------------------------------------
Note: Default priors are used for model parameters.
Divergences are an HMC diagnostic: they come from the leapfrog integrator, so Stan and PyMC report them and Stata’s Metropolis–Hastings sampler does not
A parameterisation is not “correct” or “incorrect” — the two define the same posterior and differ only in how easy that posterior is to traverse
The practical rule: start centred, and switch when divergences or a collapsed ESS say the likelihood is too weak to break the funnel
brms and rstanarm default to the non-centred form for group effects, which is why hierarchical fits there rarely diverge
Part IV: Priors
Elicitation, weak information, and honest sensitivity analysis
The prior is a modelling choice, not a nuisance to hide. The useful distinctions:
Conjugate: analytically convenient (Beta–Binomial, Normal–Inverse-Gamma) — great for teaching and Gibbs
Weakly-informative: rule out absurd values, let the data drive — e.g. \(\beta\sim\mathcal{N}(0, 2.5^2)\) on standardised predictors, \(\sigma\sim\text{Half-Cauchy}(0,1)\)
Informative: genuine external knowledge — an elasticity in \([-1, 0]\), a discount factor near \(0.99\)
Improper / flat: \(p(\theta)\propto 1\) — can yield an improper posterior; use only when you have checked integrability
A tight Normal prior on a slope is exactly ridge regression; a Laplace prior is the LASSO — regularisation is a prior.
Before touching the data, ask: what datasets does my prior imply? Draw from the prior predictive
and inspect the simulated \(\tilde y^{(s)}\). If the prior generates GDP growth of \(\pm500\%\) or wages of a billion euro, it is too diffuse, not “uninformative”. A vague prior is still a strong claim on the outcome scale.
Report inference under several defensible priors, not one
When the likelihood is informative, the posterior is robust — priors barely matter, and you can say so
When priors do move the posterior, that is a finding: the data are weak on that parameter (weak instruments, near-unit roots, flat DSGE likelihoods)
The honest report shows the range, not the single prior that gave the prettiest answer
Same linear model, three priors on the slope \(\beta_1\), from tight to diffuse. A tight prior centred at zero shrinks the estimate toward zero (ridge); a diffuse prior lets the likelihood win. We report the posterior mean and 95% credible interval for \(\beta_1\) under each — the data here are informative, so we expect only the tightest prior to bite.
A vast range of macro and finance models — unobserved trends, cycles, time-varying parameters, DSGE solutions — share one form. A latent state \(\alpha_t\) evolves; we observe a noisy function of it:
When everything is linear and Gaussian, the filtering distribution \(p(\alpha_t\mid y_{1:t})\) is Normal, and the Kalman filter computes its mean and variance exactly — no simulation needed.
with \(\sigma_\varepsilon = 1\), \(\sigma_\eta = 0.3\), \(T = 200\). The filter extracts the signal\(\mu_t\) from the noisy \(y_t\). R writes ../data/bayes-ll.csv with both the observed series and the hidden truth.
# This block lives in bayesian-computation-data.R, run once before rendering.# Local level model: mu_t = mu_{t-1} + eta_t, y_t = mu_t + eps_tset.seed(14159)Tn <-200; s_eta <-0.3; s_eps <-1.0mu <-numeric(Tn); mu[1] <-0for (t in2:Tn) mu[t] <- mu[t -1] +rnorm(1, 0, s_eta)y <- mu +rnorm(Tn, 0, s_eps)write.csv(data.frame(t =1:Tn, y = y, mu_true = mu), "../data/bayes-ll.csv", row.names =FALSE)
Code
d <-read.csv("../data/bayes-ll.csv")y <- d$y; mu <- d$mu_true; Tn <-length(y)s_eta <-0.3; s_eps <-1.0# Kalman filter for the local level modela <-0; P <-1e6; af <-numeric(Tn); ll <-0for (t in1:Tn) { Pp <- P + s_eta^2# predict v <- y[t] - a; Ft <- Pp + s_eps^2; K <- Pp / Ft # update a <- a + K * v; P <- Pp - K^2* Ft af[t] <- a; ll <- ll -0.5* (log(2* pi) +log(Ft) + v^2/ Ft)}cat(sprintf("log-likelihood = %.2f\n", ll))df <-data.frame(t =1:Tn, y = y, mu = mu, filt = af)ggplot(df, aes(t)) +geom_point(aes(y = y), color ="grey70", size =0.9) +geom_line(aes(y = mu), color ="#185FA5", linewidth =1) +geom_line(aes(y = filt), color ="#D85A30", linewidth =1) +labs(x ="t", y ="level",title ="Kalman filter recovers the hidden level (orange) from noisy data (grey)",subtitle ="Blue = true state mu_t; orange = filtered state a_t") + theme_lecture
log-likelihood = -318.50
Code
import numpy as np, pandas as pd, matplotlib.pyplot as pltd = pd.read_csv("../data/bayes-ll.csv") # reads R's CSVy = d["y"].values; mu = d["mu_true"].values; Tn =len(y)s_eta, s_eps =0.3, 1.0a, P =0.0, 1e6; af = np.empty(Tn); ll =0.0for t inrange(Tn): Pp = P + s_eta**2 v = y[t] - a; Ft = Pp + s_eps**2; K = Pp / Ft a = a + K * v; P = Pp - K**2* Ft af[t] = a; ll +=-0.5* (np.log(2*np.pi) + np.log(Ft) + v**2/ Ft)print(f"log-likelihood = {ll:.2f}")
log-likelihood = -318.50
Code
fig, ax = plt.subplots(figsize=(10, 4.4))ax.plot(d["t"], y, ".", color="grey", ms=3, label="observed y")ax.plot(d["t"], mu, color="#185FA5", lw=1.4, label="true state")ax.plot(d["t"], af, color="#D85A30", lw=1.4, label="filtered state")ax.set_xlabel("t"); ax.set_ylabel("level"); ax.legend()ax.set_title("Kalman filter recovers the hidden level from noisy data")plt.tight_layout(); plt.show()
The Kalman gain \(K_t\) is an optimal weight: it trades off signal noise \(\sigma_\eta^2\) against measurement noise \(\sigma_\varepsilon^2\)
With more measurement noise the filter trusts its own prediction more (\(K_t\) small); with more state noise it chases the data (\(K_t\) large)
The recursion returns the exact Gaussian filtering distribution — no Monte Carlo error at all
The by-product \(\log p(y_{1:T}\mid\theta)\) is what we hand to Metropolis–Hastings in Part VIII to estimate structural parameters
When the model is non-linear or non-Gaussian, this exactness breaks — enter the particle filter
The Kalman smoother returns \(\mathbb{E}[\mu_t \mid y_{1:T}]\) for each \(t\). Stringing those means together does not give a draw from the posterior of the path — it gives the average of all paths, which is far too smooth to be a plausible history.
That matters the moment the state path becomes one block of a Gibbs sampler. To draw \(\sigma_\eta^2 \mid \mu_{1:T}, y\) we need an actual path, with the right roughness, not its expectation.
Carter–Kohn (1994) and Frühwirth-Schnatter (1994) give the forward filter, backward sampling recursion. Factor the joint posterior of the path backwards:
Run the filter forward once storing \((a_t, P_t)\), draw \(\mu_T\) from the final filtered distribution, then sweep backwards. Cost is linear in \(T\).
Two things must hold, and both are testable:
Averaging many FFBS draws must reproduce the RTS smoother — same mean, up to Monte Carlo error
A single draw must have the roughness of the true state: the period-to-period change should have standard deviation \(\sigma_\eta = 0.3\), whereas the smoothed mean is much flatter
Note this slide has no Stata tab. FFBS is a loop over \(T\) with a random draw at each step; in Stata that means a Mata port of code we would not reuse elsewhere in the deck, so the cost is not worth it. Stata’s native bayes: machinery does not expose a state-path sampler.
Code
d <-read.csv("../data/bayes-ll.csv")y <- d$y; mu_true <- d$mu_true; Tn <-length(y)s_eta <-0.3; s_eps <-1.0# forward pass: store the filtered mean and variance at every tforward <-function() { a <-numeric(Tn); P <-numeric(Tn); at <-0; Pt <-1e6for (t in1:Tn) { Pp <- Pt + s_eta^2 Ft <- Pp + s_eps^2; K <- Pp / Ft at <- at + K * (y[t] - at); Pt <- Pp - K^2* Ft a[t] <- at; P[t] <- Pt }list(a = a, P = P)}# backward pass: sample the path from T down to 1backward <-function(f) { mu <-numeric(Tn) mu[Tn] <-rnorm(1, f$a[Tn], sqrt(f$P[Tn]))for (t in (Tn -1):1) { J <- f$P[t] / (f$P[t] + s_eta^2) mu[t] <-rnorm(1, f$a[t] + J * (mu[t +1] - f$a[t]), sqrt(f$P[t] * (1- J))) } mu}# RTS smoother, for the reference meansmoother <-function(f) { s <-numeric(Tn); s[Tn] <- f$a[Tn]for (t in (Tn -1):1) { J <- f$P[t] / (f$P[t] + s_eta^2) s[t] <- f$a[t] + J * (s[t +1] - f$a[t]) } s}f <-forward()set.seed(14159)draws <-matrix(NA, 2000, Tn)for (i in1:2000) draws[i, ] <-backward(f)sm <-smoother(f)cat(sprintf("FFBS mean vs RTS smoother: max abs difference = %.4f\n",max(abs(colMeans(draws) - sm))))cat(sprintf("roughness of one draw = %.3f (true s_eta = %.2f)\n",sd(diff(draws[1, ])), s_eta))cat(sprintf("roughness of the smoothed mean = %.3f\n", sd(diff(sm))))
FFBS mean vs RTS smoother: max abs difference = 0.0216 (2000 draws)
roughness of one draw = 0.297 (true s_eta = 0.30)
roughness of the smoothed mean = 0.111 (less than half as variable)
The average of the draws is the smoother; no single draw looks like it.
Code
import numpy as np, pandas as pdd = pd.read_csv("../data/bayes-ll.csv")y = d["y"].values; Tn =len(y)s_eta, s_eps =0.3, 1.0def forward(): # store filtered mean and variance a = np.empty(Tn); P = np.empty(Tn); at, Pt =0.0, 1e6for t inrange(Tn): Pp = Pt + s_eta**2 Ft = Pp + s_eps**2; K = Pp / Ft at = at + K * (y[t] - at); Pt = Pp - K**2* Ft a[t] = at; P[t] = Ptreturn a, Pdef backward(a, P, rng): # sample the path from T down to 1 mu = np.empty(Tn) mu[-1] = rng.normal(a[-1], np.sqrt(P[-1]))for t inrange(Tn -2, -1, -1): J = P[t] / (P[t] + s_eta**2) mu[t] = rng.normal(a[t] + J * (mu[t+1] - a[t]), np.sqrt(P[t] * (1- J)))return mudef smoother(a, P): # RTS reference mean s = np.empty(Tn); s[-1] = a[-1]for t inrange(Tn -2, -1, -1): J = P[t] / (P[t] + s_eta**2) s[t] = a[t] + J * (s[t+1] - a[t])return sa, P = forward()rng = np.random.default_rng(14159)draws = np.array([backward(a, P, rng) for _ inrange(2000)])sm = smoother(a, P)out = (f"FFBS mean vs RTS smoother: max abs difference = {np.abs(draws.mean(0) - sm).max():.4f}\n"f"roughness of one draw = {np.std(np.diff(draws[0]), ddof=1):.3f} (true s_eta = 0.30)\n"f"roughness of the smoothed mean = {np.std(np.diff(sm), ddof=1):.3f}")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
FFBS mean vs RTS smoother: max abs difference = 0.0207
roughness of one draw = 0.289 (true s_eta = 0.30)
roughness of the smoothed mean = 0.111
154
df <-data.frame(t =1:Tn, truth = mu_true, smooth = ffbs_sm)paths <-data.frame(t =rep(1:Tn, 3),value =c(ffbs_draws[1, ], ffbs_draws[2, ], ffbs_draws[3, ]),draw =rep(1:3, each = Tn))ggplot(df) +aes(t) +geom_line(data = paths, aes(t, value, group = draw),color ="grey60", linewidth =0.4, alpha =0.8) +geom_line(aes(y = truth), color ="#185FA5", linewidth =1.1) +geom_line(aes(y = smooth), color ="#D85A30", linewidth =1.1) +labs(x ="t", y ="state",title ="Three FFBS draws (grey) around the smoothed mean (orange)",subtitle ="Blue = true state. The draws are as rough as the truth; the mean is not.") + theme_lecture
Code
import matplotlib.pyplot as pltmu_true = d["mu_true"].valuesfig, ax = plt.subplots(figsize=(10, 4.4))for i inrange(3): ax.plot(range(1, Tn +1), draws[i], color="grey", lw=0.6, alpha=0.8)ax.plot(range(1, Tn +1), mu_true, color="#185FA5", lw=1.6, label="true state")ax.plot(range(1, Tn +1), sm, color="#D85A30", lw=1.6, label="smoothed mean")axopts = ax.set(xlabel="t", ylabel="state", title="Three FFBS draws (grey) around the smoothed mean")ax.legend()plt.tight_layout(); plt.show()
The grey paths wander above and below the orange mean by roughly the posterior standard deviation of the state, about 0.39 here
Each grey path is a coherent history: it moves period to period the way the model says the state moves, which the orange line does not
Plugging the smoothed mean into a variance update would therefore underestimate\(\sigma^2_\eta\) badly — the mean has less than half the true roughness
This is the missing block for a full Gibbs sampler over \((\mu_{1:T}, \sigma^2_\eta, \sigma^2_\varepsilon)\), and the same recursion carries over to time-varying-parameter models where the “state” is a vector of regression coefficients
Stochastic volatility, regime switching, non-linear DSGE solutions — the filtering distribution is no longer Gaussian, so the Kalman recursions do not apply. The particle filter (Gordon–Salmond–Smith 1993) represents \(p(\alpha_t\mid y_{1:t})\) by a cloud of weighted particles and updates it by sequential importance resampling (SIR):
with \(\mu = -0.5\), \(\phi = 0.95\), \(\sigma_\eta = 0.25\), \(T = 300\). The weight of particle \(i\) at time \(t\) is the Gaussian return density \(\phi\big(y_t;\, 0,\, e^{x_t^{(i)}}\big)\). The filter recovers the hidden volatility path and, by averaging the incremental weights, an unbiased estimate of the likelihood.
The averaged one-step weights give an unbiased likelihood estimate — the key to particle MCMC (Andrieu–Doucet–Holenstein 2010) for non-linear DSGEs
Weight degeneracy: without resampling, one particle eventually carries all the weight — resampling fixes it but adds Monte Carlo noise
Estimated likelihood is noisy, so plug-in Metropolis–Hastings must use enough particles or the chain gets stuck
Cost scales with \(N\); variance falls as \(1/N\) — the familiar Monte Carlo trade-off
Watch the effective sample size
Even with resampling the cloud can collapse: if one weight dominates, the effective sample size \[
N_{\text{eff}} \;=\; \frac{1}{\sum_{i} \big(w_t^{(i)}\big)^2}
\] crashes toward 1 and the filter loses the state. Remedies: resample only when\(N_{\text{eff}} < N/2\) (adaptive resampling), use systematic resampling to cut Monte Carlo noise, and raise \(N\) until the variance of the log-likelihood estimate is below about 1–3 — above that, the outer particle-MCMC chain sticks.
Part V left a promise unkept. The particle filter delivers \(\log \hat p(y \mid \theta)\) for a fixed\(\theta\), but the structural parameters \((\mu, \phi, \sigma_\eta)\) of the stochastic-volatility model were never estimated, because Metropolis–Hastings appears to need the exact likelihood.
It does not. Andrieu, Doucet & Holenstein (2010) show that if the estimate is unbiased,
then substituting it into the Metropolis ratio leaves a chain whose stationary distribution is exactly\(p(\theta \mid y)\) — no approximation, for any number of particles \(N\):
The bootstrap particle filter is unbiased for the likelihood, which is precisely the property that makes this work. This is pseudo-marginal MCMC: the particles are auxiliary variables integrated out by the chain itself.
\(N\) does not change the target, only the efficiency. A noisy likelihood estimate can, by luck, take an unusually high value at the current draw — and then no proposal can beat it, so the chain sticks for many iterations.
The usual rule of thumb is to choose \(N\) so this variance is around 1. We run \(N = 100\) and \(N = 500\) on the same data and compare acceptance rates and effective sample size — the target is identical, the cost of reaching it is not.
There is no Stata tab: bayesmh cannot accept a user-supplied simulated likelihood of this kind.
Code
d <-read.csv("../data/bayes-sv.csv"); y <- d$y; Tn <-length(y)# the Part V bootstrap filter, now returning only the likelihood estimatepf_loglik <-function(mu, phi, s_eta, N) { x <-rnorm(N, mu, s_eta /sqrt(1- phi^2)); ll <-0for (t in1:Tn) { x <- mu + phi * (x - mu) + s_eta *rnorm(N) w <-dnorm(y[t], 0, exp(x /2)) ll <- ll +log(mean(w))if (!is.finite(ll)) return(-Inf) x <- x[sample.int(N, N, replace =TRUE, prob = w)] } ll}log_prior <-function(th) dnorm(th[1], 0, 1, log =TRUE) +dbeta((th[2] +1) /2, 20, 1.5, log =TRUE) +dnorm(th[3], 0, 0.5, log =TRUE)# random-walk Metropolis, with the estimated likelihood in the ratiopmmh <-function(N, S =2500, step =c(0.10, 0.02, 0.05)) { th <-c(-0.5, 0.95, 0.25) ll <-pf_loglik(th[1], th[2], th[3], N); lp <-log_prior(th) chain <-matrix(NA, S, 3); acc <-0for (i in1:S) { prop <- th +rnorm(3, 0, step)if (abs(prop[2]) <1&& prop[3] >0) { llp <-pf_loglik(prop[1], prop[2], prop[3], N); lpp <-log_prior(prop)if (log(runif(1)) < (llp + lpp) - (ll + lp)) { th <- prop; ll <- llp; lp <- lpp; acc <- acc +1 } } chain[i, ] <- th }list(chain = chain, acc = acc / S)}for (N inc(100, 500)) {set.seed(14159); r <-pmmh(N); kp <- r$chain[1001:2500, ]cat(sprintf("N = %3d : acceptance %.2f ESS(phi) = %4.0f of 1500\n", N, r$acc, coda::effectiveSize(kp[, 2])))}
The PMMH intervals cover the truth but are wide, and the posterior means sit away from it. Before blaming the algorithm, look at the likelihood surface itself. We hold two parameters at their true values and profile the particle-filter log-likelihood over the third.
If the surface is flat, no sampler can do better: the data, not the method, are the binding constraint. Stochastic-volatility parameters are famously weakly identified — \(\phi\) near one and a small \(\sigma_\eta\) produce almost the same observable series as a slightly different pair.
This is the same diagnosis as the weak-identification warning in Part VIII, arrived at from the other direction.
Code
set.seed(14159)cat("profile over s_eta (mu and phi held at the truth):\n")for (s inc(0.10, 0.15, 0.20, 0.25, 0.30, 0.40))cat(sprintf(" s_eta %.2f : log-likelihood %.2f\n", s, pf_loglik(-0.5, 0.95, s, 4000)))cat("profile over phi:\n")for (p inc(0.85, 0.90, 0.95, 0.97, 0.99))cat(sprintf(" phi %.2f : log-likelihood %.2f\n", p, pf_loglik(-0.5, p, 0.25, 4000)))
profile over s_eta (mu and phi held at the truth):
Across the whole plausible range of s_eta the log-likelihood moves by about
five units, and between phi = 0.95 and 0.97 by almost nothing. The posterior
is wide because the likelihood is flat, not because PMMH is failing.
The particle filter’s own Monte Carlo error is a few tenths of a log unit, comparable to the curvature being measured — so profiles like this must be read with the filter noise in mind
PMMH remains exact regardless: a noisy likelihood costs efficiency, never correctness
When a chain sticks, the first response should be more particles, not a smaller proposal step — the stickiness comes from the likelihood noise, not the proposal
For this model a purpose-built Gibbs sampler using the Kim–Shephard–Chib mixture is far more efficient; PMMH earns its keep when the model is non-linear or non-Gaussian enough that no such trick exists
Part VI: Bayesian VARs
The Minnesota prior, conjugate posteriors and impulse responses
A VAR(\(p\)) in \(m\) variables has \(m(mp+1)\) coefficients — a modest 7-variable, 4-lag VAR already has 203. With 150 quarters of data, OLS overfits wildly: great in-sample, hopeless out-of-sample.
\[
\text{Var}\big[(A_\ell)_{ij}\big] = \left(\frac{\lambda}{\ell}\right)^2 \times
\begin{cases} 1 & i = j \\ \dfrac{\sigma_i^2}{\sigma_j^2} & i \ne j \end{cases}
\]
The single hyperparameter \(\lambda\) tunes overall shrinkage: \(\lambda\to0\) pins the system to the prior mean, \(\lambda\to\infty\) recovers OLS.
With a Normal–inverse-Wishart prior the posterior is again Normal–inverse-Wishart — no MCMC needed, we draw directly:
\[
\Sigma \mid Y \sim \mathcal{IW}(\bar S, \bar\nu), \qquad \text{vec}(B)\mid\Sigma, Y \sim \mathcal{N}\big(\text{vec}(\bar B),\; \Sigma\otimes \bar N^{-1}\big)
\]
Each draw of \((B,\Sigma)\) implies an impulse-response function; the spread across draws gives honest credible bands — inference that propagates parameter uncertainty automatically.
This holds only when the prior variance has the Kronecker form\(\Sigma\otimes N_0^{-1}\). The Minnesota prior sets a separate variance per coefficient, which breaks that structure — so the implementation on the next slide uses a short Gibbs sampler instead.
We sample the posterior under a Minnesota-style prior with shrinkage \(\lambda\), and compare posterior means to unrestricted OLS. The data are ../data/bayes-var.csv.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.set.seed(14159)Tn <-200; A <-matrix(c(0.5, -0.2, 0.1, 0.6), 2, 2, byrow =TRUE)Sig <-matrix(c(1, 0.3, 0.3, 1), 2, 2); Lc <-t(chol(Sig))Y <-matrix(0, Tn, 2)for (t in2:Tn) Y[t, ] <-as.vector(A %*% Y[t -1, ]) +as.vector(Lc %*%rnorm(2))write.csv(data.frame(y1 = Y[, 1], y2 = Y[, 2]), "../data/bayes-var.csv", row.names =FALSE)
The Minnesota prior gives each coefficient its own variance, so the prior is not of the Kronecker form \(\Sigma\otimes N_0^{-1}\) and the one-shot Normal–inverse-Wishart draw of the previous slide no longer applies. Two conditionals are still standard, so we cycle between them: \(B\mid\Sigma\) is Normal, \(\Sigma\mid B\) is inverse-Wishart.
Code
d <-read.csv("../data/bayes-var.csv"); Y <-as.matrix(d); m <-2Yt <- Y[-1, ]; Z <-cbind(1, Y[-nrow(Y), ]); k <-ncol(Z); Tt <-nrow(Yt)# Minnesota-style prior with shrinkage lambda. The own first lag is centred at# 0.9 rather than the textbook 1, because this simulated DGP is stationary.lambda <-0.2B0 <-matrix(0, k, m); B0[2, 1] <-0.9; B0[3, 2] <-0.9Vb <-rep(lambda^2, k * m); Vb[1] <-100; Vb[k +1] <-100# loose on interceptsPr <-diag(1/ Vb); bp <-as.vector(B0)riwish <-function(v, S) solve(rWishart(1, v, solve(S))[, , 1])ZtZ <-crossprod(Z); v0 <- m +2; S0 <-diag(m)set.seed(14159)# two-block Gibbs: B | Sigma, then Sigma | BS <-2000; keepB <-array(NA, c(S, k, m))Bmat <- B0; Sig_d <-diag(m)for (it in1:S) { Sinv <-solve(Sig_d) Vpost <-solve(kronecker(Sinv, ZtZ) + Pr) rhs <-as.vector(crossprod(Z, Yt) %*% Sinv) + Pr %*% bp bvec <- Vpost %*% rhs +t(chol(Vpost)) %*%rnorm(k * m) Bmat <-matrix(bvec, k, m) E <- Yt - Z %*% Bmat Sig_d <-riwish(v0 + Tt, S0 +crossprod(E)) keepB[it, , ] <- Bmat}Bpost <-apply(keepB, c(2, 3), mean)Bols <-solve(ZtZ, crossprod(Z, Yt))cat("Own-lag coefficients (true 0.5 and 0.6):\n")cat(sprintf(" OLS : %.3f %.3f\n", Bols[2, 1], Bols[3, 2]))cat(sprintf(" BVAR : %.3f %.3f (shrunk toward the Minnesota prior)\n", Bpost[2, 1], Bpost[3, 2]))
Own-lag coefficients (true 0.5 and 0.6):
OLS : 0.439 0.590
BVAR : 0.475 0.617 (shrunk toward the Minnesota prior)
Each posterior draw \((B^{(s)}, \Sigma^{(s)})\) gives a dynamic system. With a Cholesky identification \(P^{(s)} = \text{chol}(\Sigma^{(s)})\), the response of the system \(h\) periods after a unit structural shock is
\[
\text{IRF}_h^{(s)} = A_1^{(s)h}\, P^{(s)}
\]
Collecting these across draws yields the posterior distribution of the impulse response — we plot the median and a 90% credible band. This is inference that carries parameter uncertainty through to the object economists actually care about.
Code
H <-12; S <-dim(bvar_keepB)[1]irf <-array(NA, c(S, H +1)) # response of y1 to a unit shock in y1for (s in1:S) { A1 <-t(bvar_keepB[s, 2:3, ]) # 2x2 lag matrix P <-t(chol(bvar_keepS[s, , ])) # lower Cholesky Ah <-diag(2)for (h in0:H) { irf[s, h +1] <- (Ah %*% P)[1, 1]; Ah <- Ah %*% A1 }}band <-apply(irf, 2, quantile, c(0.05, 0.5, 0.95))df <-data.frame(h =0:H, med = band[2, ], lo = band[1, ], hi = band[3, ])ggplot(df, aes(h)) +geom_ribbon(aes(ymin = lo, ymax = hi), fill ="#185FA5", alpha =0.25) +geom_line(aes(y = med), color ="#185FA5", linewidth =1.2) +geom_hline(yintercept =0, color ="grey50") +labs(x ="horizon h", y ="response of y1",title ="Posterior impulse response of y1 to its own shock",subtitle ="Solid = posterior median; band = 90% credible interval") + theme_lecture
Code
import numpy as np, pandas as pd, matplotlib.pyplot as pltfrom scipy import stats# Re-run the two-block Gibbs sampler (compact), then build IRFsd = pd.read_csv("../data/bayes-var.csv"); Y = d.values; m =2Yt = Y[1:]; Z = np.column_stack([np.ones(len(Y) -1), Y[:-1]]); k = Z.shape[1]; Tt =len(Yt)lam =0.2; B0 = np.zeros((k, m)); B0[1, 0] =0.9; B0[2, 1] =0.9Vb = np.full(k * m, lam**2); Vb[0] =100; Vb[k] =100Pr = np.diag(1/ Vb); bp = B0.flatten("F")def riwish(v, S): return np.linalg.inv(stats.wishart.rvs(df=v, scale=np.linalg.inv(S)))ZtZ = Z.T @ Z; rng = np.random.default_rng(14159); S =2000; H =12Sig = np.eye(m); irf = np.empty((S, H +1))for s inrange(S): Sinv = np.linalg.inv(Sig) Vpost = np.linalg.inv(np.kron(Sinv, ZtZ) + Pr) rhs = (Z.T @ Yt @ Sinv).flatten("F") + Pr @ bp Bmat = (Vpost @ rhs + np.linalg.cholesky(Vpost) @ rng.standard_normal(k*m)).reshape((k, m), order="F") Sig = riwish(m +2+ Tt, np.eye(m) + (Yt - Z @ Bmat).T @ (Yt - Z @ Bmat)) A1 = Bmat[1:3].T; P = np.linalg.cholesky(Sig); Ah = np.eye(2)for h inrange(H +1): irf[s, h] = (Ah @ P)[0, 0]; Ah = Ah @ A1lo, med, hi = np.percentile(irf, [5, 50, 95], axis=0)fig, ax = plt.subplots(figsize=(10, 4.4))ax.fill_between(range(H +1), lo, hi, color="#185FA5", alpha=0.25)ax.plot(range(H +1), med, color="#185FA5", lw=2); ax.axhline(0, color="grey")ax.set_xlabel("horizon h"); ax.set_ylabel("response of y1")ax.set_title("Posterior impulse response of y1 to its own shock (90% band)")plt.tight_layout(); plt.show()
The response decays as \(\phi^h\) — the persistence the Minnesota prior anchored near 0.9
The credible band widens with the horizon: uncertainty compounds through the dynamics
Unlike bootstrap IRF bands, this interval is a genuine posterior probability statement about the response
Structural identification (here recursive/Cholesky) is a separate assumption layered on top — sign restrictions and proxy-SVAR identifications slot into the same posterior loop
We reuse the bivariate VAR(1) data (../data/bayes-var.csv, \(T = 200\), two series y1, y2). The hand-coded sampler above recovered the coefficients; a real study wants the structural objects built from every posterior draw:
Impulse responses — how each variable reacts to a structural shock
Variance decompositions — what share of each variable’s forecast error each shock explains
Forecasts — predictive densities with credible bands
bsvars (Woźniak 2024) delivers all three from one estimate() call, propagating parameter uncertainty automatically. Identification here is the recursive (lower-triangular) scheme, the package default.
Recursive identification depends on the variable order
The lower-triangular (Cholesky) scheme assumes variable 1 reacts to its own shock within the period but not to variable 2’s, variable 2 reacts to both, and so on down the ordering. Reorder the columns of Y and the structural shocks change — it is a genuine economic assumption, not a technicality. Order variables from most exogenous to most endogenous, report robustness to the ordering, or switch to bsvars’ heteroskedasticity-based identification (specify_bsvar_sv), which identifies the shocks without an ordering.
Code
library(bsvars)d <-read.csv("../data/bayes-var.csv"); Y <-as.matrix(d)set.seed(14159)spec <- specify_bsvar$new(Y, p =1) # R6 model: data + prior + recursive IDburn <-estimate(spec, S =1000, show_progress =FALSE) # 1000 burn-in drawspost <-estimate(burn, S =1000, show_progress =FALSE) # 1000 posterior drawsA <-apply(post$posterior$A, c(1, 2), mean) # posterior-mean AR matrixcat(sprintf("own-lags (true 0.5, 0.6): %.3f %.3f\n", A[1, 1], A[2, 2]))
bsvars Gibbs sampler — 1000 posterior draws:
own-lags (true 0.5, 0.6) : 0.445 0.593
cross-lag y1<-y2 (true -0.2) : -0.204
Code
irf <-compute_impulse_responses(post, horizon =20) # structural IRFs, all drawsplot(irf) # median + credible bands, N x N grid
Forecast-error variance decomposition at the 20-period horizon (%):
y1 variance: 89.9% own shock, 10.1% from shock 2
y2 variance: 20.6% from shock 1, 79.4% own shock
Code
fc <-forecast(post, horizon =8) # 8-step predictive densityplot(fc) # fan chart with credible bands
specify_bsvar$new(Y, p) builds an R6 model object: data matrices, a recursive identification by default, and a hierarchical Minnesota-style prior whose shrinkage is estimated from the data
estimate() runs a Gibbs sampler — structural matrix, autoregressive slopes and prior hyper-parameters are each drawn from their full conditionals, so there is no step size or acceptance rate to tune, unlike NUTS
The sampler is compiled C++ via RcppArmadillo, so thousands of draws take seconds; chaining estimate(spec) then estimate() continues from the last state (burn-in, then posterior)
compute_impulse_responses(), compute_variance_decompositions(), forecast() and compute_structural_shocks() act on every draw, returning full posterior distributions with ready-made plot() methods
Richer variants slot into the same workflow: specify_bsvar_sv (stochastic volatility), _msh (Markov-switching), _t (Student-t) — heteroskedasticity that sharpens identification
Package, vignette and papers by Tomasz Woźniak; run vignette("bsvars") locally — see Further Reading for the arXiv reference
A constant-coefficient VAR assumes the economy’s transmission mechanism never changes. Over a span containing the Great Inflation, the Volcker disinflation and the Great Moderation that is hard to defend. Primiceri (2005) lets both the coefficients and the shock variances drift:
Time variation in \(\beta\) changes the propagation of shocks; time variation in \(\Sigma\) changes their size. Distinguishing the two is the whole empirical question — did policy get better, or did the shocks get smaller?
Estimating both at once is not a free lunch, and the reason is worth stating plainly.
A residual that is too large can be explained two ways: the coefficients moved, or the volatility rose. With a single time series the data are nearly indifferent between them. Taken to the limit the likelihood is unbounded — let the coefficients interpolate the data, drive the residuals to zero, and send \(h_t \to -\infty\).
What stops it is the prior on \(Q\). Primiceri calibrates it from a training sample and keeps it deliberately tight, so the coefficients are only allowed to crawl. That is a modelling decision, not something the data settle — and the next two slides show exactly how much it matters.
We therefore hand-code the coefficient block with a fixed, calibrated drift variance, and let bvarsv supply the full model with stochastic volatility.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.# Equation 1 has a random-walk own-lag coefficient and random-walk log-volatility;# equation 2 is constant and homoskedastic, so the estimator must find the# time variation only where it really is.set.seed(14159)Tn <-250b11 <-numeric(Tn); b11[1] <-0.70h1 <-numeric(Tn); h1[1] <-0for (t in2:Tn) { b11[t] <- b11[t -1] +rnorm(1, 0, 0.015) h1[t] <- h1[t -1] +rnorm(1, 0, 0.10)}b12 <--0.20; a21 <-0.10; a22 <-0.60; s2 <-1.0Y <-matrix(0, Tn, 2)for (t in2:Tn) { Y[t, 1] <- b11[t] * Y[t-1, 1] + b12 * Y[t-1, 2] +rnorm(1, 0, exp(h1[t] /2)) Y[t, 2] <- a21 * Y[t-1, 1] + a22 * Y[t-1, 2] +rnorm(1, 0, s2)}write.csv(data.frame(t =1:Tn, y1 = Y[, 1], y2 = Y[, 2],b11_true = b11, h1_true = h1),"../data/bayes-tvpvar.csv", row.names =FALSE)
Equation 1 of the VAR is a regression whose coefficients follow a random walk — exactly the state-space form the FFBS recursion of Part V was built for, with the coefficient vector playing the role of the state:
\(\beta_{1:T} \mid \sigma^2, Q, y\) — FFBS, exactly as before but with a regressor vector in the observation equation
\(\sigma^2 \mid \beta\) — inverse-gamma
The drift variance \(Q\) is held fixed at a calibrated \(0.02^2\) rather than sampled, for the reason on the previous slide. What we can then check honestly is whether the credible band covers the true path, and whether the implied drift magnitude is sensible.
Code
d <-read.csv("../data/bayes-tvpvar.csv")y <- d$y1[-1]; X <-cbind(d$y1[-nrow(d)], d$y2[-nrow(d)])Tn <-length(y); k <-ncol(X)# FFBS with a regressor vector: same recursion as the local level modelffbs <-function(yv, Xm, Vt, Q) { n <-length(yv); kk <-ncol(Xm) a <-matrix(0, n, kk); P <-array(0, c(n, kk, kk)) at <-rep(0, kk); Pt <-diag(10, kk)for (t in1:n) { Pp <- Pt + Q; xt <- Xm[t, ] Ft <-as.numeric(t(xt) %*% Pp %*% xt + Vt[t]) K <- (Pp %*% xt) / Ft at <- at + K *as.numeric(yv[t] -t(xt) %*% at) Pt <- Pp - K %*%t(xt) %*% Pp a[t, ] <- at; P[t, , ] <- Pt } b <-matrix(0, n, kk) b[n, ] <-mvrnorm(1, a[n, ], (P[n, , ] +t(P[n, , ])) /2+diag(1e-10, kk))for (t in (n -1):1) { Pt <- P[t, , ]; Pp <- Pt + Q J <- Pt %*%solve(Pp) m <- a[t, ] + J %*% (b[t +1, ] - a[t, ]) V <- Pt - J %*% Pp %*%t(J) b[t, ] <-mvrnorm(1, m, (V +t(V)) /2+diag(1e-10, kk)) } b}set.seed(14159)S <-4000; burn <-1000beta <-matrix(0, Tn, k); s2 <-1; Q <-rep(0.02^2, k) # drift variance held fixedkeepB <-matrix(NA, S - burn, Tn); keepS <-numeric(S - burn)for (it in1:S) { beta <-ffbs(y, X, rep(s2, Tn), diag(Q, k)) res <- y -rowSums(X * beta) s2 <-1/rgamma(1, 2+ Tn /2, 1+0.5*sum(res^2))if (it > burn) { keepB[it - burn, ] <- beta[, 1]; keepS[it - burn] <- s2 }}b11 <-colMeans(keepB); tb <- d$b11_true[-1]lo <-apply(keepB, 2, quantile, 0.05); hi <-apply(keepB, 2, quantile, 0.95)cat(sprintf("corr with the true path = %.3f\n", cor(b11, tb)))cat(sprintf("truth inside the 90%% band %.0f%% of the time\n", 100*mean(tb >= lo & tb <= hi)))
corr with the true path = 0.452 RMSE = 0.088
estimated path range [0.59, 0.70] vs true [0.41, 0.76]
The posterior mean is heavily shrunk toward a constant -- but the band covers
the truth, and OLS reports one number for a coefficient that plainly moves.
Code
import numpy as np, pandas as pdd = pd.read_csv("../data/bayes-tvpvar.csv")y = d["y1"].values[1:]X = np.column_stack([d["y1"].values[:-1], d["y2"].values[:-1]])Tn, k =len(y), X.shape[1]def ffbs(yv, Xm, Vt, Q, rng): n, kk =len(yv), Xm.shape[1] a = np.zeros((n, kk)); P = np.zeros((n, kk, kk)) at = np.zeros(kk); Pt = np.eye(kk) *10for t inrange(n): Pp = Pt + Q; xt = Xm[t] Ft = xt @ Pp @ xt + Vt[t] K = (Pp @ xt) / Ft at = at + K * (yv[t] - xt @ at) Pt = Pp - np.outer(K, xt) @ Pp a[t] = at; P[t] = Pt b = np.zeros((n, kk)) b[-1] = rng.multivariate_normal(a[-1], (P[-1] + P[-1].T)/2+ np.eye(kk)*1e-10)for t inrange(n -2, -1, -1): Pt = P[t]; Pp = Pt + Q J = Pt @ np.linalg.inv(Pp) V = Pt - J @ Pp @ J.T b[t] = rng.multivariate_normal(a[t] + J @ (b[t+1] - a[t]), (V + V.T)/2+ np.eye(kk)*1e-10)return brng = np.random.default_rng(14159)S, burn =4000, 1000beta = np.zeros((Tn, k)); s2 =1.0; Q = np.full(k, 0.02**2) # drift variance fixedkeepB = np.empty((S-burn, Tn)); keepS = np.empty(S-burn)for it inrange(S): beta = ffbs(y, X, np.full(Tn, s2), np.diag(Q), rng) res = y - np.sum(X * beta, axis=1) s2 =1/ rng.gamma(2+ Tn/2, 1/(1+0.5* res @ res))if it >= burn: keepB[it-burn] = beta[:, 0]; keepS[it-burn] = s2b11 = keepB.mean(0); tb = d["b11_true"].values[1:]lo, hi = np.percentile(keepB, [5, 95], axis=0)out = (f"corr with the true path = {np.corrcoef(b11, tb)[0,1]:.3f} "f"RMSE = {np.sqrt(((b11-tb)**2).mean()):.3f}\n"f"estimated path range [{b11.min():.2f}, {b11.max():.2f}] vs true [{tb.min():.2f}, {tb.max():.2f}]\n"f"truth inside the 90% band {100*np.mean((tb>=lo)&(tb<=hi)):.0f}% of the time\n"f"residual sd = {np.sqrt(keepS).mean():.3f}")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
corr with the true path = 0.414 RMSE = 0.089
estimated path range [0.59, 0.69] vs true [0.41, 0.76]
truth inside the 90% band 99% of the time
residual sd = 0.786
164
bvarsv implements Primiceri (2005) exactly: time-varying coefficients, a time-varying covariance factored into a triangular matrix and log-volatilities, and the Kim–Shephard–Chib mixture sampler for the volatility blocks. It is R-only — there is no Python or Stata equivalent, and Stata’s bayes: prefix has no state-path sampler at all.
The interesting question is not whether it runs but what it concludes. Its prior on the coefficient-drift variance is controlled by k_Q, defaulting to a very tight 0.01, so we vary it and watch two things: how much coefficient movement the model reports, and whether the volatility path cares.
[1] "2026-08-01 19:19:01.382029 -- now starting MCMC"
[1] "2026-08-01 19:19:02.722458 -- now at iteration 1000"
k_Q = 0.01 : coefficient path sd 0.0007 corr 0.764 | log-volatility corr 0.988
[1] "2026-08-01 19:19:06.960003 -- now starting MCMC"
[1] "2026-08-01 19:19:08.292466 -- now at iteration 1000"
k_Q = 0.05 : coefficient path sd 0.0145 corr 0.895 | log-volatility corr 0.989
[1] "2026-08-01 19:19:12.500767 -- now starting MCMC"
[1] "2026-08-01 19:19:13.837491 -- now at iteration 1000"
k_Q = 0.10 : coefficient path sd 0.0334 corr 0.888 | log-volatility corr 0.989
true coefficient path sd = 0.0958
The volatility path is recovered almost perfectly and does not care about k_Q.
How much the coefficients move is, to a first approximation, a prior choice.
The volatility path is the easy part: bvarsv tracks it with correlation near 0.99 regardless of the prior, because a burst of large residuals is unambiguous evidence
The coefficient path is the hard part. Its reported amplitude rises with k_Q and never reaches the truth; the shape is recovered far better than the size
The hand-coded sampler with a fixed drift variance and bvarsv at its default prior disagree about how much the coefficient moved — and neither is wrong, because the data barely speak to it
Practical reading: report TVP coefficient paths with their bands, never as point estimates, and always say what prior on the drift variance produced them
This is also why the literature moved toward shrinkage on the drift — testing whether time variation is there at all, rather than assuming it
Part VI fixed the Minnesota tightness at \(\lambda = 0.2\). That number does real work — it decides how much the data are allowed to speak — and picking it by hand is the weakest link in an otherwise fully Bayesian procedure.
Giannone, Lenza & Primiceri (2015) point out the obvious fix: \(\lambda\) is a hyperparameter, so give it a prior and integrate it out. The marginal likelihood of the VAR is available in closed form given \(\lambda\), so
can be sampled directly. The data then choose their own degree of shrinkage, and the uncertainty about \(\lambda\) propagates into every downstream object — coefficients, impulse responses, forecasts.
This is empirical Bayes done properly: not maximising over \(\lambda\) and pretending it was known, but averaging over it.
Code
library(BVAR)d <-read.csv("../data/bayes-var.csv")Y <-as.matrix(d)set.seed(14159)# lambda gets a prior and is drawn by Metropolis-Hastings alongside the VARmn <-bv_minnesota(lambda =bv_lambda(mode =0.2, sd =0.4, min =0.0001, max =5))fit <-bvar(Y, lags =4, n_draw =8000, n_burn =3000,priors =bv_priors(mn = mn), verbose =FALSE)lam <- fit$hyper[, "lambda"]cat(sprintf("lambda: posterior mean %.3f 90%% CI [%.3f, %.3f]\n",mean(lam), quantile(lam, .05), quantile(lam, .95)))cf <-apply(fit$beta, c(2, 3), mean)cat(sprintf("y1(-1) = %+.3f (true 0.5)\n", cf[2, 1]))cat(sprintf("y2(-1) = %+.3f (true -0.2)\n", cf[3, 1]))
lambda: posterior mean 0.272 90% CI [0.188, 0.388] (we had fixed it at 0.2)
equation 1, y1(-1) = +0.480 (true 0.5)
equation 1, y2(-1) = -0.188 (true -0.2)
equation 1, y1(-4) = -0.000 (true 0.0)
The data want slightly looser shrinkage than the value we assumed, and the
interval is narrow enough that fixing lambda would have understated uncertainty.
The posterior for \(\lambda\) sits above the conventional 0.2, so the hand-set value was shrinking a little too hard
Its 90% interval is not negligible: treating \(\lambda\) as known throws away that uncertainty everywhere downstream
This is cheap because the conditional marginal likelihood is closed-form; for priors without that structure the same idea needs the machinery of Part IX
Python and Stata have no equivalent of this hierarchical Minnesota implementation, so this slide is R-only
Everything at lags 2, 3 and 4, and the constant, should be found to be zero. All four implementations below see the same 196 observations, and the question is whether they agree on which two terms survive.
Code
d <-read.csv("../data/bayes-var.csv")Y <-as.matrix(d); p <-4; Tn <-nrow(Y)Xl <-do.call(cbind, lapply(1:p, function(j) Y[(p +1- j):(Tn - j), ]))yv <- Y[(p +1):Tn, 1]; X <-cbind(1, Xl); n <-length(yv); k <-ncol(X)nm <-c("const", paste0(rep(c("y1", "y2"), p), "(-", rep(1:p, each =2), ")"))set.seed(14159)S <-8000; burn <-2000b <-rep(0, k); s2 <-1; g <-rep(1, k) # g holds the inclusion indicatorstau0 <-0.01; tau1 <-1; pin <-0.5# spike scale, slab scale, prior inclusionkeepG <-matrix(NA, S - burn, k); keepB <-matrix(NA, S - burn, k)XtX <-crossprod(X); Xty <-crossprod(X, yv)for (it in1:S) { D <-ifelse(g ==1, tau1^2, tau0^2) # prior variance follows the indicator V <-solve(XtX / s2 +diag(1/ D)) b <-as.vector(mvrnorm(1, V %*% (Xty / s2), V))for (j in1:k) { # each indicator, given its coefficient l1 <-dnorm(b[j], 0, tau1, log =TRUE) +log(pin) l0 <-dnorm(b[j], 0, tau0, log =TRUE) +log(1- pin) g[j] <-rbinom(1, 1, 1/ (1+exp(l0 - l1))) } r <- yv - X %*% b s2 <-1/rgamma(1, 2+ n /2, 1+0.5*sum(r^2))if (it > burn) { keepG[it - burn, ] <- g; keepB[it - burn, ] <- b }}pip <-colMeans(keepG); pm <-colMeans(keepB)for (j in1:k) cat(sprintf(" %-8s PIP = %.3f mean = %+.3f\n", nm[j], pip[j], pm[j]))
SSVS on equation 1 of a VAR(4); the true model is a VAR(1)
const PIP = 0.095 posterior mean = -0.008
y1(-1) PIP = 1.000 posterior mean = +0.422 <- truly non-zero
y2(-1) PIP = 0.797 posterior mean = -0.151 <- truly non-zero
y1(-2) PIP = 0.106 posterior mean = -0.008
y2(-2) PIP = 0.138 posterior mean = -0.012
y1(-3) PIP = 0.207 posterior mean = +0.026
y2(-3) PIP = 0.070 posterior mean = -0.004
y1(-4) PIP = 0.059 posterior mean = +0.000
y2(-4) PIP = 0.165 posterior mean = -0.019
Both real terms are picked out; every spurious lag stays below 0.21.
Code
library(brms)df <-data.frame(y = yv, Xl)names(df)[-1] <-paste0(rep(c("y1L", "y2L"), p), rep(1:p, each =2))fit <-brm(y ~ ., data = df, prior =prior(horseshoe(1), class = b),chains =2, iter =2000, warmup =1000, seed =14159,refresh =0, control =list(adapt_delta =0.99))fixef(fit)[, c("Estimate", "Q2.5", "Q97.5")]
brms with a horseshoe prior (continuous shrinkage, no indicators):
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pd, pymc as pm, arviz as azd = pd.read_csv("../data/bayes-var.csv")Y = d.values; p =4; Tn =len(Y)Xl = np.column_stack([Y[p-j:Tn-j] for j inrange(1, p+1)])y = Y[p:, 0]nm = [f"{v}L{j}"for j inrange(1, p+1) for v in ("y1", "y2")]with pm.Model(): tau = pm.HalfCauchy("tau", 1) # global shrinkage lam = pm.HalfCauchy("lam", 1, shape=Xl.shape[1]) # local scales b = pm.Normal("b", 0, tau * lam, shape=Xl.shape[1]) # horseshoe by construction c = pm.Normal("c", 0, 10); s = pm.HalfNormal("s", 5) pm.Normal("y", c + Xl @ b, s, observed=y) i = pm.sample(1000, tune=2000, chains=2, random_seed=14159, progressbar=False, target_accept=0.95)
y
Code
su = az.summary(i, var_names=["b"])[["mean", "hdi_3%", "hdi_97%"]].round(3)su.index = nmout ="PyMC, horseshoe built from its global and local scales:\n"+ su.to_string()import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
PyMC, horseshoe built from its global and local scales:
mean hdi_3% hdi_97%
y1L1 0.383 0.268 0.530
y2L1 -0.131 -0.269 0.014
y1L2 -0.031 -0.171 0.052
y2L2 -0.017 -0.139 0.082
y1L3 0.059 -0.047 0.185
y2L3 -0.014 -0.122 0.075
y1L4 0.003 -0.100 0.092
y2L4 -0.052 -0.166 0.035
317
Code
setlinesize 255quietly import delimited "../data/bayes-var.csv", clearquietlydestring_all, replacequietlygenerate t = _nquietlytsset t* bayesselect is newin Stata 19 and defaults to a horseshoe global-local prior.* It reports an inclusion coefficient per regressor and drops the rest.bayesselect y1 L(1/4).y1 L(1/4).y2, rseed(14159)
All four methods agree on the answer: y1(-1) and y2(-1) survive, everything else is shrunk to noise
The spike-and-slab reports a probability per regressor; the horseshoe reports a shrunken coefficient whose interval barely excludes zero. Neither is a hypothesis test
Stata’s bayesselect is a horseshoe under the hood, which is why its inclusion coefficients rank the regressors the same way the hand-coded PIPs do
The spurious lag with the highest PIP, y1(-3), is the same one brms and PyMC shrink least — the methods are agreeing about the noise as well as the signal
Selection is not free: with correlated regressors, inclusion probabilities spread across a group rather than picking one, so read them jointly
Everything so far has run on simulated data where we knew the answer. The capstone drops that safety net: three US quarterly series from FRED, 1959Q2 to 2019Q4, in the standard recursive order
We stop at 2019Q4 deliberately. The 2020 collapse and rebound are \(-33\%\) and \(+30\%\) annualised — two observations that would dominate any linear VAR estimated through them, and handling them properly is a research question, not a lecture slide.
A BVAR(4) in three variables has 39 coefficients for 243 quarters. That is exactly the regime where the Minnesota prior earns its place.
Code
# This block lives in bayesian-computation-data.R and is run once, offline.# The raw downloads are cached in ../data/, so nobody needs an API key and the# render never touches the network.fred_get <-function(id) { raw <-sprintf("../data/bayes-macro-raw-%s.csv", id)if (!file.exists(raw)) { url <-sprintf(paste0("https://fred.stlouisfed.org/graph/fredgraph.csv","?id=%s&cosd=1959-01-01&coed=2024-10-01"), id)download.file(url, raw, quiet =TRUE) } z <-read.csv(raw); names(z) <-c("date", "value") z$date <-as.Date(z$date); z$value <-as.numeric(z$value); z}gdp <-fred_get("GDPC1"); defl <-fred_get("GDPDEF"); ffr <-fred_get("FEDFUNDS")ffr$q <-as.Date(cut(ffr$date, "quarter")) # monthly rate -> quarterly averageffrq <-aggregate(value ~ q, data = ffr, FUN = mean)# ... merge, then annualised log differences for GDP and the deflator ...write.csv(macro, "../data/bayes-macro.csv", row.names =FALSE)
Code
m <-read.csv("../data/bayes-macro.csv")m <- m[m$date <="2019-10-01", ] # stop before the COVID outliersY <-as.matrix(m[, c("dlgdp", "infl", "ffr")]); nv <-3; p <-4; Tn <-nrow(Y)build <-function(Y, p) { Tn <-nrow(Y) X <-cbind(1, do.call(cbind, lapply(1:p, function(j) Y[(p +1- j):(Tn - j), ])))list(y = Y[(p +1):Tn, , drop =FALSE], X = X)}gibbs_bvar <-function(Y, p, lambda =0.2, S =3000, burn =1000) { z <-build(Y, p); yv <- z$y; X <- z$X; k <-ncol(X); nv <-ncol(Y); n <-nrow(yv) sig <-sapply(1:nv, function(i) summary(lm(Y[-1, i] ~ Y[-nrow(Y), i]))$sigma) B0 <-matrix(0, k, nv); for (i in1:nv) B0[1+ i, i] <-0.9 V <-matrix(0, k, nv) # Minnesota prior variancesfor (i in1:nv) { V[1, i] <-100for (j in1:p) for (q in1:nv) V[1+ (j -1) * nv + q, i] <- (lambda / j)^2*ifelse(i == q, 1, (sig[i] / sig[q])^2) } Pr <-diag(1/as.vector(V)); bp <-as.vector(B0) XtX <-crossprod(X); Sig <-diag(nv); v0 <- nv +2; S0 <-diag(nv) kB <-array(NA, c(S - burn, k, nv)); kS <-array(NA, c(S - burn, nv, nv))set.seed(14159)for (it in1:S) { # the same two-block Gibbs Si <-solve(Sig) Vp <-solve(kronecker(Si, XtX) + Pr) bv <- Vp %*% (as.vector(crossprod(X, yv) %*% Si) + Pr %*% bp) +t(chol(Vp)) %*%rnorm(k * nv) B <-matrix(bv, k, nv); E <- yv - X %*% B Sig <-solve(rWishart(1, v0 + n, solve(S0 +crossprod(E)))[, , 1])if (it > burn) { kB[it - burn, , ] <- B; kS[it - burn, , ] <- Sig } }list(B = kB, S = kS, k = k, nv = nv, p = p)}f <-gibbs_bvar(Y, p)# impulse responses to a funds-rate shock, recursive orderingH <-20; S <-dim(f$B)[1]; irf <-array(NA, c(S, H +1, nv))for (s in1:S) { B <- f$B[s, , ]; P <-t(chol(f$S[s, , ])); shock <- P[, 3] comp <-matrix(0, nv * p, nv * p) comp[1:nv, ] <-t(B[-1, ])if (p >1) comp[(nv +1):(nv * p), 1:(nv * (p -1))] <-diag(nv * (p -1)) st <-c(shock, rep(0, nv * (p -1)))for (h in0:H) { irf[s, h +1, ] <- st[1:nv]; st <- comp %*% st }}q <-apply(irf, c(2, 3), quantile, c(.05, .5, .95))
sample: 1959-04-01 to 2019-10-01, 243 quarters, 39 coefficients
posterior mean own first lags:
dlgdp +0.313
infl +0.638
ffr +1.008
response to a one-standard-deviation funds-rate shock, median [90% band]:
Output falls sharply, bottoming after about two quarters with a band that excludes zero, and is back to nothing within three years — the textbook contractionary effect
Inflation rises slightly first. This is the price puzzle, a well-known artefact of small recursive VARs: the funds rate reacts to inflation pressure the three-variable system cannot see. Adding a commodity-price index is the classical fix
The policy rate itself is highly persistent, with the shock still half-alive after two years
Every band here is a genuine posterior probability statement, and it carries the uncertainty in \(B\) and \(\Sigma\) jointly — nothing was plugged in
Shrinkage is justified by a bias–variance argument, so the way to check it is out of sample. We re-estimate on everything up to 2014Q4, then score one-step-ahead predictive densities over the final 20 quarters against a random-walk benchmark:
The random walk is not a straw man — for macro series at quarterly frequency it is notoriously hard to beat, and much of the forecasting literature exists because of it. This is the same log predictive score used in Part IX, now on a multivariate target.
Code
nout <-20; Tr <- Tn - noutftr <-gibbs_bvar(Y[1:Tr, ], p) # re-estimate on the training sample onlyzf <-build(Y, p)idx <- (nrow(zf$y) - nout +1):nrow(zf$y)lps_bvar <-numeric(nout)for (i inseq_along(idx)) { xt <- zf$X[idx[i], ]; yt <- zf$y[idx[i], ] dens <-numeric(dim(ftr$B)[1])for (s inseq_along(dens)) dens[s] <- mvtnorm::dmvnorm(yt, as.vector(t(ftr$B[s, , ]) %*% xt), ftr$S[s, , ]) lps_bvar[i] <-log(mean(dens)) # average the density, then log}Srw <-cov(diff(Y[1:Tr, ])) # random-walk benchmarklps_rw <-numeric(nout)for (i inseq_along(idx)) { tt <- p + idx[i] lps_rw[i] <- mvtnorm::dmvnorm(Y[tt, ], Y[tt -1, ], Srw, log =TRUE)}cat(sprintf("BVAR(4) Minnesota : %8.2f\n", sum(lps_bvar)))cat(sprintf("random walk : %8.2f\n", sum(lps_rw)))
out-of-sample: last 20 quarters, from 2015-01-01, one step ahead
BVAR(4) Minnesota : total log predictive score -85.95
random walk : total log predictive score -96.33
difference : +10.38 in favour of the BVAR
the BVAR wins in 18 of the 20 quarters
A gap of ten log points over twenty quarters is decisive: the shrinkage is
not merely tidy, it buys real predictive accuracy on data nobody simulated.
The comparison is honest: the benchmark’s covariance and the BVAR’s coefficients are both estimated on training data only, and neither sees the test period
Log predictive scores reward calibrated uncertainty, not just accurate central forecasts — a model that is confidently wrong loses badly here
Because we averaged the predictive density over posterior draws before taking logs, the score already accounts for parameter uncertainty; plugging in \(\hat B\) would overstate the BVAR’s performance
The natural next steps are the ones this deck has already built: put a prior on \(\lambda\) rather than fixing it, let the volatilities drift, and compare the variants by exactly this score
With a flat prior on the grand mean \(\mu\) and conjugate priors elsewhere, every full conditional — \(\alpha_j\), \(\beta\), \(\mu\), \(\sigma^2\), \(\tau^2\) — is a standard distribution, so a Gibbs sampler cycles through them with no tuning. This is exactly what lme4 does by REML and what Stan / brms do by HMC; here we build it by hand.
\(J = 20\) groups of \(n_j = 8\) observations each. True hyperparameters \(\mu = 1\), \(\tau = 1.2\), common slope \(\beta = 0.7\), noise \(\sigma = 1\). Small groups make shrinkage visible: the no-pooling intercepts scatter widely, the hierarchical ones pull toward the centre. The data are ../data/bayes-panel.csv.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.set.seed(14159)J <-20; nj <-8; mu <-1; tau <-1.2; beta <-0.7; sigma <-1alpha <-rnorm(J, mu, tau)dat <-data.frame()for (j in1:J) { xj <-rnorm(nj) yj <- alpha[j] + beta * xj +rnorm(nj, 0, sigma) dat <-rbind(dat, data.frame(group = j, x = xj, y = yj))}write.csv(dat, "../data/bayes-panel.csv", row.names =FALSE)
Code
dat <-read.csv("../data/bayes-panel.csv")J <-max(dat$group)# Gibbs samplerset.seed(14159)g <- dat$group; x <- dat$x; y <- dat$y; N <-nrow(dat)S <-4000; a <-rep(0, J); b <-0; mu_s <-0; tau2 <-1; s2 <-1keep_a <-matrix(NA, S, J); keep_b <-numeric(S); keep_tau <-numeric(S)for (t in1:S) {for (j in1:J) { # group intercepts idx <- g == j; njj <-sum(idx) prec <- njj / s2 +1/ tau2 mij <- (sum(y[idx] - b * x[idx]) / s2 + mu_s / tau2) / prec a[j] <-rnorm(1, mij, sqrt(1/ prec)) } vb <-1/ (sum(x^2) / s2 +1/100) # common slope mb <- vb *sum(x * (y - a[g])) / s2 b <-rnorm(1, mb, sqrt(vb)) mu_s <-rnorm(1, mean(a), sqrt(tau2 / J)) # grand mean (flat prior on mu) tau2 <-1/rgamma(1, 2+ J /2, 1+0.5*sum((a - mu_s)^2)) resid <- y - a[g] - b * x # error variance s2 <-1/rgamma(1, 2+ N /2, 1+0.5*sum(resid^2)) keep_a[t, ] <- a; keep_b[t] <- b; keep_tau[t] <-sqrt(tau2)}kp <-1001:Sa_hier <-colMeans(keep_a[kp, ]); b_hat <-mean(keep_b[kp])a_nopool <-sapply(1:J, function(j) mean(y[g == j] - b_hat * x[g == j]))cat(sprintf("slope beta (true 0.7) = %.3f ; tau (true 1.2) = %.2f\n", b_hat, mean(keep_tau[kp])))df <-rbind(data.frame(group =1:J, est = a_nopool, type ="No pooling"),data.frame(group =1:J, est = a_hier, type ="Hierarchical"))ggplot(df, aes(group, est, color = type)) +geom_hline(yintercept =mean(a_hier), color ="grey50", linetype ="dashed") +geom_point(size =2.5) +scale_color_manual(values =c("No pooling"="#D85A30", "Hierarchical"="#185FA5"), name =NULL) +labs(x ="group j", y ="intercept estimate",title ="Partial pooling shrinks noisy group intercepts toward the mean",subtitle ="Orange = no-pooling; blue = hierarchical (pulled toward the dashed grand mean)") + theme_lecture
library(brms)d <-read.csv("../data/bayes-panel.csv")# Random-intercept model: y ~ x + (1 | group), fitted by Stan's NUTS.# iter = 2000 => 1000 warmup + 1000 kept draws per chain.fit <-brm(y ~ x + (1| group), data = d,prior =c(prior(normal(0, 10), class = b),prior(student_t(3, 0, 10), class = sd),prior(student_t(3, 0, 10), class = sigma)),chains =2, iter =2000, warmup =1000, seed =14159, refresh =0)fixef(fit)["x", ] # common slope betaVarCorr(fit)$group$sd # between-group SD tau
brms — hierarchical model via Stan NUTS:
slope beta (true 0.7) = 0.864 [0.694, 1.037]
tau (true 1.2) = 1.047
sigma (true 1.0) = 1.026
Matches the hand-coded Gibbs sampler (beta ~ 0.86, tau ~ 1.0).
y ~ x + (1 | group): a fixed slope on x plus a random intercept per group — the (1 | group) term is the hierarchical prior \(\alpha_j\sim\mathcal{N}(\mu,\tau^2)\)
family = gaussian() by default; prior() sets priors by class — b (slopes), sd (group SD \(\tau\)), sigma (residual); get_prior(y ~ x + (1 | group), d) lists every settable prior
Backend: brm()writes and compiles a Stan program — inspect it with make_stancode(fit) — and samples with NUTS; the same chains, iter, warmup and control = list(adapt_delta = ) knobs apply
Pro: one line replaces the entire Gibbs sampler and generalises to GLMs, splines and survival models; Con: compile time and less transparency than the hand-coded loop
Code
import numpy as np, pandas as pdd = pd.read_csv("../data/bayes-panel.csv") # reads R's CSVg = d["group"].values; x = d["x"].values; y = d["y"].valuesJ =int(g.max()); N =len(y)rng = np.random.default_rng(14159)S =4000; a = np.zeros(J +1); b =0.0; mu_s =0.0; tau2 =1.0; s2 =1.0keep_a = np.empty((S, J)); keep_tau = np.empty(S); keep_b = np.empty(S)for t inrange(S):for j inrange(1, J +1): idx = g == j; njj = idx.sum() prec = njj / s2 +1/ tau2 mij = (np.sum(y[idx] - b * x[idx]) / s2 + mu_s / tau2) / prec a[j] = rng.normal(mij, np.sqrt(1/ prec)) vb =1/ (np.sum(x**2) / s2 +1/100) b = rng.normal(vb * np.sum(x * (y - a[g])) / s2, np.sqrt(vb)) ag = a[1:] mu_s = rng.normal(ag.mean(), np.sqrt(tau2 / J)) # grand mean (flat prior on mu) tau2 =1/ rng.gamma(2+ J /2, 1/ (1+0.5* np.sum((ag - mu_s)**2))) resid = y - a[g] - b * x s2 =1/ rng.gamma(2+ N /2, 1/ (1+0.5* resid @ resid)) keep_a[t] = ag; keep_tau[t] = np.sqrt(tau2); keep_b[t] = bkp =slice(1000, S)b_hat = keep_b[kp].mean()print(f"slope beta (true 0.7) = {b_hat:.3f} tau (true 1.2) = {keep_tau[kp].mean():.2f}")
A DSGE model is a system of structural equations from optimising agents. Estimation, following Herbst & Schorfheide (2016), is a four-step pipeline:
Solve the linearised model (Blanchard–Kahn / gensys) → a state-space form in the structural parameters \(\theta\)
Filter: the Kalman filter (Part V) delivers the likelihood \(p(y_{1:T}\mid\theta)\)
Prior: economics disciplines \(\theta\) — a discount factor near \(0.99\), persistence in \([0,1)\), positive standard deviations
Sample: Metropolis–Hastings (or HMC) explores the posterior \(p(\theta\mid y)\propto p(y_{1:T}\mid\theta)\,p(\theta)\)
Bayesian methods dominate here because DSGE likelihoods are often flat or multimodal — the prior regularises weakly-identified structural parameters.
A full solution is beyond one slide, so we take the reduced state-space as given: a persistent technology state \(a_t\) observed through measurement error — the skeleton of an estimated RBC shock process.
The structural parameters are \(\theta = (\rho, \sigma)\) with \(\rho\) the shock persistence and \(\sigma\) its volatility. Truth: \(\rho = 0.8\), \(\sigma = 1.0\).
Economically motivated priors, standard in the DSGE literature:
The Kalman filter returns \(\log p(y_{1:T}\mid\rho,\sigma)\) for any candidate \(\theta\)
Random-walk Metropolis proposes \(\theta^\star\), accepts on the log posterior — exactly the Part II sampler, now with a filter computing the likelihood
Modern practice replaces RW-MH with HMC/NUTS (differentiating through the filter) or SMC for multimodal posteriors
Flat likelihoods hide behind the prior
DSGE likelihoods are often flat or ridged in some structural parameters — the data barely move them. Then the posterior just tracks the prior, and a careless reader mistakes prior for evidence. Guard against it: (i) plot the prior against the posterior for each parameter — little updating flags weak identification; (ii) run a prior-sensitivity check as in Part IV; (iii) watch for multimodality, where a single RW-MH chain gets trapped and SMC or many dispersed chains are required.
# This block lives in bayesian-computation-data.R, run once before rendering.# AR(1) state plus measurement noise; only y is observed.set.seed(14159)Tn <-200; rho_t <-0.8; sig_t <-1.0; s_obs <-0.5a <-numeric(Tn); a[1] <-rnorm(1, 0, sig_t /sqrt(1- rho_t^2))for (t in2:Tn) a[t] <- rho_t * a[t -1] + sig_t *rnorm(1)y <- a +rnorm(Tn, 0, s_obs)write.csv(data.frame(t =1:Tn, y = y), "../data/bayes-dsge.csv", row.names =FALSE)
Code
d <-read.csv("../data/bayes-dsge.csv")y <- d$y; Tn <-length(y); s_obs <-0.5# Kalman log-likelihood for the AR(1)-plus-noise state spacekalman_ll <-function(rho, sig) {if (rho <=0|| rho >=1|| sig <=0) return(-Inf) q <- sig^2; r <- s_obs^2 a_f <-0; P <- q / (1- rho^2); ll <-0for (t in1:Tn) { ap <- rho * a_f; Pp <- rho^2* P + q # predict v <- y[t] - ap; Ft <- Pp + r; K <- Pp / Ft # update a_f <- ap + K * v; P <- Pp - K^2* Ft ll <- ll -0.5* (log(2* pi) +log(Ft) + v^2/ Ft) } ll}log_post <-function(th) kalman_ll(th[1], th[2]) +dbeta(th[1], 5, 2, log =TRUE) +dnorm(th[2], 0, 1, log =TRUE)set.seed(14159)S <-5000; chain <-matrix(NA, S, 2); th <-c(0.5, 1)lp <-log_post(th); step <-c(0.05, 0.08); acc <-0for (t in1:S) { prop <- th +rnorm(2, 0, step); lpp <-log_post(prop)if (log(runif(1)) < lpp - lp) { th <- prop; lp <- lpp; acc <- acc +1 } chain[t, ] <- th}keep <- chain[1001:S, ]cat(sprintf("acceptance = %.2f\n", acc / S))cat(sprintf("rho (true 0.8): mean %.3f 95%% CI [%.3f, %.3f]\n",mean(keep[, 1]), quantile(keep[, 1], .025), quantile(keep[, 1], .975)))cat(sprintf("sigma (true 1.0): mean %.3f 95%% CI [%.3f, %.3f]\n",mean(keep[, 2]), quantile(keep[, 2], .025), quantile(keep[, 2], .975)))df <-data.frame(rho = keep[, 1], sigma = keep[, 2])ggplot(df, aes(rho, sigma)) +geom_point(alpha =0.15, color ="#185FA5", size =0.7) +geom_vline(xintercept =0.8, color ="#D85A30", linetype ="dashed") +geom_hline(yintercept =1.0, color ="#D85A30", linetype ="dashed") +labs(x =expression(rho), y =expression(sigma),title ="Joint posterior of the structural parameters",subtitle ="Dashed orange = true values; cloud = 4000 posterior draws") + theme_lecture
acceptance = 0.49
rho (true 0.8): mean 0.788 95% CI [0.695, 0.870]
sigma (true 1.0): mean 0.999 95% CI [0.876, 1.128]
Code
import numpy as np, pandas as pd, matplotlib.pyplot as pltfrom scipy import statsd = pd.read_csv("../data/bayes-dsge.csv") # reads R's CSVy = d["y"].values; Tn =len(y); s_obs =0.5def kalman_ll(rho, sig):ifnot (0< rho <1) or sig <=0: return-np.inf q, r = sig**2, s_obs**2 a, P, ll =0.0, q / (1- rho**2), 0.0for t inrange(Tn): ap = rho * a; Pp = rho**2* P + q v = y[t] - ap; Ft = Pp + r; K = Pp / Ft a = ap + K * v; P = Pp - K**2* Ft ll +=-0.5* (np.log(2*np.pi) + np.log(Ft) + v**2/ Ft)return lldef log_post(th):return (kalman_ll(th[0], th[1]) + stats.beta.logpdf(th[0], 5, 2)+ stats.norm.logpdf(th[1], 0, 1))rng = np.random.default_rng(14159)S =5000; chain = np.empty((S, 2)); th = np.array([0.5, 1.0])lp = log_post(th); step = np.array([0.05, 0.08]); acc =0for t inrange(S): prop = th + rng.normal(0, step); lpp = log_post(prop)if np.log(rng.uniform()) < lpp - lp: th, lp = prop, lpp; acc +=1 chain[t] = thkeep = chain[1000:]print(f"acceptance = {acc/S:.2f}")
acceptance = 0.48
Code
print(f"rho (true 0.8): mean {keep[:,0].mean():.3f} "f"CI [{np.percentile(keep[:,0],2.5):.3f}, {np.percentile(keep[:,0],97.5):.3f}]")
rho (true 0.8): mean 0.792 CI [0.700, 0.877]
Code
print(f"sigma (true 1.0): mean {keep[:,1].mean():.3f} "f"CI [{np.percentile(keep[:,1],2.5):.3f}, {np.percentile(keep[:,1],97.5):.3f}]")
The recipe scales: swap the AR(1) for a solved New Keynesian state space and \(\theta\) for \((\beta, \kappa, \phi_\pi, \dots)\) — the sampler is unchanged
Smets & Wouters (2007) estimate ~36 parameters this way; Dynare automates the solve-filter-sample pipeline
Non-linear DSGEs replace the Kalman filter with a particle filter (Part V) → particle MCMC
Current frontier: HMC/NUTS differentiating through the solution and filter, and Sequential Monte Carlo for the multimodal posteriors that defeat single-chain MH
A posterior can be sharp, converged and completely wrong. The check that catches this asks the model to generate new data and compares them with what we actually observed:
Drawing \(\theta^{(s)}\) from the posterior and then \(y^{\text{rep},(s)} \sim p(y \mid \theta^{(s)})\) gives replicated datasets that carry both sources of uncertainty. Pick a discrepancy measure \(T(\cdot)\) — the mean, the variance, the minimum, a skewness — and locate the observed value inside its replicated distribution:
A \(p_B\) near 0 or 1 means the model cannot reproduce that feature of the data. A \(p_B\) near 0.5 means only that this particular feature is unremarkable — it is not evidence the model is correct, and these values are not calibrated \(p\)-values.
Code
library(brms); library(bayesplot)d <-read.csv("../data/bayes-linreg.csv")fit <-brm(y ~ x, data = d, chains =2, iter =2000, warmup =1000,seed =14159, refresh =0)pp_check(fit, ndraws =50) +labs(title ="Observed density (dark) against 50 replicated datasets") + theme_lecture
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pd, pymc as pm, arviz as az, matplotlib.pyplot as pltd = pd.read_csv("../data/bayes-linreg.csv")with pm.Model(): b = pm.Normal("b", 0, 10, shape=2); s = pm.HalfNormal("s", 5) pm.Normal("y", b[0] + b[1] * d["x"].values, s, observed=d["y"].values) idata = pm.sample(1000, tune=1000, chains=2, random_seed=14159, progressbar=False) idata.extend(pm.sample_posterior_predictive(idata, random_seed=14159, progressbar=False))
Because it integrates over the prior, the marginal likelihood penalises complexity automatically — and is genuinely sensitive to the prior, even to parts of it the likelihood ignores. A prior you would call uninformative for estimation can dominate a Bayes factor. On the Jeffreys scale, \(\text{BF}\) of 3–20 is positive evidence, 20–150 strong, above 150 very strong.
For a nested point null \(H_0: \beta_2 = 0\) inside a larger model, the Bayes factor needs no integration at all. It is the ratio of the posterior to the prior density, both evaluated at the null:
The posterior piles up at zero relative to the prior exactly when the data support the restriction. We test whether the linear model needs a quadratic term — the true DGP is linear, so the honest answer is no:
d <-read.csv("../data/bayes-linreg.csv")y <- d$y; n <-length(y)X <-cbind(1, d$x, d$x^2) # the enlarged modelset.seed(14159)S <-8000; b <-rep(0, 3); s2 <-1; keep <-matrix(NA, S, 3)V0inv <-diag(1/100, 3); XtX <-crossprod(X); Xty <-crossprod(X, y)for (t in1:S) { Vb <-solve(V0inv + XtX / s2) b <-as.vector(mvrnorm(1, Vb %*% (Xty / s2), Vb)) r <- y - X %*% b s2 <-1/rgamma(1, 2+ n /2, 1+0.5*sum(r^2)) keep[t, ] <- b}b2 <- keep[2001:S, 3]dens <-density(b2, n =4096) # posterior density at zeropost0 <-approx(dens$x, dens$y, xout =0)$yprior0 <-dnorm(0, 0, 10) # prior density at zerocat(sprintf("posterior density at 0 = %.4f ; prior density at 0 = %.4f\n", post0, prior0))cat(sprintf("BF_01 = %.2f\n", post0 / prior0))
posterior mean of beta_2 = -0.0978 95% CI [-0.285, 0.089]
posterior density at 0 = 2.5479 ; prior density at 0 = 0.0399
BF_01 = 63.87 -- strong evidence FOR the null: no quadratic term
Code
import numpy as np, pandas as pdfrom scipy import statsd = pd.read_csv("../data/bayes-linreg.csv")y = d["y"].values; n =len(y)X = np.column_stack([np.ones(n), d["x"].values, d["x"].values**2])rng = np.random.default_rng(14159)S =8000; b = np.zeros(3); s2 =1.0; keep = np.empty((S, 3))V0inv = np.diag([1/100]*3); XtX = X.T @ X; Xty = X.T @ yfor t inrange(S): Vb = np.linalg.inv(V0inv + XtX / s2) b = rng.multivariate_normal(Vb @ (Xty / s2), Vb) r = y - X @ b s2 =1/ rng.gamma(2+ n/2, 1/ (1+0.5* r @ r)) keep[t] = bb2 = keep[2000:, 2]post0 =float(stats.gaussian_kde(b2)(0)) # posterior density at zeroprior0 = stats.norm.pdf(0, 0, 10) # prior density at zeroout = (f"posterior mean of beta_2 = {b2.mean():.4f}\n"f"posterior density at 0 = {post0:.4f} ; prior density at 0 = {prior0:.4f}\n"f"BF_01 = {post0/prior0:.2f}")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
posterior mean of beta_2 = -0.0958
posterior density at 0 = 2.3915 ; prior density at 0 = 0.0399
BF_01 = 59.95
111
Code
setlinesize 255quietly import delimited "../data/bayes-linreg.csv", clearquietlydestring_all, replacequietlygeneratedoublex2 = x^2* Stata compares whole models rather than restricting one coefficient:* fit both and let bayestest modeldo the odds arithmetic.quietly bayesmh y x, likelihood(normal({sig2})) prior({y:x _cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(m0sim, replace)quietlyestimatesstore M0quietly bayesmh y x x2, likelihood(normal({sig2})) prior({y:x x2_cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(m1sim, replace)quietlyestimatesstore M1bayestest model M0 M1quietly erase m0sim.dtaquietly erase m1sim.dta
Bayesian model tests
----------------------------------------------
| log(ML) P(M) P(M|y)
-------------+--------------------------------
M0 | -236.9681 0.5000 0.9836
M1 | -241.0619 0.5000 0.0164
----------------------------------------------
Note: Marginal likelihood (ML) is computed using
Laplace–Metropolis approximation.
The marginal likelihood is an integral over the whole parameter space, and estimating it well is much harder than estimating a posterior mean. Four approaches, in ascending order of trustworthiness:
Harmonic mean — \(\hat p(y)^{-1} = S^{-1}\sum_s p(y\mid\theta^{(s)})^{-1}\). Consistent, but with infinite variance: it is dominated by the draws of lowest likelihood, so it is biased upward and unstable. Newton and Raftery proposed it; it has been called the worst Monte Carlo method ever
Chib (1995) — rearrange \(p(y) = p(y\mid\theta^\star)p(\theta^\star)/p(\theta^\star\mid y)\) at a single high-density point, and get the denominator from the Gibbs full conditionals. Exact for conjugate blocks
Bridge sampling — iteratively finds an optimal bridge between the posterior and a proposal; the current default for general models
SMC — a sequential sampler returns the marginal likelihood as a by-product of its tempering path
To judge them we need a case where the answer is known. Under the conjugate \(g\)-prior, \(\beta\mid\sigma^2\sim\mathcal{N}(0,\, g\sigma^2 I)\) with \(\sigma^2\sim\mathcal{IG}(a_0, d_0)\), the marginal likelihood is analytic:
harmonic mean across four sub-chains: -228.94 -230.16 -228.86 -228.18 (spread 1.99 nats)
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pd, pymc as pmfrom scipy import special, statsd = pd.read_csv("../data/bayes-linreg.csv")y = d["y"].values; x = d["x"].values; n =len(y)X = np.column_stack([np.ones(n), x]); k =2; g, a0, d0 =100.0, 2.0, 1.0# analytic answer under the same g-prior as the R tabB0inv = np.diag([1/g]*k); Bn = np.linalg.inv(B0inv + X.T @ X); bn = Bn @ (X.T @ y)an = a0 + n/2dn = d0 +0.5* (y @ y - bn @ np.linalg.inv(Bn) @ bn)analytic = (-n/2*np.log(2*np.pi)+0.5*(np.linalg.slogdet(Bn)[1] - np.linalg.slogdet(np.diag([g]*k))[1])+ a0*np.log(d0) - an*np.log(dn) + special.gammaln(an) - special.gammaln(a0))# SMC returns the marginal likelihood as a by-product of temperingwith pm.Model(): s2 = pm.InverseGamma("s2", alpha=a0, beta=d0) b = pm.Normal("b", 0, pm.math.sqrt(s2 * g), shape=2) pm.Normal("y", b[0] + b[1]*x, pm.math.sqrt(s2), observed=y) i = pm.sample_smc(2000, chains=2, random_seed=14159, progressbar=False)
y
Code
smc =float(i.sample_stats["log_marginal_likelihood"].mean())out = (f"analytic log ML = {analytic:.3f}\n"f"SMC log ML = {smc:.3f} (error {smc-analytic:+.3f})")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
analytic log ML = -237.946
SMC log ML = -238.000 (error -0.055)
73
Code
setlinesize 255quietly import delimited "../data/bayes-linreg.csv", clearquietlydestring_all, replacequietlygeneratedoublex2 = x^2* bayesmh reports a Laplace-Metropolis log ML for every fit; bayesstats ic* collects them. Note the prior here is independentof sigma2, not a g-prior,* so the level differs from the R and Python tabs by a fraction of a nat.quietly bayesmh y x, likelihood(normal({sig2})) prior({y:x _cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(l0sim, replace)quietlyestimatesstore M0quietly bayesmh y x x2, likelihood(normal({sig2})) prior({y:x x2_cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(l1sim, replace)quietlyestimatesstore M1bayesstats ic M0 M1quietly erase l0sim.dtaquietly erase l1sim.dta
Bayesian information criteria
----------------------------------------------
| DIC log(ML) log(BF)
-------------+--------------------------------
M0 | 456.3534 -236.9681 .
M1 | 457.4386 -241.0619 -4.093743
----------------------------------------------
Note: Marginal likelihood (ML) is computed
using Laplace–Metropolis approximation.
Bayes factors ask which model generated the data. A different and often more useful question is which model predicts best. The target is the expected log pointwise predictive density for a new observation:
Two estimators, both computed from the same \(S \times n\) matrix of pointwise log-likelihoods:
WAIC — the log pointwise predictive density from the fitted sample, minus a variance-based penalty \(p_{\text{WAIC}} = \sum_i \text{Var}_s\big[\log p(y_i\mid\theta^{(s)})\big]\)
PSIS-LOO — leave-one-out cross-validation done by importance sampling, with a Pareto tail fit stabilising the weights. The fitted shape \(\hat k\) flags observations where the approximation fails (\(\hat k > 0.7\))
Both are on the deviance scale here (lower is better) and both come with a standard error — which is what tells you whether a difference between models means anything. Unlike Bayes factors these are insensitive to the prior in the way that matters, because they condition on the posterior rather than integrating over the prior.
Code
library(loo)d <-read.csv("../data/bayes-linreg.csv")y <- d$y; n <-length(y)gibbs <-function(X, S =8000) { k <-ncol(X); V0inv <-diag(1/100, k); XtX <-crossprod(X); Xty <-crossprod(X, y) b <-rep(0, k); s2 <-1; kb <-matrix(NA, S, k); ks <-numeric(S)set.seed(14159)for (t in1:S) { Vb <-solve(V0inv + XtX / s2); b <-as.vector(mvrnorm(1, Vb %*% (Xty / s2), Vb)) r <- y - X %*% b; s2 <-1/rgamma(1, 2+ n /2, 1+0.5*sum(r^2)) kb[t, ] <- b; ks[t] <- s2 }list(b = kb[2001:S, , drop =FALSE], s2 = ks[2001:S])}# pointwise log-likelihood matrix: rows are draws, columns are observationsllmat <-function(G, X) { m <-matrix(NA, nrow(G$b), n)for (t in1:nrow(G$b)) m[t, ] <-dnorm(y, X %*% G$b[t, ], sqrt(G$s2[t]), log =TRUE) m}X1 <-cbind(1, d$x); X2 <-cbind(1, d$x, d$x^2)L1 <-llmat(gibbs(X1), X1); L2 <-llmat(gibbs(X2), X2)cat(sprintf("M1 (linear) WAIC = %7.2f LOO = %7.2f\n",waic(L1)$estimates["waic", "Estimate"], loo(L1)$estimates["looic", "Estimate"]))cat(sprintf("M2 (quadratic) WAIC = %7.2f LOO = %7.2f\n",waic(L2)$estimates["waic", "Estimate"], loo(L2)$estimates["looic", "Estimate"]))print(loo_compare(loo(L1), loo(L2)))
The difference is smaller than its own standard error: on predictive grounds
the two models are indistinguishable, even though BF_01 = 64 favoured the null.
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pd, arviz as azfrom scipy import statsd = pd.read_csv("../data/bayes-linreg.csv")y = d["y"].values; n =len(y)X1 = np.column_stack([np.ones(n), d["x"].values])X2 = np.column_stack([np.ones(n), d["x"].values, d["x"].values**2])def gibbs(X, S=8000): k = X.shape[1]; V0inv = np.diag([1/100]*k); XtX = X.T @ X; Xty = X.T @ y b = np.zeros(k); s2 =1.0; kb = np.empty((S, k)); ks = np.empty(S) rng = np.random.default_rng(14159)for t inrange(S): Vb = np.linalg.inv(V0inv + XtX/s2); b = rng.multivariate_normal(Vb @ (Xty/s2), Vb) r = y - X @ b; s2 =1/ rng.gamma(2+ n/2, 1/(1+0.5* r @ r)) kb[t] = b; ks[t] = s2return kb[2000:], ks[2000:]def idata(X): bs, ss = gibbs(X) ll = np.empty((len(ss), n))for t inrange(len(ss)): ll[t] = stats.norm.logpdf(y, X @ bs[t], np.sqrt(ss[t]))return az.from_dict(posterior={"b": bs[None, ...]}, log_likelihood={"y": ll[None, ...]})i1, i2 = idata(X1), idata(X2)lines = []for nm, i in (("M1 (linear)", i1), ("M2 (quadratic)", i2)): lo = az.loo(i); wa = az.waic(i) lines.append(f"{nm:15} WAIC = {-2*wa.elpd_waic:7.2f} LOO = {-2*lo.elpd_loo:7.2f} p_loo = {lo.p_loo:.2f}")cmp= az.compare({"M1": i1, "M2": i2}, ic="loo")lines.append(f"elpd difference = {cmp['elpd_diff'].iloc[1]:.2f} with standard error {cmp['dse'].iloc[1]:.2f}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
M1 (linear) WAIC = 456.60 LOO = 456.61 p_loo = 3.11
M2 (quadratic) WAIC = 457.78 LOO = 457.90 p_loo = 4.19
elpd difference = 0.65 with standard error 0.99
172
Code
setlinesize 255quietly import delimited "../data/bayes-linreg.csv", clearquietlydestring_all, replacequietlygeneratedoublex2 = x^2* Stata has no WAIC and no PSIS-LOO. What it offers is DIC, an older* effective-parameter penalty, reported alongside the marginal likelihood.quietly bayesmh y x, likelihood(normal({sig2})) prior({y:x _cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(d0sim, replace)quietlyestimatesstore M0quietly bayesmh y x x2, likelihood(normal({sig2})) prior({y:x x2_cons}, normal(0, 100)) prior({sig2}, igamma(2,1)) rseed(14159) nomodelsummary saving(d1sim, replace)quietlyestimatesstore M1bayesstats ic M0 M1displayastext"DIC carries no standard error, so it cannot say whether a gap is noise."quietly erase d0sim.dtaquietly erase d1sim.dta
Bayesian information criteria
----------------------------------------------
| DIC log(ML) log(BF)
-------------+--------------------------------
M0 | 456.3534 -236.9681 .
M1 | 457.4386 -241.0619 -4.093743
----------------------------------------------
Note: Marginal likelihood (ML) is computed
using Laplace–Metropolis approximation.
DIC carries no standard error, so it cannot say whether a gap is noise.
LOO approximates cross-validation from a single fit. When the data have a natural split — a hold-out sample, or the end of a time series — we can do the real thing and score honest forecasts. Fit on the training set alone, then evaluate the log predictive density of each held-out observation under the posterior predictive:
Higher is better. This is a strictly proper scoring rule: it rewards calibrated uncertainty, not just accurate point forecasts, so a model that is confidently wrong is punished harder than one that is honestly unsure. It is the criterion we will use again for forecast comparison in the BVAR setting.
Code
d <-read.csv("../data/bayes-linreg.csv")y <- d$y; n <-length(y)te <-seq(4, n, by =4); tr <-setdiff(1:n, te) # every 4th point held out: 30 of 120score <-function(X) { yt <- y[tr]; Xt <- X[tr, , drop =FALSE]; k <-ncol(X) V0inv <-diag(1/100, k); XtX <-crossprod(Xt); Xty <-crossprod(Xt, yt) b <-rep(0, k); s2 <-1; S <-6000 acc <-matrix(0, S -1000, length(te))set.seed(14159)for (t in1:S) { Vb <-solve(V0inv + XtX / s2); b <-as.vector(mvrnorm(1, Vb %*% (Xty / s2), Vb)) r <- yt - Xt %*% b s2 <-1/rgamma(1, 2+length(tr) /2, 1+0.5*sum(r^2))if (t >1000) acc[t -1000, ] <-dnorm(y[te], X[te, , drop =FALSE] %*% b, sqrt(s2)) }sum(log(colMeans(acc))) # average first, then log}s1 <-score(cbind(1, d$x)); s2 <-score(cbind(1, d$x, d$x^2))cat(sprintf("M1 (linear) log predictive score = %.3f\n", s1))cat(sprintf("M2 (quadratic) log predictive score = %.3f\n", s2))
M1 (linear) log predictive score = -56.255
M2 (quadratic) log predictive score = -56.698
difference = 0.444 over 30 held-out points, favouring the linear model
All three criteria agree that the quadratic term earns nothing -- but only the
Bayes factor says so with confidence. LOO and this score call it a near-tie.
Code
import numpy as np, pandas as pdfrom scipy import statsd = pd.read_csv("../data/bayes-linreg.csv")y = d["y"].values; n =len(y)te = np.arange(3, n, 4); tr = np.setdiff1d(np.arange(n), te) # same split as the R tabdef score(X): yt = y[tr]; Xt = X[tr]; k = X.shape[1] V0inv = np.diag([1/100]*k); XtX = Xt.T @ Xt; Xty = Xt.T @ yt b = np.zeros(k); s2 =1.0; S =6000 acc = np.zeros((S -1000, len(te))) rng = np.random.default_rng(14159)for t inrange(S): Vb = np.linalg.inv(V0inv + XtX/s2); b = rng.multivariate_normal(Vb @ (Xty/s2), Vb) r = yt - Xt @ b s2 =1/ rng.gamma(2+len(tr)/2, 1/(1+0.5* r @ r))if t >=1000: acc[t -1000] = stats.norm.pdf(y[te], X[te] @ b, np.sqrt(s2))returnfloat(np.sum(np.log(acc.mean(axis=0))))X1 = np.column_stack([np.ones(n), d["x"].values])X2 = np.column_stack([np.ones(n), d["x"].values, d["x"].values**2])s1, s2 = score(X1), score(X2)out = (f"M1 (linear) log predictive score = {s1:.3f}\n"f"M2 (quadratic) log predictive score = {s2:.3f}\n"f"difference = {s1-s2:.3f} over 30 held-out points")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Re-run the posterior predictive check on data you deliberately break: simulate \(y\) with \(t_3\) errors instead of Normal, fit the Normal model, and find which discrepancy measure catches it. Does the mean? Does the minimum?
Compute the Savage–Dickey ratio for \(H_0: \beta_2 = 0\) under prior standard deviations 1, 10 and 100 on \(\beta_2\). Plot \(\text{BF}_{01}\) against the prior scale and explain the monotonicity — this is the Jeffreys–Lindley paradox in one picture.
Show numerically that the harmonic-mean estimator is dominated by low-likelihood draws: order the draws by likelihood and plot the running estimate as you add them from worst to best.
Implement Chib’s method for the hierarchical model of Part VII, where the posterior ordinate needs two blocks rather than one, and check it against bridgesampling.
Compare WAIC, PSIS-LOO and the log predictive score across the linear, quadratic and cubic models. Report the Pareto \(\hat k\) diagnostics and say whether any observation is influential enough to distrust the LOO approximation.
Split bayes-linreg.csv into five folds and compute exact five-fold cross-validated log predictive density. How close is PSIS-LOO to the answer it is approximating, and how much cheaper was it?
Part X: Extensions
Four directions the toolkit goes next
Variational Inference — Optimisation Instead of Sampling
The mean-field family assumes the parameters are independent under \(q\):
\[
q(\theta) = \prod_{j=1}^{d} q_j(\theta_j)
\]
ADVI (automatic differentiation VI) makes this push-button: transform every parameter to the real line, put a Gaussian \(q\) there, and climb \(\mathcal{L}\) with stochastic gradients. Both Stan and PyMC ship it.
The trade is honest. MCMC is asymptotically exact — run it longer and you converge to \(p\). VI converges to the best member of \(\mathcal{Q}\), and if \(p \notin \mathcal{Q}\) that is where it stops.
Code
library(brms)d <-read.csv("../data/bayes-panel.csv") # the Part VII hierarchical modelpr <-c(prior(normal(0, 10), class = b),prior(student_t(3, 0, 10), class = sd),prior(student_t(3, 0, 10), class = sigma))# same model, same priors, two enginesfit_nuts <-brm(y ~ x + (1| group), data = d, prior = pr,chains =2, iter =2000, warmup =1000, seed =14159, refresh =0)fit_vi <-brm(y ~ x + (1| group), data = d, prior = pr,algorithm ="meanfield", # Stan's ADVItol_rel_obj =0.001, iter =50000, output_samples =2000,seed =14159, refresh =0)sn <- posterior::summarise_draws(as_draws_df(fit_nuts))sv <- posterior::summarise_draws(as_draws_df(fit_vi))keep <-c("b_x", "sd_group__Intercept", "sigma")cbind(sn[sn$variable %in% keep, c("variable", "mean", "sd")], sv[sv$variable %in% keep, c("mean", "sd")])
Stan flags this fit with a Pareto k above 0.7: the VI approximation is
too poor for importance resampling to repair it.
Code
import warnings, logging; warnings.filterwarnings("ignore")logging.getLogger("arviz").setLevel(logging.ERROR) # q has one chain, not twoimport numpy as np, pandas as pd, pymc as pm, arviz as azd = pd.read_csv("../data/bayes-panel.csv")g = d["group"].values -1; J =int(d["group"].max())x = d["x"].values; y = d["y"].valuesdef build(): # same model, built twicewith pm.Model() as m: mu = pm.Normal("mu", 0, 10) tau = pm.HalfStudentT("tau", nu=3, sigma=10) # matches the brms prior a = pm.Normal("a", mu, tau, shape=J) beta = pm.Normal("beta", 0, 10) sig = pm.HalfStudentT("sig", nu=3, sigma=10) pm.Normal("y", a[g] + beta * x, sig, observed=y)return mwith build(): nuts = pm.sample(1000, tune=1000, chains=2, random_seed=14159, progressbar=False, target_accept=0.9)with build(): approx = pm.fit(30000, method="advi", random_seed=14159, progressbar=False) vi = approx.sample(2000, random_seed=14159) # draws from qlines = ["parameter NUTS mean (sd) ADVI mean (sd)"]for v in ["beta", "tau", "sig"]: a1 = az.summary(nuts, var_names=[v]); a2 = az.summary(vi, var_names=[v]) lines.append(f"{v:10s}{a1['mean'][0]:6.3f} ({a1['sd'][0]:.3f}) "f"{a2['mean'][0]:6.3f} ({a2['sd'][0]:.3f})")lines.append(f"final ELBO = {float(approx.hist[-1]):.1f}")import sys; sys.stdout.write("\n".join(lines) +"\n"); sys.stdout.flush()
parameter NUTS mean (sd) ADVI mean (sd)
beta 0.865 (0.086) 0.860 (0.089)
tau 1.066 (0.213) 1.080 (0.198)
sig 1.022 (0.063) 1.033 (0.063)
final ELBO = 267.1
204
Both tabs fit the same model with the same priors, and both mean-field approximations recover the slope \(\beta\) and the residual \(\sigma\) well
They disagree about \(\tau\): Stan’s ADVI reports a standard deviation about 2.5 times too small, PyMC’s lands on the NUTS answer
The difference is the optimiser, not the family — Stan stops on a relative-ELBO rule with one gradient sample per step, PyMC took 30,000 Adam steps. With MCMC, more computation always buys accuracy; with VI it buys a better solution to a problem that may still be the wrong one
Stan’s Pareto \(\hat k\) diagnostic (Yao et al. 2018) catches the failure — treat \(\hat k > 0.7\) as “do not trust this VI posterior”
Use VI to explore, to initialise a sampler, or when \(n\) is large enough that NUTS is out of reach; confirm anything you publish with MCMC
The narrowness is structural, not a tuning failure. Minimising \(\mathrm{KL}(q\|p) = \mathbb{E}_q[\log q - \log p]\) punishes \(q\) heavily wherever \(q\) has mass and \(p\) has none, and not at all where \(p\) has mass and \(q\) has none. The optimiser therefore hides inside the target rather than covering it.
For a Gaussian target with precision matrix \(\Lambda = \Sigma^{-1}\), the mean-field solution is available in closed form: it matches the conditional variances, not the marginal ones,
At \(\rho = 0.95\) variational inference reports 31% of the true standard deviation — and no amount of extra optimisation fixes it, because the tilted ellipse is simply not in the family.
The blue contours tilt; the orange ones cannot — independence under \(q\) is exactly the assumption that forbids it
ADVI’s numerical answer lands within a few hundredths of the analytic \(\sqrt{1-\rho^2}\), confirming this is the optimum of the objective and not a convergence failure
The means are fine — VI is usually good for point estimates and bad for uncertainty, which is the opposite of what a Bayesian analysis is for
Fixes: a full-rank Gaussian family (method="fullrank", algorithm="fullrank") restores the tilt at \(O(d^2)\) cost; reparameterising to reduce posterior correlation (Part III) helps everywhere
The same pathology explains why mean-field VI collapses on Neal’s funnel and understates \(\tau\) in hierarchical models — \(\tau\) and the group effects are strongly dependent
Every result so far conditioned on one specification. With \(K\) candidate regressors there are \(2^K\) of them, and picking one by \(t\)-statistics throws away the uncertainty about that choice. BMA keeps the whole model space and weights by posterior model probability:
Under Zellner’s \(g\)-prior, \(\beta_m \mid \sigma^2 \sim \mathcal{N}\big(0, g\,\sigma^2 (X_m'X_m)^{-1}\big)\) with the usual improper prior on the intercept and \(\sigma^2\), the marginal likelihood of every model is analytic — one determinant-free formula in the model’s \(R^2\):
With \(g = n\) (the unit information prior) a whole model space is one loop over subsets. When \(2^K\) is too large, MC3 walks the space instead, proposing to add or drop one regressor at a time.
The Fernández–Ley–Steel growth dataset: average GDP growth 1960–1992 for 72 countries against 41 candidate regressors — the canonical demonstration that growth empirics is a model-uncertainty problem. We keep 15 regressors so that all \(2^{15} = 32{,}768\) models can be enumerated exactly and R, Python and Stata can be compared against a known answer.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.data("datafls", package ="BMS")fls_keep <-c("y", "GDP60", "Confucian", "LifeExp", "EquipInv", "SubSahara","Muslim", "RuleofLaw", "NequipInv", "LatAmerica", "PrScEnroll","Protestants", "Mining", "YrsOpen", "Buddha", "RFEXDist")write.csv(datafls[, fls_keep], "../data/bayes-growth.csv", row.names =FALSE)
Code
d <-read.csv("../data/bayes-growth.csv")y <- d$y -mean(d$y) # demean: the intercept drops outX <-as.matrix(d[, -1]); X <-sweep(X, 2, colMeans(X))n <-nrow(X); K <-ncol(X); g <- n # unit information priorXtX <-crossprod(X); Xty <-crossprod(X, y); yty <-sum(y^2)M <-2^Klogml <-numeric(M); size <-integer(M)inc <-matrix(FALSE, M, K) # which regressors each model holdsbhat <-matrix(0, M, K)for (m in0:(M -1)) { s <-which(bitwAnd(m, bitwShiftL(1L, 0:(K -1))) >0) # subset from the bits k <-length(s); r2 <-0if (k >0) { bb <-solve(XtX[s, s, drop =FALSE], Xty[s, , drop =FALSE]) r2 <-sum(bb * Xty[s, ]) / yty bhat[m +1, s] <- bb } logml[m +1] <- (n -1- k) /2*log(1+ g) - (n -1) /2*log(1+ g * (1- r2)) size[m +1] <- k; inc[m +1, s] <-TRUE}w <-exp(logml -max(logml)); w <- w /sum(w) # posterior model probabilitiespip <-colSums(w * inc)pmean <-colSums(w * bhat) * g / (1+ g) # g-prior shrinks each model's OLSres <-data.frame(regressor =colnames(X), PIP =round(pip, 3),post_mean =round(pmean, 4))print(res[order(-res$PIP), ], row.names =FALSE)cat(sprintf("posterior expected model size = %.2f of %d\n", sum(w * size), K))
posterior expected model size = 10.18 of 15 regressors
best single model carries only 3.4% of the posterior mass: GDP60 Confucian LifeExp EquipInv SubSahara Muslim RuleofLaw NequipInv Protestants Mining YrsOpen
Code
library(BMS)d <-read.csv("../data/bayes-growth.csv")# g = "UIP" and a uniform model prior are exactly the assumptions coded by hand;# mcmc = "enumerate" visits all 32768 models rather than sampling them.bm <-bms(d, mprior ="uniform", g ="UIP", mcmc ="enumerate", user.int =FALSE)coef(bm)[, c("PIP", "Post Mean", "Post SD")]
* bmaregress is native to Stata (BMA manual) - nothing to install.quietly import delimited "../data/bayes-growth.csv", case(lower) clearquietlydestring_all, replace* gprior(uip) and mprior(uniform) are the assumptions coded by hand in the R tab;* with 15 predictors Stata samples the model space by MC3 rather than enumerating.bmaregress y gdp60 confucian lifeexp equipinv subsahara muslim ruleoflaw /// nequipinv latamerica prscenroll protestants mining yrsopen buddha rfexdist, /// gprior(uip) mprior(uniform) rseed(14159)
Burn-in ...
Simulation ...
Computing model probabilities ...
Bayesian model averaging No. of obs = 72
Linear regression No. of predictors = 15
MC3 sampling Groups = 15
Always = 0
No. of models = 664
For CPMP >= .9 = 271
Priors: Mean model size = 10.210
Models: Uniform Burn-in = 2,500
Cons.: Noninformative MCMC sample size = 10,000
Coef.: Zellner's g Acceptance rate = 0.3508
g: Unit-information, g = 72 Shrinkage, g/(1+g) = 0.9863
sigma2: Noninformative Mean sigma2 = .000056
Sampling correlation = 0.9653
------------------------------------------------------------------------------
y | Mean Std. dev. Group PIP
-------------+----------------------------------------------------------------
gdp60 | -.0154102 .0028044 1 1
confucian | .0551303 .0120197 2 1
lifeexp | .0009234 .0002505 3 .99645
equipinv | .1597231 .0489516 4 .99013
yrsopen | .0124618 .0054843 13 .92933
muslim | .0116013 .0054067 6 .91063
mining | .0298715 .0201489 12 .78933
subsahara | -.0088613 .0061967 5 .78642
protestants | -.0064342 .0060254 11 .63458
ruleoflaw | .0062074 .0065614 7 .57245
nequipinv | .021669 .0261865 8 .50296
prscenroll | .0056753 .0088061 10 .38336
buddha | .0025063 .0050911 14 .27608
rfexdist | -.0000116 .0000264 15 .24522
latamerica | -.0006152 .002642 9 .19298
-------------+----------------------------------------------------------------
Always |
_cons | .0605145 .018088 0 1
------------------------------------------------------------------------------
Note: Coefficient posterior means and std. dev. estimated from 664 models.
The hand-coded enumeration and BMS agree to the fifth decimal on every PIP — the same formula, coded twice
Python’s hand-coded MC3 and Stata’s bmaregress each visit a fraction of the 32,768 models and land within 0.03 of the exact PIPs — the sampler works, which is what lets BMA scale to \(K = 41\) or \(K = 100\) where enumeration is hopeless
Initial income (GDP60), Confucian, LifeExp and EquipInv are in essentially every model, and YrsOpen and Muslim in nine out of ten; at the other end LatAmerica and RFEXDist sit near a quarter and would still turn up “significant” in some single regression
The best single model holds only 3% of the posterior probability — reporting it alone, with its standard errors, would be a serious overstatement of confidence
Posterior means are shrunk twice: by \(g/(1+g)\) within each model, and by the PIP across models — a regressor in half the models contributes half its coefficient
Maximising it over \(\mu = x'\beta\) is exactly minimising the check loss, so the posterior mode reproduces \(\hat\beta_\tau\) and the posterior supplies the uncertainty. Yu and Moyeed (2001) showed the posterior is proper even under a flat prior on \(\beta\).
This is a working likelihood: nobody believes the errors are asymmetric Laplace. The \(\tau\)-th quantile is still consistently estimated, but the posterior spread is the spread of a misspecified model, so applied work often rescales it with a sandwich correction.
Heteroskedastic by construction, so every quantile has a different slope:
so the true slope is 0.280 at \(\tau = 0.1\), 0.600 at the median and 0.920 at \(\tau = 0.9\). \(n = 300\), in ../data/bayes-quantile.csv.
Code
# This block lives in bayesian-computation-data.R, run once before rendering.set.seed(14159)n <-300x <-runif(n, 1, 10)y <-2+0.6* x + (0.3+0.25* x) *rnorm(n)write.csv(data.frame(x = x, y = y), "../data/bayes-quantile.csv",row.names =FALSE)
Code
d <-read.csv("../data/bayes-quantile.csv")x <- d$x; y <- d$y; n <-length(y)rho_tau <-function(u, tau) u * (tau - (u <0)) # the check functionfit_q <-function(tau, S =20000) { loglik <-function(p) { # p = (b0, b1, log sigma) r <- (y - p[1] - p[2] * x) /exp(p[3]) n *log(tau * (1- tau)) - n * p[3] -sum(rho_tau(r, tau)) } p <-c(mean(y), 0, log(sd(y))); lp <-loglik(p) keep <-matrix(NA, S, 3)for (t in1:S) { # random-walk Metropolis q <- p +rnorm(3, 0, c(0.12, 0.02, 0.05)) lq <-loglik(q)if (log(runif(1)) < lq - lp) { p <- q; lp <- lq } keep[t, ] <- p } keep[(S /2+1):S, ]}set.seed(14159)for (tau inc(0.1, 0.5, 0.9)) { dr <-fit_q(tau); z <-qnorm(tau)cat(sprintf("tau=%.1f b0 = %.3f (%.3f) b1 = %.3f (%.3f) true b1 = %.3f\n", tau, mean(dr[, 1]), sd(dr[, 1]), mean(dr[, 2]), sd(dr[, 2]),0.6+0.25* z))}
The three tabs are three implementations of one model and they agree: the slope rises from about 0.23 at \(\tau = 0.1\) to about 0.92 at \(\tau = 0.9\), tracking the widening conditional spread
The posterior means sit next to quantreg::rq, as they must — the ALD mode is the check-loss minimiser
Fitting each \(\tau\) separately allows the lines to cross at extreme \(x\); joint models (Bayesian simultaneous quantile regression) enforce monotonicity
A single conditional mean regression would report one slope, 0.6, and miss that the top of the distribution moves three times faster than the bottom — the whole point of quantile methods
Extends directly to quantile VARs and to Bayesian conditional value-at-risk in the risk literature
A single MCMC chain has one way to move between separated modes: cross the valley. SMC avoids the problem by starting easy. Define a path of bridging densities from the prior to the posterior,
and carry a cloud of \(N\) particles along it. At each step: reweight by the incremental likelihood \(p(y\mid\theta)^{\beta_k - \beta_{k-1}}\), resample when the effective sample size drops, then move every particle with a few MCMC steps that target \(\pi_k\).
At \(\beta = 0\) the target is the prior and the particles are everywhere; the modes are found while the landscape is still flat, and the cooling schedule keeps them populated in proportion.
The normalising constants telescope, so the marginal likelihood is free:
which is why Part IX listed SMC among the marginal-likelihood estimators. It also parallelises across particles, unlike a Markov chain.
Code
# A deliberately bimodal target: prior N(0, 5^2), two well-separated likelihood peakslogprior <-function(th) dnorm(th, 0, 5, log =TRUE)loglik <-function(th) log(0.5*dnorm(th, -3, 0.5) +0.5*dnorm(th, 3, 0.5))logZ_true <-dnorm(3, 0, sqrt(0.5^2+5^2), log =TRUE) # analyticset.seed(14159)N <-2000; K <-20beta <-seq(0, 1, length.out = K +1)th <-rnorm(N, 0, 5) # particles start from the priorlogZ <-0for (k in1:K) { lw <- (beta[k +1] - beta[k]) *loglik(th) # incremental weights logZ <- logZ +max(lw) +log(mean(exp(lw -max(lw)))) # telescoping constant w <-exp(lw -max(lw)); w <- w /sum(w) th <- th[sample(N, N, replace =TRUE, prob = w)] # resamplefor (r in1:5) { # move at temperature k prop <- th +rnorm(N, 0, 0.8) la <- (logprior(prop) + beta[k +1] *loglik(prop)) - (logprior(th) + beta[k +1] *loglik(th)) take <-log(runif(N)) < la th[take] <- prop[take] }}cat(sprintf("SMC log Z = %.3f (analytic %.3f)\n", logZ, logZ_true))cat(sprintf("SMC particles in the left mode = %.1f%%\n", 100*mean(th <0)))# one random-walk chain of comparable cost, started in the left modeset.seed(14159)S <-20000; p <--3; keep <-numeric(S); lp <-logprior(p) +loglik(p)for (t in1:S) { q <- p +rnorm(1, 0, 0.8); lq <-logprior(q) +loglik(q)if (log(runif(1)) < lq - lp) { p <- q; lp <- lq } keep[t] <- p}cat(sprintf("RW-MH draws in the left mode = %.1f%%\n", 100*mean(keep <0)))
SMC log Z = -2.737 (analytic -2.712, error -0.026)
SMC particles in the left mode = 53.3% (truth 50%)
RW-MH draws in the left mode = 100.0% (truth 50%)
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pymc as pm, pytensor.tensor as ptfrom scipy import statslogZ_true = stats.norm.logpdf(3, 0, np.sqrt(0.5**2+5**2)) # same target as Rwith pm.Model(): th = pm.Normal("th", 0, 5) # prior pm.Potential("lik", pt.log( # bimodal likelihood0.5* pt.exp(pm.logp(pm.Normal.dist(-3, 0.5), th)) +0.5* pt.exp(pm.logp(pm.Normal.dist(3, 0.5), th)))) idata = pm.sample_smc(2000, chains=2, random_seed=14159, progressbar=False)
lik
Code
lz =float(idata.sample_stats["log_marginal_likelihood"].mean())dr = idata.posterior["th"].values.ravel()out = (f"SMC log Z = {lz:.3f} (analytic {logZ_true:.3f}, error {lz-logZ_true:+.3f})\n"f"particles in the left mode = {100*np.mean(dr <0):.1f}% (truth 50%)")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
SMC log Z = -2.713 (analytic -2.712, error -0.001)
particles in the left mode = 50.6% (truth 50%)
100
The random-walk chain spends 100% of its draws in the mode it started in and reports a confident, wrong, unimodal posterior — with a perfectly healthy trace plot and \(\hat R\) from that single chain
SMC splits the particles about evenly between the modes, which is the correct answer, and both implementations recover the analytic \(\log Z\) to within a few hundredths
The cost is the tempering schedule: too few bridging densities and the weights degenerate exactly as in the particle filter of Part V; pm.sample_smc chooses \(\beta_k\) adaptively from a target ESS
Multimodality is not exotic in economics — mixture models, regime-switching, DSGE parameters with weak identification, and any likelihood with a label-switching symmetry
SMC is also the natural tool when the posterior arrives sequentially, as with streaming or nowcasting data: the particle cloud is updated, not restarted
Exercises — Extensions
Refit the hierarchical model with algorithm = "fullrank" in brms and method = "fullrank" in PyMC. Does the extra \(O(d^2)\) covariance recover the NUTS standard deviation of \(\tau\), and what does it cost in time?
Reproduce the mean-field blind-spot experiment at \(\rho \in \{0.5, 0.9, 0.99\}\) and confirm the reported standard deviation follows \(\sqrt{1-\rho^2}\) in each case. Then repeat it after rotating the parameters to their principal axes — where does the pathology go?
Run the BMA enumeration under \(g = n\), \(g = k^2\) and the “benchmark” \(g = \max(n, k^2)\) of Fernández, Ley and Steel. Plot each PIP against \(g\) and identify the regressors whose inclusion is prior-driven rather than data-driven.
Replace the uniform model prior with a Beta-binomial prior on model size that centres on 7 regressors. Show how the posterior expected model size and the top PIPs move, and explain why the uniform prior is not uninformative about size.
Extend the Python MC3 sampler with a swap move (drop one regressor and add another in the same step) and compare its mixing to the single-flip sampler using the PIP path over iterations.
Fit the quantile regression at \(\tau = 0.05\) and \(\tau = 0.95\) and check whether the fitted lines cross within the observed range of \(x\). If they do, what does that imply about the three separate ALD posteriors as a joint model?
Use SMC to estimate the marginal likelihood of the linear and quadratic models of Part IX and compare it against the bridge-sampling and Chib answers. How many particles does SMC need to match bridge sampling’s precision?
Break the SMC sampler on purpose: set \(K = 3\) bridging densities and watch the incremental weights degenerate. Report the effective sample size at each step and the resulting bias in \(\log Z\).
Exercises & Further Reading
Exercises — Estimation
Re-run the Part II random-walk Metropolis sampler with step sizes scaled by \(0.25\times\), \(1\times\) and \(4\times\). Tabulate the acceptance rate and the effective sample size of \(\beta_1\) for each, and locate the efficiency peak near the 0.234 rule.
Replace the Gibbs sampler’s Inverse-Gamma\((2,1)\) prior on \(\sigma^2\) with a Half-Cauchy\((0,1)\) prior on \(\sigma\). This conditional is no longer conjugate — implement a Metropolis-within-Gibbs step for \(\sigma\) and confirm the posterior barely moves.
Extend the HMC sampler to a logistic regression (no conjugacy, no closed form). Derive the gradient of the log posterior and compare HMC’s ESS against random-walk Metropolis on the same model.
In the local-level Kalman example, add a Metropolis–Hastings loop over \((\sigma_\eta, \sigma_\varepsilon)\) using the filter likelihood, and report the posterior signal-to-noise ratio \(\sigma_\eta/\sigma_\varepsilon\).
Refit the BVAR at shrinkage \(\lambda\in\{0.05, 0.2, 1.0\}\). Plot the own-lag posterior means and the width of the IRF credible band against \(\lambda\); explain the bias–variance trade-off you see.
In the hierarchical model, shrink the group size to \(n_j = 3\) and grow it to \(n_j = 50\). Show that the amount of shrinkage toward the grand mean falls as \(n_j\) rises, matching the precision-weight formula.
For the DSGE toy, profile the Kalman log-likelihood over a grid in \(\rho\) with \(\sigma\) fixed at its truth. Overlay the Beta\((5,2)\) prior and the resulting posterior to see how the prior sharpens a flat likelihood region near \(\rho \to 1\).
Exercises — Diagnostics & Priors
Run four MH chains from over-dispersed starts on the linear model and compute the Gelman–Rubin \(\hat R\) for each parameter. How many iterations are needed before all \(\hat R < 1.01\)?
Build a prior-predictive check for the linear model: draw \(\theta\) from the prior, simulate \(\tilde y\), and compare its range to the observed \(y\). Show that \(\beta\sim\mathcal{N}(0,100^2)\) generates implausible datasets while \(\mathcal{N}(0,2.5^2)\) does not.
Compute the Monte Carlo standard error of the posterior mean of \(\beta_1\) from the MH chain using its effective sample size, and verify it shrinks at rate \(1/\sqrt{\text{ESS}}\) as you lengthen the chain.
For the particle filter, vary \(N\in\{200, 1000, 5000\}\) and plot the variance of the estimated log-likelihood across 50 independent runs. Confirm the \(1/N\) decay that governs particle-MCMC tuning.
Repeat the prior-sensitivity study but shrink the sample to \(n = 15\). Show that with weak data the posterior now moves substantially with the prior — a diagnostic that the parameter is poorly identified.
Implement thinning on the RW-MH chain (keep every 10th draw). Show it barely changes posterior estimates but reduces storage, and discuss why thinning wastes information relative to keeping all draws.
Compare your hand-coded Gibbs posterior for the hierarchical model against lme4::lmer REML point estimates and against brms/Stan HMC. Do the three agree on \(\hat\beta\), \(\hat\tau\) and the shrunken intercepts?
Exercises — State Space & Macro
Check the FFBS sampler the way a simulation smoother should be checked: draw 2,000 state paths, average them pointwise, and confirm the average reproduces the Kalman smoothed mean. Then compare the pointwise variance of the draws against the smoothed variance \(P_{t\mid T}\) — the two must agree, and the smoother alone gives you only the first.
In the PMMH sampler, cut the particle count to \(N = 50\) and raise it to \(N = 2000\). Plot the acceptance rate and the longest run of repeated draws against \(N\), and relate the sticking you see to the variance of the estimated log-likelihood.
Feed the TVP-VAR sampler data generated with constant coefficients. Does it report spurious drift? Tighten the prior on the state innovation variance until the estimated paths flatten, and say what that implies about reading time variation off a TVP model.
Vary the SSVS spike and slab scales over a grid and plot each PIP against the ratio \(c = \tau_{\text{slab}}/\tau_{\text{spike}}\). Where do the SSVS and horseshoe answers part company, and which regressors are sensitive to the choice?
Re-estimate the hierarchical Minnesota BVAR with \(\lambda\) fixed at the posterior mean instead of sampled. Compare the IRF credible bands: how much of the width came from uncertainty about the shrinkage itself?
Split the capstone sample at 1984Q1 and estimate the BVAR separately on each half. Compare the monetary-policy IRFs across the Great Moderation break, and state honestly whether the credible bands are narrow enough to support the comparison.
Extend the forecast comparison to horizons \(h = 1, \dots, 8\) and score the BVAR against a random walk at each. At which horizon does the Minnesota prior stop paying for itself, and does the ranking change if you score the joint density instead of the marginals?
Metropolis, N., Rosenbluth, A. W., Rosenbluth, M. N., Teller, A. H., & Teller, E. (1953). Equation of state calculations by fast computing machines. Journal of Chemical Physics, 21(6), 1087–1092. DOI: 10.1063/1.1699114
Hastings, W. K. (1970). Monte Carlo sampling methods using Markov chains. Biometrika, 57(1), 97–109. DOI: 10.1093/biomet/57.1.97
Geman, S., & Geman, D. (1984). Stochastic relaxation, Gibbs distributions, and the Bayesian restoration of images. IEEE PAMI, 6(6), 721–741. DOI: 10.1109/TPAMI.1984.4767596
Gelfand, A. E., & Smith, A. F. M. (1990). Sampling-based approaches to calculating marginal densities. JASA, 85(410), 398–409. DOI: 10.1080/01621459.1990.10476213
Chib, S., & Greenberg, E. (1995). Understanding the Metropolis–Hastings algorithm. The American Statistician, 49(4), 327–335. DOI: 10.1080/00031305.1995.10476177
Albert, J. H., & Chib, S. (1993). Bayesian analysis of binary and polychotomous response data. JASA, 88(422), 669–679. DOI: 10.1080/01621459.1993.10476321
Gelman, A., et al. (2013). Bayesian Data Analysis, 3rd ed. CRC Press. Book page
Duane, S., Kennedy, A. D., Pendleton, B. J., & Roweth, D. (1987). Hybrid Monte Carlo. Physics Letters B, 195(2), 216–222. DOI: 10.1016/0370-2693(87)91197-X
Neal, R. M. (2011). MCMC using Hamiltonian dynamics. Handbook of MCMC. arXiv:1206.1901
Hoffman, M. D., & Gelman, A. (2014). The No-U-Turn Sampler. JMLR, 15, 1593–1623. Article
Neal, R. M. (2003). Slice sampling. Annals of Statistics, 31(3), 705–767 — where the funnel is introduced. DOI: 10.1214/aos/1056562461
Betancourt, M., & Girolami, M. (2015). Hamiltonian Monte Carlo for hierarchical models. In Current Trends in Bayesian Methodology. arXiv:1312.0906
Carpenter, B., et al. (2017). Stan: A probabilistic programming language. Journal of Statistical Software, 76(1). DOI: 10.18637/jss.v076.i01
Kalman, R. E. (1960). A new approach to linear filtering and prediction problems. J. Basic Engineering, 82(1), 35–45. DOI: 10.1115/1.3662552
Gordon, N. J., Salmond, D. J., & Smith, A. F. M. (1993). Novel approach to nonlinear/non-Gaussian Bayesian state estimation. IEE Proc. F, 140(2), 107–113. DOI: 10.1049/ip-f-2.1993.0015
Carter, C. K., & Kohn, R. (1994). On Gibbs sampling for state space models. Biometrika, 81(3), 541–553 — the FFBS recursion. DOI: 10.1093/biomet/81.3.541
Frühwirth-Schnatter, S. (1994). Data augmentation and dynamic linear models. Journal of Time Series Analysis, 15(2), 183–202. DOI: 10.1111/j.1467-9892.1994.tb00184.x
Kim, S., Shephard, N., & Chib, S. (1998). Stochastic volatility: likelihood inference and comparison with ARCH models. Review of Economic Studies, 65(3), 361–393 — the mixture sampler. DOI: 10.1111/1467-937X.00050
Andrieu, C., Doucet, A., & Holenstein, R. (2010). Particle Markov chain Monte Carlo methods. JRSS-B, 72(3), 269–342 — PMMH. DOI: 10.1111/j.1467-9868.2009.00736.x
Doan, T., Litterman, R., & Sims, C. (1984). Forecasting and conditional projection using realistic prior distributions. Econometric Reviews, 3(1), 1–100. DOI: 10.1080/07474938408800053
Sims, C. A., & Zha, T. (1998). Bayesian methods for dynamic multivariate models. International Economic Review, 39(4), 949–968. DOI: 10.2307/2527347
Bańbura, M., Giannone, D., & Reichlin, L. (2010). Large Bayesian vector auto regressions. Journal of Applied Econometrics, 25(1), 71–92. DOI: 10.1002/jae.1137
Primiceri, G. E. (2005). Time varying structural vector autoregressions and monetary policy. Review of Economic Studies, 72(3), 821–852. DOI: 10.1111/j.1467-937X.2005.00353.x
Giannone, D., Lenza, M., & Primiceri, G. E. (2015). Prior selection for vector autoregressions. Review of Economics and Statistics, 97(2), 436–451 — the hierarchical Minnesota prior. DOI: 10.1162/REST_a_00483
George, E. I., & McCulloch, R. E. (1993). Variable selection via Gibbs sampling. JASA, 88(423), 881–889 — spike-and-slab / SSVS. DOI: 10.1080/01621459.1993.10476353
Carvalho, C. M., Polson, N. G., & Scott, J. G. (2010). The horseshoe estimator for sparse signals. Biometrika, 97(2), 465–480. DOI: 10.1093/biomet/asq017
Woźniak, T. (2024). Fast and efficient Bayesian analysis of structural vector autoregressions using the R package bsvars. University of Melbourne Working Paper. arXiv:2410.15090 · package + vignette
Lütkepohl, H., Shang, F., Uzeda, L., & Woźniak, T. (2024). Partial identification of heteroskedastic structural VARs: theory and Bayesian inference. University of Melbourne Working Paper. arXiv:2404.11057
An, S., & Schorfheide, F. (2007). Bayesian analysis of DSGE models. Econometric Reviews, 26(2–4), 113–172. DOI: 10.1080/07474930701220071
Fernández-Villaverde, J., & Rubio-Ramírez, J. F. (2007). Estimating macroeconomic models: a likelihood approach. Review of Economic Studies, 74(4), 1059–1087. DOI: 10.1111/j.1467-937X.2007.00437.x
Smets, F., & Wouters, R. (2007). Shocks and frictions in US business cycles. American Economic Review, 97(3), 586–606. DOI: 10.1257/aer.97.3.586
Herbst, E. P., & Schorfheide, F. (2016). Bayesian Estimation of DSGE Models. Princeton University Press. Publisher page
Newton, M. A., & Raftery, A. E. (1994). Approximate Bayesian inference with the weighted likelihood bootstrap. JRSS-B, 56(1), 3–48 — the harmonic-mean estimator, and the discussion that buried it. DOI: 10.1111/j.2517-6161.1994.tb01956.x
Meng, X.-L., & Wong, W. H. (1996). Simulating ratios of normalizing constants via a simple identity. Statistica Sinica, 6(4), 831–860 — the bridge-sampling identity. Article
Gronau, Q. F., et al. (2017). A tutorial on bridge sampling. Journal of Mathematical Psychology, 81, 80–97. DOI: 10.1016/j.jmp.2017.09.005
Watanabe, S. (2010). Asymptotic equivalence of Bayes cross validation and widely applicable information criterion. JMLR, 11, 3571–3594. Article
Vehtari, A., Gelman, A., & Gabry, J. (2017). Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC. Statistics and Computing, 27(5), 1413–1432. DOI: 10.1007/s11222-016-9696-4
Gelman, A., Meng, X.-L., & Stern, H. (1996). Posterior predictive assessment of model fitness via realized discrepancies. Statistica Sinica, 6(4), 733–760. Article
Gneiting, T., & Raftery, A. E. (2007). Strictly proper scoring rules, prediction, and estimation. JASA, 102(477), 359–378. DOI: 10.1198/016214506000001437
Jordan, M. I., Ghahramani, Z., Jaakkola, T. S., & Saul, L. K. (1999). An introduction to variational methods for graphical models. Machine Learning, 37(2), 183–233. DOI: 10.1023/A:1007665907178
Blei, D. M., Kucukelbir, A., & McAuliffe, J. D. (2017). Variational inference: a review for statisticians. JASA, 112(518), 859–877. DOI: 10.1080/01621459.2017.1285773
Kucukelbir, A., Tran, D., Ranganath, R., Gelman, A., & Blei, D. M. (2017). Automatic differentiation variational inference. JMLR, 18(14), 1–45 — the ADVI behind algorithm = "meanfield" and pm.fit(). Article
Yao, Y., Vehtari, A., Simpson, D., & Gelman, A. (2018). Yes, but did it work?: evaluating variational inference. ICML, 80, 5581–5590 — the Pareto \(\hat k\) diagnostic. arXiv:1802.02538
Raftery, A. E., Madigan, D., & Hoeting, J. A. (1997). Bayesian model averaging for linear regression models. JASA, 92(437), 179–191. DOI: 10.1080/01621459.1997.10473615
Fernández, C., Ley, E., & Steel, M. F. J. (2001). Model uncertainty in cross-country growth regressions. Journal of Applied Econometrics, 16(5), 563–576 — the growth dataset used here. DOI: 10.1002/jae.623
Sala-i-Martin, X., Doppelhofer, G., & Miller, R. I. (2004). Determinants of long-term growth: a Bayesian averaging of classical estimates (BACE) approach. American Economic Review, 94(4), 813–835. DOI: 10.1257/0002828042002570
Zeugner, S., & Feldkircher, M. (2015). Bayesian model averaging employing fixed and flexible priors: the BMS package for R. Journal of Statistical Software, 68(4). DOI: 10.18637/jss.v068.i04
Koenker, R., & Bassett, G. (1978). Regression quantiles. Econometrica, 46(1), 33–50 — the check function. DOI: 10.2307/1913643
Yu, K., & Moyeed, R. A. (2001). Bayesian quantile regression. Statistics & Probability Letters, 54(4), 437–447 — the asymmetric-Laplace working likelihood. DOI: 10.1016/S0167-7152(01)00124-9
Kozumi, H., & Kobayashi, G. (2011). Gibbs sampling methods for Bayesian quantile regression. Journal of Statistical Computation and Simulation, 81(11), 1565–1578 — the scale-mixture representation that replaces Metropolis with Gibbs. DOI: 10.1080/00949655.2010.496117
Chopin, N. (2002). A sequential particle filter method for static models. Biometrika, 89(3), 539–551. DOI: 10.1093/biomet/89.3.539
Herbst, E., & Schorfheide, F. (2014). Sequential Monte Carlo sampling for DSGE models. Journal of Applied Econometrics, 29(7), 1073–1098. DOI: 10.1002/jae.2397
Thank You
Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab Department of Economics University of Ioannina, Greece