Skip to content

Commit 07d94b6

Browse files
committed
Merge with main
1 parent e76fc1a commit 07d94b6

File tree

1 file changed

+143
-23
lines changed

1 file changed

+143
-23
lines changed

pymc_extras/statespace/models/structural/components/seasonality.py

Lines changed: 143 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,28 @@ class TimeSeasonality(Component):
1414
----------
1515
season_length: int
1616
The number of periods in a single seasonal cycle, e.g. 12 for monthly data with annual seasonal pattern, 7 for
17-
daily data with weekly seasonal pattern, etc.
17+
daily data with weekly seasonal pattern, etc. It must be greater than one.
18+
19+
duration: int, default 1
20+
Number of time steps for each seasonal period.
21+
This determines how long each seasonal period is held constant before moving to the next.
1822
1923
innovations: bool, default True
2024
Whether to include stochastic innovations in the strength of the seasonal effect
2125
2226
name: str, default None
2327
A name for this seasonal component. Used to label dimensions and coordinates. Useful when multiple seasonal
24-
components are included in the same model. Default is ``f"Seasonal[s={season_length}]"``
28+
components are included in the same model. Default is ``f"Seasonal[s={season_length}, d={duration}]"``
2529
2630
state_names: list of str, default None
2731
List of strings for seasonal effect labels. If provided, it must be of length ``season_length``. An example
2832
would be ``state_names = ['Mon', 'Tue', 'Wed', 'Thur', 'Fri', 'Sat', 'Sun']`` when data is daily with a weekly
2933
seasonal pattern (``season_length = 7``).
3034
31-
If None, states will be numbered ``[State_0, ..., State_s]``
35+
If None, states will be numbered ``[State_0, ..., State_s-1]``
3236
3337
remove_first_state: bool, default True
34-
If True, the first state will be removed from the model. This is done because there are only n-1 degrees of
38+
If True, the first state will be removed from the model. This is done because there are only ``season_length-1`` degrees of
3539
freedom in the seasonal component, and one state is not identified. If False, the first state will be
3640
included in the model, but it will not be identified -- you will need to handle this in the priors (e.g. with
3741
ZeroSumNormal).
@@ -41,17 +45,76 @@ class TimeSeasonality(Component):
4145
4246
Notes
4347
-----
44-
A seasonal effect is any pattern that repeats every fixed interval. Although there are many possible ways to
45-
model seasonal effects, the implementation used here is the one described by [1] as the "canonical" time domain
46-
representation. The seasonal component can be expressed:
48+
A seasonal effect is any pattern that repeats at fixed intervals. There are several ways to model such effects;
49+
here, we present two models that are straightforward extensions of those described in [1].
50+
51+
**First model** (``remove_first_state=True``)
52+
53+
In this model, the state vector is defined as:
54+
55+
.. math::
56+
\alpha_t :=(\gamma_t, \ldots, \gamma_{t-d(s-1)+1}), \quad t \ge 0.
57+
58+
This vector has length :math:`d(s-1)`, where:
59+
60+
- :math:`s` is the ``seasonal_length`` parameter, and
61+
- :math:`d` is the ``duration`` parameter.
62+
63+
The components of the initial vector :math:`\alpha_{0}` are given by
64+
65+
.. math::
66+
\gamma_{-l} := \tilde{\gamma}_{k_l}, \quad \text{where} \quad k_l := \left\lfloor \frac{l}{d} \right\rfloor \bmod s \quad \text{and} \quad l=0,\ldots, d(s-1)-1.
67+
68+
Here, the values
69+
70+
.. math::
71+
\tilde{\gamma}_{0}, \ldots, \tilde{\gamma}_{s-2},
72+
73+
represent the initial seasonal states. The transition matrix of this model is the :math:`d(s-1) \times d(s-1)` matrix
74+
75+
.. math::
76+
\begin{bmatrix}
77+
-\mathbf{1}_d & -\mathbf{1}_d & \cdots & -\mathbf{1}_d & -\mathbf{1}_d \\
78+
\mathbf{1}_d & \mathbf{0}_d & \cdots & \mathbf{0}_d & \mathbf{0}_d \\
79+
\mathbf{0}_d & \mathbf{1}_d & \cdots & \mathbf{0}_d & \mathbf{0}_d \\
80+
\vdots & \vdots & \ddots & \vdots \\
81+
\mathbf{0}_d & \mathbf{0}_d & \cdots & \mathbf{1}_d & \mathbf{0}_d
82+
\end{bmatrix}
83+
84+
where :math:`\mathbf{1}_d` and :math:`\mathbf{0}_d` denote the :math:`d \times d` identity and null matrices, respectively.
85+
86+
**Second model** (``remove_first_state=False``)
87+
88+
In contrast, the state vector in the second model is defined as:
4789
4890
.. math::
49-
\gamma_t = -\sum_{i=1}^{s-1} \gamma_{t-i} + \omega_t, \quad \omega_t \sim N(0, \sigma_\gamma)
91+
\alpha_t=(\gamma_t, \ldots, \gamma_{t-ds+1}), \quad t \ge 0.
5092
51-
Where :math:`s` is the ``seasonal_length`` parameter and :math:`\omega_t` is the (optional) stochastic innovation.
52-
To give interpretation to the :math:`\gamma` terms, it is helpful to work through the algebra for a simple
53-
example. Let :math:`s=4`, and omit the shock term. Define initial conditions :math:`\gamma_0, \gamma_{-1},
54-
\gamma_{-2}`. The value of the seasonal component for the first 5 timesteps will be:
93+
This vector has length :math:`ds`. The components of the initial state vector :math:`\alpha_{0}` are defined similarly:
94+
95+
.. math::
96+
\gamma_{-l} := \tilde{\gamma}_{k_l}, \quad \text{where} \quad k_l := \left\lfloor \frac{l}{d} \right\rfloor \bmod s \quad \text{and} \quad l=0,\ldots, ds-1.
97+
98+
In this case, the initial seasonal states :math:`\tilde{\gamma}_{0}, \ldots, \tilde{\gamma}_{s-1}` are required to satisfy the following condition:
99+
100+
.. math::
101+
\sum_{i=0}^{s-1} \tilde{\gamma}_{i} = 0.
102+
103+
The transition matrix of this model is the following :math:`ds \times ds` circulant matrix:
104+
105+
.. math::
106+
\begin{bmatrix}
107+
0 & 1 & 0 & \cdots & 0 \\
108+
0 & 0 & 1 & \cdots & 0 \\
109+
\vdots & \vdots & \ddots & \ddots & \vdots \\
110+
0 & 0 & \cdots & 0 & 1 \\
111+
1 & 0 & \cdots & 0 & 0
112+
\end{bmatrix}
113+
114+
To give interpretation to the :math:`\gamma` terms, it is helpful to work through the algebra for a simple
115+
example. Let :math:`s=4`, :math:`d=1`, ``remove_first_state=True``, and omit the shock term. Then, we have
116+
:math:`\gamma_{-i} = \tilde{\gamma}_{-i}`, for :math:`i=-2,\ldots, 0` and the value of the seasonal component
117+
for the first 5 timesteps will be:
55118
56119
.. math::
57120
\begin{align}
@@ -85,10 +148,38 @@ class TimeSeasonality(Component):
85148
And so on. So for interpretation, the ``season_length - 1`` initial states are, when reversed, the coefficients
86149
associated with ``state_names[1:]``.
87150
151+
In the next example, we set :math:`s=3`, :math:`d=2`, ``remove_first_state=True``, and omit the shock term.
152+
By definition, the initial vector :math:`\alpha_{0}` is
153+
154+
.. math::
155+
\alpha_0=(\tilde{\gamma}_{0}, \tilde{\gamma}_{0}, \tilde{\gamma}_{-1}, \tilde{\gamma}_{-1})
156+
157+
and the transition matrix is
158+
159+
.. math::
160+
\begin{bmatrix}
161+
-1 & 0 & -1 & 0 \\
162+
0 & -1 & 0 & -1 \\
163+
1 & 0 & 0 & 0 \\
164+
0 & 1 & 0 & 0 \\
165+
\end{bmatrix}
166+
167+
It is easy to verify that:
168+
169+
.. math::
170+
\begin{align}
171+
\gamma_1 &= -\tilde{\gamma}_0 - \tilde{\gamma}_{-1}\\
172+
\gamma_2 &= -(-\tilde{\gamma}_0 - \tilde{\gamma}_{-1})-\tilde{\gamma}_0\\
173+
&= \tilde{\gamma}_{-1}\\
174+
\gamma_3 &= -\tilde{\gamma}_{-1} +(\tilde{\gamma}_0 + \tilde{\gamma}_{-1})\\
175+
&= \tilde{\gamma}_{0}\\
176+
\gamma_4 &= -\tilde{\gamma}_0 - \tilde{\gamma}_{-1}.\\
177+
\end{align}
178+
88179
.. warning::
89-
Although the ``state_names`` argument expects a list of length ``season_length``, only ``state_names[1:]``
90-
will be saved as model dimensions, since the 1st coefficient is not identified (it is defined as
91-
:math:`-\sum_{i=1}^{s} \gamma_{t-i}`).
180+
Although the ``state_names`` argument expects a list of length ``season_length`` times ``duration``,
181+
only ``state_names[1:]`` will be saved as model dimensions, since the first coefficient is not identified
182+
(it is defined as :math:`-\sum_{i=1}^{s-1} \tilde{\gamma}_{-i}`).
92183
93184
Examples
94185
--------
@@ -137,6 +228,7 @@ class TimeSeasonality(Component):
137228
def __init__(
138229
self,
139230
season_length: int,
231+
duration: int = 1,
140232
innovations: bool = True,
141233
name: str | None = None,
142234
state_names: list | None = None,
@@ -146,29 +238,38 @@ def __init__(
146238
if observed_state_names is None:
147239
observed_state_names = ["data"]
148240

241+
if season_length <= 1:
242+
raise ValueError(f"season_length must be greater than 1, got {season_length}")
149243
if name is None:
150-
name = f"Seasonal[s={season_length}]"
244+
name = f"Seasonal[s={season_length}, d={duration}]"
151245
if state_names is None:
152-
state_names = [f"{name}_{i}" for i in range(season_length)]
246+
if duration > 1:
247+
state_names = [
248+
f"{name}_{i}_{j}" for i in range(season_length) for j in range(duration)
249+
]
250+
else:
251+
state_names = [f"{name}_{i}" for i in range(season_length)]
153252
else:
154-
if len(state_names) != season_length:
253+
if len(state_names) != season_length * duration:
155254
raise ValueError(
156-
f"state_names must be a list of length season_length, got {len(state_names)}"
255+
f"state_names must be a list of length season_length*duration, got {len(state_names)}"
157256
)
158257
state_names = state_names.copy()
159258

160259
self.innovations = innovations
260+
self.duration = duration
161261
self.remove_first_state = remove_first_state
262+
self.season_length = season_length
162263

163264
if self.remove_first_state:
164265
# In traditional models, the first state isn't identified, so we can help out the user by automatically
165266
# discarding it.
166267
# TODO: Can this be stashed and reconstructed automatically somehow?
167-
state_names.pop(0)
268+
state_names = state_names[duration:]
168269

169270
self.provided_state_names = state_names
170271

171-
k_states = season_length - int(self.remove_first_state)
272+
k_states = season_length * duration - int(self.remove_first_state) * duration
172273
k_endog = len(observed_state_names)
173274
k_posdef = int(innovations)
174275

@@ -236,8 +337,27 @@ def make_symbolic_graph(self) -> None:
236337
if self.remove_first_state:
237338
# In this case, parameters are normalized to sum to zero, so the current state is the negative sum of
238339
# all previous states.
239-
T = np.eye(k_states, k=-1)
240-
T[0, :] = -1
340+
zero_d = np.zeros((self.duration, self.duration))
341+
id_d = np.eye(self.duration)
342+
343+
blocks = []
344+
345+
# First row: all -1_d blocks
346+
first_row = [-id_d for _ in range(self.season_length - 1)]
347+
blocks.append(first_row)
348+
349+
# Rows 2 to season_length-1: shifted identity blocks
350+
for i in range(self.season_length - 2):
351+
row = []
352+
for j in range(self.season_length - 1):
353+
if j == i:
354+
row.append(id_d)
355+
else:
356+
row.append(zero_d)
357+
blocks.append(row)
358+
359+
# Stack blocks
360+
T = np.block(blocks)
241361
else:
242362
# In this case we assume the user to be responsible for ensuring the states sum to zero, so T is just a
243363
# circulant matrix that cycles between the states.

0 commit comments

Comments
 (0)