skills/statistical-analysis/references/bayesian_statistics.md
This document provides guidance on conducting and interpreting Bayesian statistical analyses, which offer an alternative framework to frequentist (classical) statistics.
| Aspect | Frequentist | Bayesian |
|---|---|---|
| Probability interpretation | Long-run frequency of events | Degree of belief/uncertainty |
| Parameters | Fixed but unknown | Random variables with distributions |
| Inference | Based on sampling distributions | Based on posterior distributions |
| Primary output | p-values, confidence intervals | Posterior probabilities, credible intervals |
| Prior information | Not formally incorporated | Explicitly incorporated via priors |
| Hypothesis testing | Reject/fail to reject null | Probability of hypotheses given data |
| Sample size | Often requires minimum | Works at any n, but small-n posteriors are prior-dominated — report a prior-sensitivity check |
| Interpretation | Indirect (probability of data given H₀) | Direct (probability of hypothesis given data) |
Frequentist: "If the null hypothesis is true, what is the probability of observing data this extreme or more extreme?"
Bayesian: "Given the observed data, what is the probability that the hypothesis is true?"
The Bayesian question is more intuitive and directly addresses what researchers want to know.
Formula:
P(θ|D) = P(D|θ) × P(θ) / P(D)
In words:
Posterior = Likelihood × Prior / Evidence
Where:
When to use: When you have substantial prior knowledge from:
Example: Meta-analysis shows effect size d ≈ 0.40, SD = 0.15
Advantages:
Disadvantages:
When to use: Default choice for most applications
Characteristics:
Example priors:
Advantages:
When to use: When attempting to be "objective"
Example: Uniform(-∞, ∞) for any value
⚠️ Caution:
Better alternative: Use weakly informative priors
Always conduct: Test how results change with different priors
Process:
Reporting:
Python example:
import pymc as pm
prior_specs = [
('weakly_informative', 0, 1),
('diffuse', 0, 10),
('informative', 0.5, 0.3),
]
results = {}
for name, mu_prior, sigma_prior in prior_specs:
with pm.Model() as model:
effect = pm.Normal('effect', mu=mu_prior, sigma=sigma_prior)
# ... likelihood and observed data
trace = pm.sample(2000, tune=1000)
results[name] = trace
What it is: Ratio of evidence for two competing hypotheses
Formula:
BF₁₀ = P(D|H₁) / P(D|H₀)
Interpretation:
| BF₁₀ | Evidence |
|---|---|
| >100 | Decisive for H₁ |
| 30-100 | Very strong for H₁ |
| 10-30 | Strong for H₁ |
| 3-10 | Moderate for H₁ |
| 1-3 | Anecdotal for H₁ |
| 1 | No evidence |
| 1/3-1 | Anecdotal for H₀ |
| 1/10-1/3 | Moderate for H₀ |
| 1/30-1/10 | Strong for H₀ |
| 1/100-1/30 | Very strong for H₀ |
| <1/100 | Decisive for H₀ |
Advantages over p-values:
Python calculation:
# Pingouin 0.5+: BF10 for independent two-sided t-tests; one-sided BF removed.
import pingouin as pg
result = pg.ttest(group1, group2, correction=False)
bf10 = result['BF10'].values[0]
# Rigorous Bayes Factors: BayesFactor (R), JASP, or PyMC model comparison (see pymc skill)
Purpose: Define range of negligible effect sizes
Process:
95% in ROPE: Accept practical equivalence
95% outside ROPE: Reject equivalence
Advantage: Directly tests for practical significance
Python example:
# Define ROPE
rope_lower, rope_upper = -0.1, 0.1
# Calculate % of posterior in ROPE
in_rope = np.mean((posterior_samples > rope_lower) &
(posterior_samples < rope_upper))
print(f"{in_rope*100:.1f}% of posterior in ROPE")
What it is: Interval containing parameter with X% probability
95% Credible Interval interpretation:
"There is a 95% probability that the true parameter lies in this interval."
This is what people THINK confidence intervals mean (but don't in frequentist framework)
Types:
Python calculation:
import arviz as az
# Equal-tailed interval
eti = np.percentile(posterior_samples, [2.5, 97.5])
# HDI (ArviZ 1.x renamed the keyword hdi_prob= to prob=)
hdi = az.hdi(posterior_samples, prob=0.95)
Interpreting posterior distributions:
Central tendency:
Uncertainty:
Shape:
Visualization:
import matplotlib.pyplot as plt
import arviz as az
# Posterior plot with 95% credible interval
# (ArviZ 1.x replaced plot_posterior with plot_dist and hdi_prob= with ci_prob=)
az.plot_dist(trace, ci_prob=0.95)
# Trace plot (check convergence)
az.plot_trace(trace)
# Forest plot (multiple parameters)
az.plot_forest(trace)
Purpose: Compare two groups (Bayesian alternative to t-test)
Outputs:
Python implementation:
import pymc as pm
import arviz as az
# Bayesian independent samples t-test
with pm.Model() as model:
# Priors for group means
mu1 = pm.Normal('mu1', mu=0, sigma=10)
mu2 = pm.Normal('mu2', mu=0, sigma=10)
# Prior for pooled standard deviation
sigma = pm.HalfNormal('sigma', sigma=10)
# Likelihood
y1 = pm.Normal('y1', mu=mu1, sigma=sigma, observed=group1)
y2 = pm.Normal('y2', mu=mu2, sigma=sigma, observed=group2)
# Derived quantity: mean difference
diff = pm.Deterministic('diff', mu1 - mu2)
# Sample posterior
trace = pm.sample(2000, tune=1000)
# Analyze results
print(az.summary(trace, var_names=['mu1', 'mu2', 'diff']))
# Probability that group1 > group2
prob_greater = np.mean(trace.posterior['diff'].values > 0)
print(f"P(μ₁ > μ₂) = {prob_greater:.3f}")
# Plot posterior (ArviZ 1.x: plot_posterior was replaced by plot_dist;
# add a reference line at 0 with matplotlib if needed)
az.plot_dist(trace, var_names=['diff'])
Purpose: Compare three or more groups
Model:
import pymc as pm
with pm.Model() as anova_model:
# Hyperpriors
mu_global = pm.Normal('mu_global', mu=0, sigma=10)
sigma_between = pm.HalfNormal('sigma_between', sigma=5)
sigma_within = pm.HalfNormal('sigma_within', sigma=5)
# Group means (hierarchical)
group_means = pm.Normal('group_means',
mu=mu_global,
sigma=sigma_between,
shape=n_groups)
# Likelihood
y = pm.Normal('y',
mu=group_means[group_idx],
sigma=sigma_within,
observed=data)
trace = pm.sample(2000, tune=1000)
# Posterior contrasts
contrast_1_2 = trace.posterior['group_means'][:,:,0] - trace.posterior['group_means'][:,:,1]
Purpose: Estimate correlation between two variables
Advantage: Provides distribution of correlation values
Python implementation:
import numpy as np
import pymc as pm
# Standardize both variables first: with z-scored data the bivariate normal
# can fix mu = [0, 0] and unit variances, leaving rho as the only free
# parameter (correlation is unchanged by linear rescaling). Alternatively,
# model the means and SDs as parameters (or use pm.LKJCholeskyCov).
xz = (x - x.mean()) / x.std()
yz = (y - y.mean()) / y.std()
with pm.Model() as corr_model:
# Prior on correlation
rho = pm.Uniform('rho', lower=-1, upper=1)
# Correlation (= covariance) matrix for standardized data
cov_matrix = pm.math.stack([[1, rho],
[rho, 1]])
# Likelihood (bivariate normal on standardized data)
obs = pm.MvNormal('obs',
mu=[0, 0],
cov=cov_matrix,
observed=np.column_stack([xz, yz]))
trace = pm.sample(2000, tune=1000)
# Summarize correlation
print(az.summary(trace, var_names=['rho']))
# Probability that correlation is positive
prob_positive = np.mean(trace.posterior['rho'].values > 0)
Purpose: Model relationship between predictors and outcome
Advantages:
Python implementation:
import pymc as pm
with pm.Model() as regression_model:
# Mutable data container: required for pm.set_data() to swap in new
# predictors later (a raw array would make set_data fail)
X_data = pm.Data('X', X)
# Priors for coefficients
alpha = pm.Normal('alpha', mu=0, sigma=10) # Intercept
beta = pm.Normal('beta', mu=0, sigma=10, shape=n_predictors)
sigma = pm.HalfNormal('sigma', sigma=10)
# Expected value
mu = alpha + pm.math.dot(X_data, beta)
# Likelihood (shape=mu.shape lets predictions resize with new data)
y_obs = pm.Normal('y_obs', mu=mu, sigma=sigma, observed=y, shape=mu.shape)
trace = pm.sample(2000, tune=1000)
# Posterior predictive checks
with regression_model:
ppc = pm.sample_posterior_predictive(trace)
az.plot_ppc_dist(ppc) # ArviZ 1.x: plot_ppc was replaced by plot_ppc_dist
# Predictions with uncertainty
with regression_model:
pm.set_data({'X': X_new})
posterior_pred = pm.sample_posterior_predictive(trace, predictions=True)
When to use:
Key concept: Partial pooling
Example: Varying intercepts:
with pm.Model() as hierarchical_model:
# Hyperpriors
mu_global = pm.Normal('mu_global', mu=0, sigma=10)
sigma_between = pm.HalfNormal('sigma_between', sigma=5)
sigma_within = pm.HalfNormal('sigma_within', sigma=5)
# Group-level intercepts
alpha = pm.Normal('alpha',
mu=mu_global,
sigma=sigma_between,
shape=n_groups)
# Likelihood
y_obs = pm.Normal('y_obs',
mu=alpha[group_idx],
sigma=sigma_within,
observed=y)
trace = pm.sample()
WAIC (Widely Applicable Information Criterion):
az.waic was removed in ArviZ 1.x — use LOOLOO (Leave-One-Out Cross-Validation):
Python calculation:
import arviz as az
import pymc as pm
# LOO needs pointwise log-likelihoods
with model:
pm.compute_log_likelihood(trace)
loo = az.loo(trace)
print(f"LOO elpd: {loo.elpd:.2f}") # higher is better
# Compare multiple models: az.compare ranks them correctly (rank 0 = best)
comparison = az.compare({
'model1': trace1,
'model2': trace2,
'model3': trace3
})
print(comparison)
R-hat (Gelman-Rubin statistic):
Effective Sample Size (ESS):
Trace plots:
Python checking:
# Automatic summary with diagnostics
print(az.summary(trace, var_names=['parameter']))
# Visual diagnostics
az.plot_trace(trace)
az.plot_rank(trace) # Rank plots
Purpose: Does model generate data similar to observed data?
Process:
Python implementation:
with model:
ppc = pm.sample_posterior_predictive(trace)
# Visual check (ArviZ 1.x: plot_ppc was replaced by plot_ppc_dist)
az.plot_ppc_dist(ppc, num_samples=100)
# Quantitative check: compute the statistic per posterior draw over the
# observation dimension (do NOT iterate the array directly - that loops
# over chains, not draws)
obs_mean = np.mean(observed_data)
pp = ppc.posterior_predictive['y_obs'] # dims: (chain, draw, obs)
pred_means = pp.mean(dim=pp.dims[-1]).values.ravel() # one mean per draw
p_value = np.mean(pred_means >= obs_mean) # Bayesian (posterior predictive) p-value
"A Bayesian independent samples t-test was conducted to compare groups A and B. Weakly informative priors were used: Normal(0, 1) for the mean difference and Half-Cauchy(0, 1) for the pooled standard deviation. The posterior distribution of the mean difference had a mean of 5.2 (95% CI [2.3, 8.1]), indicating that Group A scored higher than Group B. The Bayes Factor BF₁₀ = 23.5 provided strong evidence for a difference between groups, and there was a 99.7% probability that Group A's mean exceeded Group B's mean."
"A Bayesian linear regression was fitted with weakly informative priors (Normal(0, 10) for coefficients, Half-Cauchy(0, 5) for residual SD). The model explained substantial variance (R² = 0.47, 95% CI [0.38, 0.55]). Study hours (β = 0.52, 95% CI [0.38, 0.66]) and prior GPA (β = 0.31, 95% CI [0.17, 0.45]) were credible predictors (95% CIs excluded zero). Posterior predictive checks showed good model fit. Convergence diagnostics were satisfactory (all R-hat < 1.01, ESS > 1000)."
Install with uv (see SKILL.md). ArviZ requires Python >= 3.10. ArviZ 1.x is the current line and is a breaking rewrite: az.summary defaults to 89% intervals and takes ci_prob= (the old hdi_prob= keyword is gone), az.hdi takes prob=, az.plot_posterior/az.plot_ppc were replaced by az.plot_dist/az.plot_ppc_dist, and az.waic was removed (use az.loo).
pymc>=5): Full Bayesian modeling frameworkarviz>=1.0): Visualization and diagnostics (docs)uv pip install bambi)Use Bayesian when:
Frequentist may be sufficient when: