Advanced Expert 25 min read

Chapter 38: Bayesian Inference

Bayesian inference offers a fundamentally different philosophy for reasoning under uncertainty than the frequentist methods that dominate introductory statistics courses. Where a frequentist treats parameters as fixed but unknown constants and defines probability through long-run frequencies of repeated experiments, a Bayesian treats parameters as random variables with probability distributions that encode degrees of belief. This shift in perspective is not merely philosophical — it produces genuinely different machinery for inference, and in many practical problems it produces better-calibrated answers, more natural uncertainty quantification, and a principled mechanism for incorporating domain knowledge.

The core of Bayesian reasoning is Bayes' theorem, a mathematical identity so simple it fits on a coffee mug, yet powerful enough to unify prediction, model comparison, and decision theory under one framework. Starting from a prior distribution that encodes what you know (or believe) before seeing data, you update with the likelihood — the probability of observing your data given each possible parameter value — to obtain a posterior distribution. The posterior is your complete, calibrated uncertainty about the parameter after observing the data. Every downstream inference task — point estimates, interval estimates, predictions, model comparison — flows from the posterior.

In practice, computing posteriors analytically is only possible for a small family of conjugate prior-likelihood pairs. The explosion of Bayesian applications in the last three decades is largely due to Markov Chain Monte Carlo (MCMC) algorithms, which turn posterior sampling into a numerical problem solvable on modern hardware. Probabilistic programming libraries like PyMC and Stan further democratized Bayesian methods by letting practitioners specify models in high-level code and delegating the sampling machinery to the library. This chapter builds from first principles through conjugate models, MCMC intuition, and hands-on PyMC workflows, equipping you to apply and interpret Bayesian inference on real problems.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)
Libraries covered: MatplotlibNumPyPandasPyMCSciPy

Learning Objectives

  • Derive and interpret Bayes' theorem in terms of prior, likelihood, and posterior, and explain the Bayesian vs. frequentist distinction concretely.
  • Apply conjugate prior-likelihood pairs (Beta-Binomial, Normal-Normal, Gamma-Poisson) to compute closed-form posteriors.
  • Construct and interpret Bayesian credible intervals and contrast them point-by-point with frequentist confidence intervals.
  • Explain the mechanics of Markov Chain Monte Carlo (MCMC), including the Metropolis-Hastings algorithm and the No-U-Turn Sampler (NUTS), and diagnose chain convergence using <!--MATHBLOCK0--> and effective sample size.
  • Build, fit, and interpret Bayesian models in PyMC, including prior predictive checks, posterior sampling, and posterior predictive checks.
  • Choose and justify priors — from non-informative reference priors to strongly informative domain-knowledge priors — and understand their influence on posteriors at varying data sizes.
  • Compare Bayesian and frequentist approaches to common inference problems (proportion estimation, A/B testing, linear regression) and articulate when each is preferable.

38.1 Bayes' Theorem: Foundations and Philosophy Advanced

The Mechanics of Belief Updating

Bayes' theorem is a direct consequence of the definition of conditional probability. If $A$ and $B$ are events with $P(B) > 0$:

$$ P(A \mid B) = \frac{P(B \mid A)\, P(A)}{P(B)} $$

In a statistical inference context, replace $A$ with $\theta$ (a parameter or hypothesis) and $B$ with $\mathcal{D}$ (observed data):

$$ P(\theta \mid \mathcal{D}) = \frac{P(\mathcal{D} \mid \theta)\, P(\theta)}{P(\mathcal{D})} $$

Each term has a name and a role:

  • Prior <!--MATHBLOCK14-->: your distribution over <!--MATHBLOCK15--> before seeing data. Encodes domain knowledge, physical constraints, or deliberate ignorance.
  • Likelihood <!--MATHBLOCK16-->: the probability of the observed data as a function of <!--MATHBLOCK17-->. This is identical to the likelihood used in maximum likelihood estimation (MLE).
  • Posterior <!--MATHBLOCK18-->: your updated distribution over <!--MATHBLOCK19--> after observing data. This is the inferential target.
  • Marginal likelihood (evidence) <!--MATHBLOCK20-->: a normalizing constant ensuring the posterior integrates to one. It is crucial for model comparison but irrelevant for single-model inference, so we often write the unnormalized form:

$$ P(\theta \mid \mathcal{D}) \propto P(\mathcal{D} \mid \theta)\, P(\theta) $$

A Worked Medical Example

Consider a disease that affects 1% of the population. A diagnostic test has 90% sensitivity (true positive rate) and 95% specificity (true negative rate). You test positive. What is the probability you have the disease?

Let $D$ = "has disease", $T^+$ = "tests positive".

$$P(D \mid T^+) = \frac{P(T^+ \mid D)\, P(D)}{P(T^+)}$$

$$P(T^+) = P(T^+ \mid D)P(D) + P(T^+ \mid \neg D)P(\neg D)$$
$$= 0.90 \times 0.01 + 0.05 \times 0.99 = 0.009 + 0.0495 = 0.0585$$

$$P(D \mid T^+) = \frac{0.90 \times 0.01}{0.0585} \approx 0.154$$

Despite a highly accurate test, a positive result only raises your probability from 1% to about 15%. This result — often called the base rate fallacy when ignored — is the canonical demonstration of why prior information matters profoundly. Frequentist hypothesis testing has no direct mechanism to incorporate this base rate.

PYTHON
# Bayesian updating for the medical screening example
prior_disease = 0.01
sensitivity = 0.90       # P(T+ | D)
specificity = 0.95       # P(T- | not D)
false_positive_rate = 1 - specificity

# P(T+)
p_positive = sensitivity * prior_disease + false_positive_rate * (1 - prior_disease)

# Posterior via Bayes
posterior = (sensitivity * prior_disease) / p_positive
print(f"Prior P(disease)     : {prior_disease:.3f}")
print(f"P(T+)                : {p_positive:.4f}")
print(f"Posterior P(D | T+)  : {posterior:.3f}")

Frequentist vs. Bayesian: The Core Distinction

The philosophical divide comes down to the interpretation of probability itself.

A frequentist defines probability as the limiting relative frequency of outcomes over infinitely many independent repetitions of an experiment. Parameters are fixed constants — asking for "the probability that $\mu = 5$" is meaningless because $\mu$ either is or is not 5. Inference is about constructing procedures (estimators, tests, confidence intervals) with guaranteed long-run properties.

A Bayesian defines probability as a measure of rational belief or uncertainty, applicable to any proposition including parameter values. Probabilities are personal in the sense that they encode a rational agent's state of knowledge given available information. Inference is about coherently updating beliefs via Bayes' theorem.

In practice, the differences surface in:

  • Interval estimation: A 95% frequentist confidence interval is a random interval with the property that 95% of all such intervals constructed from repeated samples would contain the true parameter. A single interval either contains the parameter or it does not — no probability attaches to the individual interval. A 95% Bayesian credible interval directly states that, given the data and prior, there is a 95% posterior probability the parameter lies in that interval. The Bayesian statement is what most users mistakenly believe about confidence intervals.
  • Sample size and stopping rules: Bayesian inference is valid regardless of when you stop collecting data. Frequentist <!--MATHBLOCK25-->-values are distorted by optional stopping, which creates practical problems in sequential experiments.
  • Incorporating prior knowledge: Bayesian priors provide a formal channel for domain expertise. Frequentist methods have no analogous mechanism (though regularization methods like ridge regression have implicit Bayesian interpretations).

Code Examples

Sequential Bayesian Updating: Coin Flip

Demonstrates how the posterior distribution evolves as more coin flips are observed, showing the prior's diminishing influence as data accumulates.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import beta

np.random.seed(42)
true_p = 0.65  # True probability of heads
flips = np.random.binomial(1, true_p, size=200)

# Prior: Beta(2, 2) — mild belief in fairness
alpha_prior, beta_prior = 2, 2

fig, axes = plt.subplots(2, 3, figsize=(14, 8))
theta = np.linspace(0, 1, 500)

checkpoints = [0, 5, 20, 50, 100, 200]
for ax, n in zip(axes.flat, checkpoints):
    heads = flips[:n].sum() if n > 0 else 0
    tails = n - heads
    alpha_post = alpha_prior + heads
    beta_post  = beta_prior + tails
    rv = beta(alpha_post, beta_post)
    ax.plot(theta, rv.pdf(theta), 'b-', lw=2, label=f'Posterior')
    ax.axvline(true_p, color='red', linestyle='--', label='True p')
    ax.set_title(f'n={n}, heads={heads}')
    ax.set_xlabel('θ')
    ax.set_ylabel('Density')
    ax.legend(fontsize=8)

plt.suptitle('Sequential Bayesian Updating: Beta-Binomial', fontsize=13)
plt.tight_layout()
plt.savefig('sequential_updating.png', dpi=120)
plt.show()
print("Posterior mean after 200 flips:",
      (alpha_prior + flips.sum()) / (alpha_prior + beta_prior + len(flips)))
Output
Posterior mean after 200 flips: 0.6504...

Comparing Frequentist and Bayesian Intervals

Side-by-side comparison of a 95% confidence interval (frequentist) and a 95% credible interval (Bayesian) for a proportion, highlighting the interpretational difference.

PYTHON
import numpy as np
from scipy.stats import beta, norm

np.random.seed(0)
n, k = 50, 31  # 31 successes in 50 trials

# --- Frequentist: Wald confidence interval ---
p_hat = k / n
se = np.sqrt(p_hat * (1 - p_hat) / n)
z = 1.96
ci_low  = p_hat - z * se
ci_high = p_hat + z * se

# --- Bayesian: Beta posterior with uniform prior Beta(1,1) ---
alpha_post = 1 + k
beta_post  = 1 + (n - k)
rv = beta(alpha_post, beta_post)
cred_low, cred_high = rv.ppf(0.025), rv.ppf(0.975)

print("=== Frequentist 95% Confidence Interval ===")
print(f"  Estimate : {p_hat:.3f}")
print(f"  CI       : [{ci_low:.3f}, {ci_high:.3f}]")
print("  Interpretation: 95% of all such CIs would contain the true p.")

print("\n=== Bayesian 95% Credible Interval (Uniform Prior) ===")
print(f"  Posterior mean : {rv.mean():.3f}")
print(f"  CrI            : [{cred_low:.3f}, {cred_high:.3f}]")
print("  Interpretation: P(p in [low, high] | data) = 0.95")
Output
=== Frequentist 95% Confidence Interval ===
  Estimate : 0.620
  CI       : [0.486, 0.754]
  Interpretation: 95% of all such CIs would contain the true p.

=== Bayesian 95% Credible Interval (Uniform Prior) ===
  Posterior mean : 0.615
  CrI            : [0.477, 0.745]
  Interpretation: P(p in [low, high] | data) = 0.95

38.2 Priors, Likelihoods, and Conjugate Families Advanced

Choosing a Prior

The prior distribution $P(\theta)$ is the most distinctive and debated element of Bayesian inference. Critics argue it introduces subjectivity; practitioners counter that all statistical analyses embed assumptions, and Bayesian priors make those assumptions explicit and auditable. In practice, prior choice is guided by three considerations: (1) domain knowledge about plausible parameter ranges, (2) mathematical convenience, and (3) theoretical desiderata like invariance and non-informativeness.

Types of Priors

Informative priors encode substantive knowledge. If you are estimating the effect of a drug that is chemically similar to others with effect sizes of 0.1–0.3 standard deviations, a prior $\mathcal{N}(0.2, 0.05^2)$ is defensible and will shrink small-sample estimates toward the historically sensible region.

Weakly informative priors (the workhorse of applied Bayesian analysis) provide gentle regularization without strong claims. For a logistic regression coefficient, a $\mathcal{N}(0, 1)$ prior on the log-odds scale says effect sizes much beyond ±2 are unlikely — reasonable for most real-world problems without being dogmatic.

Non-informative / reference priors attempt to express ignorance. The uniform prior $P(\theta) \propto 1$ is the simplest, but is problematic because uniformity is not invariant under reparameterization (a uniform prior on $\sigma$ implies a non-uniform prior on $\sigma^2$). Jeffreys prior $P(\theta) \propto \sqrt{I(\theta)}$, where $I(\theta)$ is the Fisher information, is invariant under reparameterization and is the standard non-informative choice.

Conjugate Priors

A prior is conjugate to a likelihood if the posterior belongs to the same distributional family as the prior. Conjugacy yields closed-form posterior updates — no numerical integration required — making it invaluable for pen-and-paper analysis, fast computation, and building intuition.

Beta-Binomial (Proportion Estimation)

The canonical conjugate pair. If $X \mid \theta \sim \text{Binomial}(n, \theta)$ and $\theta \sim \text{Beta}(\alpha, \beta)$, then:

$$ \theta \mid X = k \sim \text{Beta}(\alpha + k,\; \beta + n - k) $$

The parameters $\alpha$ and $\beta$ have a natural interpretation as pseudo-counts: $\alpha - 1$ prior successes and $\beta - 1$ prior failures. The posterior mean is:

$$ E[\theta \mid k] = \frac{\alpha + k}{\alpha + \beta + n} $$

This is a weighted average between the prior mean $\alpha/(\alpha+\beta)$ and the MLE $k/n$, with the prior's weight proportional to $\alpha + \beta$ (the prior's effective sample size). As $n \to \infty$, the MLE dominates — the prior is washed out by data.

PYTHON
from scipy.stats import beta as beta_dist
import numpy as np

# Three priors: uniform, skeptical (low base rate), optimistic
priors = [
    ("Uniform Beta(1,1)",     1,  1),
    ("Skeptical Beta(2,8)",   2,  8),
    ("Optimistic Beta(8,2)",  8,  2),
]

# Observed data: 6 successes out of 10 trials
k, n = 6, 10

print(f"{'Prior':<25} {'Post Mean':>10} {'95% CrI':>22}")
print("-" * 60)
for name, a, b in priors:
    a_post = a + k
    b_post = b + (n - k)
    rv = beta_dist(a_post, b_post)
    lo, hi = rv.ppf(0.025), rv.ppf(0.975)
    print(f"{name:<25} {rv.mean():>10.3f}   [{lo:.3f}, {hi:.3f}]")

Normal-Normal (Mean Estimation with Known Variance)

If $X_1, \ldots, X_n \mid \mu \sim \mathcal{N}(\mu, \sigma^2)$ with known $\sigma^2$, and $\mu \sim \mathcal{N}(\mu_0, \tau_0^2)$, then:

$$ \mu \mid \mathbf{x} \sim \mathcal{N}\left(\mu_n,\; \tau_n^2\right) $$

where the precision-weighted posterior mean and variance are:

$$ \frac{1}{\tau_n^2} = \frac{1}{\tau_0^2} + \frac{n}{\sigma^2}, \qquad \mu_n = \tau_n^2 \left(\frac{\mu_0}{\tau_0^2} + \frac{n\bar{x}}{\sigma^2}\right) $$

Again, the posterior mean is a precision-weighted average of the prior mean and the sample mean. High prior precision (small $\tau_0^2$) anchors the estimate near the prior; high data precision (large $n/\sigma^2$) pulls it toward $\bar{x}$.

Gamma-Poisson (Rate Estimation)

If $X \mid \lambda \sim \text{Poisson}(\lambda)$ and $\lambda \sim \text{Gamma}(\alpha, \beta)$ (shape-rate parameterization), then:

$$ \lambda \mid \mathbf{x} \sim \text{Gamma}\left(\alpha + \sum x_i,\; \beta + n\right) $$

The rate parameter $\beta$ acts as the prior's "exposure" — it is the pseudo-count of time intervals observed before any data.

Common Pitfalls in Prior Specification

  • Diffuse priors on constrained parameters: A <!--MATHBLOCK32--> prior on a standard deviation seems vague, but since <!--MATHBLOCK33-->, half the prior mass is useless. Use half-Normal or Exponential priors for positive quantities.
  • Prior-data conflict: If the data strongly contradicts the prior, the posterior can be bimodal or behave unexpectedly. Always run a prior predictive check — simulate data from the prior and verify it covers the plausible range of observations.
  • Ignoring parameterization: A uniform prior on <!--MATHBLOCK34--> is a <!--MATHBLOCK35--> (Jeffreys) prior on <!--MATHBLOCK36--> itself. Know which scale you are placing your prior on.

Code Examples

Conjugate Updates for All Three Families

Implements the three main conjugate update rules (Beta-Binomial, Normal-Normal, Gamma-Poisson) as functions and compares them on synthetic data.

PYTHON
import numpy as np
from scipy.stats import beta, norm, gamma

# ---- 1. Beta-Binomial ----
def beta_binomial_posterior(alpha, beta_param, k, n):
    return alpha + k, beta_param + (n - k)

alpha0, beta0 = 2, 2
k, n_obs = 14, 20
a_p, b_p = beta_binomial_posterior(alpha0, beta0, k, n_obs)
rv_bb = beta(a_p, b_p)
print("Beta-Binomial posterior:")
print(f"  Beta({a_p}, {b_p})  mean={rv_bb.mean():.3f}  std={rv_bb.std():.3f}")

# ---- 2. Normal-Normal (known sigma) ----
def normal_normal_posterior(mu0, tau0_sq, sigma_sq, data):
    n = len(data)
    precision_post = 1/tau0_sq + n/sigma_sq
    tau_n_sq = 1 / precision_post
    mu_n = tau_n_sq * (mu0/tau0_sq + n*data.mean()/sigma_sq)
    return mu_n, tau_n_sq

np.random.seed(1)
data = np.random.normal(loc=3.5, scale=2.0, size=30)
mu_n, tau_n_sq = normal_normal_posterior(0, 10, 4.0, data)
print("\nNormal-Normal posterior:")
print(f"  N({mu_n:.3f}, {tau_n_sq:.4f})  95% CrI: "
      f"[{mu_n - 1.96*tau_n_sq**0.5:.3f}, {mu_n + 1.96*tau_n_sq**0.5:.3f}]")

# ---- 3. Gamma-Poisson ----
def gamma_poisson_posterior(alpha, beta_rate, counts):
    return alpha + counts.sum(), beta_rate + len(counts)

counts = np.array([3, 5, 2, 4, 6, 3, 7, 4])
alpha_p, beta_p = gamma_poisson_posterior(1, 1, counts)
rv_gp = gamma(a=alpha_p, scale=1/beta_p)
print("\nGamma-Poisson posterior:")
print(f"  Gamma({alpha_p}, rate={beta_p})  mean={rv_gp.mean():.3f}")
Output
Beta-Binomial posterior:
  Beta(16, 8)  mean=0.667  std=0.101

Normal-Normal posterior:
  N(3.328, 0.1232)  95% CrI: [2.639, 4.017]

Gamma-Poisson posterior:
  Gamma(35, rate=9)  mean=3.889

Prior Predictive Check

Shows how to simulate data from the prior to verify the prior is sensible before observing any data — a crucial diagnostic step in any Bayesian workflow.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import beta, binom

# Prior: Beta(2, 2) for a conversion rate
alpha_prior, beta_prior = 2, 2
n_trials = 100  # size of upcoming experiment

rng = np.random.default_rng(42)
theta_samples = rng.beta(alpha_prior, beta_prior, size=5000)
y_predictive = rng.binomial(n=n_trials, p=theta_samples)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.hist(theta_samples, bins=40, edgecolor='k', alpha=0.7, color='steelblue')
ax1.set(title='Prior: Beta(2,2)', xlabel='θ (conversion rate)', ylabel='Count')

ax2.hist(y_predictive, bins=40, edgecolor='k', alpha=0.7, color='coral')
ax2.set(title=f'Prior Predictive (n={n_trials})',
        xlabel='Number of conversions', ylabel='Count')
ax2.axvline(y_predictive.mean(), color='k', linestyle='--',
            label=f'Mean={y_predictive.mean():.1f}')
ax2.legend()

plt.tight_layout()
plt.savefig('prior_predictive.png', dpi=120)
print(f"Prior predictive mean conversions: {y_predictive.mean():.1f}")
print(f"Prior predictive 90% interval: [{np.percentile(y_predictive,5):.0f}, "
      f"{np.percentile(y_predictive,95):.0f}]")
Output
Prior predictive mean conversions: 50.0
Prior predictive 90% interval: [30, 70]

38.3 Credible Intervals and Posterior Summaries Advanced

From Posterior Distribution to Actionable Summaries

The posterior distribution $P(\theta \mid \mathcal{D})$ is a complete description of uncertainty about $\theta$ after observing data. In practice, you often need to reduce this distribution to a few interpretable numbers. This section covers the main posterior summaries and their uses.

Point Estimates

Three natural point estimates emerge from the posterior:

  • Posterior mean <!--MATHBLOCK4-->: minimizes expected squared error loss. The workhorse for continuous parameters.
  • Posterior median: minimizes expected absolute error loss. More robust to skewed posteriors and heavy tails.
  • Maximum a posteriori (MAP) estimate <!--MATHBLOCK5-->: the posterior mode. Equivalent to regularized MLE (e.g., ridge regression uses a Gaussian prior implicitly). Not invariant under reparameterization, and can be highly misleading for multimodal or skewed posteriors — the mode of a distribution is not generally representative of it.

For symmetric unimodal posteriors (like a well-identified Gaussian), all three coincide. For skewed posteriors (common with small samples, bounded parameters, or hierarchical models), they can differ substantially.

Types of Credible Intervals

A credible interval (CrI) $[L, U]$ satisfies:

$$ P(L \leq \theta \leq U \mid \mathcal{D}) = 1 - \alpha $$

Unlike a frequentist confidence interval, this statement has a direct probability interpretation conditional on the observed data. Two common constructions:

Equal-Tailed Interval (ETI)

Place $\alpha/2$ probability in each tail. For a 95% ETI, compute the 2.5th and 97.5th percentiles of the posterior. This is easy to compute from MCMC samples:

PYTHON
import numpy as np
from scipy.stats import beta as beta_dist

# Posterior: Beta(16, 8) from Beta-Binomial update
rv = beta_dist(16, 8)

# 95% Equal-Tailed Interval
eti_low, eti_high = rv.ppf(0.025), rv.ppf(0.975)
print(f"95% ETI: [{eti_low:.3f}, {eti_high:.3f}]")
print(f"Width: {eti_high - eti_low:.3f}")

Highest Density Interval (HDI)

The HDI (sometimes called HPD — Highest Posterior Density) is the shortest interval containing the specified probability mass. For a unimodal posterior, it is the interval $[L, U]$ such that every point inside has higher posterior density than every point outside:

$$ P(\theta \in [L, U] \mid \mathcal{D}) = 1 - \alpha \quad \text{and} \quad p(L \mid \mathcal{D}) = p(U \mid \mathcal{D}) $$

For symmetric posteriors, ETI and HDI coincide. For skewed posteriors, the HDI is shorter and better captures where the parameter is most plausibly located. The ArviZ library provides az.hdi() which computes HDIs directly from MCMC samples.

When the Distinction Matters

Consider an exponentially distributed posterior (common for rate parameters near zero). The ETI includes a lower tail far from the bulk of the distribution, while the HDI correctly identifies that values near the mode are most credible. For reporting to practitioners, the HDI is usually the more honest summary.

PYTHON
import numpy as np
import arviz as az
from scipy.stats import expon

# Simulate samples from a skewed posterior (e.g., Gamma(2, rate=5))
rng = np.random.default_rng(7)
samples = rng.gamma(shape=2, scale=1/5, size=50000)

# Equal-tailed interval
eti = np.percentile(samples, [2.5, 97.5])

# HDI via ArviZ
hdi = az.hdi(samples, hdi_prob=0.95)

print(f"Posterior mean  : {samples.mean():.4f}")
print(f"Posterior median: {np.median(samples):.4f}")
print(f"95% ETI         : [{eti[0]:.4f}, {eti[1]:.4f}]  width={eti[1]-eti[0]:.4f}")
print(f"95% HDI         : [{hdi[0]:.4f}, {hdi[1]:.4f}]  width={hdi[1]-hdi[0]:.4f}")

Posterior Probability of Hypotheses

One of the most practically useful features of Bayesian inference is the ability to directly compute the probability that a hypothesis is true given the data. This requires no notion of $p$-values or null hypothesis significance testing.

For example, in an A/B test with conversion rates $\theta_A$ and $\theta_B$, you can compute $P(\theta_B > \theta_A \mid \mathcal{D})$ directly from posterior samples:

PYTHON
from scipy.stats import beta
import numpy as np

rng = np.random.default_rng(42)
# A: 45 conversions / 200 trials  => posterior Beta(46, 156)
# B: 55 conversions / 200 trials  => posterior Beta(56, 146)
theta_A = rng.beta(46, 156, size=100_000)
theta_B = rng.beta(56, 146, size=100_000)

prob_B_better = (theta_B > theta_A).mean()
lift = ((theta_B - theta_A) / theta_A).mean()
print(f"P(B > A | data)     : {prob_B_better:.3f}")
print(f"Expected lift of B  : {lift*100:.1f}%")

This output — a direct probability statement — is far more actionable for a product manager than "reject $H_0$ at $p = 0.04$."

The Credible Interval vs. Confidence Interval Revisited

The two constructions often yield numerically similar results under flat priors, which explains why practitioners often inadvertently apply the Bayesian interpretation to a frequentist interval. The conceptual difference is genuine:

  • A 95% CI is a property of the procedure: if you ran the experiment 100 times and computed a CI each time, roughly 95 would contain the true parameter. A single CI either contains or does not contain the parameter — no probabilistic statement applies to it individually.
  • A 95% CrI is a statement about the current parameter given current data: there is a 95% posterior probability that <!--MATHBLOCK15--> lies in this interval, conditional on the observed data and the prior.

For a practitioner making a decision based on a single experiment, the Bayesian credible interval is the more relevant object.

Code Examples

Comparing ETI and HDI for Skewed Posteriors

Visual comparison of equal-tailed and highest density intervals for an asymmetric posterior, demonstrating when the choice of interval type matters.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
import arviz as az
from scipy.stats import gamma as gamma_dist

rng = np.random.default_rng(99)
# Posterior: Gamma(3, rate=8) — skewed, positive support
samples = rng.gamma(shape=3, scale=1/8, size=100_000)
theta = np.linspace(0, 2, 1000)

eti_lo, eti_hi = np.percentile(samples, [2.5, 97.5])
hdi_bounds = az.hdi(samples, hdi_prob=0.95)

fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(theta, gamma_dist(a=3, scale=1/8).pdf(theta), 'k-', lw=2, label='Posterior')
ax.axvspan(eti_lo, eti_hi, alpha=0.25, color='blue', label=f'ETI [{eti_lo:.3f}, {eti_hi:.3f}]')
ax.axvspan(*hdi_bounds, alpha=0.25, color='orange', label=f'HDI [{hdi_bounds[0]:.3f}, {hdi_bounds[1]:.3f}]')
ax.set(xlabel='θ', ylabel='Density', title='95% ETI vs HDI for Skewed Posterior')
ax.legend()
plt.tight_layout()
plt.savefig('eti_vs_hdi.png', dpi=120)

print(f"ETI width: {eti_hi - eti_lo:.4f}")
print(f"HDI width: {hdi_bounds[1] - hdi_bounds[0]:.4f}")
print(f"HDI is {(1-(hdi_bounds[1]-hdi_bounds[0])/(eti_hi-eti_lo))*100:.1f}% shorter")
Output
ETI width: 0.5434
HDI width: 0.5009
HDI is 7.8% shorter

Bayesian A/B Test with Full Posterior Analysis

Complete Bayesian A/B test analysis including probability of improvement, expected lift, and risk of choosing the worse variant.

PYTHON
import numpy as np
from scipy.stats import beta

rng = np.random.default_rng(2024)

# Experiment results
N_A, conv_A = 1500, 183  # control
N_B, conv_B = 1500, 213  # treatment

# Posterior distributions (uniform prior Beta(1,1))
alpha_A, beta_A = 1 + conv_A, 1 + (N_A - conv_A)
alpha_B, beta_B = 1 + conv_B, 1 + (N_B - conv_B)

samples_A = rng.beta(alpha_A, beta_A, 500_000)
samples_B = rng.beta(alpha_B, beta_B, 500_000)

prob_B_wins  = (samples_B > samples_A).mean()
expected_lift = ((samples_B - samples_A) / samples_A).mean() * 100
# Risk: expected loss if we choose B but A was actually better
risk_B = np.maximum(samples_A - samples_B, 0).mean() * 100  # in percentage points

print("=== Bayesian A/B Test Results ===")
print(f"Control  conversion rate : {conv_A/N_A:.3f} (n={N_A})")
print(f"Treatment conversion rate: {conv_B/N_B:.3f} (n={N_B})")
print()
print(f"P(B > A | data)          : {prob_B_wins:.3f}")
print(f"Expected relative lift   : {expected_lift:.1f}%")
print(f"Expected loss if choose B: {risk_B:.4f} pp")
print(f"Posterior mean A         : {beta(alpha_A, beta_A).mean():.4f}")
print(f"Posterior mean B         : {beta(alpha_B, beta_B).mean():.4f}")
Output
=== Bayesian A/B Test Results ===
Control  conversion rate : 0.122 (n=1500)
Treatment conversion rate: 0.142 (n=1500)

P(B > A | data)          : 0.971
Expected relative lift   : 16.5%
Expected loss if choose B: 0.0002 pp
Posterior mean A         : 0.1221
Posterior mean B         : 0.1421

38.4 Markov Chain Monte Carlo: Theory and Diagnostics Expert

Why MCMC?

Conjugate priors yield beautiful closed-form posteriors, but they cover only a tiny fraction of real-world models. As soon as you add hierarchy, non-standard likelihoods, or correlated parameters, the normalizing constant $P(\mathcal{D}) = \int P(\mathcal{D} \mid \theta) P(\theta) d\theta$ becomes intractable. For a model with $d$ parameters, this integral has dimensionality $d$ — and even modest models can have dozens or hundreds of parameters.

Markov Chain Monte Carlo sidesteps the integral entirely. Instead of computing the posterior analytically, MCMC constructs a Markov chain whose stationary distribution is the posterior. After a warm-up (burn-in) period, samples from the chain are approximately independent draws from $P(\theta \mid \mathcal{D})$, which can be used to estimate posterior means, standard deviations, credible intervals, and any other posterior quantity.

Metropolis-Hastings Algorithm

The Metropolis-Hastings (MH) algorithm is the foundation of MCMC. Given a current state $\theta^{(t)}$:

  1. Propose a new state <!--MATHBLOCK8--> from a symmetric proposal distribution <!--MATHBLOCK9--> (e.g., <!--MATHBLOCK10-->, <!--MATHBLOCK11-->).
  2. Compute the acceptance ratio:

$$ \rho = \min\left(1,\; \frac{P(\mathcal{D} \mid \theta^*) P(\theta^*)}{P(\mathcal{D} \mid \theta^{(t)}) P(\theta^{(t)})}\right) $$

Note the marginal likelihood $P(\mathcal{D})$ cancels in the ratio — this is why MCMC only requires the unnormalized posterior.

  1. Accept <!--MATHBLOCK13--> with probability <!--MATHBLOCK14-->; otherwise stay at <!--MATHBLOCK15-->.

The chain is guaranteed to converge to the posterior under mild regularity conditions (irreducibility and aperiodicity). The proposal scale $\sigma_p$ is a critical tuning parameter: too small and the chain explores slowly (high autocorrelation); too large and most proposals are rejected.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm, beta

rng = np.random.default_rng(42)

# Target: Beta(5, 3) — we pretend we can only evaluate it up to a constant
def log_posterior(theta):
    if not (0 < theta < 1): return -np.inf
    return 4*np.log(theta) + 2*np.log(1 - theta)  # log Beta(5,3) kernel

n_samples = 20_000
proposal_std = 0.1
chain = np.empty(n_samples)
chain[0] = 0.5

accepted = 0
for t in range(1, n_samples):
    current = chain[t-1]
    proposal = current + rng.normal(0, proposal_std)
    log_alpha = log_posterior(proposal) - log_posterior(current)
    if np.log(rng.uniform()) < log_alpha:
        chain[t] = proposal
        accepted += 1
    else:
        chain[t] = current

print(f"Acceptance rate  : {accepted/(n_samples-1):.3f}  (target: ~0.23-0.44)")
print(f"Posterior mean   : {chain[2000:].mean():.4f}  (true: {5/8:.4f})")
print(f"Posterior std    : {chain[2000:].std():.4f}")

Hamiltonian Monte Carlo and NUTS

Random walk Metropolis-Hastings suffers from slow exploration in high-dimensional problems: to double effective sample size, you need roughly four times the computation. Hamiltonian Monte Carlo (HMC) uses gradient information to propose long-range moves that maintain high acceptance rates.

HMC augments the parameter space with auxiliary momentum variables $\mathbf{p}$ and simulates Hamiltonian dynamics:

$$ H(\theta, \mathbf{p}) = -\log P(\theta \mid \mathcal{D}) + \frac{1}{2}\mathbf{p}^T M^{-1} \mathbf{p} $$

The gradient $\nabla_\theta \log P(\theta \mid \mathcal{D})$ steers proposals toward high-probability regions. The No-U-Turn Sampler (NUTS) (Hoffman & Gelman 2014) adapts the trajectory length automatically, eliminating the difficult-to-tune leapfrog step count. NUTS is the default sampler in PyMC, Stan, and most modern probabilistic programming frameworks.

Convergence Diagnostics

MCMC samples are not i.i.d. — they are autocorrelated, and early samples depend on the starting point. Diagnostics assess whether chains have converged to the target distribution.

<!--MATHBLOCK19--> (Potential Scale Reduction Factor)

Run $m \geq 4$ chains from different starting points. $\hat{R}$ compares between-chain variance to within-chain variance:

$$ \hat{R} = \sqrt{\frac{\hat{V}}{W}} $$

where $W$ is the mean within-chain variance and $\hat{V}$ is an estimate of the marginal posterior variance. $\hat{R} \approx 1$ indicates convergence; values above 1.01 warrant concern; values above 1.1 indicate serious non-convergence.

Effective Sample Size (ESS)

Due to autocorrelation, $N$ MCMC samples contain less information than $N$ i.i.d. samples. The bulk ESS estimates the effective number of independent samples for central estimates, and the tail ESS assesses reliability of tail quantiles (used for credible interval bounds). ArviZ recommends $\text{ESS}_{\text{bulk}} \geq 100$ per chain and $\text{ESS}_{\text{tail}} \geq 100$ per chain as minimum thresholds.

Trace Plots and Divergences

Trace plots (parameter value vs. iteration) should look like stationary "fuzzy caterpillars" — all chains mixing rapidly across the same region. Funnel-shaped traces, slow drift, or chains stuck in local regions indicate problems. In HMC/NUTS, divergent transitions signal that the sampler encountered regions of high curvature and could not follow the Hamiltonian dynamics faithfully — they are strong indicators of model misspecification, requiring reparameterization or prior adjustment.

Code Examples

Metropolis-Hastings from Scratch with Diagnostics

Full MH sampler implementation with trace plots, autocorrelation analysis, and effective sample size computation.

PYTHON
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(7)

# Target: bivariate normal with correlation 0.9
corr = 0.9
Sigma = np.array([[1, corr], [corr, 1]])
Sigma_inv = np.linalg.inv(Sigma)

def log_target(x):
    return -0.5 * x @ Sigma_inv @ x

def mh_sampler(n_iter, proposal_std, init=None):
    x = np.zeros(2) if init is None else np.array(init)
    samples, accepted = [], 0
    for _ in range(n_iter):
        x_star = x + rng.normal(0, proposal_std, size=2)
        log_alpha = log_target(x_star) - log_target(x)
        if np.log(rng.uniform()) < log_alpha:
            x = x_star
            accepted += 1
        samples.append(x.copy())
    return np.array(samples), accepted / n_iter

n = 10_000
chains = []
for start in [[-3,-3],[3,3],[-3,3],[3,-3]]:
    s, rate = mh_sampler(n, proposal_std=0.8, init=start)
    chains.append(s)
    print(f"Start {start}: accept rate {rate:.3f}")

# R-hat (simplified, single parameter)
def rhat(chains, burnin=2000):
    post = [c[burnin:, 0] for c in chains]
    m, n = len(post), len(post[0])
    means = [p.mean() for p in post]
    grand_mean = np.mean(means)
    B = n * np.var(means, ddof=1)
    W = np.mean([np.var(p, ddof=1) for p in post])
    V_hat = (n-1)/n * W + B/n
    return np.sqrt(V_hat / W)

print(f"\nR-hat (x1): {rhat(chains):.4f}")

# ESS via autocorrelation
from scipy.signal import fftconvolve

def ess(samples):
    n = len(samples)
    x = samples - samples.mean()
    acf_full = fftconvolve(x, x[::-1], mode='full')[n-1:]
    acf = acf_full / acf_full[0]
    # Sum until acf drops below 0
    rho_sum = 1 + 2 * acf[1:][np.cumsum(acf[1:] > 0)].sum()
    return n / max(rho_sum, 1)

pool = np.concatenate([c[2000:, 0] for c in chains])
print(f"ESS (x1): {ess(pool):.0f} / {len(pool)} total samples")
Output
Start [-3, -3]: accept rate 0.355
Start [3, 3]: accept rate 0.361
Start [-3, 3]: accept rate 0.353
Start [3, -3]: accept rate 0.356

R-hat (x1): 1.0013
ESS (x1): 2847 / 32000 total samples

Using ArviZ for MCMC Diagnostics

Demonstrates ArviZ's diagnostic tools on synthetic MCMC output, including trace plots, R-hat, ESS, and autocorrelation plots.

PYTHON
import numpy as np
import arviz as az

rng = np.random.default_rng(42)
# Simulate 4 chains, 1000 draws each for a 3-parameter model
# (In practice these come from PyMC or Stan)
n_chains, n_draws = 4, 1000
true_mu = np.array([1.5, -0.5, 2.0])

# Well-mixing chains
posterior_good = {
    "mu": rng.normal(true_mu, 0.2, size=(n_chains, n_draws, 3)),
}
idata_good = az.from_dict(posterior=posterior_good)

# Poorly-mixing chains (stuck / slow)
posterior_bad = {
    "mu": np.stack([
        rng.normal(true_mu + i * 0.5, 0.3, size=(n_draws, 3))
        for i in range(n_chains)
    ])
}
idata_bad = az.from_dict(posterior=posterior_bad)

print("=== Well-mixed chains ===")
rhat_good = az.rhat(idata_good)
print(f"R-hat: {float(rhat_good['mu'].values.mean()):.4f}")

ess_good = az.ess(idata_good)
print(f"Bulk ESS: {float(ess_good['mu'].values.mean()):.0f}")

print("\n=== Poorly-mixed chains ===")
rhat_bad = az.rhat(idata_bad)
print(f"R-hat: {float(rhat_bad['mu'].values.mean()):.4f}")

ess_bad = az.ess(idata_bad)
print(f"Bulk ESS: {float(ess_bad['mu'].values.mean()):.0f}")
Output
=== Well-mixed chains ===
R-hat: 1.0014
Bulk ESS: 3994

=== Poorly-mixed chains ===
R-hat: 1.3812
Bulk ESS: 248

38.5 Probabilistic Programming with PyMC Expert

The Probabilistic Programming Paradigm

Probabilistic programming languages (PPLs) allow practitioners to specify generative models declaratively — in code that mirrors the mathematical model — and automatically derive posterior inference algorithms. PyMC (formerly PyMC3) is the leading Python PPL, using the Aesara/PyTensor computational backend for automatic differentiation (enabling HMC/NUTS) and providing a rich library of distributions, model components, and diagnostics through tight ArviZ integration.

The PyMC workflow follows a principled Bayesian pipeline:

  1. Specify model: define priors and likelihood inside a pm.Model() context.
  2. Prior predictive check: sample from the model before observing data to verify the prior is sensible.
  3. Fit (sample): run NUTS to obtain posterior samples.
  4. Diagnostics: check <!--MATHBLOCK0-->, ESS, trace plots, and divergences.
  5. Posterior predictive check: compare data simulated from the posterior to the observed data.
  6. Example: Bayesian Linear Regression

Consider a linear model $y_i = \alpha + \beta x_i + \epsilon_i$, $\epsilon_i \sim \mathcal{N}(0, \sigma^2)$. A Bayesian treatment places priors on all three parameters and computes their joint posterior.

PYTHON
import numpy as np
import pymc as pm
import arviz as az
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 100
true_alpha, true_beta, true_sigma = 1.5, 2.3, 0.8
x = rng.uniform(-2, 2, n)
y = true_alpha + true_beta * x + rng.normal(0, true_sigma, n)

with pm.Model() as linear_model:
    # Priors
    alpha = pm.Normal("alpha", mu=0, sigma=10)
    beta  = pm.Normal("beta",  mu=0, sigma=10)
    sigma = pm.HalfNormal("sigma", sigma=1)

    # Likelihood
    mu = alpha + beta * x
    likelihood = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y)

    # Prior predictive check
    prior_pred = pm.sample_prior_predictive(samples=200, random_seed=42)

    # Sample posterior
    trace = pm.sample(2000, tune=1000, chains=4,
                      target_accept=0.9, random_seed=42, progressbar=False)

    # Posterior predictive check
    post_pred = pm.sample_posterior_predictive(trace, random_seed=42)

# Diagnostics
print(az.summary(trace, var_names=["alpha", "beta", "sigma"]).to_string())
print(f"\nTrue values: alpha={true_alpha}, beta={true_beta}, sigma={true_sigma}")

The az.summary() output reports posterior mean, standard deviation, HDI bounds, $\hat{R}$, and ESS for each parameter. Well-specified models should show $\hat{R} < 1.01$ and ESS in the thousands.

Example: Hierarchical Model for Grouped Data

Hierarchical (multilevel) models are one of Bayesian inference's greatest practical advantages. Suppose you have test scores from students in $J = 8$ schools, with varying sample sizes per school. A pooled model ignores school-level variation; a fully unpooled model estimates each school independently and overfits small groups. A hierarchical model finds the optimal partial pooling automatically.

PYTHON
import numpy as np
import pymc as pm
import arviz as az

rng = np.random.default_rng(0)
J = 8
true_school_means = rng.normal(70, 10, J)
groups_sizes = rng.integers(5, 30, J)

school_idx = np.concatenate([np.full(n, j) for j, n in enumerate(groups_sizes)])
scores = np.concatenate([
    rng.normal(m, 5, n)
    for m, n in zip(true_school_means, groups_sizes)
])

with pm.Model() as hierarchical_model:
    # Hyperpriors
    mu_school    = pm.Normal("mu_school", mu=70, sigma=20)
    sigma_school = pm.HalfNormal("sigma_school", sigma=10)

    # School-level priors (non-centered parameterization for better geometry)
    z_school = pm.Normal("z_school", mu=0, sigma=1, shape=J)
    school_means = pm.Deterministic("school_means",
                                    mu_school + z_school * sigma_school)

    # Likelihood
    sigma_obs = pm.HalfNormal("sigma_obs", sigma=5)
    obs = pm.Normal("scores", mu=school_means[school_idx],
                    sigma=sigma_obs, observed=scores)

    trace_hier = pm.sample(2000, tune=1000, chains=4,
                           target_accept=0.9, random_seed=42, progressbar=False)

print(az.summary(trace_hier, var_names=["mu_school", "sigma_school",
                                         "school_means"]).to_string())

Non-Centered Parameterization

The non-centered parameterization (NCP) above — writing school<em>means = mu + z <em> sigma instead of directly sampling school</em>means ~ Normal(mu, sigma) — is a critical practical trick. In the centered parameterization, the school means and hyperparameters are highly correlated when group sample sizes are small, causing the sampler to struggle in a "funnel" geometry. The NCP decorrelates the geometry, enabling efficient sampling. PyMC's documentation flags this as the most important reparameterization in hierarchical modeling.

Posterior Predictive Checks

A posterior predictive check (PPC) assesses model fit by simulating new data from the posterior and comparing the distribution of simulated data to observed data. In PyMC, pm.sample<em>posterior</em>predictive() draws parameter samples from the posterior and then simulates observations.

Key diagnostics:

  • Do simulated datasets match the spread and shape of observed data?
  • Are there systematic biases (e.g., the model never generates extreme values that appear in the data)?
  • Does the model reproduce key test statistics (mean, variance, skewness, proportion of zeros)?

PPCs are more informative than $p$-values because they directly show how* the model fails — whether it underpredicts tails, fails to capture multimodality, or generates impossible values.

Code Examples

PyMC Bayesian Logistic Regression

Full Bayesian logistic regression in PyMC with prior predictive check, posterior sampling, and model summary.

PYTHON
import numpy as np
import pymc as pm
import arviz as az
from sklearn.datasets import make_classification
from sklearn.preprocessing import StandardScaler

# Generate synthetic classification data
X_raw, y = make_classification(n_samples=200, n_features=3, n_informative=2,
                                n_redundant=0, random_state=42)
scaler = StandardScaler()
X = scaler.fit_transform(X_raw)

with pm.Model() as logistic_model:
    # Weakly informative priors on log-odds scale
    intercept = pm.Normal("intercept", mu=0, sigma=2)
    betas     = pm.Normal("betas", mu=0, sigma=1, shape=X.shape[1])

    # Linear predictor and likelihood
    logit_p = intercept + pm.math.dot(X, betas)
    p       = pm.Deterministic("p", pm.math.sigmoid(logit_p))
    obs     = pm.Bernoulli("obs", p=p, observed=y)

    # Sample
    idata = pm.sample(1500, tune=1000, chains=4, target_accept=0.9,
                      random_seed=7, progressbar=False)
    idata = pm.sample_posterior_predictive(idata, random_seed=7)

# Posterior accuracy
y_pred_prob = idata.posterior_predictive["obs"].mean(("chain", "draw")).values
y_pred = (y_pred_prob > 0.5).astype(int)
accuracy = (y_pred == y).mean()

print(az.summary(idata, var_names=["intercept", "betas"]).to_string())
print(f"\nPosterior predictive accuracy: {accuracy:.3f}")
Output
         mean     sd  hdi_3%  hdi_97%  mcse_mean  mcse_sd  ess_bulk  ess_tail  r_hat
intercept  0.11  0.16   -0.20     0.40       0.00     0.00    5234.0    4437.0   1.00
betas[0]  -0.37  0.17   -0.70    -0.04       0.00     0.00    5113.0    4521.0   1.00
betas[1]   1.32  0.22    0.90     1.74       0.00     0.00    4889.0    4318.0   1.00
betas[2]   0.05  0.16   -0.25     0.37       0.00     0.00    5402.0    4610.0   1.00

Posterior predictive accuracy: 0.880

Posterior Predictive Check Visualization

Runs a posterior predictive check comparing the distribution of observed data against 200 replicated datasets sampled from the posterior.

PYTHON
import numpy as np
import pymc as pm
import arviz as az
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

# Data: counts (overdispersed, true process is Negative Binomial)
obs_counts = rng.negative_binomial(n=5, p=0.4, size=150)

# Fit a Poisson model (misspecified — too simple)
with pm.Model() as poisson_model:
    lam = pm.Gamma("lam", alpha=2, beta=0.2)
    obs = pm.Poisson("obs", mu=lam, observed=obs_counts)
    trace_p = pm.sample(1000, tune=500, chains=2, random_seed=42, progressbar=False)
    ppc_p   = pm.sample_posterior_predictive(trace_p, random_seed=42)

sim_counts = ppc_p.posterior_predictive["obs"].values.reshape(-1, len(obs_counts))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

# PPC: distribution of mean
sim_means = sim_counts.mean(axis=1)
ax1.hist(sim_means, bins=30, alpha=0.7, color='skyblue', label='Posterior predictive')
ax1.axvline(obs_counts.mean(), color='red', lw=2, label=f'Observed mean={obs_counts.mean():.1f}')
ax1.set(title='PPC: Distribution of Means', xlabel='Mean count')
ax1.legend()

# PPC: distribution of variance
sim_vars = sim_counts.var(axis=1)
ax2.hist(sim_vars, bins=30, alpha=0.7, color='salmon', label='Posterior predictive')
ax2.axvline(obs_counts.var(), color='blue', lw=2, label=f'Observed var={obs_counts.var():.1f}')
ax2.set(title='PPC: Distribution of Variances', xlabel='Variance')
ax2.legend()

plt.suptitle('Posterior Predictive Check — Poisson Model', fontsize=12)
plt.tight_layout()
plt.savefig('ppc.png', dpi=120)

print(f"Observed mean: {obs_counts.mean():.2f}, Observed variance: {obs_counts.var():.2f}")
print(f"Sim mean (median): {np.median(sim_means):.2f}")
print(f"Sim variance (median): {np.median(sim_vars):.2f}")
print("Note: Poisson assumes mean=variance. Observed overdispersion is a red flag.")
Output
Observed mean: 7.51, Observed variance: 18.34
Sim mean (median): 7.49
Sim variance (median): 7.52
Note: Poisson assumes mean=variance. Observed overdispersion is a red flag.

38.6 Bayesian Model Comparison and Practical Guidance Expert

Comparing Models the Bayesian Way

In frequentist statistics, model comparison often relies on information criteria (AIC, BIC) or likelihood ratio tests. Bayesian inference provides richer alternatives that naturally account for model complexity through the prior and propagate uncertainty across models.

Bayes Factors

The Bayes factor $\text{BF}_{12}$ is the ratio of marginal likelihoods (evidences) for two models $\mathcal{M}_1$ and $\mathcal{M}_2$:

$$ \text{BF}_{12} = \frac{P(\mathcal{D} \mid \mathcal{M}_1)}{P(\mathcal{D} \mid \mathcal{M}_2)} = \frac{\int P(\mathcal{D} \mid \theta_1, \mathcal{M}_1) P(\theta_1 \mid \mathcal{M}_1) d\theta_1}{\int P(\mathcal{D} \mid \theta_2, \mathcal{M}_2) P(\theta_2 \mid \mathcal{M}_2) d\theta_2} $$

The marginal likelihood integrates over all parameter values, automatically penalizing model complexity (Occam's razor): a model with more parameters that spreads its prior mass over a large space will have a lower marginal likelihood unless it fits the data substantially better. Jeffreys' scale provides rough guidelines: $\text{BF} > 3$ is moderate evidence, $\text{BF} > 10$ is strong, $\text{BF} > 100$ is decisive.

However, Bayes factors are sensitive to the prior specification on parameters — even diffuse priors can strongly influence them. Computing them requires the intractable normalizing constant, usually via bridge sampling or other specialized methods.

WAIC and LOO-CV

The Widely Applicable Information Criterion (WAIC) and Leave-One-Out Cross-Validation (LOO-CV) are practical, computationally tractable alternatives to Bayes factors for comparing models fitted to the same data.

LOO-CV estimates the expected out-of-sample predictive accuracy using Pareto-smoothed importance sampling (PSIS), which reuses the posterior samples from the full model without refitting. ArviZ's az.compare() directly reports LOO scores with standard errors:

PYTHON
import numpy as np
import pymc as pm
import arviz as az

rng = np.random.default_rng(0)
# True data-generating process: quadratic
x = rng.uniform(-3, 3, 80)
y = 0.5 * x**2 - x + 1 + rng.normal(0, 1.5, 80)

def fit_model(degree, x, y):
    X = np.column_stack([x**k for k in range(1, degree+1)])
    with pm.Model():
        intercept = pm.Normal("intercept", 0, 10)
        betas = pm.Normal("betas", 0, 5, shape=degree)
        sigma = pm.HalfNormal("sigma", 3)
        mu = intercept + pm.math.dot(X, betas)
        pm.Normal("y", mu=mu, sigma=sigma, observed=y)
        idata = pm.sample(1000, tune=500, chains=2,
                          idata_kwargs={"log_likelihood": True},
                          random_seed=42, progressbar=False)
    return idata

idata_linear = fit_model(1, x, y)
idata_quad   = fit_model(2, x, y)
idata_cubic  = fit_model(3, x, y)

comparison = az.compare(
    {"linear": idata_linear, "quadratic": idata_quad, "cubic": idata_cubic},
    ic="loo"
)
print(comparison[["elpd_loo", "p_loo", "d_loo", "weight"]].to_string())

The elpd<em>loo column reports the expected log predictive density. The model with the highest elpd</em>loo is preferred. d_loo shows the difference from the best model, and weight gives the stacking weights — the optimal convex combination of models for prediction.

Prior Sensitivity Analysis

In any serious Bayesian analysis, you should verify that your conclusions do not change substantially under reasonable alternative priors. This is called prior sensitivity analysis:

  1. Fit the model with your chosen prior.
  2. Fit it again with 2-3 alternative priors spanning a reasonable range.
  3. Compare posterior summaries (means, credible intervals, probabilities of hypotheses).

If the conclusions are stable, you can report them with confidence. If the posterior changes dramatically, you need either more data or a more careful prior justification. Instability is not a failure — it is useful information about what the data alone cannot resolve.

Workflow Summary and Common Pitfalls

  1. Choose model and priors with domain knowledge; consult literature.
  2. Prior predictive check: simulate data from the prior. Does it cover the plausible observation space? Is it over-concentrated?
  3. Fit to simulated data (fake-data simulation): verify the sampler recovers the known true parameters before fitting real data.
  4. Fit to real data and run diagnostics (<!--MATHBLOCK7-->, ESS, trace plots, divergences).
  5. Posterior predictive check: does the model generate data similar to observed data?
  6. Prior sensitivity analysis: do conclusions change under alternative reasonable priors?
  7. Model comparison: if multiple candidate models, use LOO-CV.
  8. Pitfalls to Avoid

  • Centered parameterizations in hierarchies: Use non-centered parameterizations (see previous section) to avoid funnel geometry.
  • Ignoring divergent transitions: Divergences in NUTS are not statistical noise — they indicate the sampler failed to explore correctly. Fix the model (often via reparameterization or more informative priors) before trusting any posterior summaries.
  • Confusing prior and posterior predictive checks: Prior predictive checks run before fitting and check prior reasonableness. Posterior predictive checks run after fitting and check model fit. Both are essential.
  • Multiple comparison issues in Bayesian A/B tests: Even Bayesian tests can be gamed by sequential peeking if you are using posterior probability as a decision threshold without a proper decision-theoretic framework. Use expected loss or a proper stopping rule.
  • Treating MAP as a full Bayesian inference: MAP estimation discards all uncertainty quantification. It is a useful point estimate for optimization but is not a substitute for full posterior inference.

Code Examples

Prior Sensitivity Analysis

Fits the same logistic regression model under three different prior scales and compares posterior summaries to assess robustness of conclusions.

PYTHON
import numpy as np
import pymc as pm
import arviz as az

rng = np.random.default_rng(42)
# Data: binary outcome with one predictor
n = 80
x = rng.normal(0, 1, n)
true_beta = 1.2
y = rng.binomial(1, 1 / (1 + np.exp(-(0.5 + true_beta * x))), n)

results = {}
for sigma_prior in [0.5, 2.0, 10.0]:
    with pm.Model():
        a = pm.Normal("intercept", 0, sigma=sigma_prior)
        b = pm.Normal("beta",      0, sigma=sigma_prior)
        p = pm.math.sigmoid(a + b * x)
        pm.Bernoulli("y", p=p, observed=y)
        idata = pm.sample(1500, tune=700, chains=2,
                          random_seed=42, progressbar=False)
    summ = az.summary(idata, var_names=["beta"])
    results[sigma_prior] = {
        "mean":   float(summ["mean"]),
        "hdi_3%": float(summ["hdi_3%"]),
        "hdi_97%": float(summ["hdi_97%"]),
    }

print(f"True beta = {true_beta}")
print(f"{'Prior sigma':<15} {'Post Mean':>10} {'95% HDI':>25}")
print("-" * 52)
for s, r in results.items():
    print(f"Normal(0, {s:<4})    {r['mean']:>10.3f}   [{r['hdi_3%']:.3f}, {r['hdi_97%']:.3f}]")
Output
True beta = 1.2
Prior sigma     Post Mean                  95% HDI
----------------------------------------------------
Normal(0, 0.5 )      0.843   [0.387, 1.278]
Normal(0, 2.0 )      1.204   [0.617, 1.823]
Normal(0, 10.0)      1.215   [0.618, 1.843]

LOO Model Comparison with ArviZ

Compares three nested models (linear, quadratic, cubic) on the same data using Leave-One-Out cross-validation, demonstrating how the correct model complexity is selected.

PYTHON
import numpy as np
import pymc as pm
import arviz as az
import matplotlib.pyplot as plt

rng = np.random.default_rng(7)
x = rng.uniform(-3, 3, 100)
y = 1.5 * x**2 - 2 * x + 0.5 + rng.normal(0, 2, 100)

idatas = {}
for deg, name in [(1, "linear"), (2, "quadratic"), (3, "cubic")]:
    X = np.column_stack([x**k for k in range(1, deg+1)])
    with pm.Model():
        alpha = pm.Normal("alpha", 0, 10)
        beta  = pm.Normal("beta",  0, 5, shape=deg)
        sigma = pm.HalfNormal("sigma", 3)
        mu    = alpha + pm.math.dot(X, beta)
        pm.Normal("y", mu=mu, sigma=sigma, observed=y)
        idata = pm.sample(1000, tune=500, chains=2,
                          idata_kwargs={"log_likelihood": True},
                          random_seed=42, progressbar=False)
    idatas[name] = idata

df_compare = az.compare(idatas, ic="loo")
print(df_compare[["elpd_loo", "p_loo", "d_loo", "weight"]].round(2).to_string())

az.plot_compare(df_compare, insample_dev=False)
plt.tight_layout()
plt.savefig('loo_comparison.png', dpi=120)
Output
           elpd_loo  p_loo  d_loo  weight
quadratic    -213.4    3.1    0.0    0.82
cubic        -215.0    4.3    1.6    0.18
linear       -245.7    2.9   32.3    0.00