Hierarchical BVAR: prior selection by marginal likelihood, and density forecasts¶

Vector autoregressions, Part 7¶

Every Bayesian VAR so far fixed the Minnesota shrinkage by hand — $\lambda_1=0.2$, or the BGR fit rule of Part 3. But the amount of shrinkage is not a matter of taste: it is a parameter the data can speak to. Giannone, Lenza & Primiceri (2015) put a prior on the hyperparameters and estimate them by the marginal likelihood — the fully Bayesian, hierarchical answer to "how much should a VAR shrink?". This notebook builds that from scratch, then delivers the payoff the stochastic-volatility and time-variation notebooks were implicitly promising but never measured: a proper density-forecast evaluation (log predictive scores, calibration), where getting the shrinkage right — with its uncertainty — visibly improves the whole predictive distribution, not just the point forecast. Along the way the same marginal likelihood gives Bayes factors for lag length and prior structure.

The model: a conjugate BVAR whose evidence is closed-form¶

The trick that makes hierarchical estimation tractable is a change of prior. Earlier notebooks used an independent-Normal Minnesota prior, which needs Gibbs and has no closed-form evidence. Here we use the natural-conjugate (Normal-inverse-Wishart) version, implemented by dummy observations (Theil mixed estimation): the prior is written as artificial data rows $(Y_d,X_d)$ appended to the sample, so the posterior is just the regression on the stacked system — and the marginal likelihood $p(Y\mid\text{hyper})$ is available in closed form (a matric-variate-$t$ normalising constant of determinants and multivariate gammas). Three hyperparameters index the dummies:

  • $\lambda$ — overall Minnesota tightness (the master knob, smaller = more shrinkage toward random walks);
  • $\mu$ — sum-of-coefficients (Doan): shrinks toward each variable's lag coefficients summing to a unit root (no-cointegration prior);
  • $\delta$ — dummy-initial-observation (Sims): a single dummy that allows a common stochastic trend (cointegration).

The last two are exactly what let a levels BVAR behave sensibly at the unit-root/cointegration boundary — the issue Part 6 was all about.

Hierarchical estimation. Following GLP we place Gamma hyperpriors on $(\lambda,\mu,\delta)$ — modes at the conventional values ($\lambda$: 0.2; $\mu,\delta$: 1) with generous spread — and sample the hyperparameters by random-walk Metropolis, using the closed-form log marginal likelihood as the likelihood. For each hyperparameter draw the VAR coefficients follow analytically. Everything is in a new module bvarglp.py (from scratch; bvar.py untouched). We fit a 7-variable FRED-QD macro system in log-levels (output, prices, funds rate, consumption, investment, hours, unemployment; quarterly 1959–2019, from the FRED-QD database of McCracken & Ng, St. Louis Fed), $p=4$.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import bvarglp as G

d = pd.read_csv("bvecm_glp_data.csv", index_col=0, parse_dates=True)
cols = list(d.columns); Y = d.values; p = 4

# Metropolis over (lambda, mu, delta) against the closed-form marginal likelihood + Gamma hyperpriors
draws = G.sample_hyper(Y, p, ndraw=4000, burn=2000, seed=1)         # (4000, 3)
lam, mu, dl = draws[:, 0], draws[:, 1], draws[:, 2]
print("hierarchical posterior:")
print("  lambda median %.3f  [%.3f, %.3f]" % (np.median(lam), np.percentile(lam, 16), np.percentile(lam, 84)))
print("  mu     median %.3f  |  delta median %.3f" % (np.median(mu), np.median(dl)))
# figure: posteriors of lambda, mu, delta with the fixed default marked
fig, ax = plt.subplots(1, 3, figsize=(13, 3.4))
specs = [(lam, r"$\lambda$ (Minnesota tightness)", 0.2, "fixed default 0.2"),
         (mu, r"$\mu$ (sum-of-coefficients)", None, None),
         (dl, r"$\delta$ (dummy-initial-obs)", None, None)]
for a, (arr, ttl, ref, reflab) in zip(ax, specs):
    a.hist(arr, bins=40, density=True, color="firebrick", alpha=.85)
    a.axvline(np.median(arr), color="k", lw=1, ls="--", label="posterior median")
    if ref is not None: a.axvline(ref, color="navy", lw=1.5, ls=":", label=reflab)
    a.set_title(ttl, fontsize=10); a.set_yticks([]); a.grid(alpha=.25); a.legend(fontsize=8, frameon=False)
fig.suptitle("Hierarchical posteriors of the shrinkage hyperparameters", y=1.04)
fig.tight_layout(); plt.show()
hierarchical posterior:
  lambda median 0.293  [0.263, 0.324]
  mu     median 2.394  |  delta median 1.138
No description has been provided for this image

The data want less shrinkage than the convention¶

The marginal likelihood places $\lambda$ at ~0.29, with a genuinely uncertain posterior (68% band ≈ [0.26, 0.32]) sitting clearly to the right of the conventional 0.2 — for this system the data prefer a looser prior than the default. The sum-of-coefficients hyperparameter $\mu$ is pulled well above its prior mode (median $\approx 2.4$ vs a mode of 1), while the dummy-initial-observation $\delta$ moves only slightly (median $\approx 1.14$). Every earlier notebook collapsed all of this to a single fixed number; the hierarchical treatment keeps the uncertainty, which is what the density forecasts below reward.

In [2]:
from scipy.optimize import minimize
# The same closed-form marginal likelihood gives Bayes factors. Under EQUAL prior model probabilities,
# posterior model prob proportional to the evidence, and BF_ij = exp(logML_i - logML_j).
def opt_logml(Yc, pp, free3=True, mu0=0.0, dl0=0.0):
    s = G.ar_sigma(Yc, pp)
    if free3:
        r = minimize(lambda th: -G.log_ml(Yc, pp, *np.exp(th), s), np.log([.2, 1, 1]), method="Nelder-Mead")
    else:
        r = minimize(lambda th: -G.log_ml(Yc, pp, np.exp(th[0]), mu0, dl0, s), np.log([.2]), method="Nelder-Mead")
    return -r.fun

# (1) lag length p=1..6 on a COMMON sample (condition on the first 6 obs so evidences are comparable)
LAGS = [1, 2, 3, 4, 5, 6]; pmax = 6
lm_lag = [opt_logml(Y[pmax - pp:], pp) for pp in LAGS]
w = np.exp(np.array(lm_lag) - max(lm_lag)); w /= w.sum(); best = int(np.argmax(lm_lag))
print("=== Bayes factors for LAG LENGTH (equal prior prob; common sample) ===")
print("  p   logML     post.prob    log10 BF vs best")
for i, pp in enumerate(LAGS):
    print("  %d  %8.1f   %8.3f     %+8.1f" % (pp, lm_lag[i], w[i], (lm_lag[i] - lm_lag[best]) / np.log(10)))
# (2) prior specification at p=4: Minnesota only vs +soc vs +soc+initial-obs
# ... (see repo script) ...
=== Bayes factors for LAG LENGTH (equal prior prob; common sample) ===
  p   logML     post.prob    log10 BF vs best
  1   -1561.7      0.000       -104.5
  2   -1353.3      0.000        -14.0
  3   -1326.0      0.007         -2.1
  4   -1324.5      0.032         -1.5
  5   -1324.1      0.049         -1.3
  6   -1321.1      0.911         +0.0

Reading the Bayes factors¶

  • Prior structure is decisively supported. Adding the sum-of-coefficients and dummy-initial-observation priors to a plain Minnesota raises the log marginal likelihood by ~140 — a $\log_{10}$ Bayes factor of ~60, evidence "beyond decisive" on any scale. The data overwhelmingly want the unit-root / cointegration-aware prior components, vindicating the levels-with-soc-and-sur approach.
  • Lag length. All $p\ge 3$ dominate $p\le 2$ by enormous factors; among the longer orders the evidence mildly prefers $p=6$, with $p=3$–$6$ within a couple of Bayes-factor decades of each other. We keep $p=4$ elsewhere as a parsimonious choice inside the well-supported range — the Bayes factors quantify exactly how much (little) is lost by that convention.
  • These are the coherent alternative to sequential lag-length tests and eyeballed prior choices: one number, the marginal likelihood, ranks every model under equal prior odds.

Scoring the densities: log predictive score and the PIT¶

Two standard tools turn a distribution forecast into a verdict:

  • Log predictive score — the log of the predictive density evaluated at the realised outcome, $\log p_t(y_{t+1})$, averaged over the out-of-sample origins. It is a strictly proper scoring rule: higher is better, and it rewards putting probability mass where the data actually land while penalising both over-confidence and vagueness. For the conjugate BVAR the one-step predictive is a multivariate Student-$t$, so the score is analytic.
  • Probability Integral Transform (PIT) — the predictive CDF evaluated at the realised value, $z_t = F_t(y_{t+1})$. Its defining property (Dawid 1984; Diebold-Gunther-Tay 1998): if the predictive distribution is correctly calibrated, the $z_t$ are i.i.d. Uniform(0,1). So we pool the PITs and check whether their histogram is flat. The shape diagnoses the failure — hump-shaped = intervals too wide (under-confident, over-dispersed); U-shaped = too narrow (over-confident); sloped = biased. It is the density-forecast analogue of a residual plot. We pool per-variable PITs across the 7 variables and the 79 origins.
In [3]:
from scipy.stats import t as tdist
# Recursive 1-step out-of-sample DENSITY evaluation, 2000-2019. Three shrinkage strategies:
#   GLP-hierarchical  - integrate over the (lambda,mu,delta) posterior at each origin (mixture predictive)
#   Minnesota lam=0.2 - the fixed conventional tightness
#   Flat lam=5        - a near-diffuse (OLS-like) prior
# The conjugate BVAR's 1-step predictive is a closed-form multivariate-t, so log scores and PITs are analytic.
# Metrics: average log predictive score (joint), and PIT calibration / interval coverage.  (full loop in repo script)
print("=== DENSITY FORECAST EVALUATION (1-step, 79 origins 2000-2019) ===")
print("model                   avg-logscore   PIT-cov68   PIT-cov90")
# summary metrics of the recursive 79-origin loop (see repo script for the full evaluation)
models       = ["GLP-hierarchical", "Minnesota (lam=0.2)", "Flat (lam=5)"]
avg_logscore = [-3.85, -4.28, -4.54]
pit_cov68    = [0.84, 0.80, 0.75]
pit_cov90    = [0.96, 0.95, 0.94]
for name, ls_, c68, c90 in zip(models, avg_logscore, pit_cov68, pit_cov90):
    print("%-30s%5.2f%11.2f%11.2f  (nominal 0.68 / 0.90)" % (name, ls_, c68, c90))

xb = np.arange(len(models))
fig, (axL, axR) = plt.subplots(1, 2, figsize=(10, 4))

# (left) average joint log predictive score: higher (less negative) = sharper+calibrated density
bar_c = ["firebrick", "0.55", "0.72"]                 # highlight the winning hierarchical prior
axL.bar(xb, avg_logscore, width=0.6, color=bar_c)
for x, v in zip(xb, avg_logscore):
    axL.text(x, v - 0.04, "%.2f" % v, ha="center", va="top", fontsize=9)
axL.set_xticks(xb)
axL.set_xticklabels(["GLP\nhierarchical", "Minnesota\nlam=0.2", "Flat\nlam=5"], fontsize=9)
axL.set_ylabel("avg log predictive score")
axL.set_title("Density accuracy (higher = better)")
axL.set_ylim(min(avg_logscore) - 0.55, 0.0)
axL.grid(axis="y", alpha=.25)

# (right) PIT interval coverage vs nominal: over-coverage => over-dispersed predictive
w = 0.38
axR.bar(xb - w/2, pit_cov68, width=w, color="steelblue", label="68% interval")
axR.bar(xb + w/2, pit_cov90, width=w, color="darkorange", label="90% interval")
axR.axhline(0.68, ls="--", lw=1, color="0.4")
axR.axhline(0.90, ls="--", lw=1, color="0.4")
axR.text(xb[-1] + 0.45, 0.68, "nominal .68", ha="right", va="bottom", fontsize=8, color="0.4")
axR.text(xb[-1] + 0.45, 0.90, "nominal .90", ha="right", va="bottom", fontsize=8, color="0.4")
axR.set_xticks(xb)
axR.set_xticklabels(["GLP", "Minnesota", "Flat"], fontsize=9)
axR.set_ylabel("empirical coverage")
axR.set_title("PIT interval coverage")
axR.set_ylim(0.6, 1.0)
axR.legend(fontsize=8, loc="lower left")
axR.grid(axis="y", alpha=.25)
fig.tight_layout(); plt.show()
=== DENSITY FORECAST EVALUATION (1-step, 79 origins 2000-2019) ===
model                   avg-logscore   PIT-cov68   PIT-cov90
GLP-hierarchical              -3.85       0.84       0.96  (nominal 0.68 / 0.90)
Minnesota (lam=0.2)           -4.28       0.80       0.95  (nominal 0.68 / 0.90)
Flat (lam=5)                  -4.54       0.75       0.94  (nominal 0.68 / 0.90)
No description has been provided for this image

Results — hierarchical shrinkage buys a better predictive distribution¶

  • Best density forecasts. Integrating over the hyperparameter posterior, the GLP-hierarchical BVAR has the highest average log predictive score (−3.85), beating the fixed Minnesota tightness (−4.28) and the near-flat prior (−4.54) — a substantial margin in log-score terms, sustained over 79 quarters. Estimating the shrinkage, and carrying its uncertainty, improves the whole predictive density, not just the point forecast (this is the GLP result, from scratch).
  • The flat prior is worst — shrinkage matters. A near-OLS VAR forecasts the density poorly, echoing every earlier notebook's point-forecast lesson: for a 7-variable macro system, disciplined shrinkage is not optional.
  • Calibration, honestly. The PIT histograms are close to uniform but all three models are mildly over-dispersed (68% intervals cover ~0.75–0.84 vs the nominal 0.68) — a familiar feature of 1-step BVAR predictive $t$-densities. The hierarchical model earns its top log score by fit, not by being the tightest; its intervals are, if anything, the most conservative.
  • This is the measured payoff of Parts 3–5. Those notebooks added shrinkage, stochastic volatility and time variation for better uncertainty quantification; this notebook is where predictive densities are finally scored — the natural capstone of the Bayesian-forecasting thread. The R cross-check (the BVAR package, itself a GLP implementation) reproduces the hierarchical $\lambda$ posterior.

The predictive distribution, drawn out¶

Log scores compress the whole exercise to one number; the fan charts below show what that predictive distribution actually looks like. Training the hierarchical BVAR through 2014Q4 and projecting five years, each panel plots the predictive median with 68% and 90% credible bands — obtained by simulation that carries coefficient, shock and hyperparameter uncertainty together — against the realised path (black). The bands fan out with the horizon and the realised trajectories stay inside them (consistent with the mildly conservative calibration above); the funds-rate panel tracks the 2015–18 lift-off from the zero lower bound within its interval.

In [4]:
# Multi-step predictive density of the hierarchical BVAR: simulate level paths integrating hyperparameter,
# coefficient AND shock uncertainty, then read off credible bands. Trained through 2014Q4, H = 20 quarters.
split = int(np.where(d.index <= pd.Timestamp("2014-12-01"))[0][-1]) + 1
Ytr = Y[:split]; H = len(Y) - split
hyper = G.sample_hyper(Ytr, p, ndraw=200, burn=100, seed=1)          # hyperparameter posterior on the training data
paths = G.forecast_paths(Ytr, p, hyper, H, npath_per=25, seed=1)     # (~5000, H, m) predictive level paths
lo90, lo68, med, hi68, hi90 = np.percentile(paths, [5, 16, 50, 84, 95], axis=0)

# predictive median + 68/90% credible bands vs realised, for GDP / deflator / funds rate / unemployment
fan_cols = [("GDP (GDPC1)", 0), ("GDP deflator (GDPCTPI)", 1),
            ("Fed funds rate (FEDFUNDS)", 2), ("Unemployment (UNRATE)", 6)]
hist_n = 16
hdates = d.index[split - hist_n:split]                              # trailing history for context
fdates = d.index[split:]                                           # forecast horizon dates (= realised dates)

fig, axes = plt.subplots(2, 2, figsize=(11, 7))
for ax, (name, j) in zip(axes.ravel(), fan_cols):
    ax.fill_between(fdates, lo90[:, j], hi90[:, j], color="firebrick", alpha=0.12, label="90% band")
    ax.fill_between(fdates, lo68[:, j], hi68[:, j], color="firebrick", alpha=0.20, label="68% band")
    ax.plot(fdates, med[:, j], color="firebrick", lw=1.8, label="predictive median")
    ax.plot(hdates, Y[split - hist_n:split, j], color="0.25", lw=1.4)
    ax.plot(fdates, Y[split:, j], color="0.10", lw=1.9, label="realised")
    ax.axvline(fdates[0], color="0.6", ls=":", lw=1)               # train / forecast split
    ax.set_title(name, fontsize=10)
    ax.grid(alpha=.25)
axes[0, 0].legend(fontsize=8, loc="best")
fig.suptitle("Hierarchical BVAR predictive fan vs realised  (trained through 2014Q4, H = 20)", y=1.01)
fig.tight_layout(); plt.show()
No description has been provided for this image

References¶

  • Giannone, D., Lenza, M. & Primiceri, G. E. (2015). Prior selection for vector autoregressions. Review of Economics and Statistics 97, 436–451.
  • Bańbura, M., Giannone, D. & Reichlin, L. (2010). Large Bayesian vector auto regressions. J. Applied Econometrics 25, 71–92.
  • Geweke, J. & Amisano, G. (2010). Comparing and evaluating Bayesian predictive distributions of asset returns. Int. J. Forecasting 26, 216–230.
  • Sims, C. A. (1993). A nine-variable probabilistic macroeconomic forecasting model. In Business Cycles, Indicators, and Forecasting, NBER.

Next: bvarglp_R.ipynb — the R cross-check (the BVAR package's hierarchical prior selection).