Skip to content

Commit 9f3c480

Browse files
Match statsmodels implementation
Add direct and transformed parameterizations
1 parent a4f7f90 commit 9f3c480

File tree

2 files changed

+105
-38
lines changed

2 files changed

+105
-38
lines changed

pymc_experimental/statespace/models/ETS.py

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,52 +67,65 @@ class BayesianETS(PyMCStateSpace):
6767
6868
\begin{align}
6969
y_t &= l_{t-1} + b_{t-1} + \epsilon_t \\
70-
l_t &= l_{t-1} + \alpha \epsilon_t \\
71-
b_t &= b_{t-1} + \beta \epsilon_t
70+
l_t &= l_{t-1} + b_{t-1} + \alpha \epsilon_t \\
71+
b_t &= b_{t-1} + \alpha \beta^\star \epsilon_t
7272
\end{align}
7373
74+
[1]_ also consider an alternative parameterization with :math:`\beta = \alpha \beta^\star`.
75+
7476
* `ETS(A,N,A)`: Additive seasonal method
7577
7678
.. math::
7779
7880
\begin{align}
7981
y_t &= l_{t-1} + s_{t-m} + \epsilon_t \\
8082
l_t &= l_{t-1} + \alpha \epsilon_t \\
81-
s_t &= s_{t-m} + \gamma \epsilon_t
83+
s_t &= s_{t-m} + (1 - \alpha)\gamma^\star \epsilon_t
8284
\end{align}
8385
86+
[1]_ also consider an alternative parameterization with :math:`\gamma = (1 - \alpha) \gamma^\star`.
87+
8488
* `ETS(A,A,A)`: Additive Holt-Winters method
8589
8690
.. math::
8791
8892
\begin{align}
8993
y_t &= l_{t-1} + b_{t-1} + s_{t-m} + \epsilon_t \\
9094
l_t &= l_{t-1} + \alpha \epsilon_t \\
91-
b_t &= b_{t-1} + \beta \epsilon_t \\
92-
s_t &= s_{t-m} + \gamma \epsilon_t
95+
b_t &= b_{t-1} + \alpha \beta^\star \epsilon_t \\
96+
s_t &= s_{t-m} + (1 - \alpha) \gamma^\star \epsilon_t
9397
\end{align}
9498
99+
[1]_ also consider an alternative parameterization with :math:`\beta = \alpha \beta^star` and
100+
:math:`\gamma = (1 - \alpha) \gamma^\star`.
101+
95102
* `ETS(A, Ad, N)`: Dampened trend method
96103
97104
.. math::
98105
99106
\begin{align}
100107
y_t &= l_{t-1} + b_{t-1} + \epsilon_t \\
101108
l_t &= l_{t-1} + \alpha \epsilon_t \\
102-
b_t &= \phi b_{t-1} + \beta \epsilon_t
109+
b_t &= \phi b_{t-1} + \alpha \beta^\star \epsilon_t
103110
\end{align}
104111
112+
[1]_ also consider an alternative parameterization with :math:`\beta = \alpha \beta^\star`.
113+
105114
* `ETS(A, Ad, A)`: Dampened trend with seasonal method
106115
107116
.. math::
108117
109118
\begin{align}
110119
y_t &= l_{t-1} + b_{t-1} + s_{t-m} + \epsilon_t \\
111120
l_t &= l_{t-1} + \alpha \epsilon_t \\
112-
b_t &= \phi b_{t-1} + \beta \epsilon_t \\
113-
s_t &= s_{t-m} + \gamma \epsilon_t
121+
b_t &= \phi b_{t-1} + \alpha \beta^\star \epsilon_t \\
122+
s_t &= s_{t-m} + (1 - \alpha) \gamma^\star \epsilon_t
114123
\end{align}
115124
125+
[1]_ also consider an alternative parameterization with :math:`\beta = \alpha \beta^star` and
126+
:math:`\gamma = (1 - \alpha) \gamma^\star`.
127+
128+
116129
Parameters
117130
----------
118131
endog: pd.DataFrame
@@ -138,6 +151,17 @@ class BayesianETS(PyMCStateSpace):
138151
The number of periods in a complete seasonal cycle. Ignored if `seasonal` is `False`.
139152
measurement_error: bool
140153
Whether to include a measurement error term in the model. Default is `False`.
154+
use_transformed_parameterization: bool, default False
155+
If true, use the :math:`\alpha, \beta, \gamma` parameterization, otherwise use the :math:`\alpha, \beta^\star,
156+
\gamma^\star` parameterization. This will change the admissible region for the priors.
157+
158+
- Under the **non-transformed** parameterization, all of :math:`\alpha, \beta^\star, \gamma^\star` should be
159+
between 0 and 1.
160+
- Under the **transformed** parameterization, :math:`\alpha \in (0, 1)`, :math:`\beta \in (0, \alpha)`, and
161+
:math:`\gamma \in (0, 1 - \alpha)`
162+
163+
The :meth:`param_info` method will change to reflect the suggested intervals based on the value of this
164+
argument.
141165
filter_type: str, default "standard"
142166
The type of Kalman Filter to use. Options are "standard", "single", "univariate", "steady_state",
143167
and "cholesky". See the docs for kalman filters for more details.
@@ -157,6 +181,7 @@ def __init__(
157181
seasonal: bool = False,
158182
seasonal_periods: int | None = None,
159183
measurement_error: bool = False,
184+
use_transformed_parameterization: bool = False,
160185
filter_type: str = "standard",
161186
verbose: bool = True,
162187
):
@@ -184,6 +209,7 @@ def __init__(
184209
self.damped_trend = damped_trend
185210
self.seasonal = seasonal
186211
self.seasonal_periods = seasonal_periods
212+
self.use_transformed_parameterization = use_transformed_parameterization
187213

188214
if self.seasonal and self.seasonal_periods is None:
189215
raise ValueError("If seasonal is True, seasonal_periods must be provided.")
@@ -258,15 +284,19 @@ def param_info(self) -> dict[str, dict[str, Any]]:
258284
},
259285
"alpha": {
260286
"shape": None,
261-
"constraints": "0 < Sum(alpha, beta, gamma) < 1",
287+
"constraints": "0 < alpha < 1",
262288
},
263289
"beta": {
264290
"shape": None,
265-
"constraints": "0 < Sum(alpha, beta, gamma) < 1",
291+
"constraints": "0 < beta < 1"
292+
if not self.use_transformed_parameterization
293+
else "0 < beta < alpha",
266294
},
267295
"gamma": {
268296
"shape": None,
269-
"constraints": "0 < Sum(alpha, beta, gamma) < 1",
297+
"constraints": "0 < gamma< 1"
298+
if not self.use_transformed_parameterization
299+
else "0 < gamma < (1 - alpha)",
270300
},
271301
"phi": {
272302
"shape": None,
@@ -342,11 +372,18 @@ def make_symbolic_graph(self) -> None:
342372

343373
# The shape of R can be pre-allocated, then filled with the required parameters
344374
R = pt.zeros((self.k_states, self.k_posdef))
345-
R = pt.set_subtensor(R[0, :], 1.0) # We will always have y_t = ... + e_t
346375

347376
alpha = self.make_and_register_variable("alpha", shape=(), dtype=floatX)
348377
R = pt.set_subtensor(R[1, 0], alpha) # and l_t = ... + alpha * e_t
349378

379+
# The R[0, 0] entry needs to be adjusted for a shift in the time indices. Consider the (A, N, N) model:
380+
# y_t = l_{t-1} + e_t
381+
# l_t = l_{t-1} + alpha * e_t
382+
# We want the first equation to be in terms of time t on the RHS, because our observation equation is always
383+
# y_t = Z @ x_t. Re-arranging equation 2, we get l_{t-1} = l_t - alpha * e_t --> y_t = l_t + e_t - alpha * e_t
384+
# --> y_t = l_t + (1 - alpha) * e_t
385+
R = pt.set_subtensor(R[0, :], (1 - alpha))
386+
350387
# Shock and level component always exists, the base case is e_t = e_t and l_t = l_{t-1}
351388
T_base = pt.as_tensor_variable(np.array([[0.0, 0.0], [0.0, 1.0]]))
352389

@@ -357,10 +394,12 @@ def make_symbolic_graph(self) -> None:
357394
self.ssm["initial_state", 2] = initial_trend
358395

359396
beta = self.make_and_register_variable("beta", shape=(), dtype=floatX)
360-
R = pt.set_subtensor(R[2, 0], beta)
397+
if self.use_transformed_parameterization:
398+
R = pt.set_subtensor(R[2, 0], beta)
399+
else:
400+
R = pt.set_subtensor(R[2, 0], alpha * beta)
361401

362402
# If a trend is requested, we have the following transition equations (omitting the shocks):
363-
# y_t = l_{t-1} + b_{t-1}
364403
# l_t = l_{t-1} + b_{t-1}
365404
# b_t = b_{t-1}
366405
T_base = pt.as_tensor_variable(([0.0, 0.0, 0.0], [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]))
@@ -369,7 +408,6 @@ def make_symbolic_graph(self) -> None:
369408
phi = self.make_and_register_variable("phi", shape=(), dtype=floatX)
370409
# We are always in the case where we have a trend, so we can add the dampening parameter to T_base defined
371410
# in that branch. Transition equations become:
372-
# y_t = l_{t-1} + phi * b_{t-1}
373411
# l_t = l_{t-1} + phi * b_{t-1}
374412
# b_t = phi * b_{t-1}
375413
T_base = pt.set_subtensor(T_base[1:, 2], phi)
@@ -384,7 +422,21 @@ def make_symbolic_graph(self) -> None:
384422
self.ssm["initial_state", 2 + int(self.trend) :] = initial_seasonal
385423

386424
gamma = self.make_and_register_variable("gamma", shape=(), dtype=floatX)
387-
R = pt.set_subtensor(R[2 + int(self.trend), 0], gamma)
425+
426+
if self.use_transformed_parameterization:
427+
param = gamma
428+
else:
429+
param = (1 - alpha) * gamma
430+
431+
R = pt.set_subtensor(R[2 + int(self.trend), 0], param)
432+
433+
# Additional adjustment to the R[0, 0] position is required. Start from:
434+
# y_t = l_{t-1} + s_{t-m} + e_t
435+
# l_t = l_{t-1} + alpha * e_t
436+
# s_t = s_{t-m} + gamma * e_t
437+
# Solve for l_{t-1} and s_{t-m} in terms of l_t and s_t, then substitute into the observation equation:
438+
# y_t = l_t + s_t - alpha * e_t - gamma * e_t + e_t --> y_t = l_t + s_t + (1 - alpha - gamma) * e_t
439+
R = pt.set_subtensor(R[0, 0], R[0, 0] - param)
388440

389441
# The seasonal component is always going to look like a TimeFrequency structural component, see that
390442
# docstring for more details

tests/statespace/test_ETS.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,28 @@ def test_param_info(order: tuple[str, str, str], expected_params):
104104

105105

106106
@pytest.mark.parametrize("order, expected_params", zip(orders, order_params), ids=order_names)
107-
def test_statespace_matrices(order: tuple[str, str, str], expected_params: list[str]):
107+
@pytest.mark.parametrize("use_transformed", [True, False], ids=["transformed", "untransformed"])
108+
def test_statespace_matrices(
109+
rng, order: tuple[str, str, str], expected_params: list[str], use_transformed: bool
110+
):
108111
seasonal_periods = np.random.randint(3, 12)
109-
mod = BayesianETS(order=order, seasonal_periods=seasonal_periods, measurement_error=True)
112+
mod = BayesianETS(
113+
order=order,
114+
seasonal_periods=seasonal_periods,
115+
measurement_error=True,
116+
use_transformed_parameterization=use_transformed,
117+
)
110118
expected_states = 2 + int(order[1] != "N") + int(order[2] != "N") * seasonal_periods
111119

112120
test_values = {
113-
"alpha": 0.7,
114-
"beta": 0.15,
115-
"gamma": 0.15,
116-
"phi": 0.95,
117-
"sigma_state": 0.1,
118-
"sigma_obs": 0.1,
119-
"initial_level": 3.0,
120-
"initial_trend": 1.0,
121+
"alpha": rng.beta(1, 1),
122+
"beta": rng.beta(1, 1),
123+
"gamma": rng.beta(1, 1),
124+
"phi": rng.beta(1, 1),
125+
"sigma_state": rng.normal() ** 2,
126+
"sigma_obs": rng.normal() ** 2,
127+
"initial_level": rng.normal() ** 2,
128+
"initial_trend": rng.normal() ** 2,
121129
"initial_seasonal": np.ones(seasonal_periods),
122130
"initial_state_cov": np.eye(expected_states),
123131
}
@@ -145,7 +153,7 @@ def test_statespace_matrices(order: tuple[str, str, str], expected_params: list[
145153
assert_allclose(Q, np.eye(1) * test_values["sigma_state"] ** 2)
146154

147155
R_val = np.zeros((expected_states, 1))
148-
R_val[0] = 1.0
156+
R_val[0] = 1.0 - test_values["alpha"]
149157
R_val[1] = test_values["alpha"]
150158

151159
Z_val = np.zeros((1, expected_states))
@@ -159,15 +167,24 @@ def test_statespace_matrices(order: tuple[str, str, str], expected_params: list[
159167
T_val = np.array([[0.0, 0.0], [0.0, 1.0]])
160168
else:
161169
x0_val[2] = test_values["initial_trend"]
162-
R_val[2] = test_values["beta"]
170+
R_val[2] = (
171+
test_values["beta"] if use_transformed else test_values["beta"] * test_values["alpha"]
172+
)
163173
T_val = np.array([[0.0, 0.0, 0.0], [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]])
164174

165175
if order[1] == "Ad":
166176
T_val[1:, -1] *= test_values["phi"]
167177

168178
if order[2] == "A":
169179
x0_val[2 + int(order[1] != "N") :] = test_values["initial_seasonal"]
170-
R_val[2 + int(order[1] != "N")] = test_values["gamma"]
180+
gamma = (
181+
test_values["gamma"]
182+
if use_transformed
183+
else (1 - test_values["alpha"]) * test_values["gamma"]
184+
)
185+
R_val[2 + int(order[1] != "N")] = gamma
186+
R_val[0] = R_val[0] - gamma
187+
171188
S = np.eye(seasonal_periods, k=-1)
172189
S[0, -1] = 1.0
173190
Z_val[0, 2 + int(order[1] != "N")] = 1.0
@@ -186,7 +203,12 @@ def test_statespace_matrices(order: tuple[str, str, str], expected_params: list[
186203
def test_statespace_matches_statsmodels(rng, order: tuple[str, str, str], params):
187204
seasonal_periods = rng.integers(3, 12)
188205
data = rng.normal(size=(100,))
189-
mod = BayesianETS(order=order, seasonal_periods=seasonal_periods, measurement_error=False)
206+
mod = BayesianETS(
207+
order=order,
208+
seasonal_periods=seasonal_periods,
209+
measurement_error=False,
210+
use_transformed_parameterization=True,
211+
)
190212
sm_mod = sm.tsa.statespace.ExponentialSmoothing(
191213
data,
192214
trend=mod.trend,
@@ -232,11 +254,4 @@ def test_statespace_matches_statsmodels(rng, order: tuple[str, str, str], params
232254
sm_matrices = [sm_mod.ssm[name] for name in LONG_MATRIX_NAMES[2:]]
233255

234256
for matrix, sm_matrix, name in zip(matrices[2:], sm_matrices, LONG_MATRIX_NAMES[2:]):
235-
if name == "selection":
236-
# statsmodel selection matrix seems to be wrong? They set the first element of the selection matrix to
237-
# 1 - sum(alpha, beta, gamma), which doesn't match the equations presented in ffp3
238-
assert_allclose(matrix[1:], sm_matrix[1:], err_msg=f"{name} does not match")
239-
assert matrix[0] == 1.0
240-
assert sm_matrix[0] != 1.0
241-
else:
242-
assert_allclose(matrix, sm_matrix, err_msg=f"{name} does not match")
257+
assert_allclose(matrix, sm_matrix, err_msg=f"{name} does not match")

0 commit comments

Comments
 (0)