Bayesian Markov-Switching AR
Python · R · Hamilton (1989) · Download Gibbs sampler
Model
A -regime AR() model in which the intercept switches with a latent discrete state that evolves as a first-order Markov chain. The regime governs the mean level — low-growth (recession) vs. high-growth (expansion) in Hamilton's formulation — while the AR coefficients and innovation variance remain constant across regimes. The transition matrix encodes regime persistence: expected duration of regime is .
- — regime-specific intercept; identification enforced by
- — AR coefficients, constant across regimes
- — innovation variance, constant across regimes
- — transition matrix (rows = from-state, columns = to-state)
Identification
Regimes are exchangeable — the likelihood is identical if we swap all labels — so the posterior is multimodal. Identification is enforced by relabelling each Gibbs sweep so the intercepts are ascending (). In the NumPyro implementation the equivalent is an on . Both devices remove label-switching without constraining the chain's behaviour.
5-Block Gibbs — FFBS (Chib 1996)
The five conjugate blocks are straightforward except Block 1, where updating one at a time mixes slowly (adjacent regimes are strongly correlated). Forward-Filter Backward-Sampling (Chib 1996) draws the entire path jointly in two passes. Forward pass (Hamilton filter): sweep , carrying filtered probabilities via predict → update. Backward pass: sweep , drawing actual regime labels — then — which is exact by the Markov property. Smoothed regime probabilities come for free by averaging the sampled paths; no separate smoother is needed.
| Block | Target | Posterior |
|---|---|---|
| (1) | FFBS: forward Hamilton filter, backward sample — exact joint draw | |
| (2) | Normal — GLS on demeaned series with lagged levels | |
| (3) | per regime | Normal — conjugate update on observations in regime |
| (4) | Inverse-Gamma from pooled residuals | |
| (5) | rows of | Dirichlet where = transition count |
NumPyro alternative: marginalize states, run NUTS
An alternative implementation in NumPyro marginalizes the discrete states analytically via the forward filter — adding the resulting marginal log-likelihood with numpyro.factor — and runs NUTS on the continuous parameters only. Post-fit smoothed probabilities are recovered with a JAX Kim forward–backward pass. Marginalizing is cleaner and easier to extend (switching variance, covariates, hierarchy), but the from-scratch FFBS makes the algorithm explicit and runs with no dependencies beyond NumPy. The two implementations target the same posterior, so their agreement is a genuine cross-check.
| FFBS Gibbs (Section 3) | NumPyro NUTS (Sections 1–2) | |
|---|---|---|
| Discrete states | Sampled via FFBS each sweep | Marginalized by forward filter; only continuous params sampled |
| Continuous params | Conjugate Gibbs blocks | NUTS (gradient-based) |
| Regime probs | Average sampled paths (free) | Post-hoc Kim forward–backward (JAX) |
| Identification | Relabel by ascending each sweep | OrderedTransform on |
| Dependencies | NumPy only | NumPyro + JAX |
| Extensibility | Transparent; pedagogically explicit | Easy to add switching variance, covariates, hierarchy |
Results
Applied to two datasets. Sections 1–2 Synthetic + Hamilton (1989) GNP (Bayesian): the synthetic AR(2) with known intercepts and transition matrix diag(0.90, 0.80) is recovered with 88.9% regime classification accuracy. On Hamilton's quarterly US real GNP growth (1951Q2–1984Q4), the sampler finds and — nearly identical to Hamilton's −0.36 and 1.16 — and the smoothed correlates 0.77 with NBER dates (never used in estimation) with 91% classification accuracy. Section 3 FFBS vs. NumPyro cross-check: both converge to the same regime story; the hand-rolled FFBS lands slightly closer to Hamilton's durations (3.6q / 9.0q vs. 2.3q / 6.9q) and achieves 94% NBER accuracy. Frequentist validation (Python statsmodels and R MSwM): a volatility-switching AR(2) on US gasoline log-returns (BLS APU000074714, 1976–2026) gives identical two-regime σ = 1.83% / 6.03% (low / high vol) across both packages, and the AR dynamics match the McCulloch–Tsay Bayesian change-point estimates from the MTS notebook to within rounding.
Section 2 — Hamilton (1989) US GNP: Bayesian vs. paper
| Quantity | FFBS Gibbs | NumPyro NUTS | Hamilton (1989) |
|---|---|---|---|
| −0.33, 1.07 | −0.38, 0.97 | −0.36, 1.16 | |
| 0.86 | 0.75 | ~0.77 | |
| Recession duration (q) | 3.6 | 2.3 | ~4 |
| Expansion duration (q) | 9.0 | 6.9 | ~10 |
| corr(P(rec), NBER) | 0.83 | 0.77 | — |
| NBER classification | 94% | 91% | — |
Frequentist validation — gasoline log-returns (MTS cross-check)
| Quantity | Python statsmodels | R MSwM | MTS Bayesian (mts_python) |
|---|---|---|---|
| 0.606, −0.241 | 0.538, −0.234 | 0.589, −0.252 | |
| Low-vol | 1.83% | 1.85% | ~2.6% (pre-2000) |
| High-vol | 6.03% | 6.04% | ~5–7% (modern) |
| P(high-vol) 1976–1999 | 0.15 | 0.15 | low regime |
| P(high-vol) 2000–2009 | 0.97 | 0.96 | dominant ~2000 break |
| P(high-vol) 2010–2019 | 0.83 | 0.82 | elevated baseline |
| P(high-vol) 2020–2026 | 0.75 | 0.74 | 2022 Russia–Ukraine, 2026 Iran/Hormuz |
The ~2000 volatility transition appears unprompted in all three implementations. The AR(2) dynamics match the McCulloch–Tsay change-point estimates to within rounding — a three-way validation across Bayesian change-point, frequentist Hamilton filter (Python), and R EM estimation.
Notebooks
Downloads
bayesian_msar_gibbs.py From-scratch FFBS Gibbs sampler for switching-intercept MS-AR(p) — NumPy only hamilton_gnp.csv Hamilton (1989) quarterly US real GNP growth, 1951Q2–1984Q4, with NBER recession indicator APU000074714.csv BLS series APU000074714 — US city average regular unleaded gasoline, monthly (1976–2026) References
- Hamilton, J. D. (1989). A new approach to the economic analysis of nonstationary time series and the business cycle. Econometrica 57(2), 357–384. — the Markov-switching AR model and the US GNP business-cycle application
- Goldfeld, S. M. & Quandt, R. E. (1973). A Markov model for switching regressions. Journal of Econometrics 1(1), 3–15. — the original Markov-switching regression
- Chib, S. (1996). Calculating posterior distributions and modal estimates in Markov mixture models. Journal of Econometrics 75(1), 79–97. — the Forward-Filter Backward-Sampling (FFBS) draw of the regime path
- Kim, C.-J. & Nelson, C. R. (1999). State-Space Models with Regime Switching. MIT Press. — the Kim smoother and Bayesian estimation of switching models
- Frühwirth-Schnatter, S. (2006). Finite Mixture and Markov Switching Models. Springer. — label-switching, identification, and the ordering constraint used here
Gibbs Sampler — Source Code
"""
Bayesian Markov-switching AR(p) — from-scratch Gibbs sampler (FFBS).
Model (switching intercept, constant AR coefficients and variance):
y_t = c_{S_t} + sum_{j=1}^p phi_j y_{t-j} + e_t, e_t ~ N(0, sigma^2)
S_t in {0,...,K-1} a first-order Markov chain with transition matrix P.
Sampler (fully conjugate Gibbs), one sweep:
1. States S_{p:T-1} -- Forward-Filter Backward-Sample (Chib 1996)
2. phi | S, c, sigma2 -- Normal-Normal (GLS on lagged levels)
3. c_k | S, phi, sigma2 -- Normal-Normal (per regime)
4. sigma2 | . -- Inverse-Gamma
5. P rows | S -- Dirichlet (conjugate to transition counts)
Identification: regimes relabelled each sweep so intercepts are ascending
(c_0 < c_1 < ...), which removes label switching.
No external MCMC packages -- NumPy only. Companion to mts_gibbs.py.
"""
import numpy as np
def simulate_msar(T, c, phi, sigma, P, seed=1):
"""Simulate a switching-intercept AR(p). Returns (y, S_true)."""
rng = np.random.default_rng(seed)
c = np.asarray(c, float); phi = np.asarray(phi, float)
K = len(c); p = len(phi)
S = np.zeros(T, int)
for t in range(1, T):
S[t] = rng.choice(K, p=P[S[t - 1]])
y = np.zeros(T)
for t in range(T):
ar = sum(phi[j] * y[t - 1 - j] for j in range(p) if t - 1 - j >= 0)
y[t] = c[S[t]] + ar + rng.normal(0, sigma)
return y, S
def _stationary(P):
"""Stationary distribution of transition matrix P (rows = from-state)."""
K = P.shape[0]
A = np.vstack([P.T - np.eye(K), np.ones(K)])
b = np.append(np.zeros(K), 1.0)
pi, *_ = np.linalg.lstsq(A, b, rcond=None)
pi = np.clip(pi, 1e-12, None)
return pi / pi.sum()
def msar_gibbs(y, p=2, K=2, R=4000, burn=1000, seed=0,
m0=0.0, v0=25.0, Vphi=0.25, a0=2.0, b0=1.0,
alpha_diag=8.0, alpha_off=2.0, nprint=0):
"""Gibbs sampler for the switching-intercept MS-AR(p). Returns dict of draws."""
rng = np.random.default_rng(seed)
y = np.asarray(y, float); T = len(y)
idx = np.arange(p, T); n = len(idx)
X = np.column_stack([y[idx - j - 1] for j in range(p)]) # (n, p) lagged levels
yt = y[idx]
# --- inits ---
phi = np.zeros(p)
sigma2 = float(np.var(yt))
c = np.quantile(yt, np.linspace(0.3, 0.7, K))
P = np.full((K, K), 0.1); np.fill_diagonal(P, 0.9); P /= P.sum(1, keepdims=True)
alpha = np.full((K, K), alpha_off); np.fill_diagonal(alpha, alpha_diag)
keep = R - burn
Sd = np.zeros((keep, n), np.int8); cD = np.zeros((keep, K))
phiD = np.zeros((keep, p)); sigD = np.zeros(keep); Pd = np.zeros((keep, K, K))
for g in range(R):
# 1. FFBS -----------------------------------------------------------
mu_tk = c[None, :] + (X @ phi)[:, None] # (n, K)
loglik = -0.5 * np.log(2 * np.pi * sigma2) - 0.5 * (yt[:, None] - mu_tk) ** 2 / sigma2
filt = np.zeros((n, K))
a_ = np.log(_stationary(P)) + loglik[0]; a_ -= a_.max()
w = np.exp(a_); filt[0] = w / w.sum()
for t in range(1, n):
pred = filt[t - 1] @ P
a_ = np.log(pred + 1e-300) + loglik[t]; a_ -= a_.max()
w = np.exp(a_); filt[t] = w / w.sum()
S = np.zeros(n, int)
S[-1] = rng.choice(K, p=filt[-1])
for t in range(n - 2, -1, -1):
w = filt[t] * P[:, S[t + 1]]; w /= w.sum()
S[t] = rng.choice(K, p=w)
# 2. phi | S, c, sigma2 (Normal-Normal) ----------------------------
z = yt - c[S]
Bcov = np.linalg.inv(np.eye(p) / Vphi + X.T @ X / sigma2)
phi = rng.multivariate_normal(Bcov @ (X.T @ z / sigma2), Bcov)
# 3. c_k | S, phi, sigma2 ------------------------------------------
u = yt - X @ phi
for k in range(K):
mk = (S == k); nk = int(mk.sum())
prec = 1.0 / v0 + nk / sigma2
mean = (m0 / v0 + u[mk].sum() / sigma2) / prec
c[k] = rng.normal(mean, np.sqrt(1.0 / prec))
# 4. sigma2 | . (Inverse-Gamma) -----------------------------------
e = yt - c[S] - X @ phi
sigma2 = 1.0 / rng.gamma(a0 + n / 2.0, 1.0 / (b0 + 0.5 * np.sum(e ** 2)))
# 5. P rows | S (Dirichlet) ---------------------------------------
Nc = np.zeros((K, K))
np.add.at(Nc, (S[:-1], S[1:]), 1.0)
for i in range(K):
P[i] = rng.dirichlet(alpha[i] + Nc[i])
# identification: relabel so intercepts ascending ------------------
order = np.argsort(c)
if not np.all(order == np.arange(K)):
c = c[order]; P = P[np.ix_(order, order)]
S = np.argsort(order)[S]
if g >= burn:
j = g - burn
Sd[j] = S; cD[j] = c; phiD[j] = phi; sigD[j] = sigma2; Pd[j] = P
if nprint and (g + 1) % nprint == 0:
print(f" [msar] {g+1}/{R}", flush=True)
return dict(S=Sd, c=cD, phi=phiD, sigma2=sigD, P=Pd, idx=idx, p=p, K=K)
def regime_probs(out):
"""Smoothed P(S_t = k | y), shape (n, K), by averaging sampled state paths."""
S = out['S']; K = out['K']
return np.stack([(S == k).mean(0) for k in range(K)], axis=1)
def summary(out):
"""Posterior means of the structural parameters."""
return dict(c=out['c'].mean(0), phi=out['phi'].mean(0),
sigma=np.sqrt(out['sigma2'].mean()),
P=out['P'].mean(0))