|
| 1 | +"""Bayesian VAR with Minnesota (Litterman) prior. |
| 2 | +
|
| 3 | +The Minnesota prior shrinks towards a random-walk model: each variable's |
| 4 | +own first lag coefficient is centred at 1, all other coefficients at 0, |
| 5 | +with tightness controlled by hyperparameter λ₁ (overall tightness) and |
| 6 | +λ₂ (cross-variable shrinkage). |
| 7 | +
|
| 8 | +The posterior is analytically tractable (normal-inverse-Wishart) and |
| 9 | +computed in closed form — no MCMC required for the posterior mean and |
| 10 | +credible intervals. |
| 11 | +
|
| 12 | +References |
| 13 | +---------- |
| 14 | +Litterman, R.B. (1986). "Forecasting with Bayesian Vector Autoregressions." |
| 15 | + *JBE&S*, 4(1), 25–38. |
| 16 | +Doan, T., Litterman, R. & Sims, C. (1984). "Forecasting and Conditional |
| 17 | + Projection Using Realistic Prior Distributions." |
| 18 | + *ECREV*, 3(1), 1–100. |
| 19 | +Kilian, L. & Lütkepohl, H. (2017). *Structural Vector Autoregressive |
| 20 | + Analysis*. Cambridge. |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +from dataclasses import dataclass |
| 25 | +from typing import Optional |
| 26 | + |
| 27 | +import numpy as np |
| 28 | +import pandas as pd |
| 29 | + |
| 30 | + |
| 31 | +@dataclass |
| 32 | +class BVARResult: |
| 33 | + coef: np.ndarray # (K*p + 1, K) posterior mean B |
| 34 | + sigma: np.ndarray # (K, K) posterior mean of Sigma |
| 35 | + fitted: np.ndarray # (T-p, K) |
| 36 | + residuals: np.ndarray # (T-p, K) |
| 37 | + var_names: list |
| 38 | + lags: int |
| 39 | + n: int |
| 40 | + lambda1: float |
| 41 | + lambda2: float |
| 42 | + |
| 43 | + def forecast(self, horizon: int = 8) -> pd.DataFrame: |
| 44 | + K = self.sigma.shape[0] |
| 45 | + p = self.lags |
| 46 | + B = self.coef |
| 47 | + last_obs = self.fitted[-1:] + self.residuals[-1:] # last actual |
| 48 | + # gather last p observations |
| 49 | + history = np.zeros((p, K)) |
| 50 | + T_data = self.fitted.shape[0] |
| 51 | + for lag in range(p): |
| 52 | + idx = T_data - 1 - lag |
| 53 | + if idx >= 0: |
| 54 | + history[lag] = self.fitted[idx] + self.residuals[idx] |
| 55 | + fc = np.empty((horizon, K)) |
| 56 | + for h in range(horizon): |
| 57 | + x = np.concatenate([history.ravel(), [1.0]]) |
| 58 | + fc[h] = x @ B |
| 59 | + history = np.roll(history, 1, axis=0) |
| 60 | + history[0] = fc[h] |
| 61 | + cols = self.var_names |
| 62 | + return pd.DataFrame(fc, columns=cols, |
| 63 | + index=np.arange(1, horizon + 1)) |
| 64 | + |
| 65 | + def irf(self, shock_var: int = 0, horizon: int = 20) -> np.ndarray: |
| 66 | + """Orthogonalised impulse responses (Cholesky decomposition).""" |
| 67 | + K = self.sigma.shape[0] |
| 68 | + p = self.lags |
| 69 | + B = self.coef[:-1] # exclude constant row |
| 70 | + # Companion form |
| 71 | + A_mats = [B[k * K:(k + 1) * K].T for k in range(p)] |
| 72 | + chol = np.linalg.cholesky(self.sigma) |
| 73 | + irfs = np.zeros((horizon, K)) |
| 74 | + phi = np.eye(K) |
| 75 | + irfs[0] = chol[:, shock_var] |
| 76 | + for h in range(1, horizon): |
| 77 | + phi_new = np.zeros((K, K)) |
| 78 | + for j in range(min(h, p)): |
| 79 | + phi_new += A_mats[j] @ (irfs[h - 1 - j][:, None] @ np.eye(1, K, 0) |
| 80 | + if h - 1 - j == 0 else np.diag(irfs[h - 1 - j])) |
| 81 | + # Simplified: multiply lag coefficients |
| 82 | + contrib = np.zeros(K) |
| 83 | + for j in range(min(h, p)): |
| 84 | + contrib += A_mats[j] @ irfs[h - 1 - j] |
| 85 | + irfs[h] = contrib |
| 86 | + return irfs |
| 87 | + |
| 88 | + def summary(self) -> str: |
| 89 | + K = len(self.var_names) |
| 90 | + lines = [ |
| 91 | + f"Bayesian VAR (Minnesota prior)", |
| 92 | + f" lags = {self.lags}, K = {K}, T = {self.n}", |
| 93 | + f" λ₁ = {self.lambda1}, λ₂ = {self.lambda2}", |
| 94 | + "", |
| 95 | + "Posterior mean coefficients (first 5 rows):", |
| 96 | + str(pd.DataFrame(self.coef[:5], columns=self.var_names).round(3)), |
| 97 | + ] |
| 98 | + return "\n".join(lines) |
| 99 | + |
| 100 | + def __repr__(self) -> str: |
| 101 | + return self.summary() |
| 102 | + |
| 103 | + |
| 104 | +def bvar( |
| 105 | + data: pd.DataFrame, |
| 106 | + lags: int = 4, |
| 107 | + lambda1: float = 0.1, |
| 108 | + lambda2: float = 0.5, |
| 109 | +) -> BVARResult: |
| 110 | + """Bayesian VAR with Minnesota (Litterman) prior. |
| 111 | +
|
| 112 | + Parameters |
| 113 | + ---------- |
| 114 | + data : pd.DataFrame |
| 115 | + Columns are the endogenous variables. |
| 116 | + lags : int, default 4 |
| 117 | + lambda1 : float, default 0.1 |
| 118 | + Overall tightness (smaller = stronger shrinkage toward RW). |
| 119 | + lambda2 : float, default 0.5 |
| 120 | + Cross-variable shrinkage relative to own-lag. |
| 121 | + """ |
| 122 | + Y_raw = data.to_numpy(dtype=float) |
| 123 | + var_names = list(data.columns) |
| 124 | + T, K = Y_raw.shape |
| 125 | + |
| 126 | + # Build VAR design: Y = X B + E |
| 127 | + Y = Y_raw[lags:] # (T-p, K) |
| 128 | + n = Y.shape[0] |
| 129 | + X_parts = [] |
| 130 | + for lag in range(1, lags + 1): |
| 131 | + X_parts.append(Y_raw[lags - lag: T - lag]) |
| 132 | + X = np.column_stack(X_parts + [np.ones(n)]) # (n, K*p + 1) |
| 133 | + m = X.shape[1] |
| 134 | + |
| 135 | + # OLS as starting point for σ² estimates |
| 136 | + B_ols = np.linalg.lstsq(X, Y, rcond=None)[0] |
| 137 | + E_ols = Y - X @ B_ols |
| 138 | + sigma_ols = np.diag(E_ols.T @ E_ols / n) |
| 139 | + |
| 140 | + # Minnesota prior: V_prior is diagonal, B_prior is near RW |
| 141 | + V_prior = np.zeros(m) |
| 142 | + B_prior = np.zeros((m, K)) |
| 143 | + for k in range(K): |
| 144 | + for lag in range(1, lags + 1): |
| 145 | + for j in range(K): |
| 146 | + idx = (lag - 1) * K + j |
| 147 | + if j == k: |
| 148 | + V_prior[idx] = (lambda1 / lag) ** 2 |
| 149 | + if lag == 1: |
| 150 | + B_prior[idx, k] = 1.0 # RW prior |
| 151 | + else: |
| 152 | + V_prior[idx] = (lambda1 * lambda2 / lag) ** 2 * ( |
| 153 | + sigma_ols[k] / max(sigma_ols[j], 1e-12) |
| 154 | + ) |
| 155 | + V_prior[-1] = 100.0 # flat prior on constant |
| 156 | + |
| 157 | + # Posterior: B_post = (X'X + V^{-1})^{-1} (X'Y + V^{-1} B_prior) |
| 158 | + V_inv = np.diag(1.0 / np.maximum(V_prior, 1e-12)) |
| 159 | + XtX = X.T @ X |
| 160 | + precision = XtX + V_inv |
| 161 | + try: |
| 162 | + precision_inv = np.linalg.inv(precision) |
| 163 | + except np.linalg.LinAlgError: |
| 164 | + precision_inv = np.linalg.pinv(precision) |
| 165 | + B_post = precision_inv @ (X.T @ Y + V_inv @ B_prior) |
| 166 | + E_post = Y - X @ B_post |
| 167 | + Sigma_post = E_post.T @ E_post / n |
| 168 | + |
| 169 | + return BVARResult( |
| 170 | + coef=B_post, sigma=Sigma_post, |
| 171 | + fitted=X @ B_post, residuals=E_post, |
| 172 | + var_names=var_names, lags=lags, n=n, |
| 173 | + lambda1=lambda1, lambda2=lambda2, |
| 174 | + ) |
0 commit comments