Bayesian Multivariate Regression
Python · R · Download conjugate sampler
Model
Seven brands of canned tuna share a shelf, and whatever moves one week's sales — a holiday, a heatwave, a rival's promotion — tends to move all seven together. Running seven separate regressions throws that fact away. Fitting them jointly keeps it, and in the particular case where every equation uses the same regressors, the joint answer is available in closed form: there is a formula for the posterior, so nothing has to be simulated at all.
For observations and dependent variables, the model is:
- — matrix of dependent variables
- — design matrix (shared across all equations)
- — matrix of regression coefficients
- — cross-equation error covariance
All equations share the same design matrix . When equations have different regressors, use the SUR framework instead.
Prior — Normal–Inverse-Wishart
where is the inverse-Wishart with . The Normal-IW is conjugate: the posterior has the same form.
Posterior
The posterior hyperparameters are available in closed form:
Because the posterior is exact, each call to (R) or (Python) produces independent draws — no MCMC, no burn-in, no convergence diagnostics needed. That exemption is worth spelling out, because R-hat, effective sample size and burn-in are the standard checks applied to almost every other posterior in this collection. Those checks exist because a Markov chain only approaches its target: it begins wherever it was initialised, needs a stretch of wandering before its draws are representative, and every draw is correlated with the one before it. Here the conjugate prior leaves the posterior as a Normal–Inverse-Wishart — the standard conjugate pairing for a multivariate Normal, a Normal over the coefficients and an Inverse-Wishart over the covariance matrix, chosen precisely because it returns a posterior of the same family as the prior — with known parameters, so a draw is generated from that distribution directly — representative from the very first one, and statistically independent of all the others. There is no chain to converge, so there is nothing to diagnose.
Notebooks
The from-scratch notebook first validates the conjugate sampler on simulated data, then applies it to a tuna demand system — brands' log-movement regressed on log-prices and promotion, so is an elasticity matrix and captures the cross-brand error correlation. Three engines agree: the from-scratch conjugate i.i.d. sampler, R's bayesm rmultireg, and a PyMC/NUTS fit with an LKJ prior on — a prior over correlation matrices with one shape parameter, tunable from uniform across every valid correlation matrix toward the identity, and the usual choice when a covariance needs a prior and conjugacy is unavailable. All recover the same elasticity matrix (negative own-price for 6 of 7 brands), the same strong cross-equation error correlation (with a credible interval excluding zero), and the same collinearity artifact on one brand — validating the conjugate result against HMC on a different parameterisation. A closing figure makes the exactness claim concrete rather than asserted: one elasticity is sampled twice from the same known target, once by the conjugate route and once by a random-walk Metropolis sampler. The conjugate draws are uncorrelated and representative immediately; the Metropolis chain spends its first draws walking in from its starting value and thereafter needs roughly 36 draws to carry the information of one conjugate draw. Burn-in, and effective sample size exist to manage exactly those two problems — which is why a conjugate posterior reports none of them.
Downloads
Conjugate Sampler — Source Code
"""
mvr_conjugate.py
----------------
Conjugate posterior sampler for the multivariate regression model
Y = X B + E, E_t ~ N(0, Sigma)
Prior: B | Sigma ~ MN(Bbar, Sigma x A^{-1})
Sigma ~ IW(nu, V)
rmultireg_np() mirrors R bayesm::rmultireg exactly (column-major B storage).
"""
import numpy as np
import pandas as pd
from scipy.stats import invwishart
def rmultireg_np(Y, X, Bbar, A, nu, V, rng):
"""
One IID draw from the conjugate Normal-IW posterior.
Parameters
----------
Y : (N, M) array of dependent variables
X : (N, K) design matrix (same for all equations)
Bbar : (K, M) prior mean of B
A : (K, K) prior precision on B (small = diffuse)
nu : scalar IW degrees of freedom (must be > M-1)
V : (M, M) IW scale matrix
rng : numpy Generator (e.g. np.random.default_rng(42))
Returns
-------
B : (K, M) draw from posterior
Sigma : (M, M) draw from posterior
"""
N, M = Y.shape
K = X.shape[1]
XtX = X.T @ X
XtY = X.T @ Y
# Posterior hyperparameters
A_tilde = A + XtX
A_tilde_inv = np.linalg.inv(A_tilde)
B_tilde = A_tilde_inv @ (A @ Bbar + XtY)
V_tilde = (V
+ Y.T @ Y
+ Bbar.T @ A @ Bbar
- B_tilde.T @ A_tilde @ B_tilde)
nu_tilde = nu + N
# Draw Sigma ~ IW(nu_tilde, V_tilde)
Sigma = invwishart.rvs(df=nu_tilde, scale=V_tilde, random_state=rng)
# Draw B | Sigma ~ MN(B_tilde, Sigma x A_tilde^{-1})
L_Ainv = np.linalg.cholesky(A_tilde_inv)
L_Sig = np.linalg.cholesky(Sigma)
Z = rng.standard_normal((K, M))
B = B_tilde + L_Ainv @ Z @ L_Sig.T
return B, Sigma
def rmultireg_np_batch(Y, X, Bbar, A, nu, V, R, seed=42):
"""
Draw R IID samples from the conjugate posterior.
Returns
-------
B_draws : (R, K*M) column-major, matching R as.vector(B)
Sigma_draws : (R, M*M) column-major
"""
N, M = Y.shape
K = X.shape[1]
rng = np.random.default_rng(seed)
# Precompute posterior hyperparameters once
XtX = X.T @ X
XtY = X.T @ Y
A_tilde = A + XtX
A_tilde_inv = np.linalg.inv(A_tilde)
B_tilde = A_tilde_inv @ (A @ Bbar + XtY)
V_tilde = (V
+ Y.T @ Y
+ Bbar.T @ A @ Bbar
- B_tilde.T @ A_tilde @ B_tilde)
nu_tilde = nu + N
L_Ainv = np.linalg.cholesky(A_tilde_inv)
B_draws = np.empty((R, K * M))
Sigma_draws = np.empty((R, M * M))
for r in range(R):
Sigma = invwishart.rvs(df=nu_tilde, scale=V_tilde, random_state=rng)
L_Sig = np.linalg.cholesky(Sigma)
Z = rng.standard_normal((K, M))
B = B_tilde + L_Ainv @ Z @ L_Sig.T
B_draws[r] = B.ravel(order='F') # column-major = R's as.vector
Sigma_draws[r] = Sigma.ravel(order='F')
return B_draws, Sigma_draws
def posterior_summary(B_draws, K, M, eq_names=None, param_names=None,
B_true=None, ols_B=None):
"""
Build a tidy DataFrame summarising B posterior draws.
B_draws : (R, K*M) column-major array (output of rmultireg_np_batch)
"""
if eq_names is None: eq_names = [f'y{m+1}' for m in range(M)]
if param_names is None: param_names = [f'x{k}' for k in range(K)]
rows = []
for m in range(M):
for k in range(K):
d = B_draws[:, m * K + k]
row = dict(
equation = eq_names[m],
regressor = param_names[k],
bayes_mean= d.mean(),
bayes_sd = d.std(),
CI_lo = np.quantile(d, 0.025),
CI_hi = np.quantile(d, 0.975),
)
if B_true is not None:
row['truth'] = B_true[k, m]
if ols_B is not None:
row['ols'] = ols_B[k, m]
rows.append(row)
df = pd.DataFrame(rows)
if B_true is not None:
df['covers'] = (df['CI_lo'] <= df['truth']) & (df['truth'] <= df['CI_hi'])
return df
References
- Tiao, G. C. & Zellner, A. (1964). On the Bayesian estimation of multivariate regression. Journal of the Royal Statistical Society: Series B 26(2), 277–285. — the conjugate Bayesian multivariate regression implemented here
- Rossi, P. E., Allenby, G. M. & McCulloch, R. (2005). Bayesian Statistics and Marketing. Wiley. —
bayesm::rmultireg, and thetunadataset - Chevalier, J. A., Kashyap, A. K. & Rossi, P. E. (2003). Why don't prices rise during periods of peak demand? Evidence from scanner data. American Economic Review 93(1), 15–37. — the Dominick's Finer Foods scanner data
- Lewandowski, D., Kurowicka, D. & Joe, H. (2009). Generating random correlation matrices based on vines and extended onion method. Journal of Multivariate Analysis 100(9), 1989–2001. — the LKJ prior used in the PyMC fit