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
@@ -0,0 +1,919 @@
1
+ import functools as ft
2
+ import logging
3
+
4
+ from collections.abc import Sequence
5
+ from itertools import pairwise
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import xarray as xr
10
+
11
+ from pytensor import Mode, Variable, config
12
+ from pytensor import tensor as pt
13
+
14
+ from pymc_extras.statespace.core import PyMCStateSpace, PytensorRepresentation
15
+ from pymc_extras.statespace.models.utilities import (
16
+ add_tensors_by_dim_labels,
17
+ conform_time_varying_and_time_invariant_matrices,
18
+ join_tensors_by_dim_labels,
19
+ make_default_coords,
20
+ )
21
+ from pymc_extras.statespace.utils.constants import (
22
+ ALL_STATE_AUX_DIM,
23
+ ALL_STATE_DIM,
24
+ LONG_MATRIX_NAMES,
25
+ )
26
+
27
+ _log = logging.getLogger(__name__)
28
+ floatX = config.floatX
29
+
30
+
31
+ class StructuralTimeSeries(PyMCStateSpace):
32
+ r"""
33
+ Structural Time Series Model
34
+
35
+ The structural time series model, named by [1] and presented in statespace form in [2], is a framework for
36
+ decomposing a univariate time series into level, trend, seasonal, and cycle components. It also admits the
37
+ possibility of exogenous regressors. Unlike the SARIMAX framework, the time series is not assumed to be stationary.
38
+
39
+ Parameters
40
+ ----------
41
+ ssm : PytensorRepresentation
42
+ The state space representation containing system matrices.
43
+ name : str
44
+ Name of the model. If None, defaults to "StructuralTimeSeries".
45
+ state_names : list[str]
46
+ Names of the hidden states in the model.
47
+ observed_state_names : list[str]
48
+ Names of the observed variables.
49
+ data_names : list[str]
50
+ Names of data variables expected by the model.
51
+ shock_names : list[str]
52
+ Names of innovation/shock processes.
53
+ param_names : list[str]
54
+ Names of model parameters.
55
+ exog_names : list[str]
56
+ Names of exogenous variables.
57
+ param_dims : dict[str, tuple[int]]
58
+ Dimension specifications for parameters.
59
+ coords : dict[str, Sequence]
60
+ Coordinate specifications for the model.
61
+ param_info : dict[str, dict[str, Any]]
62
+ Information about parameters including shapes and constraints.
63
+ data_info : dict[str, dict[str, Any]]
64
+ Information about data variables.
65
+ component_info : dict[str, dict[str, Any]]
66
+ Information about model components.
67
+ measurement_error : bool
68
+ Whether the model includes measurement error.
69
+ name_to_variable : dict[str, Variable]
70
+ Mapping from parameter names to PyTensor variables.
71
+ name_to_data : dict[str, Variable] | None, optional
72
+ Mapping from data names to PyTensor variables. Default is None.
73
+ verbose : bool, optional
74
+ Whether to print model information. Default is True.
75
+ filter_type : str, optional
76
+ Type of Kalman filter to use. Default is "standard".
77
+ mode : str | Mode | None, optional
78
+ PyTensor compilation mode. Default is None.
79
+
80
+ Notes
81
+ -----
82
+ The structural time series model decomposes a time series into interpretable components:
83
+
84
+ .. math::
85
+
86
+ y_t = \mu_t + \nu_t + \cdots + \gamma_t + c_t + \xi_t + \varepsilon_t
87
+
88
+ Where:
89
+ - :math:`\mu_t` is the level component
90
+ - :math:`\nu_t` is the slope/trend component
91
+ - :math:`\cdots` represents higher-order trend components
92
+ - :math:`\gamma_t` is the seasonal component
93
+ - :math:`c_t` is the cycle component
94
+ - :math:`\xi_t` is the autoregressive component
95
+ - :math:`\varepsilon_t` is the measurement error
96
+
97
+ The model is built by combining individual components (e.g., LevelTrendComponent,
98
+ TimeSeasonality, CycleComponent) using the addition operator. Each component
99
+ contributes to the overall state space representation.
100
+
101
+ Examples
102
+ --------
103
+ Create a model with trend and seasonal components:
104
+
105
+ .. code:: python
106
+
107
+ from pymc_extras.statespace import structural as st
108
+ import pymc as pm
109
+ import pytensor.tensor as pt
110
+
111
+ trend = st.LevelTrendComponent(order=2 innovations_order=1)
112
+ seasonal = st.TimeSeasonality(season_length=12, innovations=True)
113
+ error = st.MeasurementError()
114
+
115
+ ss_mod = (trend + seasonal + error).build()
116
+
117
+ with pm.Model(coords=ss_mod.coords) as model:
118
+ P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states) * 10, dims=ss_mod.param_dims['P0'])
119
+
120
+ initial_trend = pm.Normal('initial_trend', sigma=10, dims=ss_mod.param_dims['initial_trend'])
121
+ sigma_trend = pm.HalfNormal('sigma_trend', sigma=1, dims=ss_mod.param_dims['sigma_trend'])
122
+
123
+ seasonal_coefs = pm.Normal('params_seasonal', sigma=1, dims=ss_mod.param_dims['params_seasonal'])
124
+ sigma_seasonal = pm.HalfNormal('sigma_seasonal', sigma=1)
125
+
126
+ sigma_obs = pm.Exponential('sigma_obs', 1, dims=ss_mod.param_dims['sigma_obs'])
127
+
128
+ ss_mod.build_statespace_graph(data)
129
+ idata = pm.sample()
130
+
131
+ References
132
+ ----------
133
+ .. [1] Harvey, A. C. (1989). Forecasting, structural time series models and the
134
+ Kalman filter. Cambridge University Press.
135
+ .. [2] Durbin, J., & Koopman, S. J. (2012). Time series analysis by state space
136
+ methods (2nd ed.). Oxford University Press.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ ssm: PytensorRepresentation,
142
+ name: str,
143
+ state_names: list[str],
144
+ observed_state_names: list[str],
145
+ data_names: list[str],
146
+ shock_names: list[str],
147
+ param_names: list[str],
148
+ exog_names: list[str],
149
+ param_dims: dict[str, tuple[int]],
150
+ coords: dict[str, Sequence],
151
+ param_info: dict[str, dict[str, Any]],
152
+ data_info: dict[str, dict[str, Any]],
153
+ component_info: dict[str, dict[str, Any]],
154
+ measurement_error: bool,
155
+ name_to_variable: dict[str, Variable],
156
+ name_to_data: dict[str, Variable] | None = None,
157
+ verbose: bool = True,
158
+ filter_type: str = "standard",
159
+ mode: str | Mode | None = None,
160
+ ):
161
+ name = "StructuralTimeSeries" if name is None else name
162
+
163
+ self._name = name
164
+ self._observed_state_names = observed_state_names
165
+
166
+ k_states, k_posdef, k_endog = ssm.k_states, ssm.k_posdef, ssm.k_endog
167
+ param_names, param_dims, param_info = self._add_inital_state_cov_to_properties(
168
+ param_names, param_dims, param_info, k_states
169
+ )
170
+
171
+ self._state_names = self._strip_data_names_if_unambiguous(state_names, k_endog)
172
+ self._data_names = self._strip_data_names_if_unambiguous(data_names, k_endog)
173
+ self._shock_names = self._strip_data_names_if_unambiguous(shock_names, k_endog)
174
+ self._param_names = self._strip_data_names_if_unambiguous(param_names, k_endog)
175
+ self._param_dims = param_dims
176
+
177
+ default_coords = make_default_coords(self)
178
+ coords.update(default_coords)
179
+
180
+ self._coords = {
181
+ k: self._strip_data_names_if_unambiguous(v, k_endog) for k, v in coords.items()
182
+ }
183
+ self._param_info = param_info.copy()
184
+ self._data_info = data_info.copy()
185
+ self.measurement_error = measurement_error
186
+
187
+ super().__init__(
188
+ k_endog,
189
+ k_states,
190
+ max(1, k_posdef),
191
+ filter_type=filter_type,
192
+ verbose=verbose,
193
+ measurement_error=measurement_error,
194
+ mode=mode,
195
+ )
196
+ self.ssm = ssm.copy()
197
+
198
+ if k_posdef == 0:
199
+ # If there is no randomness in the model, add dummy matrices to the representation to avoid errors
200
+ # when we go to construct random variables from the matrices
201
+ self.ssm.k_posdef = self.k_posdef
202
+ self.ssm.shapes["state_cov"] = (1, 1, 1)
203
+ self.ssm["state_cov"] = pt.zeros((1, 1, 1))
204
+
205
+ self.ssm.shapes["selection"] = (1, self.k_states, 1)
206
+ self.ssm["selection"] = pt.zeros((1, self.k_states, 1))
207
+
208
+ self._component_info = component_info.copy()
209
+
210
+ self._name_to_variable = name_to_variable.copy()
211
+ self._name_to_data = name_to_data.copy()
212
+
213
+ self._exog_names = exog_names.copy()
214
+ self._needs_exog_data = len(exog_names) > 0
215
+
216
+ P0 = self.make_and_register_variable("P0", shape=(self.k_states, self.k_states))
217
+ self.ssm["initial_state_cov"] = P0
218
+
219
+ def _strip_data_names_if_unambiguous(self, names: list[str], k_endog: int):
220
+ """
221
+ State names from components should always be of the form name[data_name], in the case that the component is
222
+ associated with multiple observed states. Not doing so leads to ambiguity -- we might have two level states,
223
+ but which goes to which observed component? So we set `level[data_1]` and `level[data_2]`.
224
+
225
+ In cases where there is only one observed state (when k_endog == 1), we can strip the data part and just use
226
+ the state name. This is a bit cleaner.
227
+ """
228
+ if k_endog == 1:
229
+ [data_name] = self.observed_states
230
+ return [
231
+ name.replace(f"[{data_name}]", "") if isinstance(name, str) else name
232
+ for name in names
233
+ ]
234
+
235
+ else:
236
+ return names
237
+
238
+ @staticmethod
239
+ def _add_inital_state_cov_to_properties(param_names, param_dims, param_info, k_states):
240
+ param_names += ["P0"]
241
+ param_dims["P0"] = (ALL_STATE_DIM, ALL_STATE_AUX_DIM)
242
+ param_info["P0"] = {
243
+ "shape": (k_states, k_states),
244
+ "constraints": "Positive semi-definite",
245
+ "dims": param_dims["P0"],
246
+ }
247
+
248
+ return param_names, param_dims, param_info
249
+
250
+ @property
251
+ def param_names(self):
252
+ return self._param_names
253
+
254
+ @property
255
+ def data_names(self) -> list[str]:
256
+ return self._data_names
257
+
258
+ @property
259
+ def state_names(self):
260
+ return self._state_names
261
+
262
+ @property
263
+ def observed_states(self):
264
+ return self._observed_state_names
265
+
266
+ @property
267
+ def shock_names(self):
268
+ return self._shock_names
269
+
270
+ @property
271
+ def param_dims(self):
272
+ return self._param_dims
273
+
274
+ @property
275
+ def coords(self) -> dict[str, Sequence]:
276
+ return self._coords
277
+
278
+ @property
279
+ def param_info(self) -> dict[str, dict[str, Any]]:
280
+ return self._param_info
281
+
282
+ @property
283
+ def data_info(self) -> dict[str, dict[str, Any]]:
284
+ return self._data_info
285
+
286
+ def make_symbolic_graph(self) -> None:
287
+ """
288
+ Assign placeholder pytensor variables among statespace matrices in positions where PyMC variables will go.
289
+
290
+ Notes
291
+ -----
292
+ This assignment is handled by the components, so this function is implemented only to avoid the
293
+ NotImplementedError raised by the base class.
294
+ """
295
+
296
+ pass
297
+
298
+ def _state_slices_from_info(self):
299
+ info = self._component_info.copy()
300
+ comp_states = np.cumsum([0] + [info["k_states"] for info in info.values()])
301
+ state_slices = [slice(i, j) for i, j in pairwise(comp_states)]
302
+
303
+ return state_slices
304
+
305
+ def _hidden_states_from_data(self, data):
306
+ state_slices = self._state_slices_from_info()
307
+ info = self._component_info
308
+ names = info.keys()
309
+ result = []
310
+
311
+ for i, (name, s) in enumerate(zip(names, state_slices)):
312
+ obs_idx = info[name]["obs_state_idx"]
313
+
314
+ if obs_idx is None:
315
+ continue
316
+
317
+ X = data[..., s]
318
+
319
+ if info[name]["combine_hidden_states"]:
320
+ sum_idx_joined = np.flatnonzero(obs_idx)
321
+ sum_idx_split = np.split(sum_idx_joined, info[name]["k_endog"])
322
+ for sum_idx in sum_idx_split:
323
+ result.append(X[..., sum_idx].sum(axis=-1)[..., None])
324
+ else:
325
+ n_components = len(self.state_names[s])
326
+ for j in range(n_components):
327
+ result.append(X[..., j, None])
328
+
329
+ return np.concatenate(result, axis=-1)
330
+
331
+ def _get_subcomponent_names(self):
332
+ state_slices = self._state_slices_from_info()
333
+ info = self._component_info
334
+ names = info.keys()
335
+ result = []
336
+
337
+ for i, (name, s) in enumerate(zip(names, state_slices)):
338
+ if info[name]["combine_hidden_states"]:
339
+ if self.k_endog == 1:
340
+ result.append(name)
341
+ else:
342
+ # If there are multiple observed states, we will combine per hidden state, preserving the
343
+ # observed state names. Note this happens even if this *component* has only 1 state for consistency,
344
+ # as long as the statespace model has multiple observed states.
345
+ result.extend(
346
+ [f"{name}[{obs_name}]" for obs_name in info[name]["observed_state_names"]]
347
+ )
348
+ else:
349
+ comp_names = self.state_names[s]
350
+ result.extend([f"{name}[{comp_name}]" for comp_name in comp_names])
351
+ return result
352
+
353
+ def extract_components_from_idata(self, idata: xr.Dataset) -> xr.Dataset:
354
+ r"""
355
+ Extract interpretable hidden states from an InferenceData returned by a PyMCStateSpace sampling method
356
+
357
+ Parameters
358
+ ----------
359
+ idata: Dataset
360
+ A Dataset object, returned by a PyMCStateSpace sampling method
361
+
362
+ Returns
363
+ -------
364
+ idata: Dataset
365
+ A Dataset object with hidden states transformed to represent only the "interpretable" subcomponents
366
+ of the structural model.
367
+
368
+ Notes
369
+ -----
370
+ In general, a structural statespace model can be represented as:
371
+
372
+ .. math::
373
+ y_t = \mu_t + \nu_t + \cdots + \gamma_t + c_t + \xi_t + \epsilon_t \tag{1}
374
+
375
+ Where:
376
+
377
+ - :math:`\mu_t` is the level of the data at time t
378
+ - :math:`\nu_t` is the slope of the data at time t
379
+ - :math:`\cdots` are higher time derivatives of the position (acceleration, jerk, etc) at time t
380
+ - :math:`\gamma_t` is the seasonal component at time t
381
+ - :math:`c_t` is the cycle component at time t
382
+ - :math:`\xi_t` is the autoregressive error at time t
383
+ - :math:`\varepsilon_t` is the measurement error at time t
384
+
385
+ In state space form, some or all of these components are represented as linear combinations of other
386
+ subcomponents, making interpretation of the outputs of the outputs difficult. The purpose of this function is
387
+ to take the expended statespace representation and return a "reduced form" of only the components shown in
388
+ equation (1).
389
+ """
390
+
391
+ def _extract_and_transform_variable(idata, new_state_names):
392
+ *_, time_dim, state_dim = idata.dims
393
+ state_func = ft.partial(self._hidden_states_from_data)
394
+ new_idata = xr.apply_ufunc(
395
+ state_func,
396
+ idata,
397
+ input_core_dims=[[time_dim, state_dim]],
398
+ output_core_dims=[[time_dim, state_dim]],
399
+ exclude_dims={state_dim},
400
+ )
401
+ new_idata.coords.update({state_dim: new_state_names})
402
+ return new_idata
403
+
404
+ var_names = list(idata.data_vars.keys())
405
+ is_latent = [idata[name].shape[-1] == self.k_states for name in var_names]
406
+ new_state_names = self._get_subcomponent_names()
407
+
408
+ latent_names = [name for latent, name in zip(is_latent, var_names) if latent]
409
+ dropped_vars = set(var_names) - set(latent_names)
410
+ if len(dropped_vars) > 0:
411
+ _log.warning(
412
+ f"Variables {', '.join(dropped_vars)} do not contain all hidden states (their last dimension "
413
+ f"is not {self.k_states}). They will not be present in the modified idata."
414
+ )
415
+ if len(dropped_vars) == len(var_names):
416
+ raise ValueError(
417
+ "Provided idata had no variables with all hidden states; cannot extract components."
418
+ )
419
+
420
+ idata_new = xr.Dataset(
421
+ {
422
+ name: _extract_and_transform_variable(idata[name], new_state_names)
423
+ for name in latent_names
424
+ }
425
+ )
426
+ return idata_new
427
+
428
+
429
+ class Component:
430
+ r"""
431
+ Base class for a component of a structural timeseries model.
432
+
433
+ This base class contains a subset of the class attributes of the PyMCStateSpace class, and none of the class
434
+ methods. The purpose of a component is to allow the partial definition of a structural model. Components are
435
+ assembled into a full model by the StructuralTimeSeries class.
436
+
437
+ Parameters
438
+ ----------
439
+ name : str
440
+ The name of the component.
441
+ k_endog : int
442
+ Number of endogenous (observed) variables being modeled.
443
+ k_states : int
444
+ Number of hidden states in the component model.
445
+ k_posdef : int
446
+ Rank of the state covariance matrix, or the number of sources of innovations
447
+ in the component model.
448
+ state_names : list[str] | None, optional
449
+ Names of the hidden states. If None, defaults to empty list.
450
+ observed_state_names : list[str] | None, optional
451
+ Names of the observed states associated with this component. Must have the same
452
+ length as k_endog. If None, defaults to empty list.
453
+ data_names : list[str] | None, optional
454
+ Names of data variables expected by the component. If None, defaults to empty list.
455
+ shock_names : list[str] | None, optional
456
+ Names of innovation/shock processes. If None, defaults to empty list.
457
+ param_names : list[str] | None, optional
458
+ Names of component parameters. If None, defaults to empty list.
459
+ exog_names : list[str] | None, optional
460
+ Names of exogenous variables. If None, defaults to empty list.
461
+ representation : PytensorRepresentation | None, optional
462
+ Pre-existing state space representation. If None, creates a new one.
463
+ measurement_error : bool, optional
464
+ Whether the component includes measurement error. Default is False.
465
+ combine_hidden_states : bool, optional
466
+ Whether to combine hidden states when extracting from data. Should be True for
467
+ components where individual states have no interpretation (e.g., seasonal,
468
+ autoregressive). Default is True.
469
+ component_from_sum : bool, optional
470
+ Whether this component is created from combining other components. Default is False.
471
+ obs_state_idxs : np.ndarray | None, optional
472
+ Indices indicating which states contribute to observed variables. If None,
473
+ defaults to None.
474
+ share_states : bool, optional
475
+ Whether states are shared across multiple endogenous variables in multivariate
476
+ models. When True, the same latent states affect all observed variables.
477
+ Default is False.
478
+
479
+ Examples
480
+ --------
481
+ Create a simple trend component:
482
+
483
+ .. code:: python
484
+
485
+ from pymc_extras.statespace import structural as st
486
+
487
+ trend = st.LevelTrendComponent(order=2, innovations_order=1)
488
+ seasonal = st.TimeSeasonality(season_length=12, innovations=True)
489
+ model = (trend + seasonal).build()
490
+
491
+ print(f"Model has {model.k_states} states and {model.k_posdef} innovations")
492
+
493
+ See Also
494
+ --------
495
+ StructuralTimeSeries : The complete model class that combines components.
496
+ LevelTrendComponent : Component for modeling level and trend.
497
+ TimeSeasonality : Component for seasonal effects.
498
+ CycleComponent : Component for cyclical effects.
499
+ RegressionComponent : Component for regression effects.
500
+ """
501
+
502
+ def __init__(
503
+ self,
504
+ name,
505
+ k_endog,
506
+ k_states,
507
+ k_posdef,
508
+ state_names=None,
509
+ observed_state_names=None,
510
+ data_names=None,
511
+ shock_names=None,
512
+ param_names=None,
513
+ exog_names=None,
514
+ representation: PytensorRepresentation | None = None,
515
+ measurement_error=False,
516
+ combine_hidden_states=True,
517
+ component_from_sum=False,
518
+ obs_state_idxs=None,
519
+ share_states: bool = False,
520
+ ):
521
+ self.name = name
522
+ self.k_endog = k_endog
523
+ self.k_states = k_states
524
+ self.share_states = share_states
525
+ self.k_posdef = k_posdef
526
+ self.measurement_error = measurement_error
527
+
528
+ self.state_names = list(state_names) if state_names is not None else []
529
+ self.observed_state_names = (
530
+ list(observed_state_names) if observed_state_names is not None else []
531
+ )
532
+ self.data_names = list(data_names) if data_names is not None else []
533
+ self.shock_names = list(shock_names) if shock_names is not None else []
534
+ self.param_names = list(param_names) if param_names is not None else []
535
+ self.exog_names = list(exog_names) if exog_names is not None else []
536
+
537
+ self.needs_exog_data = len(self.exog_names) > 0
538
+ self.coords = {}
539
+ self.param_dims = {}
540
+
541
+ self.param_info = {}
542
+ self.data_info = {}
543
+
544
+ self.param_counts = {}
545
+
546
+ if representation is None:
547
+ self.ssm = PytensorRepresentation(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
548
+ else:
549
+ self.ssm = representation
550
+
551
+ self._name_to_variable = {}
552
+ self._name_to_data = {}
553
+
554
+ if not component_from_sum:
555
+ self.populate_component_properties()
556
+ self.make_symbolic_graph()
557
+
558
+ self._component_info = {
559
+ self.name: {
560
+ "k_states": self.k_states,
561
+ "k_endog": self.k_endog,
562
+ "k_posdef": self.k_posdef,
563
+ "observed_state_names": self.observed_state_names,
564
+ "combine_hidden_states": combine_hidden_states,
565
+ "obs_state_idx": obs_state_idxs,
566
+ "share_states": self.share_states,
567
+ }
568
+ }
569
+
570
+ def make_and_register_variable(self, name, shape, dtype=floatX) -> Variable:
571
+ r"""
572
+ Helper function to create a pytensor symbolic variable and register it in the _name_to_variable dictionary
573
+
574
+ Parameters
575
+ ----------
576
+ name : str
577
+ The name of the placeholder variable. Must be the name of a model parameter.
578
+ shape : int or tuple of int
579
+ Shape of the parameter
580
+ dtype : str, default pytensor.config.floatX
581
+ dtype of the parameter
582
+
583
+ Notes
584
+ -----
585
+ Symbolic pytensor variables are used in the ``make_symbolic_graph`` method as placeholders for PyMC random
586
+ variables. The change is made in the ``_insert_random_variables`` method via ``pytensor.graph_replace``. To
587
+ make the change, a dictionary mapping pytensor variables to PyMC random variables needs to be constructed.
588
+
589
+ The purpose of this method is to:
590
+ 1. Create the placeholder symbolic variables
591
+ 2. Register the placeholder variable in the ``_name_to_variable`` dictionary
592
+
593
+ The shape provided here will define the shape of the prior that will need to be provided by the user.
594
+
595
+ An error is raised if the provided name has already been registered, or if the name is not present in the
596
+ ``param_names`` property.
597
+ """
598
+ if name not in self.param_names:
599
+ raise ValueError(
600
+ f"{name} is not a model parameter. All placeholder variables should correspond to model "
601
+ f"parameters."
602
+ )
603
+
604
+ if name in self._name_to_variable.keys():
605
+ raise ValueError(
606
+ f"{name} is already a registered placeholder variable with shape "
607
+ f"{self._name_to_variable[name].type.shape}"
608
+ )
609
+
610
+ placeholder = pt.tensor(name, shape=shape, dtype=dtype)
611
+ self._name_to_variable[name] = placeholder
612
+ return placeholder
613
+
614
+ def make_and_register_data(self, name, shape, dtype=floatX) -> Variable:
615
+ r"""
616
+ Helper function to create a pytensor symbolic variable and register it in the _name_to_data dictionary
617
+
618
+ Parameters
619
+ ----------
620
+ name : str
621
+ The name of the placeholder data. Must be the name of an expected data variable.
622
+ shape : int or tuple of int
623
+ Shape of the parameter
624
+ dtype : str, default pytensor.config.floatX
625
+ dtype of the parameter
626
+
627
+ Notes
628
+ -----
629
+ See docstring for make_and_register_variable for more details. This function is similar, but handles data
630
+ inputs instead of model parameters.
631
+
632
+ An error is raised if the provided name has already been registered, or if the name is not present in the
633
+ ``data_names`` property.
634
+ """
635
+ if name not in self.data_names:
636
+ raise ValueError(
637
+ f"{name} is not a model parameter. All placeholder variables should correspond to model "
638
+ f"parameters."
639
+ )
640
+
641
+ if name in self._name_to_data.keys():
642
+ raise ValueError(
643
+ f"{name} is already a registered placeholder variable with shape "
644
+ f"{self._name_to_data[name].type.shape}"
645
+ )
646
+
647
+ placeholder = pt.tensor(name, shape=shape, dtype=dtype)
648
+ self._name_to_data[name] = placeholder
649
+ return placeholder
650
+
651
+ def make_symbolic_graph(self) -> None:
652
+ raise NotImplementedError
653
+
654
+ def populate_component_properties(self):
655
+ raise NotImplementedError
656
+
657
+ def _get_combined_shapes(self, other):
658
+ k_states = self.k_states + other.k_states
659
+ k_posdef = self.k_posdef + other.k_posdef
660
+
661
+ # To count endog states, we have to count unique names between the two components.
662
+ combined_states = self._combine_property(
663
+ other, "observed_state_names", allow_duplicates=False
664
+ )
665
+ k_endog = len(combined_states)
666
+
667
+ return k_states, k_posdef, k_endog
668
+
669
+ def _combine_statespace_representations(self, other):
670
+ def make_slice(name, x, o_x):
671
+ ndim = max(x.ndim, o_x.ndim)
672
+ return (name,) + (slice(None, None, None),) * ndim
673
+
674
+ k_states, k_posdef, k_endog = self._get_combined_shapes(other)
675
+
676
+ self_matrices = [self.ssm[name] for name in LONG_MATRIX_NAMES]
677
+ other_matrices = [other.ssm[name] for name in LONG_MATRIX_NAMES]
678
+
679
+ self_observed_states = self.observed_state_names
680
+ other_observed_states = other.observed_state_names
681
+
682
+ x0, P0, c, d, T, Z, R, H, Q = (
683
+ self.ssm[make_slice(name, x, o_x)]
684
+ for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
685
+ )
686
+ o_x0, o_P0, o_c, o_d, o_T, o_Z, o_R, o_H, o_Q = (
687
+ other.ssm[make_slice(name, x, o_x)]
688
+ for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
689
+ )
690
+
691
+ initial_state = pt.concatenate(conform_time_varying_and_time_invariant_matrices(x0, o_x0))
692
+ initial_state.name = x0.name
693
+
694
+ initial_state_cov = pt.linalg.block_diag(P0, o_P0)
695
+ initial_state_cov.name = P0.name
696
+
697
+ state_intercept = pt.concatenate(conform_time_varying_and_time_invariant_matrices(c, o_c))
698
+ state_intercept.name = c.name
699
+
700
+ obs_intercept = add_tensors_by_dim_labels(
701
+ d, o_d, labels=self_observed_states, other_labels=other_observed_states, labeled_axis=-1
702
+ )
703
+ obs_intercept.name = d.name
704
+
705
+ transition = pt.linalg.block_diag(T, o_T)
706
+ transition = pt.specify_shape(
707
+ transition,
708
+ shape=[
709
+ sum(shapes) if not any([s is None for s in shapes]) else None
710
+ for shapes in zip(*[T.type.shape, o_T.type.shape])
711
+ ],
712
+ )
713
+ transition.name = T.name
714
+
715
+ design = join_tensors_by_dim_labels(
716
+ *conform_time_varying_and_time_invariant_matrices(Z, o_Z),
717
+ labels=self_observed_states,
718
+ other_labels=other_observed_states,
719
+ labeled_axis=-2,
720
+ join_axis=-1,
721
+ )
722
+ design.name = Z.name
723
+
724
+ selection = pt.linalg.block_diag(R, o_R)
725
+ selection = pt.specify_shape(
726
+ selection,
727
+ shape=[
728
+ sum(shapes) if not any([s is None for s in shapes]) else None
729
+ for shapes in zip(*[R.type.shape, o_R.type.shape])
730
+ ],
731
+ )
732
+ selection.name = R.name
733
+
734
+ obs_cov = add_tensors_by_dim_labels(
735
+ H,
736
+ o_H,
737
+ labels=self_observed_states,
738
+ other_labels=other_observed_states,
739
+ labeled_axis=(-1, -2),
740
+ )
741
+ obs_cov.name = H.name
742
+
743
+ state_cov = pt.linalg.block_diag(Q, o_Q)
744
+ state_cov.name = Q.name
745
+
746
+ new_ssm = PytensorRepresentation(
747
+ k_endog=k_endog,
748
+ k_states=k_states,
749
+ k_posdef=k_posdef,
750
+ initial_state=initial_state,
751
+ initial_state_cov=initial_state_cov,
752
+ state_intercept=state_intercept,
753
+ obs_intercept=obs_intercept,
754
+ transition=transition,
755
+ design=design,
756
+ selection=selection,
757
+ obs_cov=obs_cov,
758
+ state_cov=state_cov,
759
+ )
760
+
761
+ return new_ssm
762
+
763
+ def _combine_property(self, other, name, allow_duplicates=True):
764
+ self_prop = getattr(self, name)
765
+ other_prop = getattr(other, name)
766
+
767
+ if not isinstance(self_prop, type(other_prop)):
768
+ raise TypeError(
769
+ f"Property {name} of {self} and {other} are not the same and cannot be combined. Found "
770
+ f"{type(self_prop)} for {self} and {type(other_prop)} for {other}'"
771
+ )
772
+
773
+ if not isinstance(self_prop, list | dict):
774
+ raise TypeError(
775
+ f"All component properties are expected to be lists or dicts, but found {type(self_prop)}"
776
+ f"for property {name} of {self} and {type(other_prop)} for {other}'"
777
+ )
778
+
779
+ if isinstance(self_prop, list) and allow_duplicates:
780
+ return self_prop + other_prop
781
+ elif isinstance(self_prop, list) and not allow_duplicates:
782
+ return self_prop + [x for x in other_prop if x not in self_prop]
783
+ elif isinstance(self_prop, dict):
784
+ new_prop = self_prop.copy()
785
+ new_prop.update(other_prop)
786
+ return new_prop
787
+
788
+ def _combine_component_info(self, other):
789
+ combined_info = {}
790
+ for key, value in self._component_info.items():
791
+ if not key.startswith("StateSpace"):
792
+ if key in combined_info.keys():
793
+ raise ValueError(f"Found duplicate component named {key}")
794
+ combined_info[key] = value
795
+
796
+ for key, value in other._component_info.items():
797
+ if not key.startswith("StateSpace"):
798
+ if key in combined_info.keys():
799
+ raise ValueError(f"Found duplicate component named {key}")
800
+ combined_info[key] = value
801
+
802
+ return combined_info
803
+
804
+ def _make_combined_name(self):
805
+ components = self._component_info.keys()
806
+ name = f"StateSpace[{', '.join(components)}]"
807
+ return name
808
+
809
+ def __add__(self, other):
810
+ state_names = self._combine_property(other, "state_names")
811
+ data_names = self._combine_property(other, "data_names")
812
+ observed_state_names = self._combine_property(
813
+ other, "observed_state_names", allow_duplicates=False
814
+ )
815
+
816
+ param_names = self._combine_property(other, "param_names")
817
+ shock_names = self._combine_property(other, "shock_names")
818
+ param_info = self._combine_property(other, "param_info")
819
+ data_info = self._combine_property(other, "data_info")
820
+ param_dims = self._combine_property(other, "param_dims")
821
+ coords = self._combine_property(other, "coords")
822
+ exog_names = self._combine_property(other, "exog_names")
823
+
824
+ _name_to_variable = self._combine_property(other, "_name_to_variable")
825
+ _name_to_data = self._combine_property(other, "_name_to_data")
826
+
827
+ measurement_error = any([self.measurement_error, other.measurement_error])
828
+
829
+ k_states, k_posdef, k_endog = self._get_combined_shapes(other)
830
+
831
+ ssm = self._combine_statespace_representations(other)
832
+
833
+ new_comp = Component(
834
+ name="",
835
+ k_endog=k_endog,
836
+ k_states=k_states,
837
+ k_posdef=k_posdef,
838
+ observed_state_names=observed_state_names,
839
+ measurement_error=measurement_error,
840
+ representation=ssm,
841
+ component_from_sum=True,
842
+ )
843
+ new_comp._component_info = self._combine_component_info(other)
844
+ new_comp.name = new_comp._make_combined_name()
845
+
846
+ names_and_props = [
847
+ ("state_names", state_names),
848
+ ("observed_state_names", observed_state_names),
849
+ ("data_names", data_names),
850
+ ("param_names", param_names),
851
+ ("shock_names", shock_names),
852
+ ("param_dims", param_dims),
853
+ ("coords", coords),
854
+ ("param_dims", param_dims),
855
+ ("param_info", param_info),
856
+ ("data_info", data_info),
857
+ ("exog_names", exog_names),
858
+ ("_name_to_variable", _name_to_variable),
859
+ ("_name_to_data", _name_to_data),
860
+ ]
861
+
862
+ for prop, value in names_and_props:
863
+ setattr(new_comp, prop, value)
864
+
865
+ return new_comp
866
+
867
+ def build(
868
+ self, name=None, filter_type="standard", verbose=True, mode: str | Mode | None = None
869
+ ):
870
+ """
871
+ Build a StructuralTimeSeries statespace model from the current component(s)
872
+
873
+ Parameters
874
+ ----------
875
+ name: str, optional
876
+ Name of the exogenous data being modeled. Default is "data"
877
+
878
+ filter_type : str, optional
879
+ The type of Kalman filter to use. Valid options are "standard", "univariate", "single", "cholesky", and
880
+ "steady_state". For more information, see the docs for each filter. Default is "standard".
881
+
882
+ verbose : bool, optional
883
+ If True, displays information about the initialized model. Defaults to True.
884
+
885
+ mode: str or Mode, optional
886
+ Pytensor compile mode, used in auxiliary sampling methods such as ``sample_conditional_posterior`` and
887
+ ``forecast``. The mode does **not** effect calls to ``pm.sample``.
888
+
889
+ Regardless of whether a mode is specified, it can always be overwritten via the ``compile_kwargs`` argument
890
+ to all sampling methods.
891
+
892
+ Returns
893
+ -------
894
+ PyMCStateSpace
895
+ An initialized instance of a PyMCStateSpace, constructed using the system matrices contained in the
896
+ components.
897
+ """
898
+
899
+ return StructuralTimeSeries(
900
+ self.ssm,
901
+ name=name,
902
+ state_names=self.state_names,
903
+ observed_state_names=self.observed_state_names,
904
+ data_names=self.data_names,
905
+ shock_names=self.shock_names,
906
+ param_names=self.param_names,
907
+ param_dims=self.param_dims,
908
+ coords=self.coords,
909
+ param_info=self.param_info,
910
+ data_info=self.data_info,
911
+ component_info=self._component_info,
912
+ measurement_error=self.measurement_error,
913
+ exog_names=self.exog_names,
914
+ name_to_variable=self._name_to_variable,
915
+ name_to_data=self._name_to_data,
916
+ filter_type=filter_type,
917
+ verbose=verbose,
918
+ mode=mode,
919
+ )