pymc-extras 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pymc_extras/distributions/__init__.py +5 -5
  2. pymc_extras/distributions/histogram_utils.py +1 -1
  3. pymc_extras/inference/__init__.py +1 -1
  4. pymc_extras/inference/laplace_approx/find_map.py +12 -5
  5. pymc_extras/inference/laplace_approx/idata.py +4 -3
  6. pymc_extras/inference/laplace_approx/laplace.py +6 -4
  7. pymc_extras/inference/pathfinder/pathfinder.py +1 -2
  8. pymc_extras/printing.py +1 -1
  9. pymc_extras/statespace/__init__.py +4 -4
  10. pymc_extras/statespace/core/__init__.py +1 -1
  11. pymc_extras/statespace/core/representation.py +8 -8
  12. pymc_extras/statespace/core/statespace.py +94 -23
  13. pymc_extras/statespace/filters/__init__.py +3 -3
  14. pymc_extras/statespace/filters/kalman_filter.py +16 -11
  15. pymc_extras/statespace/models/SARIMAX.py +138 -74
  16. pymc_extras/statespace/models/VARMAX.py +248 -57
  17. pymc_extras/statespace/models/__init__.py +2 -2
  18. pymc_extras/statespace/models/structural/__init__.py +21 -0
  19. pymc_extras/statespace/models/structural/components/__init__.py +0 -0
  20. pymc_extras/statespace/models/structural/components/autoregressive.py +213 -0
  21. pymc_extras/statespace/models/structural/components/cycle.py +325 -0
  22. pymc_extras/statespace/models/structural/components/level_trend.py +289 -0
  23. pymc_extras/statespace/models/structural/components/measurement_error.py +154 -0
  24. pymc_extras/statespace/models/structural/components/regression.py +257 -0
  25. pymc_extras/statespace/models/structural/components/seasonality.py +628 -0
  26. pymc_extras/statespace/models/structural/core.py +919 -0
  27. pymc_extras/statespace/models/structural/utils.py +16 -0
  28. pymc_extras/statespace/models/utilities.py +285 -0
  29. pymc_extras/statespace/utils/constants.py +21 -18
  30. pymc_extras/statespace/utils/data_tools.py +4 -3
  31. {pymc_extras-0.3.1.dist-info → pymc_extras-0.4.1.dist-info}/METADATA +5 -4
  32. {pymc_extras-0.3.1.dist-info → pymc_extras-0.4.1.dist-info}/RECORD +34 -25
  33. pymc_extras/statespace/models/structural.py +0 -1679
  34. {pymc_extras-0.3.1.dist-info → pymc_extras-0.4.1.dist-info}/WHEEL +0 -0
  35. {pymc_extras-0.3.1.dist-info → pymc_extras-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,1679 +0,0 @@
1
- import functools as ft
2
- import logging
3
-
4
- from abc import ABC
5
- from collections.abc import Sequence
6
- from itertools import pairwise
7
- from typing import Any
8
-
9
- import numpy as np
10
- import pytensor
11
- import pytensor.tensor as pt
12
- import xarray as xr
13
-
14
- from pytensor import Variable
15
- from pytensor.compile.mode import Mode
16
-
17
- from pymc_extras.statespace.core import PytensorRepresentation
18
- from pymc_extras.statespace.core.statespace import PyMCStateSpace
19
- from pymc_extras.statespace.models.utilities import (
20
- conform_time_varying_and_time_invariant_matrices,
21
- make_default_coords,
22
- )
23
- from pymc_extras.statespace.utils.constants import (
24
- ALL_STATE_AUX_DIM,
25
- ALL_STATE_DIM,
26
- AR_PARAM_DIM,
27
- LONG_MATRIX_NAMES,
28
- POSITION_DERIVATIVE_NAMES,
29
- TIME_DIM,
30
- )
31
-
32
- _log = logging.getLogger("pymc.experimental.statespace")
33
-
34
- floatX = pytensor.config.floatX
35
-
36
-
37
- def order_to_mask(order):
38
- if isinstance(order, int):
39
- return np.ones(order).astype(bool)
40
- else:
41
- return np.array(order).astype(bool)
42
-
43
-
44
- def _frequency_transition_block(s, j):
45
- lam = 2 * np.pi * j / s
46
-
47
- return pt.stack([[pt.cos(lam), pt.sin(lam)], [-pt.sin(lam), pt.cos(lam)]])
48
-
49
-
50
- class StructuralTimeSeries(PyMCStateSpace):
51
- r"""
52
- Structural Time Series Model
53
-
54
- The structural time series model, named by [1] and presented in statespace form in [2], is a framework for
55
- decomposing a univariate time series into level, trend, seasonal, and cycle components. It also admits the
56
- possibility of exogenous regressors. Unlike the SARIMAX framework, the time series is not assumed to be stationary.
57
-
58
- Notes
59
- -----
60
-
61
- .. math::
62
- y_t = \mu_t + \gamma_t + c_t + \varepsilon_t
63
-
64
- """
65
-
66
- def __init__(
67
- self,
68
- ssm: PytensorRepresentation,
69
- state_names: list[str],
70
- data_names: list[str],
71
- shock_names: list[str],
72
- param_names: list[str],
73
- exog_names: list[str],
74
- param_dims: dict[str, tuple[int]],
75
- coords: dict[str, Sequence],
76
- param_info: dict[str, dict[str, Any]],
77
- data_info: dict[str, dict[str, Any]],
78
- component_info: dict[str, dict[str, Any]],
79
- measurement_error: bool,
80
- name_to_variable: dict[str, Variable],
81
- name_to_data: dict[str, Variable] | None = None,
82
- name: str | None = None,
83
- verbose: bool = True,
84
- filter_type: str = "standard",
85
- mode: str | Mode | None = None,
86
- ):
87
- # Add the initial state covariance to the parameters
88
- if name is None:
89
- name = "data"
90
- self._name = name
91
-
92
- k_states, k_posdef, k_endog = ssm.k_states, ssm.k_posdef, ssm.k_endog
93
- param_names, param_dims, param_info = self._add_inital_state_cov_to_properties(
94
- param_names, param_dims, param_info, k_states
95
- )
96
- self._state_names = state_names.copy()
97
- self._data_names = data_names.copy()
98
- self._shock_names = shock_names.copy()
99
- self._param_names = param_names.copy()
100
- self._param_dims = param_dims.copy()
101
-
102
- default_coords = make_default_coords(self)
103
- coords.update(default_coords)
104
-
105
- self._coords = coords
106
- self._param_info = param_info.copy()
107
- self._data_info = data_info.copy()
108
- self.measurement_error = measurement_error
109
-
110
- super().__init__(
111
- k_endog,
112
- k_states,
113
- max(1, k_posdef),
114
- filter_type=filter_type,
115
- verbose=verbose,
116
- measurement_error=measurement_error,
117
- mode=mode,
118
- )
119
- self.ssm = ssm.copy()
120
-
121
- if k_posdef == 0:
122
- # If there is no randomness in the model, add dummy matrices to the representation to avoid errors
123
- # when we go to construct random variables from the matrices
124
- self.ssm.k_posdef = self.k_posdef
125
- self.ssm.shapes["state_cov"] = (1, 1, 1)
126
- self.ssm["state_cov"] = pt.zeros((1, 1, 1))
127
-
128
- self.ssm.shapes["selection"] = (1, self.k_states, 1)
129
- self.ssm["selection"] = pt.zeros((1, self.k_states, 1))
130
-
131
- self._component_info = component_info.copy()
132
-
133
- self._name_to_variable = name_to_variable.copy()
134
- self._name_to_data = name_to_data.copy()
135
-
136
- self._exog_names = exog_names.copy()
137
- self._needs_exog_data = len(exog_names) > 0
138
-
139
- P0 = self.make_and_register_variable("P0", shape=(self.k_states, self.k_states))
140
- self.ssm["initial_state_cov"] = P0
141
-
142
- @staticmethod
143
- def _add_inital_state_cov_to_properties(param_names, param_dims, param_info, k_states):
144
- param_names += ["P0"]
145
- param_dims["P0"] = (ALL_STATE_DIM, ALL_STATE_AUX_DIM)
146
- param_info["P0"] = {
147
- "shape": (k_states, k_states),
148
- "constraints": "Positive semi-definite",
149
- "dims": param_dims["P0"],
150
- }
151
-
152
- return param_names, param_dims, param_info
153
-
154
- @property
155
- def param_names(self):
156
- return self._param_names
157
-
158
- @property
159
- def data_names(self) -> list[str]:
160
- return self._data_names
161
-
162
- @property
163
- def state_names(self):
164
- return self._state_names
165
-
166
- @property
167
- def observed_states(self):
168
- return [self._name]
169
-
170
- @property
171
- def shock_names(self):
172
- return self._shock_names
173
-
174
- @property
175
- def param_dims(self):
176
- return self._param_dims
177
-
178
- @property
179
- def coords(self) -> dict[str, Sequence]:
180
- return self._coords
181
-
182
- @property
183
- def param_info(self) -> dict[str, dict[str, Any]]:
184
- return self._param_info
185
-
186
- @property
187
- def data_info(self) -> dict[str, dict[str, Any]]:
188
- return self._data_info
189
-
190
- def make_symbolic_graph(self) -> None:
191
- """
192
- Assign placeholder pytensor variables among statespace matrices in positions where PyMC variables will go.
193
-
194
- Notes
195
- -----
196
- This assignment is handled by the components, so this function is implemented only to avoid the
197
- NotImplementedError raised by the base class.
198
- """
199
-
200
- pass
201
-
202
- def _state_slices_from_info(self):
203
- info = self._component_info.copy()
204
- comp_states = np.cumsum([0] + [info["k_states"] for info in info.values()])
205
- state_slices = [slice(i, j) for i, j in pairwise(comp_states)]
206
-
207
- return state_slices
208
-
209
- def _hidden_states_from_data(self, data):
210
- state_slices = self._state_slices_from_info()
211
- info = self._component_info
212
- names = info.keys()
213
- result = []
214
-
215
- for i, (name, s) in enumerate(zip(names, state_slices)):
216
- obs_idx = info[name]["obs_state_idx"]
217
- if obs_idx is None:
218
- continue
219
-
220
- X = data[..., s]
221
- if info[name]["combine_hidden_states"]:
222
- sum_idx = np.flatnonzero(obs_idx)
223
- result.append(X[..., sum_idx].sum(axis=-1)[..., None])
224
- else:
225
- comp_names = self.state_names[s]
226
- for j, state_name in enumerate(comp_names):
227
- result.append(X[..., j, None])
228
-
229
- return np.concatenate(result, axis=-1)
230
-
231
- def _get_subcomponent_names(self):
232
- state_slices = self._state_slices_from_info()
233
- info = self._component_info
234
- names = info.keys()
235
- result = []
236
-
237
- for i, (name, s) in enumerate(zip(names, state_slices)):
238
- if info[name]["combine_hidden_states"]:
239
- result.append(name)
240
- else:
241
- comp_names = self.state_names[s]
242
- result.extend([f"{name}[{comp_name}]" for comp_name in comp_names])
243
- return result
244
-
245
- def extract_components_from_idata(self, idata: xr.Dataset) -> xr.Dataset:
246
- r"""
247
- Extract interpretable hidden states from an InferenceData returned by a PyMCStateSpace sampling method
248
-
249
- Parameters
250
- ----------
251
- idata: Dataset
252
- A Dataset object, returned by a PyMCStateSpace sampling method
253
-
254
- Returns
255
- -------
256
- idata: Dataset
257
- An Dataset object with hidden states transformed to represent only the "interpretable" subcomponents
258
- of the structural model.
259
-
260
- Notes
261
- -----
262
- In general, a structural statespace model can be represented as:
263
-
264
- .. math::
265
- y_t = \mu_t + \nu_t + \cdots + \gamma_t + c_t + \xi_t + \epsilon_t \tag{1}
266
-
267
- Where:
268
-
269
- - :math:`\mu_t` is the level of the data at time t
270
- - :math:`\nu_t` is the slope of the data at time t
271
- - :math:`\cdots` are higher time derivatives of the position (acceleration, jerk, etc) at time t
272
- - :math:`\gamma_t` is the seasonal component at time t
273
- - :math:`c_t` is the cycle component at time t
274
- - :math:`\xi_t` is the autoregressive error at time t
275
- - :math:`\varepsilon_t` is the measurement error at time t
276
-
277
- In state space form, some or all of these components are represented as linear combinations of other
278
- subcomponents, making interpretation of the outputs of the outputs difficult. The purpose of this function is
279
- to take the expended statespace representation and return a "reduced form" of only the components shown in
280
- equation (1).
281
- """
282
-
283
- def _extract_and_transform_variable(idata, new_state_names):
284
- *_, time_dim, state_dim = idata.dims
285
- state_func = ft.partial(self._hidden_states_from_data)
286
- new_idata = xr.apply_ufunc(
287
- state_func,
288
- idata,
289
- input_core_dims=[[time_dim, state_dim]],
290
- output_core_dims=[[time_dim, state_dim]],
291
- exclude_dims={state_dim},
292
- )
293
- new_idata.coords.update({state_dim: new_state_names})
294
- return new_idata
295
-
296
- var_names = list(idata.data_vars.keys())
297
- is_latent = [idata[name].shape[-1] == self.k_states for name in var_names]
298
- new_state_names = self._get_subcomponent_names()
299
-
300
- latent_names = [name for latent, name in zip(is_latent, var_names) if latent]
301
- dropped_vars = set(var_names) - set(latent_names)
302
- if len(dropped_vars) > 0:
303
- _log.warning(
304
- f'Variables {", ".join(dropped_vars)} do not contain all hidden states (their last dimension '
305
- f"is not {self.k_states}). They will not be present in the modified idata."
306
- )
307
- if len(dropped_vars) == len(var_names):
308
- raise ValueError(
309
- "Provided idata had no variables with all hidden states; cannot extract components."
310
- )
311
-
312
- idata_new = xr.Dataset(
313
- {
314
- name: _extract_and_transform_variable(idata[name], new_state_names)
315
- for name in latent_names
316
- }
317
- )
318
- return idata_new
319
-
320
-
321
- class Component(ABC):
322
- r"""
323
- Base class for a component of a structural timeseries model.
324
-
325
- This base class contains a subset of the class attributes of the PyMCStateSpace class, and none of the class
326
- methods. The purpose of a component is to allow the partial definition of a structural model. Components are
327
- assembled into a full model by the StructuralTimeSeries class.
328
-
329
- Parameters
330
- ----------
331
- name: str
332
- The name of the component
333
- k_endog: int
334
- Number of endogenous variables being modeled. Currently, must be one because structural models only support
335
- univariate data.
336
- k_states: int
337
- Number of hidden states in the component model
338
- k_posdef: int
339
- Rank of the state covariance matrix, or the number of sources of innovations in the component model
340
- measurement_error: bool
341
- Whether the observation associated with the component has measurement error. Default is False.
342
- combine_hidden_states: bool
343
- Flag for the ``extract_hidden_states_from_data`` method. When ``True``, hidden states from the component model
344
- are extracted as ``hidden_states[:, np.flatnonzero(Z)]``. Should be True in models where hidden states
345
- individually have no interpretation, such as seasonal or autoregressive components.
346
- """
347
-
348
- def __init__(
349
- self,
350
- name,
351
- k_endog,
352
- k_states,
353
- k_posdef,
354
- state_names=None,
355
- data_names=None,
356
- shock_names=None,
357
- param_names=None,
358
- exog_names=None,
359
- representation: PytensorRepresentation | None = None,
360
- measurement_error=False,
361
- combine_hidden_states=True,
362
- component_from_sum=False,
363
- obs_state_idxs=None,
364
- ):
365
- self.name = name
366
- self.k_endog = k_endog
367
- self.k_states = k_states
368
- self.k_posdef = k_posdef
369
- self.measurement_error = measurement_error
370
-
371
- self.state_names = state_names if state_names is not None else []
372
- self.data_names = data_names if data_names is not None else []
373
- self.shock_names = shock_names if shock_names is not None else []
374
- self.param_names = param_names if param_names is not None else []
375
- self.exog_names = exog_names if exog_names is not None else []
376
-
377
- self.needs_exog_data = len(self.exog_names) > 0
378
- self.coords = {}
379
- self.param_dims = {}
380
-
381
- self.param_info = {}
382
- self.data_info = {}
383
-
384
- self.param_counts = {}
385
-
386
- if representation is None:
387
- self.ssm = PytensorRepresentation(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
388
- else:
389
- self.ssm = representation
390
-
391
- self._name_to_variable = {}
392
- self._name_to_data = {}
393
-
394
- if not component_from_sum:
395
- self.populate_component_properties()
396
- self.make_symbolic_graph()
397
-
398
- self._component_info = {
399
- self.name: {
400
- "k_states": self.k_states,
401
- "k_enodg": self.k_endog,
402
- "k_posdef": self.k_posdef,
403
- "combine_hidden_states": combine_hidden_states,
404
- "obs_state_idx": obs_state_idxs,
405
- }
406
- }
407
-
408
- def make_and_register_variable(self, name, shape, dtype=floatX) -> Variable:
409
- r"""
410
- Helper function to create a pytensor symbolic variable and register it in the _name_to_variable dictionary
411
-
412
- Parameters
413
- ----------
414
- name : str
415
- The name of the placeholder variable. Must be the name of a model parameter.
416
- shape : int or tuple of int
417
- Shape of the parameter
418
- dtype : str, default pytensor.config.floatX
419
- dtype of the parameter
420
-
421
- Notes
422
- -----
423
- Symbolic pytensor variables are used in the ``make_symbolic_graph`` method as placeholders for PyMC random
424
- variables. The change is made in the ``_insert_random_variables`` method via ``pytensor.graph_replace``. To
425
- make the change, a dictionary mapping pytensor variables to PyMC random variables needs to be constructed.
426
-
427
- The purpose of this method is to:
428
- 1. Create the placeholder symbolic variables
429
- 2. Register the placeholder variable in the ``_name_to_variable`` dictionary
430
-
431
- The shape provided here will define the shape of the prior that will need to be provided by the user.
432
-
433
- An error is raised if the provided name has already been registered, or if the name is not present in the
434
- ``param_names`` property.
435
- """
436
- if name not in self.param_names:
437
- raise ValueError(
438
- f"{name} is not a model parameter. All placeholder variables should correspond to model "
439
- f"parameters."
440
- )
441
-
442
- if name in self._name_to_variable.keys():
443
- raise ValueError(
444
- f"{name} is already a registered placeholder variable with shape "
445
- f"{self._name_to_variable[name].type.shape}"
446
- )
447
-
448
- placeholder = pt.tensor(name, shape=shape, dtype=dtype)
449
- self._name_to_variable[name] = placeholder
450
- return placeholder
451
-
452
- def make_and_register_data(self, name, shape, dtype=floatX) -> Variable:
453
- r"""
454
- Helper function to create a pytensor symbolic variable and register it in the _name_to_data dictionary
455
-
456
- Parameters
457
- ----------
458
- name : str
459
- The name of the placeholder data. Must be the name of an expected data variable.
460
- shape : int or tuple of int
461
- Shape of the parameter
462
- dtype : str, default pytensor.config.floatX
463
- dtype of the parameter
464
-
465
- Notes
466
- -----
467
- See docstring for make_and_register_variable for more details. This function is similar, but handles data
468
- inputs instead of model parameters.
469
-
470
- An error is raised if the provided name has already been registered, or if the name is not present in the
471
- ``data_names`` property.
472
- """
473
- if name not in self.data_names:
474
- raise ValueError(
475
- f"{name} is not a model parameter. All placeholder variables should correspond to model "
476
- f"parameters."
477
- )
478
-
479
- if name in self._name_to_data.keys():
480
- raise ValueError(
481
- f"{name} is already a registered placeholder variable with shape "
482
- f"{self._name_to_data[name].type.shape}"
483
- )
484
-
485
- placeholder = pt.tensor(name, shape=shape, dtype=dtype)
486
- self._name_to_data[name] = placeholder
487
- return placeholder
488
-
489
- def make_symbolic_graph(self) -> None:
490
- raise NotImplementedError
491
-
492
- def populate_component_properties(self):
493
- raise NotImplementedError
494
-
495
- def _get_combined_shapes(self, other):
496
- k_states = self.k_states + other.k_states
497
- k_posdef = self.k_posdef + other.k_posdef
498
- if self.k_endog != other.k_endog:
499
- raise NotImplementedError(
500
- "Merging elements with different numbers of observed states is not supported.>"
501
- )
502
- k_endog = self.k_endog
503
-
504
- return k_states, k_posdef, k_endog
505
-
506
- def _combine_statespace_representations(self, other):
507
- def make_slice(name, x, o_x):
508
- ndim = max(x.ndim, o_x.ndim)
509
- return (name,) + (slice(None, None, None),) * ndim
510
-
511
- k_states, k_posdef, k_endog = self._get_combined_shapes(other)
512
-
513
- self_matrices = [self.ssm[name] for name in LONG_MATRIX_NAMES]
514
- other_matrices = [other.ssm[name] for name in LONG_MATRIX_NAMES]
515
-
516
- x0, P0, c, d, T, Z, R, H, Q = (
517
- self.ssm[make_slice(name, x, o_x)]
518
- for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
519
- )
520
- o_x0, o_P0, o_c, o_d, o_T, o_Z, o_R, o_H, o_Q = (
521
- other.ssm[make_slice(name, x, o_x)]
522
- for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
523
- )
524
-
525
- initial_state = pt.concatenate(conform_time_varying_and_time_invariant_matrices(x0, o_x0))
526
- initial_state.name = x0.name
527
-
528
- initial_state_cov = pt.linalg.block_diag(P0, o_P0)
529
- initial_state_cov.name = P0.name
530
-
531
- state_intercept = pt.concatenate(conform_time_varying_and_time_invariant_matrices(c, o_c))
532
- state_intercept.name = c.name
533
-
534
- obs_intercept = d + o_d
535
- obs_intercept.name = d.name
536
-
537
- transition = pt.linalg.block_diag(T, o_T)
538
- transition.name = T.name
539
-
540
- design = pt.concatenate(conform_time_varying_and_time_invariant_matrices(Z, o_Z), axis=-1)
541
- design.name = Z.name
542
-
543
- selection = pt.linalg.block_diag(R, o_R)
544
- selection.name = R.name
545
-
546
- obs_cov = H + o_H
547
- obs_cov.name = H.name
548
-
549
- state_cov = pt.linalg.block_diag(Q, o_Q)
550
- state_cov.name = Q.name
551
-
552
- new_ssm = PytensorRepresentation(
553
- k_endog=k_endog,
554
- k_states=k_states,
555
- k_posdef=k_posdef,
556
- initial_state=initial_state,
557
- initial_state_cov=initial_state_cov,
558
- state_intercept=state_intercept,
559
- obs_intercept=obs_intercept,
560
- transition=transition,
561
- design=design,
562
- selection=selection,
563
- obs_cov=obs_cov,
564
- state_cov=state_cov,
565
- )
566
-
567
- return new_ssm
568
-
569
- def _combine_property(self, other, name):
570
- self_prop = getattr(self, name)
571
- if isinstance(self_prop, list):
572
- return self_prop + getattr(other, name)
573
- elif isinstance(self_prop, dict):
574
- new_prop = self_prop.copy()
575
- new_prop.update(getattr(other, name))
576
- return new_prop
577
-
578
- def _combine_component_info(self, other):
579
- combined_info = {}
580
- for key, value in self._component_info.items():
581
- if not key.startswith("StateSpace"):
582
- if key in combined_info.keys():
583
- raise ValueError(f"Found duplicate component named {key}")
584
- combined_info[key] = value
585
-
586
- for key, value in other._component_info.items():
587
- if not key.startswith("StateSpace"):
588
- if key in combined_info.keys():
589
- raise ValueError(f"Found duplicate component named {key}")
590
- combined_info[key] = value
591
-
592
- return combined_info
593
-
594
- def _make_combined_name(self):
595
- components = self._component_info.keys()
596
- name = f'StateSpace[{", ".join(components)}]'
597
- return name
598
-
599
- def __add__(self, other):
600
- state_names = self._combine_property(other, "state_names")
601
- data_names = self._combine_property(other, "data_names")
602
- param_names = self._combine_property(other, "param_names")
603
- shock_names = self._combine_property(other, "shock_names")
604
- param_info = self._combine_property(other, "param_info")
605
- data_info = self._combine_property(other, "data_info")
606
- param_dims = self._combine_property(other, "param_dims")
607
- coords = self._combine_property(other, "coords")
608
- exog_names = self._combine_property(other, "exog_names")
609
-
610
- _name_to_variable = self._combine_property(other, "_name_to_variable")
611
- _name_to_data = self._combine_property(other, "_name_to_data")
612
-
613
- measurement_error = any([self.measurement_error, other.measurement_error])
614
-
615
- k_states, k_posdef, k_endog = self._get_combined_shapes(other)
616
- ssm = self._combine_statespace_representations(other)
617
-
618
- new_comp = Component(
619
- name="",
620
- k_endog=1,
621
- k_states=k_states,
622
- k_posdef=k_posdef,
623
- measurement_error=measurement_error,
624
- representation=ssm,
625
- component_from_sum=True,
626
- )
627
- new_comp._component_info = self._combine_component_info(other)
628
- new_comp.name = new_comp._make_combined_name()
629
-
630
- names_and_props = [
631
- ("state_names", state_names),
632
- ("data_names", data_names),
633
- ("param_names", param_names),
634
- ("shock_names", shock_names),
635
- ("param_dims", param_dims),
636
- ("coords", coords),
637
- ("param_dims", param_dims),
638
- ("param_info", param_info),
639
- ("data_info", data_info),
640
- ("exog_names", exog_names),
641
- ("_name_to_variable", _name_to_variable),
642
- ("_name_to_data", _name_to_data),
643
- ]
644
-
645
- for prop, value in names_and_props:
646
- setattr(new_comp, prop, value)
647
-
648
- return new_comp
649
-
650
- def build(
651
- self, name=None, filter_type="standard", verbose=True, mode: str | Mode | None = None
652
- ):
653
- """
654
- Build a StructuralTimeSeries statespace model from the current component(s)
655
-
656
- Parameters
657
- ----------
658
- name: str, optional
659
- Name of the exogenous data being modeled. Default is "data"
660
-
661
- filter_type : str, optional
662
- The type of Kalman filter to use. Valid options are "standard", "univariate", "single", "cholesky", and
663
- "steady_state". For more information, see the docs for each filter. Default is "standard".
664
-
665
- verbose : bool, optional
666
- If True, displays information about the initialized model. Defaults to True.
667
-
668
- mode: str or Mode, optional
669
- Pytensor compile mode, used in auxiliary sampling methods such as ``sample_conditional_posterior`` and
670
- ``forecast``. The mode does **not** effect calls to ``pm.sample``.
671
-
672
- Regardless of whether a mode is specified, it can always be overwritten via the ``compile_kwargs`` argument
673
- to all sampling methods.
674
-
675
- Returns
676
- -------
677
- PyMCStateSpace
678
- An initialized instance of a PyMCStateSpace, constructed using the system matrices contained in the
679
- components.
680
- """
681
-
682
- return StructuralTimeSeries(
683
- self.ssm,
684
- name=name,
685
- state_names=self.state_names,
686
- data_names=self.data_names,
687
- shock_names=self.shock_names,
688
- param_names=self.param_names,
689
- param_dims=self.param_dims,
690
- coords=self.coords,
691
- param_info=self.param_info,
692
- data_info=self.data_info,
693
- component_info=self._component_info,
694
- measurement_error=self.measurement_error,
695
- exog_names=self.exog_names,
696
- name_to_variable=self._name_to_variable,
697
- name_to_data=self._name_to_data,
698
- filter_type=filter_type,
699
- verbose=verbose,
700
- mode=mode,
701
- )
702
-
703
-
704
- class LevelTrendComponent(Component):
705
- r"""
706
- Level and trend component of a structural time series model
707
-
708
- Parameters
709
- ----------
710
- __________
711
- order : int
712
-
713
- Number of time derivatives of the trend to include in the model. For example, when order=3, the trend will
714
- be of the form ``y = a + b * t + c * t ** 2``, where the coefficients ``a, b, c`` come from the initial
715
- state values.
716
-
717
- innovations_order : int or sequence of int, optional
718
-
719
- The number of stochastic innovations to include in the model. By default, ``innovations_order = order``
720
-
721
- Notes
722
- -----
723
- This class implements the level and trend components of the general structural time series model. In the most
724
- general form, the level and trend is described by a system of two time-varying equations.
725
-
726
- .. math::
727
- \begin{align}
728
- \mu_{t+1} &= \mu_t + \nu_t + \zeta_t \\
729
- \nu_{t+1} &= \nu_t + \xi_t
730
- \zeta_t &\sim N(0, \sigma_\zeta) \\
731
- \xi_t &\sim N(0, \sigma_\xi)
732
- \end{align}
733
-
734
- Where :math:`\mu_{t+1}` is the mean of the timeseries at time t, and :math:`\nu_t` is the drift or the slope of
735
- the process. When both innovations :math:`\zeta_t` and :math:`\xi_t` are included in the model, it is known as a
736
- *local linear trend* model. This system of two equations, corresponding to ``order=2``, can be expanded or
737
- contracted by adding or removing equations. ``order=3`` would add an acceleration term to the sytsem:
738
-
739
- .. math::
740
- \begin{align}
741
- \mu_{t+1} &= \mu_t + \nu_t + \zeta_t \\
742
- \nu_{t+1} &= \nu_t + \eta_t + \xi_t \\
743
- \eta_{t+1} &= \eta_{t-1} + \omega_t \\
744
- \zeta_t &\sim N(0, \sigma_\zeta) \\
745
- \xi_t &\sim N(0, \sigma_\xi) \\
746
- \omega_t &\sim N(0, \sigma_\omega)
747
- \end{align}
748
-
749
- After setting all innovation terms to zero and defining initial states :math:`\mu_0, \nu_0, \eta_0`, these equations
750
- can be collapsed to:
751
-
752
- .. math::
753
- \mu_t = \mu_0 + \nu_0 \cdot t + \eta_0 \cdot t^2
754
-
755
- Which clarifies how the order and initial states influence the model. In particular, the initial states are the
756
- coefficients on the intercept, slope, acceleration, and so on.
757
-
758
- In this light, allowing for innovations can be understood as allowing these coefficients to vary over time. Each
759
- component can be individually selected for time variation by passing a list to the ``innovations_order`` argument.
760
- For example, a constant intercept with time varying trend and acceleration is specified as ``order=3,
761
- innovations_order=[0, 1, 1]``.
762
-
763
- By choosing the ``order`` and ``innovations_order``, a large variety of models can be obtained. Notable
764
- models include:
765
-
766
- * Constant intercept, ``order=1, innovations_order=0``
767
-
768
- .. math::
769
- \mu_t = \mu
770
-
771
- * Constant linear slope, ``order=2, innovations_order=0``
772
-
773
- .. math::
774
- \mu_t = \mu_{t-1} + \nu
775
-
776
- * Gaussian Random Walk, ``order=1, innovations_order=1``
777
-
778
- .. math::
779
- \mu_t = \mu_{t-1} + \zeta_t
780
-
781
- * Gaussian Random Walk with Drift, ``order=2, innovations_order=1``
782
-
783
- .. math::
784
- \mu_t = \mu_{t-1} + \nu + \zeta_t
785
-
786
- * Smooth Trend, ``order=2, innovations_order=[0, 1]``
787
-
788
- .. math::
789
- \begin{align}
790
- \mu_t &= \mu_{t-1} + \nu_{t-1} \\
791
- \nu_t &= \nu_{t-1} + \xi_t
792
- \end{align}
793
-
794
- * Local Level, ``order=2, innovations_order=2``
795
-
796
- [1] notes that the smooth trend model produces more gradually changing slopes than the full local linear trend
797
- model, and is equivalent to an "integrated trend model".
798
-
799
- References
800
- ----------
801
- .. [1] Durbin, James, and Siem Jan Koopman. 2012.
802
- Time Series Analysis by State Space Methods: Second Edition.
803
- Oxford University Press.
804
-
805
- """
806
-
807
- def __init__(
808
- self,
809
- order: int | list[int] = 2,
810
- innovations_order: int | list[int] | None = None,
811
- name: str = "LevelTrend",
812
- ):
813
- if innovations_order is None:
814
- innovations_order = order
815
-
816
- self._order_mask = order_to_mask(order)
817
- max_state = np.flatnonzero(self._order_mask)[-1].item() + 1
818
-
819
- # If the user passes excess zeros, raise an error. The alternative is to prune them, but this would cause
820
- # the shape of the state to be different to what the user expects.
821
- if len(self._order_mask) > max_state:
822
- raise ValueError(
823
- f"order={order} is invalid. The highest derivative should not be set to zero. If you want a "
824
- f"lower order model, explicitly omit the zeros."
825
- )
826
- k_states = max_state
827
-
828
- if isinstance(innovations_order, int):
829
- n = innovations_order
830
- innovations_order = order_to_mask(k_states)
831
- if n > 0:
832
- innovations_order[n:] = False
833
- else:
834
- innovations_order[:] = False
835
- else:
836
- innovations_order = order_to_mask(innovations_order)
837
-
838
- self.innovations_order = innovations_order[:max_state]
839
- k_posdef = int(sum(innovations_order))
840
-
841
- super().__init__(
842
- name,
843
- k_endog=1,
844
- k_states=k_states,
845
- k_posdef=k_posdef,
846
- measurement_error=False,
847
- combine_hidden_states=False,
848
- obs_state_idxs=np.array([1.0] + [0.0] * (k_states - 1)),
849
- )
850
-
851
- def populate_component_properties(self):
852
- name_slice = POSITION_DERIVATIVE_NAMES[: self.k_states]
853
- self.param_names = ["initial_trend"]
854
- self.state_names = [name for name, mask in zip(name_slice, self._order_mask) if mask]
855
- self.param_dims = {"initial_trend": ("trend_state",)}
856
- self.coords = {"trend_state": self.state_names}
857
- self.param_info = {"initial_trend": {"shape": (self.k_states,), "constraints": None}}
858
-
859
- if self.k_posdef > 0:
860
- self.param_names += ["sigma_trend"]
861
- self.shock_names = [
862
- name for name, mask in zip(name_slice, self.innovations_order) if mask
863
- ]
864
- self.param_dims["sigma_trend"] = ("trend_shock",)
865
- self.coords["trend_shock"] = self.shock_names
866
- self.param_info["sigma_trend"] = {"shape": (self.k_posdef,), "constraints": "Positive"}
867
-
868
- for name in self.param_names:
869
- self.param_info[name]["dims"] = self.param_dims[name]
870
-
871
- def make_symbolic_graph(self) -> None:
872
- initial_trend = self.make_and_register_variable("initial_trend", shape=(self.k_states,))
873
- self.ssm["initial_state", :] = initial_trend
874
- triu_idx = np.triu_indices(self.k_states)
875
- self.ssm[np.s_["transition", triu_idx[0], triu_idx[1]]] = 1
876
-
877
- R = np.eye(self.k_states)
878
- R = R[:, self.innovations_order]
879
- self.ssm["selection", :, :] = R
880
-
881
- self.ssm["design", 0, :] = np.array([1.0] + [0.0] * (self.k_states - 1))
882
-
883
- if self.k_posdef > 0:
884
- sigma_trend = self.make_and_register_variable("sigma_trend", shape=(self.k_posdef,))
885
- diag_idx = np.diag_indices(self.k_posdef)
886
- idx = np.s_["state_cov", diag_idx[0], diag_idx[1]]
887
- self.ssm[idx] = sigma_trend**2
888
-
889
-
890
- class MeasurementError(Component):
891
- r"""
892
- Measurement error term for a structural timeseries model
893
-
894
- Parameters
895
- ----------
896
- name: str, optional
897
-
898
- Name of the observed data. Default is "obs".
899
-
900
- Notes
901
- -----
902
- This component should only be used in combination with other components, because it has no states. It's only use
903
- is to add a variance parameter to the model, associated with the observation noise matrix H.
904
-
905
- Examples
906
- --------
907
- Create and estimate a deterministic linear trend with measurement error
908
-
909
- .. code:: python
910
-
911
- from pymc_extras.statespace import structural as st
912
- import pymc as pm
913
- import pytensor.tensor as pt
914
-
915
- trend = st.LevelTrendComponent(order=2, innovations_order=0)
916
- error = st.MeasurementError()
917
- ss_mod = (trend + error).build()
918
-
919
- with pm.Model(coords=ss_mod.coords) as model:
920
- P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states) * 10, dims=ss_mod.param_dims['P0'])
921
- intitial_trend = pm.Normal('initial_trend', sigma=10, dims=ss_mod.param_dims['initial_trend'])
922
- sigma_obs = pm.Exponential('sigma_obs', 1, dims=ss_mod.param_dims['sigma_obs'])
923
-
924
- ss_mod.build_statespace_graph(data)
925
- idata = pm.sample(nuts_sampler='numpyro')
926
- """
927
-
928
- def __init__(self, name: str = "MeasurementError"):
929
- k_endog = 1
930
- k_states = 0
931
- k_posdef = 0
932
-
933
- super().__init__(
934
- name, k_endog, k_states, k_posdef, measurement_error=True, combine_hidden_states=False
935
- )
936
-
937
- def populate_component_properties(self):
938
- self.param_names = [f"sigma_{self.name}"]
939
- self.param_dims = {}
940
- self.param_info = {
941
- f"sigma_{self.name}": {
942
- "shape": (),
943
- "constraints": "Positive",
944
- "dims": None,
945
- }
946
- }
947
-
948
- def make_symbolic_graph(self) -> None:
949
- sigma_shape = ()
950
- error_sigma = self.make_and_register_variable(f"sigma_{self.name}", shape=sigma_shape)
951
- diag_idx = np.diag_indices(self.k_endog)
952
- idx = np.s_["obs_cov", diag_idx[0], diag_idx[1]]
953
- self.ssm[idx] = error_sigma**2
954
-
955
-
956
- class AutoregressiveComponent(Component):
957
- r"""
958
- Autoregressive timeseries component
959
-
960
- Parameters
961
- ----------
962
- order: int or sequence of int
963
-
964
- If int, the number of lags to include in the model.
965
- If a sequence, an array-like of zeros and ones indicating which lags to include in the model.
966
-
967
- Notes
968
- -----
969
- An autoregressive component can be thought of as a way o introducing serially correlated errors into the model.
970
- The process is modeled:
971
-
972
- .. math::
973
- x_t = \sum_{i=1}^p \rho_i x_{t-i}
974
-
975
- Where ``p``, the number of autoregressive terms to model, is the order of the process. By default, all lags up to
976
- ``p`` are included in the model. To disable lags, pass a list of zeros and ones to the ``order`` argumnet. For
977
- example, ``order=[1, 1, 0, 1]`` would become:
978
-
979
- .. math::
980
- x_t = \rho_1 x_{t-1} + \rho_2 x_{t-1} + \rho_4 x_{t-1}
981
-
982
- The coefficient :math:`\rho_3` has been constrained to zero.
983
-
984
- .. warning:: This class is meant to be used as a component in a structural time series model. For modeling of
985
- stationary processes with ARIMA, use ``statespace.BayesianSARIMA``.
986
-
987
- Examples
988
- --------
989
- Model a timeseries as an AR(2) process with non-zero mean:
990
-
991
- .. code:: python
992
-
993
- from pymc_extras.statespace import structural as st
994
- import pymc as pm
995
- import pytensor.tensor as pt
996
-
997
- trend = st.LevelTrendComponent(order=1, innovations_order=0)
998
- ar = st.AutoregressiveComponent(2)
999
- ss_mod = (trend + ar).build()
1000
-
1001
- with pm.Model(coords=ss_mod.coords) as model:
1002
- P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states) * 10, dims=ss_mod.param_dims['P0'])
1003
- intitial_trend = pm.Normal('initial_trend', sigma=10, dims=ss_mod.param_dims['initial_trend'])
1004
- ar_params = pm.Normal('ar_params', dims=ss_mod.param_dims['ar_params'])
1005
- sigma_ar = pm.Exponential('sigma_ar', 1, dims=ss_mod.param_dims['sigma_ar'])
1006
-
1007
- ss_mod.build_statespace_graph(data)
1008
- idata = pm.sample(nuts_sampler='numpyro')
1009
-
1010
- """
1011
-
1012
- def __init__(self, order: int = 1, name: str = "AutoRegressive"):
1013
- order = order_to_mask(order)
1014
- ar_lags = np.flatnonzero(order).ravel().astype(int) + 1
1015
- k_states = len(order)
1016
-
1017
- self.order = order
1018
- self.ar_lags = ar_lags
1019
-
1020
- super().__init__(
1021
- name=name,
1022
- k_endog=1,
1023
- k_states=k_states,
1024
- k_posdef=1,
1025
- measurement_error=True,
1026
- combine_hidden_states=True,
1027
- obs_state_idxs=np.r_[[1.0], np.zeros(k_states - 1)],
1028
- )
1029
-
1030
- def populate_component_properties(self):
1031
- self.state_names = [f"L{i + 1}.data" for i in range(self.k_states)]
1032
- self.shock_names = [f"{self.name}_innovation"]
1033
- self.param_names = ["ar_params", "sigma_ar"]
1034
- self.param_dims = {"ar_params": (AR_PARAM_DIM,)}
1035
- self.coords = {AR_PARAM_DIM: self.ar_lags.tolist()}
1036
-
1037
- self.param_info = {
1038
- "ar_params": {
1039
- "shape": (self.k_states,),
1040
- "constraints": None,
1041
- "dims": (AR_PARAM_DIM,),
1042
- },
1043
- "sigma_ar": {"shape": (), "constraints": "Positive", "dims": None},
1044
- }
1045
-
1046
- def make_symbolic_graph(self) -> None:
1047
- k_nonzero = int(sum(self.order))
1048
- ar_params = self.make_and_register_variable("ar_params", shape=(k_nonzero,))
1049
- sigma_ar = self.make_and_register_variable("sigma_ar", shape=())
1050
-
1051
- T = np.eye(self.k_states, k=-1)
1052
- self.ssm["transition", :, :] = T
1053
- self.ssm["selection", 0, 0] = 1
1054
- self.ssm["design", 0, 0] = 1
1055
-
1056
- ar_idx = ("transition", np.zeros(k_nonzero, dtype="int"), np.nonzero(self.order)[0])
1057
- self.ssm[ar_idx] = ar_params
1058
-
1059
- cov_idx = ("state_cov", *np.diag_indices(1))
1060
- self.ssm[cov_idx] = sigma_ar**2
1061
-
1062
-
1063
- class TimeSeasonality(Component):
1064
- r"""
1065
- Seasonal component, modeled in the time domain
1066
-
1067
- Parameters
1068
- ----------
1069
- season_length: int
1070
- The number of periods in a single seasonal cycle, e.g. 12 for monthly data with annual seasonal pattern, 7 for
1071
- daily data with weekly seasonal pattern, etc.
1072
-
1073
- innovations: bool, default True
1074
- Whether to include stochastic innovations in the strength of the seasonal effect
1075
-
1076
- name: str, default None
1077
- A name for this seasonal component. Used to label dimensions and coordinates. Useful when multiple seasonal
1078
- components are included in the same model. Default is ``f"Seasonal[s={season_length}]"``
1079
-
1080
- state_names: list of str, default None
1081
- List of strings for seasonal effect labels. If provided, it must be of length ``season_length``. An example
1082
- would be ``state_names = ['Mon', 'Tue', 'Wed', 'Thur', 'Fri', 'Sat', 'Sun']`` when data is daily with a weekly
1083
- seasonal pattern (``season_length = 7``).
1084
-
1085
- If None, states will be numbered ``[State_0, ..., State_s]``
1086
-
1087
- remove_first_state: bool, default True
1088
- If True, the first state will be removed from the model. This is done because there are only n-1 degrees of
1089
- freedom in the seasonal component, and one state is not identified. If False, the first state will be
1090
- included in the model, but it will not be identified -- you will need to handle this in the priors (e.g. with
1091
- ZeroSumNormal).
1092
-
1093
- Notes
1094
- -----
1095
- A seasonal effect is any pattern that repeats every fixed interval. Although there are many possible ways to
1096
- model seasonal effects, the implementation used here is the one described by [1] as the "canonical" time domain
1097
- representation. The seasonal component can be expressed:
1098
-
1099
- .. math::
1100
- \gamma_t = -\sum_{i=1}^{s-1} \gamma_{t-i} + \omega_t, \quad \omega_t \sim N(0, \sigma_\gamma)
1101
-
1102
- Where :math:`s` is the ``seasonal_length`` parameter and :math:`\omega_t` is the (optional) stochastic innovation.
1103
- To give interpretation to the :math:`\gamma` terms, it is helpful to work through the algebra for a simple
1104
- example. Let :math:`s=4`, and omit the shock term. Define initial conditions :math:`\gamma_0, \gamma_{-1},
1105
- \gamma_{-2}`. The value of the seasonal component for the first 5 timesteps will be:
1106
-
1107
- .. math::
1108
- \begin{align}
1109
- \gamma_1 &= -\gamma_0 - \gamma_{-1} - \gamma_{-2} \\
1110
- \gamma_2 &= -\gamma_1 - \gamma_0 - \gamma_{-1} \\
1111
- &= -(-\gamma_0 - \gamma_{-1} - \gamma_{-2}) - \gamma_0 - \gamma_{-1} \\
1112
- &= (\gamma_0 - \gamma_0 )+ (\gamma_{-1} - \gamma_{-1}) + \gamma_{-2} \\
1113
- &= \gamma_{-2} \\
1114
- \gamma_3 &= -\gamma_2 - \gamma_1 - \gamma_0 \\
1115
- &= -\gamma_{-2} - (-\gamma_0 - \gamma_{-1} - \gamma_{-2}) - \gamma_0 \\
1116
- &= (\gamma_{-2} - \gamma_{-2}) + \gamma_{-1} + (\gamma_0 - \gamma_0) \\
1117
- &= \gamma_{-1} \\
1118
- \gamma_4 &= -\gamma_3 - \gamma_2 - \gamma_1 \\
1119
- &= -\gamma_{-1} - \gamma_{-2} -(-\gamma_0 - \gamma_{-1} - \gamma_{-2}) \\
1120
- &= (\gamma_{-2} - \gamma_{-2}) + (\gamma_{-1} - \gamma_{-1}) + \gamma_0 \\
1121
- &= \gamma_0 \\
1122
- \gamma_5 &= -\gamma_4 - \gamma_3 - \gamma_2 \\
1123
- &= -\gamma_0 - \gamma_{-1} - \gamma_{-2} \\
1124
- &= \gamma_1
1125
- \end{align}
1126
-
1127
- This exercise shows that, given a list ``initial_conditions`` of length ``s-1``, the effects of this model will be:
1128
-
1129
- - Period 1: ``-sum(initial_conditions)``
1130
- - Period 2: ``initial_conditions[-1]``
1131
- - Period 3: ``initial_conditions[-2]``
1132
- - ...
1133
- - Period s: ``initial_conditions[0]``
1134
- - Period s+1: ``-sum(initial_condition)``
1135
-
1136
- And so on. So for interpretation, the ``season_length - 1`` initial states are, when reversed, the coefficients
1137
- associated with ``state_names[1:]``.
1138
-
1139
- .. warning::
1140
- Although the ``state_names`` argument expects a list of length ``season_length``, only ``state_names[1:]``
1141
- will be saved as model dimensions, since the 1st coefficient is not identified (it is defined as
1142
- :math:`-\sum_{i=1}^{s} \gamma_{t-i}`).
1143
-
1144
- Examples
1145
- --------
1146
- Estimate monthly with a model with a gaussian random walk trend and monthly seasonality:
1147
-
1148
- .. code:: python
1149
-
1150
- from pymc_extras.statespace import structural as st
1151
- import pymc as pm
1152
- import pytensor.tensor as pt
1153
- import pandas as pd
1154
-
1155
- # Get month names
1156
- state_names = pd.date_range('1900-01-01', '1900-12-31', freq='MS').month_name().tolist()
1157
-
1158
- # Build the structural model
1159
- grw = st.LevelTrendComponent(order=1, innovations_order=1)
1160
- annual_season = st.TimeSeasonality(season_length=12, name='annual', state_names=state_names, innovations=False)
1161
- ss_mod = (grw + annual_season).build()
1162
-
1163
- # Estimate with PyMC
1164
- with pm.Model(coords=ss_mod.coords) as model:
1165
- P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states) * 10, dims=ss_mod.param_dims['P0'])
1166
- intitial_trend = pm.Deterministic('initial_trend', pt.zeros(1), dims=ss_mod.param_dims['initial_trend'])
1167
- annual_coefs = pm.Normal('annual_coefs', sigma=1e-2, dims=ss_mod.param_dims['annual_coefs'])
1168
- trend_sigmas = pm.HalfNormal('trend_sigmas', sigma=1e-6, dims=ss_mod.param_dims['trend_sigmas'])
1169
- ss_mod.build_statespace_graph(data)
1170
- idata = pm.sample(nuts_sampler='numpyro')
1171
-
1172
- References
1173
- ----------
1174
- .. [1] Durbin, James, and Siem Jan Koopman. 2012.
1175
- Time Series Analysis by State Space Methods: Second Edition.
1176
- Oxford University Press.
1177
- """
1178
-
1179
- def __init__(
1180
- self,
1181
- season_length: int,
1182
- innovations: bool = True,
1183
- name: str | None = None,
1184
- state_names: list | None = None,
1185
- remove_first_state: bool = True,
1186
- ):
1187
- if name is None:
1188
- name = f"Seasonal[s={season_length}]"
1189
- if state_names is None:
1190
- state_names = [f"{name}_{i}" for i in range(season_length)]
1191
- else:
1192
- if len(state_names) != season_length:
1193
- raise ValueError(
1194
- f"state_names must be a list of length season_length, got {len(state_names)}"
1195
- )
1196
- state_names = state_names.copy()
1197
- self.innovations = innovations
1198
- self.remove_first_state = remove_first_state
1199
-
1200
- if self.remove_first_state:
1201
- # In traditional models, the first state isn't identified, so we can help out the user by automatically
1202
- # discarding it.
1203
- # TODO: Can this be stashed and reconstructed automatically somehow?
1204
- state_names.pop(0)
1205
-
1206
- k_states = season_length - int(self.remove_first_state)
1207
-
1208
- super().__init__(
1209
- name=name,
1210
- k_endog=1,
1211
- k_states=k_states,
1212
- k_posdef=int(innovations),
1213
- state_names=state_names,
1214
- measurement_error=False,
1215
- combine_hidden_states=True,
1216
- obs_state_idxs=np.r_[[1.0], np.zeros(k_states - 1)],
1217
- )
1218
-
1219
- def populate_component_properties(self):
1220
- self.param_names = [f"{self.name}_coefs"]
1221
- self.param_info = {
1222
- f"{self.name}_coefs": {
1223
- "shape": (self.k_states,),
1224
- "constraints": None,
1225
- "dims": (f"{self.name}_state",),
1226
- }
1227
- }
1228
- self.param_dims = {f"{self.name}_coefs": (f"{self.name}_state",)}
1229
- self.coords = {f"{self.name}_state": self.state_names}
1230
-
1231
- if self.innovations:
1232
- self.param_names += [f"sigma_{self.name}"]
1233
- self.param_info[f"sigma_{self.name}"] = {
1234
- "shape": (),
1235
- "constraints": "Positive",
1236
- "dims": None,
1237
- }
1238
- self.shock_names = [f"{self.name}"]
1239
-
1240
- def make_symbolic_graph(self) -> None:
1241
- if self.remove_first_state:
1242
- # In this case, parameters are normalized to sum to zero, so the current state is the negative sum of
1243
- # all previous states.
1244
- T = np.eye(self.k_states, k=-1)
1245
- T[0, :] = -1
1246
- else:
1247
- # In this case we assume the user to be responsible for ensuring the states sum to zero, so T is just a
1248
- # circulant matrix that cycles between the states.
1249
- T = np.eye(self.k_states, k=1)
1250
- T[-1, 0] = 1
1251
-
1252
- self.ssm["transition", :, :] = T
1253
- self.ssm["design", 0, 0] = 1
1254
-
1255
- initial_states = self.make_and_register_variable(
1256
- f"{self.name}_coefs", shape=(self.k_states,)
1257
- )
1258
- self.ssm["initial_state", np.arange(self.k_states, dtype=int)] = initial_states
1259
-
1260
- if self.innovations:
1261
- self.ssm["selection", 0, 0] = 1
1262
- season_sigma = self.make_and_register_variable(f"sigma_{self.name}", shape=())
1263
- cov_idx = ("state_cov", *np.diag_indices(1))
1264
- self.ssm[cov_idx] = season_sigma**2
1265
-
1266
-
1267
- class FrequencySeasonality(Component):
1268
- r"""
1269
- Seasonal component, modeled in the frequency domain
1270
-
1271
- Parameters
1272
- ----------
1273
- season_length: float
1274
- The number of periods in a single seasonal cycle, e.g. 12 for monthly data with annual seasonal pattern, 7 for
1275
- daily data with weekly seasonal pattern, etc. Non-integer seasonal_length is also permitted, for example
1276
- 365.2422 days in a (solar) year.
1277
-
1278
- n: int
1279
- Number of fourier features to include in the seasonal component. Default is ``season_length // 2``, which
1280
- is the maximum possible. A smaller number can be used for a more wave-like seasonal pattern.
1281
-
1282
- name: str, default None
1283
- A name for this seasonal component. Used to label dimensions and coordinates. Useful when multiple seasonal
1284
- components are included in the same model. Default is ``f"Seasonal[s={season_length}, n={n}]"``
1285
-
1286
- innovations: bool, default True
1287
- Whether to include stochastic innovations in the strength of the seasonal effect
1288
-
1289
- Notes
1290
- -----
1291
- A seasonal effect is any pattern that repeats every fixed interval. Although there are many possible ways to
1292
- model seasonal effects, the implementation used here is the one described by [1] as the "canonical" frequency domain
1293
- representation. The seasonal component can be expressed:
1294
-
1295
- .. math::
1296
- \begin{align}
1297
- \gamma_t &= \sum_{j=1}^{2n} \gamma_{j,t} \\
1298
- \gamma_{j, t+1} &= \gamma_{j,t} \cos \lambda_j + \gamma_{j,t}^\star \sin \lambda_j + \omega_{j, t} \\
1299
- \gamma_{j, t}^\star &= -\gamma_{j,t} \sin \lambda_j + \gamma_{j,t}^\star \cos \lambda_j + \omega_{j,t}^\star
1300
- \lambda_j &= \frac{2\pi j}{s}
1301
- \end{align}
1302
-
1303
- Where :math:`s` is the ``seasonal_length``.
1304
-
1305
- Unlike a ``TimeSeasonality`` component, a ``FrequencySeasonality`` component does not require integer season
1306
- length. In addition, for long seasonal periods, it is possible to obtain a more compact state space representation
1307
- by choosing ``n << s // 2``. Using ``TimeSeasonality``, an annual seasonal pattern in daily data requires 364
1308
- states, whereas ``FrequencySeasonality`` always requires ``2 * n`` states, regardless of the ``seasonal_length``.
1309
- The price of this compactness is less representational power. At ``n = 1``, the seasonal pattern will be a pure
1310
- sine wave. At ``n = s // 2``, any arbitrary pattern can be represented.
1311
-
1312
- One cost of the added flexibility of ``FrequencySeasonality`` is reduced interpretability. States of this model are
1313
- coefficients :math:`\gamma_1, \gamma^\star_1, \gamma_2, \gamma_2^\star ..., \gamma_n, \gamma^\star_n` associated
1314
- with different frequencies in the fourier representation of the seasonal pattern. As a result, it is not possible
1315
- to isolate and identify a "Monday" effect, for instance.
1316
- """
1317
-
1318
- def __init__(self, season_length, n=None, name=None, innovations=True):
1319
- if n is None:
1320
- n = int(season_length // 2)
1321
- if name is None:
1322
- name = f"Frequency[s={season_length}, n={n}]"
1323
-
1324
- k_states = n * 2
1325
- self.n = n
1326
- self.season_length = season_length
1327
- self.innovations = innovations
1328
-
1329
- # If the model is completely saturated (n = s // 2), the last state will not be identified, so it shouldn't
1330
- # get a parameter assigned to it and should just be fixed to zero.
1331
- # Test this way (rather than n == s // 2) to catch cases when n is non-integer.
1332
- self.last_state_not_identified = self.season_length / self.n == 2.0
1333
- self.n_coefs = k_states - int(self.last_state_not_identified)
1334
-
1335
- obs_state_idx = np.zeros(k_states)
1336
- obs_state_idx[slice(0, k_states, 2)] = 1
1337
-
1338
- super().__init__(
1339
- name=name,
1340
- k_endog=1,
1341
- k_states=k_states,
1342
- k_posdef=k_states * int(self.innovations),
1343
- measurement_error=False,
1344
- combine_hidden_states=True,
1345
- obs_state_idxs=obs_state_idx,
1346
- )
1347
-
1348
- def make_symbolic_graph(self) -> None:
1349
- self.ssm["design", 0, slice(0, self.k_states, 2)] = 1
1350
-
1351
- init_state = self.make_and_register_variable(f"{self.name}", shape=(self.n_coefs,))
1352
-
1353
- init_state_idx = np.arange(self.n_coefs, dtype=int)
1354
- self.ssm["initial_state", init_state_idx] = init_state
1355
-
1356
- T_mats = [_frequency_transition_block(self.season_length, j + 1) for j in range(self.n)]
1357
- T = pt.linalg.block_diag(*T_mats)
1358
- self.ssm["transition", :, :] = T
1359
-
1360
- if self.innovations:
1361
- sigma_season = self.make_and_register_variable(f"sigma_{self.name}", shape=())
1362
- self.ssm["state_cov", :, :] = pt.eye(self.k_posdef) * sigma_season**2
1363
- self.ssm["selection", :, :] = np.eye(self.k_states)
1364
-
1365
- def populate_component_properties(self):
1366
- self.state_names = [f"{self.name}_{f}_{i}" for i in range(self.n) for f in ["Cos", "Sin"]]
1367
- self.param_names = [f"{self.name}"]
1368
-
1369
- self.param_dims = {self.name: (f"{self.name}_state",)}
1370
- self.param_info = {
1371
- f"{self.name}": {
1372
- "shape": (self.k_states - int(self.last_state_not_identified),),
1373
- "constraints": None,
1374
- "dims": (f"{self.name}_state",),
1375
- }
1376
- }
1377
-
1378
- init_state_idx = np.arange(self.k_states, dtype=int)
1379
- if self.last_state_not_identified:
1380
- init_state_idx = init_state_idx[:-1]
1381
- self.coords = {f"{self.name}_state": [self.state_names[i] for i in init_state_idx]}
1382
-
1383
- if self.innovations:
1384
- self.shock_names = self.state_names.copy()
1385
- self.param_names += [f"sigma_{self.name}"]
1386
- self.param_info[f"sigma_{self.name}"] = {
1387
- "shape": (),
1388
- "constraints": "Positive",
1389
- "dims": None,
1390
- }
1391
-
1392
-
1393
- class CycleComponent(Component):
1394
- r"""
1395
- A component for modeling longer-term cyclical effects
1396
-
1397
- Parameters
1398
- ----------
1399
- name: str
1400
- Name of the component. Used in generated coordinates and state names. If None, a descriptive name will be
1401
- used.
1402
-
1403
- cycle_length: int, optional
1404
- The length of the cycle, in the calendar units of your data. For example, if your data is monthly, and you
1405
- want to model a 12-month cycle, use ``cycle_length=12``. You cannot specify both ``cycle_length`` and
1406
- ``estimate_cycle_length``.
1407
-
1408
- estimate_cycle_length: bool, default False
1409
- Whether to estimate the cycle length. If True, an additional parameter, ``cycle_length`` will be added to the
1410
- model. You cannot specify both ``cycle_length`` and ``estimate_cycle_length``.
1411
-
1412
- dampen: bool, default False
1413
- Whether to dampen the cycle by multiplying by a dampening factor :math:`\rho` at every timestep. If true,
1414
- an additional parameter, ``dampening_factor`` will be added to the model.
1415
-
1416
- innovations: bool, default True
1417
- Whether to include stochastic innovations in the strength of the seasonal effect. If True, an additional
1418
- parameter, ``sigma_{name}`` will be added to the model.
1419
-
1420
- Notes
1421
- -----
1422
- The cycle component is very similar in implementation to the frequency domain seasonal component, expect that it
1423
- is restricted to n=1. The cycle component can be expressed:
1424
-
1425
- .. math::
1426
- \begin{align}
1427
- \gamma_t &= \rho \gamma_{t-1} \cos \lambda + \rho \gamma_{t-1}^\star \sin \lambda + \omega_{t} \\
1428
- \gamma_{t}^\star &= -\rho \gamma_{t-1} \sin \lambda + \rho \gamma_{t-1}^\star \cos \lambda + \omega_{t}^\star \\
1429
- \lambda &= \frac{2\pi}{s}
1430
- \end{align}
1431
-
1432
- Where :math:`s` is the ``cycle_length``. [1] recommend that this component be used for longer term cyclical
1433
- effects, such as business cycles, and that the seasonal component be used for shorter term effects, such as
1434
- weekly or monthly seasonality.
1435
-
1436
- Unlike a FrequencySeasonality component, the length of a CycleComponent can be estimated.
1437
-
1438
- Examples
1439
- --------
1440
- Estimate a business cycle with length between 6 and 12 years:
1441
-
1442
- .. code:: python
1443
-
1444
- from pymc_extras.statespace import structural as st
1445
- import pymc as pm
1446
- import pytensor.tensor as pt
1447
- import pandas as pd
1448
- import numpy as np
1449
-
1450
- data = np.random.normal(size=(100, 1))
1451
-
1452
- # Build the structural model
1453
- grw = st.LevelTrendComponent(order=1, innovations_order=1)
1454
- cycle = st.CycleComponent('business_cycle', estimate_cycle_length=True, dampen=False)
1455
- ss_mod = (grw + cycle).build()
1456
-
1457
- # Estimate with PyMC
1458
- with pm.Model(coords=ss_mod.coords) as model:
1459
- P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states), dims=ss_mod.param_dims['P0'])
1460
- intitial_trend = pm.Normal('initial_trend', dims=ss_mod.param_dims['initial_trend'])
1461
- sigma_trend = pm.HalfNormal('sigma_trend', dims=ss_mod.param_dims['sigma_trend'])
1462
-
1463
- cycle_strength = pm.Normal('business_cycle')
1464
- cycle_length = pm.Uniform('business_cycle_length', lower=6, upper=12)
1465
-
1466
- sigma_cycle = pm.HalfNormal('sigma_business_cycle', sigma=1)
1467
- ss_mod.build_statespace_graph(data)
1468
-
1469
- idata = pm.sample(nuts_sampler='numpyro')
1470
-
1471
- References
1472
- ----------
1473
- .. [1] Durbin, James, and Siem Jan Koopman. 2012.
1474
- Time Series Analysis by State Space Methods: Second Edition.
1475
- Oxford University Press.
1476
- """
1477
-
1478
- def __init__(
1479
- self,
1480
- name: str | None = None,
1481
- cycle_length: int | None = None,
1482
- estimate_cycle_length: bool = False,
1483
- dampen: bool = False,
1484
- innovations: bool = True,
1485
- ):
1486
- if cycle_length is None and not estimate_cycle_length:
1487
- raise ValueError("Must specify cycle_length if estimate_cycle_length is False")
1488
- if cycle_length is not None and estimate_cycle_length:
1489
- raise ValueError("Cannot specify cycle_length if estimate_cycle_length is True")
1490
- if name is None:
1491
- cycle = int(cycle_length) if cycle_length is not None else "Estimate"
1492
- name = f"Cycle[s={cycle}, dampen={dampen}, innovations={innovations}]"
1493
-
1494
- self.estimate_cycle_length = estimate_cycle_length
1495
- self.cycle_length = cycle_length
1496
- self.innovations = innovations
1497
- self.dampen = dampen
1498
- self.n_coefs = 1
1499
-
1500
- k_states = 2
1501
- k_endog = 1
1502
- k_posdef = 2
1503
-
1504
- obs_state_idx = np.zeros(k_states)
1505
- obs_state_idx[slice(0, k_states, 2)] = 1
1506
-
1507
- super().__init__(
1508
- name=name,
1509
- k_endog=k_endog,
1510
- k_states=k_states,
1511
- k_posdef=k_posdef,
1512
- measurement_error=False,
1513
- combine_hidden_states=True,
1514
- obs_state_idxs=obs_state_idx,
1515
- )
1516
-
1517
- def make_symbolic_graph(self) -> None:
1518
- self.ssm["design", 0, slice(0, self.k_states, 2)] = 1
1519
- self.ssm["selection", :, :] = np.eye(self.k_states)
1520
- self.param_dims = {self.name: (f"{self.name}_state",)}
1521
- self.coords = {f"{self.name}_state": self.state_names}
1522
-
1523
- init_state = self.make_and_register_variable(f"{self.name}", shape=(self.k_states,))
1524
-
1525
- self.ssm["initial_state", :] = init_state
1526
-
1527
- if self.estimate_cycle_length:
1528
- lamb = self.make_and_register_variable(f"{self.name}_length", shape=())
1529
- else:
1530
- lamb = self.cycle_length
1531
-
1532
- if self.dampen:
1533
- rho = self.make_and_register_variable(f"{self.name}_dampening_factor", shape=())
1534
- else:
1535
- rho = 1
1536
-
1537
- T = rho * _frequency_transition_block(lamb, j=1)
1538
- self.ssm["transition", :, :] = T
1539
-
1540
- if self.innovations:
1541
- sigma_cycle = self.make_and_register_variable(f"sigma_{self.name}", shape=())
1542
- self.ssm["state_cov", :, :] = pt.eye(self.k_posdef) * sigma_cycle**2
1543
-
1544
- def populate_component_properties(self):
1545
- self.state_names = [f"{self.name}_{f}" for f in ["Cos", "Sin"]]
1546
- self.param_names = [f"{self.name}"]
1547
-
1548
- self.param_info = {
1549
- f"{self.name}": {
1550
- "shape": (2,),
1551
- "constraints": None,
1552
- "dims": (f"{self.name}_state",),
1553
- }
1554
- }
1555
-
1556
- if self.estimate_cycle_length:
1557
- self.param_names += [f"{self.name}_length"]
1558
- self.param_info[f"{self.name}_length"] = {
1559
- "shape": (),
1560
- "constraints": "Positive, non-zero",
1561
- "dims": None,
1562
- }
1563
-
1564
- if self.dampen:
1565
- self.param_names += [f"{self.name}_dampening_factor"]
1566
- self.param_info[f"{self.name}_dampening_factor"] = {
1567
- "shape": (),
1568
- "constraints": "0 < x ≤ 1",
1569
- "dims": None,
1570
- }
1571
-
1572
- if self.innovations:
1573
- self.param_names += [f"sigma_{self.name}"]
1574
- self.param_info[f"sigma_{self.name}"] = {
1575
- "shape": (),
1576
- "constraints": "Positive",
1577
- "dims": None,
1578
- }
1579
- self.shock_names = self.state_names.copy()
1580
-
1581
-
1582
- class RegressionComponent(Component):
1583
- def __init__(
1584
- self,
1585
- k_exog: int | None = None,
1586
- name: str | None = "Exogenous",
1587
- state_names: list[str] | None = None,
1588
- innovations=False,
1589
- ):
1590
- self.innovations = innovations
1591
- k_exog = self._handle_input_data(k_exog, state_names, name)
1592
-
1593
- k_states = k_exog
1594
- k_endog = 1
1595
- k_posdef = k_exog
1596
-
1597
- super().__init__(
1598
- name=name,
1599
- k_endog=k_endog,
1600
- k_states=k_states,
1601
- k_posdef=k_posdef,
1602
- state_names=self.state_names,
1603
- measurement_error=False,
1604
- combine_hidden_states=False,
1605
- exog_names=[f"data_{name}"],
1606
- obs_state_idxs=np.ones(k_states),
1607
- )
1608
-
1609
- @staticmethod
1610
- def _get_state_names(k_exog: int | None, state_names: list[str] | None, name: str):
1611
- if k_exog is None and state_names is None:
1612
- raise ValueError("Must specify at least one of k_exog or state_names")
1613
- if state_names is not None and k_exog is not None:
1614
- if len(state_names) != k_exog:
1615
- raise ValueError(f"Expected {k_exog} state names, found {len(state_names)}")
1616
- elif k_exog is None:
1617
- k_exog = len(state_names)
1618
- else:
1619
- state_names = [f"{name}_{i + 1}" for i in range(k_exog)]
1620
-
1621
- return k_exog, state_names
1622
-
1623
- def _handle_input_data(self, k_exog: int, state_names: list[str] | None, name) -> int:
1624
- k_exog, state_names = self._get_state_names(k_exog, state_names, name)
1625
- self.state_names = state_names
1626
-
1627
- return k_exog
1628
-
1629
- def make_symbolic_graph(self) -> None:
1630
- betas = self.make_and_register_variable(f"beta_{self.name}", shape=(self.k_states,))
1631
- regression_data = self.make_and_register_data(
1632
- f"data_{self.name}", shape=(None, self.k_states)
1633
- )
1634
-
1635
- self.ssm["initial_state", :] = betas
1636
- self.ssm["transition", :, :] = np.eye(self.k_states)
1637
- self.ssm["selection", :, :] = np.eye(self.k_states)
1638
- self.ssm["design"] = pt.expand_dims(regression_data, 1)
1639
-
1640
- if self.innovations:
1641
- sigma_beta = self.make_and_register_variable(
1642
- f"sigma_beta_{self.name}", (self.k_states,)
1643
- )
1644
- row_idx, col_idx = np.diag_indices(self.k_states)
1645
- self.ssm["state_cov", row_idx, col_idx] = sigma_beta**2
1646
-
1647
- def populate_component_properties(self) -> None:
1648
- self.shock_names = self.state_names
1649
-
1650
- self.param_names = [f"beta_{self.name}"]
1651
- self.data_names = [f"data_{self.name}"]
1652
- self.param_dims = {
1653
- f"beta_{self.name}": ("exog_state",),
1654
- }
1655
-
1656
- self.param_info = {
1657
- f"beta_{self.name}": {
1658
- "shape": (self.k_states,),
1659
- "constraints": None,
1660
- "dims": ("exog_state",),
1661
- },
1662
- }
1663
-
1664
- self.data_info = {
1665
- f"data_{self.name}": {
1666
- "shape": (None, self.k_states),
1667
- "dims": (TIME_DIM, "exog_state"),
1668
- },
1669
- }
1670
- self.coords = {"exog_state": self.state_names}
1671
-
1672
- if self.innovations:
1673
- self.param_names += [f"sigma_beta_{self.name}"]
1674
- self.param_dims[f"sigma_beta_{self.name}"] = "exog_state"
1675
- self.param_info[f"sigma_beta_{self.name}"] = {
1676
- "shape": (),
1677
- "constraints": "Positive",
1678
- "dims": ("exog_state",),
1679
- }