1
1
import numpy as np
2
2
3
3
from pytensor import tensor as pt
4
+ from scipy import linalg
4
5
5
6
from pymc_extras .statespace .models .structural .core import Component
6
7
from pymc_extras .statespace .models .structural .utils import _frequency_transition_block
@@ -10,6 +11,10 @@ class CycleComponent(Component):
10
11
r"""
11
12
A component for modeling longer-term cyclical effects
12
13
14
+ Supports both univariate and multivariate time series. For multivariate time series,
15
+ each endogenous variable gets its own independent cycle component with separate
16
+ cosine/sine states and optional variable-specific innovation variances.
17
+
13
18
Parameters
14
19
----------
15
20
name: str
@@ -32,6 +37,11 @@ class CycleComponent(Component):
32
37
innovations: bool, default True
33
38
Whether to include stochastic innovations in the strength of the seasonal effect. If True, an additional
34
39
parameter, ``sigma_{name}`` will be added to the model.
40
+ For multivariate time series, this is a vector (variable-specific innovation variances).
41
+
42
+ observed_state_names: list[str], optional
43
+ Names of the observed state variables. For univariate time series, defaults to ``["data"]``.
44
+ For multivariate time series, specify a list of names for each endogenous variable.
35
45
36
46
Notes
37
47
-----
@@ -51,8 +61,16 @@ class CycleComponent(Component):
51
61
52
62
Unlike a FrequencySeasonality component, the length of a CycleComponent can be estimated.
53
63
64
+ **Multivariate Support:**
65
+ For multivariate time series with k endogenous variables, the component creates:
66
+ - 2k states (cosine and sine components for each variable)
67
+ - Block diagonal transition and selection matrices
68
+ - Variable-specific innovation variances (optional)
69
+ - Proper parameter shapes: (k, 2) for initial states, (k,) for innovation variances
70
+
54
71
Examples
55
72
--------
73
+ **Univariate Example:**
56
74
Estimate a business cycle with length between 6 and 12 years:
57
75
58
76
.. code:: python
@@ -84,6 +102,35 @@ class CycleComponent(Component):
84
102
85
103
idata = pm.sample(nuts_sampler='numpyro')
86
104
105
+ **Multivariate Example:**
106
+ Model cycles for multiple economic indicators with variable-specific innovation variances:
107
+
108
+ .. code:: python
109
+
110
+ # Multivariate cycle component
111
+ cycle = st.CycleComponent(
112
+ name='business_cycle',
113
+ cycle_length=12,
114
+ estimate_cycle_length=False,
115
+ innovations=True,
116
+ dampen=True,
117
+ observed_state_names=['gdp', 'unemployment', 'inflation']
118
+ )
119
+
120
+ # Build the model
121
+ ss_mod = cycle.build()
122
+
123
+ # In PyMC model:
124
+ with pm.Model(coords=ss_mod.coords) as model:
125
+ # Initial states: shape (3, 2) for 3 variables, 2 states each
126
+ cycle_init = pm.Normal('business_cycle', dims=('business_cycle_endog', 'business_cycle_state'))
127
+
128
+ # Dampening factor: scalar (shared across variables)
129
+ dampening = pm.Uniform('business_cycle_dampening_factor', lower=0.8, upper=1.0)
130
+
131
+ # Innovation variances: shape (3,) for variable-specific variances
132
+ sigma_cycle = pm.HalfNormal('sigma_business_cycle', dims=('business_cycle_endog',))
133
+
87
134
References
88
135
----------
89
136
.. [1] Durbin, James, and Siem Jan Koopman. 2012.
@@ -137,14 +184,23 @@ def __init__(
137
184
)
138
185
139
186
def make_symbolic_graph (self ) -> None :
140
- self .ssm ["design" , 0 , slice (0 , self .k_states , 2 )] = 1
141
- self .ssm ["selection" , :, :] = np .eye (self .k_states )
142
- self .param_dims = {self .name : (f"{ self .name } _state" ,)}
143
- self .coords = {f"{ self .name } _state" : self .state_names }
187
+ if self .k_endog == 1 :
188
+ self .ssm ["design" , 0 , slice (0 , self .k_states , 2 )] = 1
189
+ self .ssm ["selection" , :, :] = np .eye (self .k_states )
190
+ init_state = self .make_and_register_variable (f"{ self .name } " , shape = (self .k_states ,))
191
+
192
+ else :
193
+ Z = np .array ([1.0 , 0.0 ]).reshape ((1 , - 1 ))
194
+ design_matrix = linalg .block_diag (* [Z for _ in range (self .k_endog )])
195
+ self .ssm ["design" , :, :] = pt .as_tensor_variable (design_matrix )
144
196
145
- init_state = self .make_and_register_variable (f"{ self .name } " , shape = (self .k_states ,))
197
+ R = np .eye (2 ) # 2x2 identity for each cycle component
198
+ selection_matrix = linalg .block_diag (* [R for _ in range (self .k_endog )])
199
+ self .ssm ["selection" , :, :] = pt .as_tensor_variable (selection_matrix )
146
200
147
- self .ssm ["initial_state" , :] = init_state
201
+ init_state = self .make_and_register_variable (f"{ self .name } " , shape = (self .k_endog , 2 ))
202
+
203
+ self .ssm ["initial_state" , :] = init_state .ravel ()
148
204
149
205
if self .estimate_cycle_length :
150
206
lamb = self .make_and_register_variable (f"{ self .name } _length" , shape = ())
@@ -157,23 +213,59 @@ def make_symbolic_graph(self) -> None:
157
213
rho = 1
158
214
159
215
T = rho * _frequency_transition_block (lamb , j = 1 )
160
- self .ssm ["transition" , :, :] = T
216
+ if self .k_endog == 1 :
217
+ self .ssm ["transition" , :, :] = T
218
+ else :
219
+ # can't make the linalg.block_diag logic work here
220
+ # doing it manually for now
221
+ for i in range (self .k_endog ):
222
+ start_idx = i * 2
223
+ end_idx = (i + 1 ) * 2
224
+ self .ssm ["transition" , start_idx :end_idx , start_idx :end_idx ] = T
161
225
162
226
if self .innovations :
163
- sigma_cycle = self .make_and_register_variable (f"sigma_{ self .name } " , shape = ())
164
- self .ssm ["state_cov" , :, :] = pt .eye (self .k_posdef ) * sigma_cycle ** 2
227
+ if self .k_endog == 1 :
228
+ sigma_cycle = self .make_and_register_variable (f"sigma_{ self .name } " , shape = ())
229
+ self .ssm ["state_cov" , :, :] = pt .eye (self .k_posdef ) * sigma_cycle ** 2
230
+ else :
231
+ sigma_cycle = self .make_and_register_variable (
232
+ f"sigma_{ self .name } " , shape = (self .k_endog ,)
233
+ )
234
+ # can't make the linalg.block_diag logic work here
235
+ # doing it manually for now
236
+ for i in range (self .k_endog ):
237
+ start_idx = i * 2
238
+ end_idx = (i + 1 ) * 2
239
+ Q_block = pt .eye (2 ) * sigma_cycle [i ] ** 2
240
+ self .ssm ["state_cov" , start_idx :end_idx , start_idx :end_idx ] = Q_block
165
241
166
242
def populate_component_properties (self ):
167
243
self .state_names = [f"{ self .name } _{ f } " for f in ["Cos" , "Sin" ]]
168
244
self .param_names = [f"{ self .name } " ]
169
245
170
- self .param_info = {
171
- f"{ self .name } " : {
172
- "shape" : (2 ,),
173
- "constraints" : None ,
174
- "dims" : (f"{ self .name } _state" ,),
246
+ if self .k_endog == 1 :
247
+ self .param_dims = {self .name : (f"{ self .name } _state" ,)}
248
+ self .coords = {f"{ self .name } _state" : self .state_names }
249
+ self .param_info = {
250
+ f"{ self .name } " : {
251
+ "shape" : (2 ,),
252
+ "constraints" : None ,
253
+ "dims" : (f"{ self .name } _state" ,),
254
+ }
255
+ }
256
+ else :
257
+ self .param_dims = {self .name : (f"{ self .name } _endog" , f"{ self .name } _state" )}
258
+ self .coords = {
259
+ f"{ self .name } _state" : self .state_names ,
260
+ f"{ self .name } _endog" : self .observed_state_names ,
261
+ }
262
+ self .param_info = {
263
+ f"{ self .name } " : {
264
+ "shape" : (self .k_endog , 2 ),
265
+ "constraints" : None ,
266
+ "dims" : (f"{ self .name } _endog" , f"{ self .name } _state" ),
267
+ }
175
268
}
176
- }
177
269
178
270
if self .estimate_cycle_length :
179
271
self .param_names += [f"{ self .name } _length" ]
@@ -193,9 +285,17 @@ def populate_component_properties(self):
193
285
194
286
if self .innovations :
195
287
self .param_names += [f"sigma_{ self .name } " ]
196
- self .param_info [f"sigma_{ self .name } " ] = {
197
- "shape" : (),
198
- "constraints" : "Positive" ,
199
- "dims" : None ,
200
- }
288
+ if self .k_endog == 1 :
289
+ self .param_info [f"sigma_{ self .name } " ] = {
290
+ "shape" : (),
291
+ "constraints" : "Positive" ,
292
+ "dims" : None ,
293
+ }
294
+ else :
295
+ self .param_dims [f"sigma_{ self .name } " ] = (f"{ self .name } _endog" ,)
296
+ self .param_info [f"sigma_{ self .name } " ] = {
297
+ "shape" : (self .k_endog ,),
298
+ "constraints" : "Positive" ,
299
+ "dims" : (f"{ self .name } _endog" ,),
300
+ }
201
301
self .shock_names = self .state_names .copy ()
0 commit comments