Bayesian Multivariate Regression
Python · R · Download conjugate sampler
Model
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.
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 . 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.
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