Skip to content

Commit baa2bd3

Browse files
committed
Implement forecasting for ARCH-in-mean models
ARCHInMean.forecast has raised NotImplementedError since the model was added in 2021. Implement it by mirroring ARX.forecast and adding the kappa*f(sigma2) term to the mean recursion and to the simulation and bootstrap paths. The one-step analytic forecast is exact for all form specifications; multi-horizon analytic forecasts use the variance forecast recursion, which is exact when form is 'var'.
1 parent 9b89bf4 commit baa2bd3

2 files changed

Lines changed: 300 additions & 7 deletions

File tree

arch/tests/univariate/test_arch_in_mean.py

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import numpy as np
2+
from numpy.testing import assert_allclose
23
import pandas as pd
4+
from pandas.testing import assert_frame_equal
35
import pytest
46

57
from arch.data import sp500
6-
from arch.univariate import ARCHInMean, Normal
8+
from arch.univariate import ARX, ARCHInMean, Normal
79
from arch.univariate.recursions_python import ARCHInMeanRecursion
810
from arch.univariate.volatility import (
911
ARCH,
@@ -74,10 +76,148 @@ def test_smoke(form):
7476
assert res.param_cov.shape == (5, 5)
7577
assert isinstance(res.param_cov, pd.DataFrame)
7678

77-
with pytest.raises(
78-
NotImplementedError, match=r"forecasts are not implemented for \(G\)ARCH"
79-
):
80-
res.forecast(reindex=True)
79+
fc = res.forecast()
80+
assert fc.mean.shape == (1, 1)
81+
assert np.isfinite(fc.mean.values).all()
82+
assert np.isfinite(fc.variance.values).all()
83+
assert np.isfinite(fc.residual_variance.values).all()
84+
85+
86+
@pytest.mark.parametrize(
87+
("form", "transform"),
88+
[("var", lambda v: v), ("vol", np.sqrt), ("log", np.log)],
89+
)
90+
def test_forecast_analytic_recursion(form, transform):
91+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form=form)
92+
res = gim.fit(disp="off")
93+
fc = res.forecast(horizon=3)
94+
y = np.asarray(gim._y)
95+
mp, _, _ = gim._parse_parameters(np.asarray(res.params))
96+
arp = gim._har_to_ar(mp)
97+
const = arp[0]
98+
ar = arp[1:]
99+
kappa = mp[-1]
100+
rv = fc.residual_variance.values[0]
101+
expected = np.zeros(3)
102+
expected[0] = const + kappa * transform(rv[0]) + ar[0] * y[-1] + ar[1] * y[-2]
103+
expected[1] = const + kappa * transform(rv[1]) + ar[0] * expected[0] + ar[1] * y[-1]
104+
expected[2] = (
105+
const + kappa * transform(rv[2]) + ar[0] * expected[1] + ar[1] * expected[0]
106+
)
107+
assert_allclose(fc.mean.values[0], expected)
108+
109+
110+
def test_forecast_var_simulation_matches_analytic():
111+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="var")
112+
res = gim.fit(disp="off")
113+
fc = res.forecast(horizon=3)
114+
fc_sim = res.forecast(horizon=3, method="simulation", simulations=100000)
115+
sim_mean = fc_sim.simulations.values.mean(axis=1)
116+
assert_allclose(sim_mean, fc.mean.values, atol=0.05)
117+
118+
119+
def test_forecast_kappa_zero_matches_arx():
120+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="vol")
121+
res = gim.fit(disp="off")
122+
params = np.asarray(res.params)
123+
arx = ARX(SP500, lags=2, volatility=GARCH())
124+
arx.fit(disp="off")
125+
kappa_zero = params.copy()
126+
kappa_zero[3] = 0.0
127+
fc_gim = gim.forecast(kappa_zero, horizon=3, reindex=False)
128+
fc_arx = arx.forecast(np.delete(params, 3), horizon=3, reindex=False)
129+
assert_frame_equal(fc_gim.mean, fc_arx.mean)
130+
assert_frame_equal(fc_gim.variance, fc_arx.variance)
131+
assert_frame_equal(fc_gim.residual_variance, fc_arx.residual_variance)
132+
133+
134+
def test_forecast_bootstrap():
135+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="var")
136+
res = gim.fit(disp="off")
137+
fc = res.forecast(
138+
horizon=3, start=200, method="bootstrap", simulations=100, reindex=False
139+
)
140+
assert fc.simulations.values.shape == (SP500.shape[0] - 200, 100, 3)
141+
assert np.isfinite(fc.simulations.values).all()
142+
assert np.isfinite(fc.mean.values).all()
143+
144+
145+
def test_forecast_exog():
146+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="var", x=X[0])
147+
res = gim.fit(disp="off")
148+
fc = res.forecast(horizon=2, x=X[0].iloc[-2:])
149+
y = np.asarray(gim._y)
150+
mp, _, _ = gim._parse_parameters(np.asarray(res.params))
151+
arp = gim._har_to_ar(mp)
152+
const = arp[0]
153+
ar = arp[1:]
154+
kappa = mp[-1]
155+
exog_p = mp[-2]
156+
rv = fc.residual_variance.values[0]
157+
xv = np.asarray(X[0].iloc[-2:])
158+
expected = np.zeros(2)
159+
expected[0] = const + kappa * rv[0] + ar[0] * y[-1] + ar[1] * y[-2] + exog_p * xv[0]
160+
expected[1] = (
161+
const + kappa * rv[1] + ar[0] * expected[0] + ar[1] * y[-1] + exog_p * xv[1]
162+
)
163+
assert_allclose(fc.mean.values[0], expected)
164+
165+
166+
def test_forecast_variance_one_step():
167+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="var")
168+
res = gim.fit(disp="off")
169+
fc = res.forecast(horizon=3)
170+
assert_allclose(fc.variance.values[:, 0], fc.residual_variance.values[:, 0])
171+
172+
173+
def test_forecast_egarch_analytic_horizon():
174+
gim = ARCHInMean(SP500, volatility=EGARCH(), form="log")
175+
res = gim.fit(disp="off")
176+
fc1 = res.forecast(horizon=1)
177+
assert fc1.mean.shape == (1, 1)
178+
with pytest.raises(ValueError, match=r"Analytic forecasts not available"):
179+
res.forecast(horizon=2)
180+
181+
182+
def test_forecast_errors():
183+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH())
184+
res = gim.fit(disp="off")
185+
with pytest.raises(ValueError, match=r"horizon must be an integer"):
186+
gim.forecast(np.asarray(res.params), horizon=0)
187+
with pytest.raises(ValueError, match=r"Due to backcasting"):
188+
res.forecast(horizon=3, start=0)
189+
190+
191+
def test_forecast_padded_start():
192+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH())
193+
res = gim.fit(disp="off")
194+
fc = res.forecast(horizon=3, start=1, reindex=False)
195+
assert fc.mean.shape == (SP500.shape[0] - 1, 3)
196+
assert np.isnan(fc.mean.values[0]).all()
197+
assert np.isfinite(fc.mean.values[1:]).all()
198+
fc_sim = res.forecast(
199+
horizon=3, start=1, method="simulation", simulations=100, reindex=False
200+
)
201+
assert fc_sim.simulations.values.shape == (SP500.shape[0] - 1, 100, 3)
202+
assert np.isnan(fc_sim.simulations.values[0]).all()
203+
204+
205+
def test_forecast_simulation_rng():
206+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH())
207+
res = gim.fit(disp="off")
208+
rng = np.random.RandomState(12345).standard_normal
209+
fc = res.forecast(horizon=2, method="simulation", simulations=100, rng=rng)
210+
assert np.isfinite(fc.simulations.values).all()
211+
212+
213+
def test_forecast_exog_simulation():
214+
gim = ARCHInMean(SP500, lags=2, volatility=GARCH(), form="var", x=X[0])
215+
res = gim.fit(disp="off")
216+
xf = np.zeros((1, 2))
217+
fc = res.forecast(
218+
horizon=2, method="simulation", simulations=100, reindex=False, x=xf
219+
)
220+
assert np.isfinite(fc.simulations.values).all()
81221

82222

83223
def test_example_smoke():

arch/univariate/mean.py

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ def _ar_forecast(
102102
arp: Float64Array,
103103
x: Float64Array,
104104
exogp: Float64Array,
105+
*,
106+
kappa: float = 0.0,
107+
trans_vol: Callable[[Float64Array], Float64Array] | None = None,
108+
var_fcasts: Float64Array | None = None,
105109
) -> Float64Array:
106110
"""
107111
Generate mean forecasts from an AR-X model
@@ -115,6 +119,14 @@ def _ar_forecast(
115119
arp : ndarray
116120
exogp : ndarray
117121
x : ndarray
122+
kappa : float
123+
Coefficient on the transformed conditional variance in the mean.
124+
trans_vol : callable, optional
125+
Transform of the conditional variance entering the mean equation.
126+
Required when ``kappa`` is non-zero.
127+
var_fcasts : ndarray, optional
128+
Conditional variance forecasts, aligned with the forecast horizons.
129+
Required when ``trans_vol`` is provided.
118130
119131
Returns
120132
-------
@@ -130,6 +142,9 @@ def _ar_forecast(
130142
arp_rev = arp[::-1]
131143
for i in range(p, horizon + p):
132144
fcasts[:, i] = constant + fcasts[:, i - p : i].dot(arp_rev)
145+
if trans_vol is not None:
146+
assert var_fcasts is not None
147+
fcasts[:, i] += kappa * trans_vol(var_fcasts[:, i - p])
133148
if x.shape[0] > 0:
134149
fcasts[:, i] += x[:, :, i - p].T @ exogp
135150
fcasts = cast("Float64Array2D", fcasts[:, p:])
@@ -1741,8 +1756,146 @@ def forecast(
17411756
reindex: bool | None = None,
17421757
x: dict[Label, ArrayLike] | ArrayLike | None = None,
17431758
) -> ARCHModelForecast:
1744-
raise NotImplementedError(
1745-
"forecasts are not implemented for (G)ARCH-in-mean models"
1759+
if not isinstance(horizon, (int, np.integer)) or horizon < 1:
1760+
raise ValueError("horizon must be an integer >= 1.")
1761+
# Check start
1762+
earliest, default_start = self._fit_indices
1763+
default_start = max(0, default_start - 1)
1764+
start_index = cutoff_to_index(start, self._y_series.index, default_start)
1765+
if start_index < (earliest - 1):
1766+
raise ValueError(
1767+
"Due to backcasting and/or data availability start cannot be less "
1768+
"than the index of the largest value in the right-hand-side "
1769+
"variables used to fit the first observation. In this model, "
1770+
f"this value is {max(0, earliest - 1)}."
1771+
)
1772+
# Parse params
1773+
params = to_array_1d(params)
1774+
mp, vp, dp = self._parse_parameters(params)
1775+
1776+
#####################################
1777+
# Compute residual variance forecasts
1778+
#####################################
1779+
# Back cast should use only the sample used in fitting
1780+
resids = self.resids(mp)
1781+
backcast = self._volatility.backcast(resids)
1782+
full_resids = to_array_1d(
1783+
self.resids(
1784+
mp,
1785+
cast("Float64Array1D", self._y[earliest:]),
1786+
cast("Float64Array2D", self.regressors[earliest:]),
1787+
)
1788+
)
1789+
vb = self._volatility.variance_bounds(full_resids, 2.0)
1790+
if rng is None:
1791+
rng = self._distribution.simulate(dp)
1792+
variance_start = max(0, start_index - earliest)
1793+
vfcast = self._volatility.forecast(
1794+
vp,
1795+
full_resids,
1796+
backcast,
1797+
vb,
1798+
start=variance_start,
1799+
horizon=horizon,
1800+
method=method,
1801+
simulations=simulations,
1802+
rng=rng,
1803+
random_state=random_state,
1804+
)
1805+
var_fcasts = vfcast.forecasts
1806+
assert var_fcasts is not None
1807+
if start_index < earliest:
1808+
# Pad if asking for variance forecast before earliest available
1809+
var_fcasts = _forecast_pad(earliest - start_index, var_fcasts)
1810+
1811+
arp = self._har_to_ar(mp)
1812+
nexog = 0 if self._x is None else self._x.shape[1]
1813+
exog_p = np.empty([]) if self._x is None else mp[-nexog - 1 : -1]
1814+
constant = arp[0] if self.constant else 0.0
1815+
dynp = arp[int(self.constant) :]
1816+
kappa = mp[-1]
1817+
expected_x = self._reformat_forecast_x(x, horizon, start_index)
1818+
1819+
def trans_vol(sigma2: Float64Array) -> Float64Array:
1820+
if self._form_id == 0:
1821+
return np.log(sigma2)
1822+
return sigma2 ** (self._form_power / 2.0)
1823+
1824+
mean_fcast = _ar_forecast(
1825+
self._y,
1826+
horizon,
1827+
start_index,
1828+
constant,
1829+
dynp,
1830+
expected_x,
1831+
exog_p,
1832+
kappa=kappa,
1833+
trans_vol=trans_vol,
1834+
var_fcasts=var_fcasts,
1835+
)
1836+
# Compute total variance forecasts, which depend on model
1837+
impulse = _ar_to_impulse(horizon, dynp)
1838+
longrun_var_fcasts = var_fcasts.copy()
1839+
for i in range(horizon):
1840+
lrf = var_fcasts[:, : (i + 1)].dot(impulse[i::-1] ** 2)
1841+
longrun_var_fcasts[:, i] = lrf
1842+
variance_paths: Float64Array | None = None
1843+
mean_paths: Float64Array | None = None
1844+
shocks: Float64Array | None = None
1845+
long_run_variance_paths: Float64Array | None = None
1846+
if method.lower() in ("simulation", "bootstrap"):
1847+
assert isinstance(vfcast.forecast_paths, np.ndarray)
1848+
variance_paths = vfcast.forecast_paths
1849+
assert isinstance(vfcast.shocks, np.ndarray)
1850+
shocks = vfcast.shocks
1851+
if start_index < earliest:
1852+
# Pad if asking for variance forecast before earliest available
1853+
variance_paths = _forecast_pad(earliest - start_index, variance_paths)
1854+
shocks = _forecast_pad(earliest - start_index, shocks)
1855+
1856+
long_run_variance_paths = variance_paths.copy()
1857+
for i in range(horizon):
1858+
_impulses = impulse[i::-1][:, None]
1859+
lrvp = variance_paths[:, :, : (i + 1)].dot(_impulses**2)
1860+
lrvp = lrvp[:, :, 0]
1861+
long_run_variance_paths[:, :, i] = lrvp
1862+
t, m = self._y.shape[0], self._max_lags
1863+
mean_paths = np.empty(shocks.shape[:2] + (m + horizon,))
1864+
dynp_rev = dynp[::-1]
1865+
for i in range(start_index, t):
1866+
path_loc = i - start_index
1867+
mean_paths[path_loc, :, :m] = self._y[i - m + 1 : i + 1]
1868+
1869+
for j in range(horizon):
1870+
mean_paths[path_loc, :, m + j] = (
1871+
constant
1872+
+ mean_paths[path_loc, :, j : m + j].dot(dynp_rev)
1873+
+ shocks[path_loc, :, j]
1874+
)
1875+
mean_paths[path_loc, :, m + j] += kappa * trans_vol(
1876+
variance_paths[path_loc, :, j]
1877+
)
1878+
if expected_x.shape[0] > 0:
1879+
mean_paths[path_loc, :, m + j] += (
1880+
expected_x[:, path_loc, j].T @ exog_p
1881+
)
1882+
1883+
mean_paths = mean_paths[:, :, m:]
1884+
1885+
index = self._y_series.index
1886+
reindex = True if reindex is None else reindex
1887+
return ARCHModelForecast(
1888+
index,
1889+
start_index,
1890+
mean_fcast,
1891+
longrun_var_fcasts,
1892+
var_fcasts,
1893+
align=align,
1894+
simulated_paths=mean_paths,
1895+
simulated_residuals=shocks,
1896+
simulated_variances=long_run_variance_paths,
1897+
simulated_residual_variances=variance_paths,
1898+
reindex=reindex,
17461899
)
17471900

17481901
def resids(

0 commit comments

Comments
 (0)