Bayesian Vector Error Correction — I. The term structure¶
Vector autoregressions, Part 6a: cointegration¶
Every VAR in this series has treated its variables as either stationary (growth rates) or, in the large-BVAR notebook, as unit-root levels shrunk toward random walks. Cointegration is the case in between and the most economically loaded: several series each wander like a random walk (I(1)), yet specific linear combinations of them are stationary — pinned down by a long-run equilibrium the economy is always pulled back toward. The natural laboratory is the term structure of interest rates: the 3-month, 5-, 10- and 30-year Treasury yields each drift over decades, but the expectations hypothesis says they cannot drift apart without bound — the spreads between them are mean-reverting. If so, the yields are cointegrated, share a small number of common stochastic trends, and must be modelled in error-correction form.
This is the first of three cointegration notebooks (Part 6). It carries the full model and the from-scratch Bayesian sampler; the companions apply the same machinery to the Fisher effect and money demand — two long-run relations the data will treat very differently.
Data: US Treasury constant-maturity yields (3m, 5y, 10y, 30y), monthly 1994–2024 (FRED).
The model: VAR → VECM, and the reduced-rank restriction¶
Take the level VAR($p$) and difference it into error-correction form: $$\Delta y_t = c + \Pi\, y_{t-1} + \sum_{i=1}^{p-1}\Gamma_i\,\Delta y_{t-i} + \varepsilon_t,\qquad \varepsilon_t\sim\mathcal N(0,\Sigma),$$ with $\Pi = -(I-A_1-\dots-A_p)$ the long-run impact matrix. Every term but $\Pi y_{t-1}$ is stationary; that single levels term carries the cointegration. Its rank classifies the system: $\text{rank}\,\Pi=0$ → no cointegration (a VAR in differences); $\text{rank}\,\Pi=m$ → the levels are already stationary (a VAR in levels); and $0<r<m$ → $r$ cointegrating relations, with $$\boxed{\Pi=\alpha\beta'},\qquad \alpha,\beta\ \text{are } m\times r.$$ $\beta$ holds the cointegrating vectors — $\beta'y_t$ is stationary, the $r$ combinations that don't wander — and $\alpha$ the adjustment loadings, the speed at which each variable is pulled back when the system is off its equilibrium $\beta'y_{t-1}$.
Identification. $\alpha\beta'=(\alpha M)(M^{-1}\beta')'$ for any invertible $M$, so only the column space of $\beta$ is data-identified. We impose the linear normalization $\beta=[\,I_r\,;\,B\,]$ (fix the top $r\times r$ block to the identity, estimate the free $(m-r)\times r$ block $B$). This pins down a representative $\beta$ but — a point that matters below — the individual coefficients of $\beta$ are a convention, whereas the stationarity of $\beta'y_t$ is not.
The Bayesian sampler (from scratch — bvecm.py)¶
Given the rank $r$ and the normalization, the model is conditionally linear, so a three-block Gibbs sampler with conjugate draws follows — the same machinery as the other samplers in this series, in a new self-contained module (bvar.py untouched):
- $\Sigma \mid$ rest — inverse-Wishart on the VECM residuals.
- $[\alpha,\Gamma,c]\mid\beta,\Sigma$ — with $\beta$ fixed, $\beta'y_{t-1}$ is a regressor, so this is a multivariate-Normal (SUR) draw.
- $B\mid\alpha,\Gamma,\Sigma$ — the term $\alpha\beta'y_{t-1}=\alpha[I_r;B]'y_{t-1}$ is linear in $B$, giving a Normal conditional.
Priors: Normal on $B$ (diffuse, or centred on a theory value), Normal on $\alpha,\Gamma$, inverse-Wishart on $\Sigma$. The sampler is initialised at the Johansen reduced-rank estimate. We report the frequentist Johansen trace test alongside as the standard rank reference.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from statsmodels.tsa.vector_ar.vecm import coint_johansen
import bvecm
dfy = pd.read_csv("bvar_yields_data.csv", parse_dates=["date"], index_col="date")
cols = ["y3m", "y5y", "y10y", "y30y"]; Y = dfy[cols].values; dates = dfy.index; m = 4; p = 2; r = 2
# --- ADF integration-order pre-test (cointegration requires I(1) inputs) ---
from statsmodels.tsa.stattools import adfuller
print("ADF unit-root pre-test (H0: unit root / I(1)):")
for j, cc in enumerate(cols):
a0 = adfuller(Y[:, j], maxlag=4, autolag="AIC"); a1 = adfuller(np.diff(Y[:, j]), maxlag=4, autolag="AIC")
print(" %-5s level ADF %6.2f p=%.3f -> %s | diff ADF %6.2f p=%.3f" % (
cc, a0[0], a0[1], "I(1)" if a0[1] > 0.05 else "I(0)", a1[0], a1[1]))
print(" => all four yields are I(1): unit root in levels, stationary in differences\n")
# --- Johansen trace test (frequentist rank reference) ---
jo = coint_johansen(Y, det_order=0, k_ar_diff=1)
print("JOHANSEN trace test (term structure):")
for i in range(m):
print(" r<=%d : trace %6.1f 95%%crit %5.1f %s" % (i, jo.lr1[i], jo.cvt[i, 1],
"reject" if jo.lr1[i] > jo.cvt[i, 1] else "ACCEPT -> r=%d" % i))
print(" => cointegration rank r = 2\n")
# --- Bayesian VECM (from scratch): 3-block Gibbs at r=2 ---
fit = bvecm.gibbs_vecm(Y, p=p, r=r, ndraw=4000, burn=2000, seed=1)
beta = np.median(fit["beta"], 0); alpha = np.median(fit["alpha"], 0)
ec = bvecm.ec_term(fit, Y) # (n, r) error-correction terms beta'y_t
print("Bayesian beta (median):\n", np.round(beta, 2))
print("alpha (median, adjustment speeds):\n", np.round(alpha, 3))
print("EC-term std", np.round(ec.std(0), 2), " vs yield-level std", np.round(Y.std(0), 2))
# figure: the yields wander (I(1)); the r error-correction terms are stationary -> vecm_ts_1.png
_dec = dates[p - 1:len(Y) - 1] # dates aligned to Y_{t-1} in the EC term
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
_ystyle = [("y3m", "firebrick"), ("y5y", "navy"), ("y10y", "seagreen"), ("y30y", "darkorange")]
for _k, (_cc, _col) in enumerate(_ystyle):
ax1.plot(dates, Y[:, _k], color=_col, lw=1.6, label=_cc)
ax1.set_title("Treasury yields: each wanders (I(1))")
ax1.set_ylabel("percent"); ax1.grid(alpha=.25); ax1.legend(ncol=2, fontsize=8)
_ecstyle = ["firebrick", "navy"]
for _j in range(ec.shape[1]):
ax2.plot(_dec, ec[:, _j], color=_ecstyle[_j], lw=1.6, label=r"EC term %d ($\beta'y$)" % (_j + 1))
ax2.axhline(ec[:, _j].mean(), color=_ecstyle[_j], lw=.8, ls=":", alpha=.6)
ax2.set_title("Error-correction terms: stationary, revert to a fixed level")
ax2.grid(alpha=.25); ax2.legend(fontsize=8)
fig.tight_layout(); plt.show()
ADF unit-root pre-test (H0: unit root / I(1)): y3m level ADF -2.05 p=0.265 -> I(1) | diff ADF -6.78 p=0.000 y5y level ADF -1.78 p=0.393 -> I(1) | diff ADF -16.95 p=0.000 y10y level ADF -2.15 p=0.226 -> I(1) | diff ADF -10.69 p=0.000 y30y level ADF -2.17 p=0.219 -> I(1) | diff ADF -10.78 p=0.000 => all four yields are I(1): unit root in levels, stationary in differences JOHANSEN trace test (term structure): r<=0 : trace 82.2 95%crit 47.9 reject r<=1 : trace 40.1 95%crit 29.8 reject r<=2 : trace 10.5 95%crit 15.5 ACCEPT -> r=2 r<=3 : trace 3.6 95%crit 3.8 ACCEPT -> r=3 => cointegration rank r = 2
Bayesian beta (median): [[ 1. 0. ] [ 0. 1. ] [-4.87 -2.38] [ 4.18 1.41]] alpha (median, adjustment speeds): [[-0.087 0.3 ] [ 0.006 0.171] [ 0.006 0.184] [ 0.007 0.099]] EC-term std [0.93 0.17] vs yield-level std [2.12 1.9 1.69 1.51]
Reading the cointegration¶
- The yields wander; their combinations don't. Each yield has a level standard deviation of ~1.5–2.1 and drifts over the sample (left panel). The two error-correction terms $\beta'y_t$ have standard deviations of only 0.93 and 0.17 and visibly revert to a fixed level (right panel). That gap is cointegration: four I(1) yields, but only two independent stochastic trends, so $4-2=2$ stationary long-run relations hold the curve together — the Johansen trace test agrees at $r=2$.
- Only the space is identified, not the coefficients. The normalised $\beta$ has odd-looking entries (e.g. $-4.87, 4.18$ on the long yields) rather than clean $(1,-1)$ spreads. That is expected: with $r=2$ the data identify the two-dimensional cointegrating space, not individual vectors — any rotation $\beta M$ spans the same space and yields the same (stationary) fit. What is economically real is that two stationary combinations exist, not the particular basis the normalisation prints.
- The adjustment loadings $\alpha$ show who corrects. The short rate carries the largest loadings — the front end of the curve does most of the error-correcting back toward equilibrium, while the long end (30y) barely adjusts, consistent with long rates behaving as the near-random-walk "level" of the curve.
from statsmodels.tsa.vector_ar.vecm import VECM
from statsmodels.tsa.api import VAR
# Out-of-sample race: does keeping the cointegration help forecasts? Recursive, 2012-2024.
# VECM (rank 2) vs VAR-in-levels (ignores unit roots) vs VAR-in-differences (ignores the level relations).
H = 12; hshow = [1, 3, 6, 12]; start = int(np.where(dates >= pd.Timestamp("2012-01-01"))[0][0])
sse = {k: {h: [] for h in hshow} for k in ["VECM", "VAR-diff", "VAR-levels"]}
for t in range(start, len(Y) - H):
tr = Y[:t]
fV = VECM(tr, k_ar_diff=1, coint_rank=2, deterministic="co").fit().predict(steps=H)
fL = VAR(tr).fit(p).forecast(tr[-p:], H)
dtr = np.diff(tr, axis=0); fD = tr[-1] + np.cumsum(VAR(dtr).fit(1).forecast(dtr[-1:], H), 0)
for h in hshow:
a = Y[t + h - 1]
sse["VECM"][h].append(np.mean((fV[h-1]-a)**2)); sse["VAR-levels"][h].append(np.mean((fL[h-1]-a)**2))
sse["VAR-diff"][h].append(np.mean((fD[h-1]-a)**2))
rmse = {k: [np.sqrt(np.mean(sse[k][h])) for h in hshow] for k in sse}
print("OOS RMSE (avg over yields) by horizon (months):", hshow)
for k in ["VAR-levels", "VAR-diff", "VECM"]: print(" %-11s" % k, np.round(rmse[k], 3))
# figure: RMSE vs forecast horizon, one line per model -> vecm_ts_2.png
_models = [("VECM", "firebrick"), ("VAR-diff", "navy"), ("VAR-levels", "seagreen")]
fig, ax = plt.subplots(figsize=(8, 4))
for _name, _col in _models:
ax.plot(hshow, rmse[_name], color=_col, lw=1.6, marker="o", ms=4, label=_name)
ax.set_xticks(hshow)
ax.set_xlabel("forecast horizon (months)"); ax.set_ylabel("RMSE")
ax.grid(alpha=.25); ax.legend(title="model")
ax.set_title("Out-of-sample RMSE by forecast horizon (term structure)")
fig.tight_layout(); plt.show()
OOS RMSE (avg over yields) by horizon (months): [1, 3, 6, 12] VAR-levels [0.217 0.417 0.63 0.984] VAR-diff [0.214 0.412 0.644 1.077] VECM [0.216 0.415 0.632 1.009]
Results — cointegration is real, and it matters most for not over-differencing¶
- The yield curve is cointegrated ($r=2$). Both the Johansen trace test and the Bayesian VECM find two stationary long-run relations among the four yields — the curve has only two common stochastic trends, and the spreads (in some basis) are mean-reverting, as the expectations hypothesis predicts.
- Forecasting: keep the levels. At one year out the VAR-in-differences is clearly worst (RMSE 1.08) — differencing away the level relations discards exactly the information that anchors long-horizon forecasts (at very short horizons, one-to-three months, it is actually a shade best: the level relations bind only as the horizon lengthens). The VECM (1.01) and the levels-VAR (0.98) track together and both beat it: the honest lesson (Christoffersen-Diebold 1998) is that the payoff of cointegration is chiefly in not throwing the levels away, while imposing the rank restriction on top of a levels VAR is a smaller, sample-dependent refinement.
- The Bayesian value-added. Beyond a point estimate, the Gibbs sampler delivers the full posterior of the adjustment speeds and the cointegrating space, and a coherent framework for rank and prior information — where Johansen offers a sequence of tests with non-standard critical values. The R cross-check reproduces the rank ($r=2$) and the stationary error-correction relations with
urca.
The next two notebooks turn the same machine on relations the data treat very differently: the Fisher effect (Part 6b) and money demand (Part 6c).
References¶
- Engle, R. F. & Granger, C. W. J. (1987). Co-integration and error correction. Econometrica 55, 251–276.
- Johansen, S. (1991). Estimation and hypothesis testing of cointegration vectors in Gaussian VAR models. Econometrica 59, 1551–1580.
- Campbell, J. Y. & Shiller, R. J. (1987). Cointegration and tests of present value models. J. Political Economy 95, 1062–1088.
- Christoffersen, P. F. & Diebold, F. X. (1998). Cointegration and long-horizon forecasting. J. Business & Economic Statistics 16, 450–458.
Next: bvecm_termstructure_R.ipynb — the R cross-check (urca::ca.jo).