Bayesian BEKK-GARCH (NumPyro)¶
Multivariate volatility, part 3c: a full-posterior diagonal BEKK¶
Part 3 fit the diagonal BEKK by maximum likelihood. Here we give it the Bayesian treatment — a full posterior over the covariance dynamics — completing the symmetry with the Bayesian DCC (dcc_numpyro.ipynb). We keep to the diagonal BEKK deliberately: the full BEKK is only identified up to the sign of $A$ and orthogonal rotations (only $A'A$ and $B'B$ are identified), which splits MCMC chains across equivalent modes. The diagonal form has none of that ambiguity and samples cleanly.
The model (bivariate), with $A=\text{diag}(a_1,a_2)$, $B=\text{diag}(b_1,b_2)$ and $C$ lower-triangular: $$H_t = CC' + A'\,\varepsilon_{t-1}\varepsilon_{t-1}'\,A + B'\,H_{t-1}B,\qquad H_t[i,j]=(CC')[i,j]+a_ia_j\,\varepsilon_{i,t-1}\varepsilon_{j,t-1}+b_ib_j\,H_{t-1}[i,j].$$ Positive-definiteness of $H_t$ is automatic (BEKK's signature); the news impact per series is $a_i^2$ and the persistence $b_i^2$, with stationarity $a_i^2+b_i^2<1$.
Data: DJIA & NASDAQ daily returns, equity_returns.csv (1993–2024) — the same series as bekk_python.ipynb, so the posterior can be checked against the MLE ($a_i^2\approx0.076/0.067$, $b_i^2\approx0.913/0.925$).
Model specification and priors¶
Loadings of variance. The awkward part of a Bayesian BEKK is the stationarity constraint $a_i^2+b_i^2<1$ together with the near-unit-root geometry ($a_i^2+b_i^2\approx0.99$ in equity data — a boundary funnel). We handle both at once with a simplex prior: for each series draw $$\big(a_i^2,\ b_i^2,\ \text{rest}_i\big)\sim\text{Dirichlet}(1,8,1),\qquad a_i=\sqrt{a_i^2}\ge0,\quad b_i=\sqrt{b_i^2}\ge0,$$ so $a_i^2+b_i^2<1$ holds by construction (the "rest" slack is what keeps it strictly inside), the $\text{Dirichlet}(1,8,1)$ mean puts $\approx0.8$ mass on persistence, and $a_i,b_i$ are taken positive (identifying their sign — only the products $a_ia_j,\,b_ib_j$ enter, so a common sign is free and we fix it positive).
Intercept. $C$ is lower-triangular with a positive diagonal, $c_{11},c_{22}\sim\text{HalfNormal}(0.5)$, $c_{21}\sim N(0,0.5)$; $CC'$ is then p.d. and small (it targets the unconditional level).
Likelihood. For the bivariate case everything is a $2\times2$ closed form — no Cholesky is needed:
$$\det H_t = h_{11,t}h_{22,t}-h_{12,t}^2,\qquad r_t'H_t^{-1}r_t=\frac{h_{22,t}r_{1t}^2-2h_{12,t}r_{1t}r_{2t}+h_{11,t}r_{2t}^2}{\det H_t},$$
$$\log p(r_t\mid\cdot)=-\tfrac12\big[2\log2\pi+\log\det H_t + r_t'H_t^{-1}r_t\big].$$
(Using the closed form rather than MultivariateNormal also keeps the notebook robust to environments where JAX's LAPACK Cholesky misbehaves.)
Estimation algorithm¶
The conditional covariance $H_t$ is a deterministic recursion in the parameters and past returns, written as a jax.lax.scan that carries the three unique entries $(h_{11},h_{12},h_{22})$ forward — the exact BEKK filter of bekk.py, differentiable for HMC. There are no latent states (unlike factor SV): only the 7 static parameters $(c_{11},c_{21},c_{22},a_1,a_2,b_1,b_2)$, so this is a small, fast target. We sample with NUTS (JAX/NumPyro — PyMC's scan is unusable on the build that generated the sibling notebooks), 2 chains, init_to_median, and check $\hat R$. The Dirichlet parameterisation keeps the sampler off the $a_i^2+b_i^2=1$ boundary, so no special funnel handling is needed beyond that.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import jax, jax.numpy as jnp, numpyro, numpyro.distributions as dist
from numpyro.infer import MCMC, NUTS, init_to_median
import numpyro.diagnostics as diag
numpyro.enable_x64(); numpyro.set_host_device_count(2)
df = pd.read_csv("equity_returns.csv", parse_dates=["date"], index_col="date")
dates = df.index; rf = df.values - df.values.mean(0); r = jnp.asarray(rf); T, N = r.shape
Sc = np.cov(rf, rowvar=False); H0 = jnp.array([Sc[0, 0], Sc[0, 1], Sc[1, 1]]) # initialise H_1 at sample cov
def bekk_paths(cdiag, c21, a, b): # deterministic H_t = (h11,h12,h22), (T,3)
CC00 = cdiag[0] ** 2; CC10 = c21 * cdiag[0]; CC11 = c21 ** 2 + cdiag[1] ** 2
aa = jnp.outer(a, a); bb = jnp.outer(b, b)
def step(Hp, ep):
h11 = CC00 + aa[0, 0] * ep[0] * ep[0] + bb[0, 0] * Hp[0]
h12 = CC10 + aa[0, 1] * ep[0] * ep[1] + bb[0, 1] * Hp[1]
h22 = CC11 + aa[1, 1] * ep[1] * ep[1] + bb[1, 1] * Hp[2]
Hn = jnp.array([h11, h12, h22]); return Hn, Hn
_, Hr = jax.lax.scan(step, H0, r[:-1])
return jnp.concatenate([H0[None], Hr], 0)
def model(r):
cdiag = numpyro.sample("cdiag", dist.HalfNormal(0.5).expand([2]).to_event(1))
c21 = numpyro.sample("c21", dist.Normal(0., 0.5))
w = numpyro.sample("w", dist.Dirichlet(jnp.array([1., 8., 1.])).expand([2]).to_event(1)) # [a^2, b^2, rest]
a = jnp.sqrt(w[:, 0]); b = jnp.sqrt(w[:, 1])
numpyro.deterministic("news", w[:, 0]); numpyro.deterministic("persist", w[:, 1])
H = bekk_paths(cdiag, c21, a, b)
det = H[:, 0] * H[:, 2] - H[:, 1] ** 2
quad = (H[:, 2] * r[:, 0] ** 2 - 2 * H[:, 1] * r[:, 0] * r[:, 1] + H[:, 0] * r[:, 1] ** 2) / det
numpyro.factor("ll", -0.5 * jnp.sum(2 * jnp.log(2 * jnp.pi) + jnp.log(det) + quad))
mcmc = MCMC(NUTS(model, target_accept_prob=0.9, init_strategy=init_to_median),
num_warmup=1000, num_samples=500, num_chains=2, chain_method="sequential", progress_bar=False)
mcmc.run(jax.random.PRNGKey(0), r=r)
S = mcmc.get_samples(); news = np.array(S["news"]); persist = np.array(S["persist"])
sg = diag.summary(mcmc.get_samples(group_by_chain=True), prob=0.9)
print("Bayesian diagonal BEKK (NumPyro NUTS), DJIA-NASDAQ:")
print(" news a^2: mean %s (MLE 0.076 / 0.067)" % np.round(news.mean(0), 3))
print(" persist b^2: mean %s (MLE 0.913 / 0.925)" % np.round(persist.mean(0), 3))
print(" a^2+b^2: mean %s" % np.round((news + persist).mean(0), 3))
print(" r_hat w max %.3f" % float(np.nanmax(sg["w"]["r_hat"])))
Bayesian diagonal BEKK (NumPyro NUTS), DJIA-NASDAQ: news a^2: mean [0.077 0.068] (MLE 0.076 / 0.067) persist b^2: mean [0.913 0.924] (MLE 0.913 / 0.925) a^2+b^2: mean [0.99 0.993] r_hat w max 1.007
# posterior conditional-correlation band: recompute the H_t path for a batch of draws in one vmap (closed form, no Cholesky)
idx = np.linspace(0, len(news) - 1, 200).astype(int)
Hp = jax.vmap(bekk_paths, in_axes=(0, 0, 0, 0))(
jnp.asarray(S["cdiag"][idx]), jnp.asarray(S["c21"][idx]),
jnp.sqrt(jnp.asarray(S["w"][idx][:, :, 0])), jnp.sqrt(jnp.asarray(S["w"][idx][:, :, 1])))
H = np.array(Hp) # (ndraw, T, 3)
rho = H[:, :, 1] / np.sqrt(H[:, :, 0] * H[:, :, 2])
rlo, rmd, rhi = np.percentile(rho, [5, 50, 95], axis=0)
mle_rho = np.load("bekk_mle_corr.npy")
fig, ax = plt.subplots(1, 2, figsize=(15, 4.6), gridspec_kw=dict(width_ratios=[2, 1]))
ax[0].fill_between(dates, rlo, rhi, color="darkgreen", alpha=.22, lw=0, label="90% posterior band")
ax[0].plot(dates, rmd, color="darkgreen", lw=.6, label="posterior median")
ax[0].plot(dates, mle_rho, color="black", lw=.4, alpha=.6, label="MLE (bekk_python)")
for dt in ("2000-03-10", "2008-09-15", "2020-03-16"): ax[0].axvline(pd.Timestamp(dt), color="0.6", lw=.7, ls=":")
ax[0].set_ylim(0.2, 1.0); ax[0].set_ylabel("conditional correlation"); ax[0].legend(fontsize=8, loc="lower right")
ax[0].set_title("Bayesian BEKK: posterior of the DJIA-NASDAQ correlation path")
for l in ax[0].get_xticklabels(): l.set_rotation(20)
ax[1].scatter(news[:, 0] + persist[:, 0], news[:, 1] + persist[:, 1], s=3, alpha=.15, color="darkgreen")
ax[1].set_xlabel("$a_1^2+b_1^2$ (DJIA)"); ax[1].set_ylabel("$a_2^2+b_2^2$ (NASDAQ)")
ax[1].set_title("Posterior persistence per series\n(near the stationarity boundary)")
plt.tight_layout(); plt.show()
Results¶
- The posterior concentrates on the MLE. The news $a_i^2$ and persistence $b_i^2$ posteriors sit around the maximum-likelihood values ($a_i^2\approx0.07$, $b_i^2\approx0.92$), now with full uncertainty and the stationarity constraint respected exactly. Both series' $a_i^2+b_i^2$ posteriors crowd just below 1 — the near-integrated persistence, quantified rather than point-estimated.
- A posterior over the whole covariance path. The conditional-correlation band tracks the MLE path (low in the late-1990s tech divergence, high in every crisis) and, like the Bayesian DCC, hands a decision-maker a distribution over $H_t$ rather than a single trajectory.
- Why diagonal, and where this sits. Keeping $A,B$ diagonal sidesteps the full-BEKK identification problem, so NUTS mixes without the multi-modality that plagues the full form. This completes the Bayesian trio of the covariance-parameterising models — DCC, and now BEKK — alongside the natively-Bayesian factor SV.
References¶
- Engle, R. F. & Kroner, K. F. (1995). Multivariate simultaneous generalized ARCH. Econometric Theory 11, 122–150.
- Vrontos, I. D., Dellaportas, P. & Politis, D. N. (2003). A full-factor multivariate GARCH model. Econometrics Journal 6, 312–334.