GARCH(1,1) — PyMC¶
The Bayesian-Python engine of the trio (from-scratch · PyMC · R)¶
Companion to garch_python.ipynb (from-scratch: MLE + Gauss–Hermite + importance sampling + Metropolis–Hastings + griddy Gibbs) and garch_R.ipynb (rugarch + bayesGARCH). Here the same GARCH(1,1) is fit with PyMC / NUTS, with the variance recursion written as a pytensor.scan:
$$r_t=\sigma_t\varepsilon_t,\qquad \sigma_t^2=\omega+\alpha r_{t-1}^2+\beta\sigma_{t-1}^2.$$
Stationarity is guaranteed by reparameterizing $(\alpha,\beta)$ through a persistence $p=\alpha+\beta\sim\mathrm{Beta}$ (so $p<1$) and a share $w=\alpha/p$. We fit both Normal and Student-t innovations. (The scan compiles to C via g++ in this environment, so full-sample NUTS is tractable.)
import os
_lib = r"C:\Users\user\anaconda3\envs\pymc-env\Library\bin"
if _lib not in os.environ.get("PATH", ""): os.environ["PATH"] = _lib + os.pathsep + os.environ.get("PATH", "")
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
import numpy as np, pandas as pd, pymc as pm, pytensor, pytensor.tensor as pt, arviz as az, matplotlib.pyplot as plt
import garch_scratch as G # reuse the from-scratch variance recursion for plotting
d = pd.read_csv("sp500ret.csv", parse_dates=["date"]); r = d["ret"].values - d["ret"].values.mean()
r2 = r**2; s0 = float(np.var(r)); print("%d daily returns" % len(r))
def fit_garch(student, draws=800):
with pm.Model() as m:
omega = pm.HalfNormal("omega", 0.5)
persist = pm.Beta("persist", 20, 1.5) # alpha+beta = p < 1 (stationarity)
wshare = pm.Beta("wshare", 1, 8) # alpha's share of p
alpha = pm.Deterministic("alpha", persist * wshare)
beta = pm.Deterministic("beta", persist * (1 - wshare))
def step(r2m, hprev, om, al, be):
return om + al * r2m + be * hprev
h, _ = pytensor.scan(step, sequences=[pt.as_tensor(r2[:-1])],
outputs_info=[pt.as_tensor(s0)], non_sequences=[omega, alpha, beta])
h = pt.concatenate([[pt.as_tensor(s0)], h])
sd = pt.sqrt(h)
if student:
nu = pm.Uniform("nu", 2.1, 40.0)
pm.StudentT("obs", nu=nu, mu=0.0, sigma=sd * pt.sqrt((nu - 2) / nu), observed=r)
else:
pm.Normal("obs", 0.0, sd, observed=r)
idata = pm.sample(draws, tune=draws, chains=2, cores=1, target_accept=0.9,
random_seed=1, progressbar=False)
return idata
idata_n = fit_garch(False)
idata_t = fit_garch(True)
print("done")
5523 daily returns
C:\Users\user\AppData\Local\Temp\ipykernel_63600\1739718354.py:19: DeprecationWarning: Scan return signature will change. Updates dict will not be returned, only the first argument. Pass `return_updates=False` to conform to the new API and avoid this warning h, _ = pytensor.scan(step, sequences=[pt.as_tensor(r2[:-1])],
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (2 chains in 1 job)
NUTS: [omega, persist, wshare]
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\threadpoolctl.py:1226: RuntimeWarning:
Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at
the same time. Both libraries are known to be incompatible and this
can cause random crashes or deadlocks on Linux when loaded in the
same Python program.
Using threadpoolctl may cause crashes or deadlocks. For more
information and possible workarounds, please see
https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md
warnings.warn(msg, RuntimeWarning)
Sampling 2 chains for 800 tune and 800 draw iterations (1_600 + 1_600 draws total) took 97 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
C:\Users\user\AppData\Local\Temp\ipykernel_63600\1739718354.py:19: DeprecationWarning: Scan return signature will change. Updates dict will not be returned, only the first argument. Pass `return_updates=False` to conform to the new API and avoid this warning h, _ = pytensor.scan(step, sequences=[pt.as_tensor(r2[:-1])],
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\threadpoolctl.py:1226: RuntimeWarning:
Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at
the same time. Both libraries are known to be incompatible and this
can cause random crashes or deadlocks on Linux when loaded in the
same Python program.
Using threadpoolctl may cause crashes or deadlocks. For more
information and possible workarounds, please see
https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md
warnings.warn(msg, RuntimeWarning)
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (2 chains in 1 job)
NUTS: [omega, persist, wshare, nu]
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\threadpoolctl.py:1226: RuntimeWarning:
Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at
the same time. Both libraries are known to be incompatible and this
can cause random crashes or deadlocks on Linux when loaded in the
same Python program.
Using threadpoolctl may cause crashes or deadlocks. For more
information and possible workarounds, please see
https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md
warnings.warn(msg, RuntimeWarning)
Sampling 2 chains for 800 tune and 800 draw iterations (1_600 + 1_600 draws total) took 112 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
done
def summ(idata, extra=()):
return az.summary(idata, var_names=["omega", "alpha", "beta", *extra])[["mean", "sd", "r_hat"]]
print("PyMC GARCH(1,1) — Normal innovations"); print(summ(idata_n).to_string())
print("\nPyMC GARCH(1,1) — Student-t innovations"); print(summ(idata_t, extra=["nu"]).to_string())
pn = idata_n.posterior; pt_ = idata_t.posterior
persist_n = float((pn["alpha"] + pn["beta"]).mean()); persist_t = float((pt_["alpha"] + pt_["beta"]).mean())
print("\npersistence Normal %.4f Student-t %.4f nu %.2f" % (persist_n, persist_t, float(pt_["nu"].mean())))
print("cross-check (garch_python from-scratch): Normal a+b~0.993 ; t a+b~0.997, nu~6.2")
PyMC GARCH(1,1) — Normal innovations
mean sd r_hat
omega 0.0147 0.0026 1.00
alpha 0.0901 0.008 1.00
beta 0.9013 0.0088 1.00
PyMC GARCH(1,1) — Student-t innovations
mean sd r_hat
omega 0.0071 0.00175 1.00
alpha 0.0626 0.0068 1.00
beta 0.9326 0.007 1.01
nu 6.4 0.52 1.00
persistence Normal 0.9914 Student-t 0.9952 nu 6.40
cross-check (garch_python from-scratch): Normal a+b~0.993 ; t a+b~0.997, nu~6.2
# conditional volatility (posterior-mean params, reuse from-scratch recursion) + 1% VaR + param posteriors
from scipy.stats import t as tdist
mt = np.array([float(pt_[k].mean()) for k in ["omega", "alpha", "beta"]])
h_t = G.garch_var(mt, r2, s0); vol_t = np.sqrt(h_t); nu_hat = float(pt_["nu"].mean())
var99 = -np.sqrt(h_t[-1]) * np.sqrt((nu_hat - 2)/nu_hat) * tdist.ppf(0.01, nu_hat)
fig = plt.figure(figsize=(13, 7)); gs = fig.add_gridspec(2, 3, height_ratios=[1.3, 1])
axv = fig.add_subplot(gs[0, :])
axv.plot(d["date"], vol_t, color="steelblue", lw=.7, label="PyMC GARCH(1,1)-t conditional vol")
axv.plot(d["date"], np.abs(r), color="0.8", lw=.3, zorder=0, label="|return|")
axv.set_ylim(0, 8); axv.set_ylabel("daily vol (%)"); axv.legend(fontsize=8)
axv.set_title("PyMC GARCH(1,1)-t - persistence %.3f, nu %.1f, next-day 1%% VaR %.2f%%" % (mt[1]+mt[2], nu_hat, var99))
for j, p in enumerate(["omega", "alpha", "beta"]):
a = fig.add_subplot(gs[1, j])
a.hist(idata_n.posterior[p].values.ravel(), bins=50, density=True, color="steelblue", alpha=.55, label="Normal")
a.hist(idata_t.posterior[p].values.ravel(), bins=50, density=True, color="firebrick", alpha=.55, label="Student-t")
a.set_title(p); a.set_yticks([])
if j == 0: a.legend(fontsize=8)
plt.tight_layout(); plt.savefig("garch_pymc.png", dpi=120, bbox_inches="tight"); plt.show()
Results¶
- PyMC matches the other engines. Posterior means $\omega\approx0.015,\ \alpha\approx0.09,\ \beta\approx0.90$ (Normal) and $\omega\approx0.007,\ \alpha\approx0.06,\ \beta\approx0.93,\ \nu\approx6.3$ (Student-t), with $\hat r\approx1.0$ — the same numbers the from-scratch five-method notebook and R's rugarch/bayesGARCH report. Persistence $\alpha+\beta\approx0.991$ (Normal) / $0.995$ (t), in line with the from-scratch and rugarch values (~0.993 / 0.997).
- Student-t is strongly preferred (as the from-scratch Bayes factor confirmed): the ARCH term shrinks and $\beta$ rises once $\nu$ absorbs the fat tails.
- The
pytensor.scanvariance recursion compiles to C, so full-sample NUTS runs in a couple of minutes — PyMC is a perfectly good GARCH engine here; NumPyro remains the faster option for the heavier SV scans.
This is the PyMC corner of the GARCH(1,1) trio; the from-scratch notebook shows the estimation machinery, this one the probabilistic-programming route, and garch_R.ipynb the R packages.