pymc-extras 0.3.1__py3-none-any.whl → 0.4.0__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.
@@ -0,0 +1,900 @@
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('seasonal_coefs', sigma=1, dims=ss_mod.param_dims['seasonal_coefs'])
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
+ if obs_idx is None:
314
+ continue
315
+
316
+ X = data[..., s]
317
+ if info[name]["combine_hidden_states"]:
318
+ sum_idx = np.flatnonzero(obs_idx)
319
+ result.append(X[..., sum_idx].sum(axis=-1)[..., None])
320
+ else:
321
+ comp_names = self.state_names[s]
322
+ for j, state_name in enumerate(comp_names):
323
+ result.append(X[..., j, None])
324
+
325
+ return np.concatenate(result, axis=-1)
326
+
327
+ def _get_subcomponent_names(self):
328
+ state_slices = self._state_slices_from_info()
329
+ info = self._component_info
330
+ names = info.keys()
331
+ result = []
332
+
333
+ for i, (name, s) in enumerate(zip(names, state_slices)):
334
+ if info[name]["combine_hidden_states"]:
335
+ result.append(name)
336
+ else:
337
+ comp_names = self.state_names[s]
338
+ result.extend([f"{name}[{comp_name}]" for comp_name in comp_names])
339
+ return result
340
+
341
+ def extract_components_from_idata(self, idata: xr.Dataset) -> xr.Dataset:
342
+ r"""
343
+ Extract interpretable hidden states from an InferenceData returned by a PyMCStateSpace sampling method
344
+
345
+ Parameters
346
+ ----------
347
+ idata: Dataset
348
+ A Dataset object, returned by a PyMCStateSpace sampling method
349
+
350
+ Returns
351
+ -------
352
+ idata: Dataset
353
+ An Dataset object with hidden states transformed to represent only the "interpretable" subcomponents
354
+ of the structural model.
355
+
356
+ Notes
357
+ -----
358
+ In general, a structural statespace model can be represented as:
359
+
360
+ .. math::
361
+ y_t = \mu_t + \nu_t + \cdots + \gamma_t + c_t + \xi_t + \epsilon_t \tag{1}
362
+
363
+ Where:
364
+
365
+ - :math:`\mu_t` is the level of the data at time t
366
+ - :math:`\nu_t` is the slope of the data at time t
367
+ - :math:`\cdots` are higher time derivatives of the position (acceleration, jerk, etc) at time t
368
+ - :math:`\gamma_t` is the seasonal component at time t
369
+ - :math:`c_t` is the cycle component at time t
370
+ - :math:`\xi_t` is the autoregressive error at time t
371
+ - :math:`\varepsilon_t` is the measurement error at time t
372
+
373
+ In state space form, some or all of these components are represented as linear combinations of other
374
+ subcomponents, making interpretation of the outputs of the outputs difficult. The purpose of this function is
375
+ to take the expended statespace representation and return a "reduced form" of only the components shown in
376
+ equation (1).
377
+ """
378
+
379
+ def _extract_and_transform_variable(idata, new_state_names):
380
+ *_, time_dim, state_dim = idata.dims
381
+ state_func = ft.partial(self._hidden_states_from_data)
382
+ new_idata = xr.apply_ufunc(
383
+ state_func,
384
+ idata,
385
+ input_core_dims=[[time_dim, state_dim]],
386
+ output_core_dims=[[time_dim, state_dim]],
387
+ exclude_dims={state_dim},
388
+ )
389
+ new_idata.coords.update({state_dim: new_state_names})
390
+ return new_idata
391
+
392
+ var_names = list(idata.data_vars.keys())
393
+ is_latent = [idata[name].shape[-1] == self.k_states for name in var_names]
394
+ new_state_names = self._get_subcomponent_names()
395
+
396
+ latent_names = [name for latent, name in zip(is_latent, var_names) if latent]
397
+ dropped_vars = set(var_names) - set(latent_names)
398
+ if len(dropped_vars) > 0:
399
+ _log.warning(
400
+ f"Variables {', '.join(dropped_vars)} do not contain all hidden states (their last dimension "
401
+ f"is not {self.k_states}). They will not be present in the modified idata."
402
+ )
403
+ if len(dropped_vars) == len(var_names):
404
+ raise ValueError(
405
+ "Provided idata had no variables with all hidden states; cannot extract components."
406
+ )
407
+
408
+ idata_new = xr.Dataset(
409
+ {
410
+ name: _extract_and_transform_variable(idata[name], new_state_names)
411
+ for name in latent_names
412
+ }
413
+ )
414
+ return idata_new
415
+
416
+
417
+ class Component:
418
+ r"""
419
+ Base class for a component of a structural timeseries model.
420
+
421
+ This base class contains a subset of the class attributes of the PyMCStateSpace class, and none of the class
422
+ methods. The purpose of a component is to allow the partial definition of a structural model. Components are
423
+ assembled into a full model by the StructuralTimeSeries class.
424
+
425
+ Parameters
426
+ ----------
427
+ name : str
428
+ The name of the component.
429
+ k_endog : int
430
+ Number of endogenous (observed) variables being modeled.
431
+ k_states : int
432
+ Number of hidden states in the component model.
433
+ k_posdef : int
434
+ Rank of the state covariance matrix, or the number of sources of innovations
435
+ in the component model.
436
+ state_names : list[str] | None, optional
437
+ Names of the hidden states. If None, defaults to empty list.
438
+ observed_state_names : list[str] | None, optional
439
+ Names of the observed states associated with this component. Must have the same
440
+ length as k_endog. If None, defaults to empty list.
441
+ data_names : list[str] | None, optional
442
+ Names of data variables expected by the component. If None, defaults to empty list.
443
+ shock_names : list[str] | None, optional
444
+ Names of innovation/shock processes. If None, defaults to empty list.
445
+ param_names : list[str] | None, optional
446
+ Names of component parameters. If None, defaults to empty list.
447
+ exog_names : list[str] | None, optional
448
+ Names of exogenous variables. If None, defaults to empty list.
449
+ representation : PytensorRepresentation | None, optional
450
+ Pre-existing state space representation. If None, creates a new one.
451
+ measurement_error : bool, optional
452
+ Whether the component includes measurement error. Default is False.
453
+ combine_hidden_states : bool, optional
454
+ Whether to combine hidden states when extracting from data. Should be True for
455
+ components where individual states have no interpretation (e.g., seasonal,
456
+ autoregressive). Default is True.
457
+ component_from_sum : bool, optional
458
+ Whether this component is created from combining other components. Default is False.
459
+ obs_state_idxs : np.ndarray | None, optional
460
+ Indices indicating which states contribute to observed variables. If None,
461
+ defaults to None.
462
+
463
+ Examples
464
+ --------
465
+ Create a simple trend component:
466
+
467
+ .. code:: python
468
+
469
+ from pymc_extras.statespace import structural as st
470
+
471
+ trend = st.LevelTrendComponent(order=2, innovations_order=1)
472
+ seasonal = st.TimeSeasonality(season_length=12, innovations=True)
473
+ model = (trend + seasonal).build()
474
+
475
+ print(f"Model has {model.k_states} states and {model.k_posdef} innovations")
476
+
477
+ See Also
478
+ --------
479
+ StructuralTimeSeries : The complete model class that combines components.
480
+ LevelTrendComponent : Component for modeling level and trend.
481
+ TimeSeasonality : Component for seasonal effects.
482
+ CycleComponent : Component for cyclical effects.
483
+ RegressionComponent : Component for regression effects.
484
+ """
485
+
486
+ def __init__(
487
+ self,
488
+ name,
489
+ k_endog,
490
+ k_states,
491
+ k_posdef,
492
+ state_names=None,
493
+ observed_state_names=None,
494
+ data_names=None,
495
+ shock_names=None,
496
+ param_names=None,
497
+ exog_names=None,
498
+ representation: PytensorRepresentation | None = None,
499
+ measurement_error=False,
500
+ combine_hidden_states=True,
501
+ component_from_sum=False,
502
+ obs_state_idxs=None,
503
+ ):
504
+ self.name = name
505
+ self.k_endog = k_endog
506
+ self.k_states = k_states
507
+ self.k_posdef = k_posdef
508
+ self.measurement_error = measurement_error
509
+
510
+ self.state_names = list(state_names) if state_names is not None else []
511
+ self.observed_state_names = (
512
+ list(observed_state_names) if observed_state_names is not None else []
513
+ )
514
+ self.data_names = list(data_names) if data_names is not None else []
515
+ self.shock_names = list(shock_names) if shock_names is not None else []
516
+ self.param_names = list(param_names) if param_names is not None else []
517
+ self.exog_names = list(exog_names) if exog_names is not None else []
518
+
519
+ self.needs_exog_data = len(self.exog_names) > 0
520
+ self.coords = {}
521
+ self.param_dims = {}
522
+
523
+ self.param_info = {}
524
+ self.data_info = {}
525
+
526
+ self.param_counts = {}
527
+
528
+ if representation is None:
529
+ self.ssm = PytensorRepresentation(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
530
+ else:
531
+ self.ssm = representation
532
+
533
+ self._name_to_variable = {}
534
+ self._name_to_data = {}
535
+
536
+ if not component_from_sum:
537
+ self.populate_component_properties()
538
+ self.make_symbolic_graph()
539
+
540
+ self._component_info = {
541
+ self.name: {
542
+ "k_states": self.k_states,
543
+ "k_enodg": self.k_endog,
544
+ "k_posdef": self.k_posdef,
545
+ "observed_state_names": self.observed_state_names,
546
+ "combine_hidden_states": combine_hidden_states,
547
+ "obs_state_idx": obs_state_idxs,
548
+ }
549
+ }
550
+
551
+ def make_and_register_variable(self, name, shape, dtype=floatX) -> Variable:
552
+ r"""
553
+ Helper function to create a pytensor symbolic variable and register it in the _name_to_variable dictionary
554
+
555
+ Parameters
556
+ ----------
557
+ name : str
558
+ The name of the placeholder variable. Must be the name of a model parameter.
559
+ shape : int or tuple of int
560
+ Shape of the parameter
561
+ dtype : str, default pytensor.config.floatX
562
+ dtype of the parameter
563
+
564
+ Notes
565
+ -----
566
+ Symbolic pytensor variables are used in the ``make_symbolic_graph`` method as placeholders for PyMC random
567
+ variables. The change is made in the ``_insert_random_variables`` method via ``pytensor.graph_replace``. To
568
+ make the change, a dictionary mapping pytensor variables to PyMC random variables needs to be constructed.
569
+
570
+ The purpose of this method is to:
571
+ 1. Create the placeholder symbolic variables
572
+ 2. Register the placeholder variable in the ``_name_to_variable`` dictionary
573
+
574
+ The shape provided here will define the shape of the prior that will need to be provided by the user.
575
+
576
+ An error is raised if the provided name has already been registered, or if the name is not present in the
577
+ ``param_names`` property.
578
+ """
579
+ if name not in self.param_names:
580
+ raise ValueError(
581
+ f"{name} is not a model parameter. All placeholder variables should correspond to model "
582
+ f"parameters."
583
+ )
584
+
585
+ if name in self._name_to_variable.keys():
586
+ raise ValueError(
587
+ f"{name} is already a registered placeholder variable with shape "
588
+ f"{self._name_to_variable[name].type.shape}"
589
+ )
590
+
591
+ placeholder = pt.tensor(name, shape=shape, dtype=dtype)
592
+ self._name_to_variable[name] = placeholder
593
+ return placeholder
594
+
595
+ def make_and_register_data(self, name, shape, dtype=floatX) -> Variable:
596
+ r"""
597
+ Helper function to create a pytensor symbolic variable and register it in the _name_to_data dictionary
598
+
599
+ Parameters
600
+ ----------
601
+ name : str
602
+ The name of the placeholder data. Must be the name of an expected data variable.
603
+ shape : int or tuple of int
604
+ Shape of the parameter
605
+ dtype : str, default pytensor.config.floatX
606
+ dtype of the parameter
607
+
608
+ Notes
609
+ -----
610
+ See docstring for make_and_register_variable for more details. This function is similar, but handles data
611
+ inputs instead of model parameters.
612
+
613
+ An error is raised if the provided name has already been registered, or if the name is not present in the
614
+ ``data_names`` property.
615
+ """
616
+ if name not in self.data_names:
617
+ raise ValueError(
618
+ f"{name} is not a model parameter. All placeholder variables should correspond to model "
619
+ f"parameters."
620
+ )
621
+
622
+ if name in self._name_to_data.keys():
623
+ raise ValueError(
624
+ f"{name} is already a registered placeholder variable with shape "
625
+ f"{self._name_to_data[name].type.shape}"
626
+ )
627
+
628
+ placeholder = pt.tensor(name, shape=shape, dtype=dtype)
629
+ self._name_to_data[name] = placeholder
630
+ return placeholder
631
+
632
+ def make_symbolic_graph(self) -> None:
633
+ raise NotImplementedError
634
+
635
+ def populate_component_properties(self):
636
+ raise NotImplementedError
637
+
638
+ def _get_combined_shapes(self, other):
639
+ k_states = self.k_states + other.k_states
640
+ k_posdef = self.k_posdef + other.k_posdef
641
+
642
+ # To count endog states, we have to count unique names between the two components.
643
+ combined_states = self._combine_property(
644
+ other, "observed_state_names", allow_duplicates=False
645
+ )
646
+ k_endog = len(combined_states)
647
+
648
+ return k_states, k_posdef, k_endog
649
+
650
+ def _combine_statespace_representations(self, other):
651
+ def make_slice(name, x, o_x):
652
+ ndim = max(x.ndim, o_x.ndim)
653
+ return (name,) + (slice(None, None, None),) * ndim
654
+
655
+ k_states, k_posdef, k_endog = self._get_combined_shapes(other)
656
+
657
+ self_matrices = [self.ssm[name] for name in LONG_MATRIX_NAMES]
658
+ other_matrices = [other.ssm[name] for name in LONG_MATRIX_NAMES]
659
+
660
+ self_observed_states = self.observed_state_names
661
+ other_observed_states = other.observed_state_names
662
+
663
+ x0, P0, c, d, T, Z, R, H, Q = (
664
+ self.ssm[make_slice(name, x, o_x)]
665
+ for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
666
+ )
667
+ o_x0, o_P0, o_c, o_d, o_T, o_Z, o_R, o_H, o_Q = (
668
+ other.ssm[make_slice(name, x, o_x)]
669
+ for name, x, o_x in zip(LONG_MATRIX_NAMES, self_matrices, other_matrices)
670
+ )
671
+
672
+ initial_state = pt.concatenate(conform_time_varying_and_time_invariant_matrices(x0, o_x0))
673
+ initial_state.name = x0.name
674
+
675
+ initial_state_cov = pt.linalg.block_diag(P0, o_P0)
676
+ initial_state_cov.name = P0.name
677
+
678
+ state_intercept = pt.concatenate(conform_time_varying_and_time_invariant_matrices(c, o_c))
679
+ state_intercept.name = c.name
680
+
681
+ obs_intercept = add_tensors_by_dim_labels(
682
+ d, o_d, labels=self_observed_states, other_labels=other_observed_states, labeled_axis=-1
683
+ )
684
+ obs_intercept.name = d.name
685
+
686
+ transition = pt.linalg.block_diag(T, o_T)
687
+ transition = pt.specify_shape(
688
+ transition,
689
+ shape=[
690
+ sum(shapes) if not any([s is None for s in shapes]) else None
691
+ for shapes in zip(*[T.type.shape, o_T.type.shape])
692
+ ],
693
+ )
694
+ transition.name = T.name
695
+
696
+ design = join_tensors_by_dim_labels(
697
+ *conform_time_varying_and_time_invariant_matrices(Z, o_Z),
698
+ labels=self_observed_states,
699
+ other_labels=other_observed_states,
700
+ labeled_axis=-2,
701
+ join_axis=-1,
702
+ )
703
+ design.name = Z.name
704
+
705
+ selection = pt.linalg.block_diag(R, o_R)
706
+ selection = pt.specify_shape(
707
+ selection,
708
+ shape=[
709
+ sum(shapes) if not any([s is None for s in shapes]) else None
710
+ for shapes in zip(*[R.type.shape, o_R.type.shape])
711
+ ],
712
+ )
713
+ selection.name = R.name
714
+
715
+ obs_cov = add_tensors_by_dim_labels(
716
+ H,
717
+ o_H,
718
+ labels=self_observed_states,
719
+ other_labels=other_observed_states,
720
+ labeled_axis=(-1, -2),
721
+ )
722
+ obs_cov.name = H.name
723
+
724
+ state_cov = pt.linalg.block_diag(Q, o_Q)
725
+ state_cov.name = Q.name
726
+
727
+ new_ssm = PytensorRepresentation(
728
+ k_endog=k_endog,
729
+ k_states=k_states,
730
+ k_posdef=k_posdef,
731
+ initial_state=initial_state,
732
+ initial_state_cov=initial_state_cov,
733
+ state_intercept=state_intercept,
734
+ obs_intercept=obs_intercept,
735
+ transition=transition,
736
+ design=design,
737
+ selection=selection,
738
+ obs_cov=obs_cov,
739
+ state_cov=state_cov,
740
+ )
741
+
742
+ return new_ssm
743
+
744
+ def _combine_property(self, other, name, allow_duplicates=True):
745
+ self_prop = getattr(self, name)
746
+ other_prop = getattr(other, name)
747
+
748
+ if not isinstance(self_prop, type(other_prop)):
749
+ raise TypeError(
750
+ f"Property {name} of {self} and {other} are not the same and cannot be combined. Found "
751
+ f"{type(self_prop)} for {self} and {type(other_prop)} for {other}'"
752
+ )
753
+
754
+ if not isinstance(self_prop, list | dict):
755
+ raise TypeError(
756
+ f"All component properties are expected to be lists or dicts, but found {type(self_prop)}"
757
+ f"for property {name} of {self} and {type(other_prop)} for {other}'"
758
+ )
759
+
760
+ if isinstance(self_prop, list) and allow_duplicates:
761
+ return self_prop + other_prop
762
+ elif isinstance(self_prop, list) and not allow_duplicates:
763
+ return self_prop + [x for x in other_prop if x not in self_prop]
764
+ elif isinstance(self_prop, dict):
765
+ new_prop = self_prop.copy()
766
+ new_prop.update(other_prop)
767
+ return new_prop
768
+
769
+ def _combine_component_info(self, other):
770
+ combined_info = {}
771
+ for key, value in self._component_info.items():
772
+ if not key.startswith("StateSpace"):
773
+ if key in combined_info.keys():
774
+ raise ValueError(f"Found duplicate component named {key}")
775
+ combined_info[key] = value
776
+
777
+ for key, value in other._component_info.items():
778
+ if not key.startswith("StateSpace"):
779
+ if key in combined_info.keys():
780
+ raise ValueError(f"Found duplicate component named {key}")
781
+ combined_info[key] = value
782
+
783
+ return combined_info
784
+
785
+ def _make_combined_name(self):
786
+ components = self._component_info.keys()
787
+ name = f"StateSpace[{', '.join(components)}]"
788
+ return name
789
+
790
+ def __add__(self, other):
791
+ state_names = self._combine_property(other, "state_names")
792
+ data_names = self._combine_property(other, "data_names")
793
+ observed_state_names = self._combine_property(
794
+ other, "observed_state_names", allow_duplicates=False
795
+ )
796
+
797
+ param_names = self._combine_property(other, "param_names")
798
+ shock_names = self._combine_property(other, "shock_names")
799
+ param_info = self._combine_property(other, "param_info")
800
+ data_info = self._combine_property(other, "data_info")
801
+ param_dims = self._combine_property(other, "param_dims")
802
+ coords = self._combine_property(other, "coords")
803
+ exog_names = self._combine_property(other, "exog_names")
804
+
805
+ _name_to_variable = self._combine_property(other, "_name_to_variable")
806
+ _name_to_data = self._combine_property(other, "_name_to_data")
807
+
808
+ measurement_error = any([self.measurement_error, other.measurement_error])
809
+
810
+ k_states, k_posdef, k_endog = self._get_combined_shapes(other)
811
+
812
+ ssm = self._combine_statespace_representations(other)
813
+
814
+ new_comp = Component(
815
+ name="",
816
+ k_endog=k_endog,
817
+ k_states=k_states,
818
+ k_posdef=k_posdef,
819
+ observed_state_names=observed_state_names,
820
+ measurement_error=measurement_error,
821
+ representation=ssm,
822
+ component_from_sum=True,
823
+ )
824
+ new_comp._component_info = self._combine_component_info(other)
825
+ new_comp.name = new_comp._make_combined_name()
826
+
827
+ names_and_props = [
828
+ ("state_names", state_names),
829
+ ("observed_state_names", observed_state_names),
830
+ ("data_names", data_names),
831
+ ("param_names", param_names),
832
+ ("shock_names", shock_names),
833
+ ("param_dims", param_dims),
834
+ ("coords", coords),
835
+ ("param_dims", param_dims),
836
+ ("param_info", param_info),
837
+ ("data_info", data_info),
838
+ ("exog_names", exog_names),
839
+ ("_name_to_variable", _name_to_variable),
840
+ ("_name_to_data", _name_to_data),
841
+ ]
842
+
843
+ for prop, value in names_and_props:
844
+ setattr(new_comp, prop, value)
845
+
846
+ return new_comp
847
+
848
+ def build(
849
+ self, name=None, filter_type="standard", verbose=True, mode: str | Mode | None = None
850
+ ):
851
+ """
852
+ Build a StructuralTimeSeries statespace model from the current component(s)
853
+
854
+ Parameters
855
+ ----------
856
+ name: str, optional
857
+ Name of the exogenous data being modeled. Default is "data"
858
+
859
+ filter_type : str, optional
860
+ The type of Kalman filter to use. Valid options are "standard", "univariate", "single", "cholesky", and
861
+ "steady_state". For more information, see the docs for each filter. Default is "standard".
862
+
863
+ verbose : bool, optional
864
+ If True, displays information about the initialized model. Defaults to True.
865
+
866
+ mode: str or Mode, optional
867
+ Pytensor compile mode, used in auxiliary sampling methods such as ``sample_conditional_posterior`` and
868
+ ``forecast``. The mode does **not** effect calls to ``pm.sample``.
869
+
870
+ Regardless of whether a mode is specified, it can always be overwritten via the ``compile_kwargs`` argument
871
+ to all sampling methods.
872
+
873
+ Returns
874
+ -------
875
+ PyMCStateSpace
876
+ An initialized instance of a PyMCStateSpace, constructed using the system matrices contained in the
877
+ components.
878
+ """
879
+
880
+ return StructuralTimeSeries(
881
+ self.ssm,
882
+ name=name,
883
+ state_names=self.state_names,
884
+ observed_state_names=self.observed_state_names,
885
+ data_names=self.data_names,
886
+ shock_names=self.shock_names,
887
+ param_names=self.param_names,
888
+ param_dims=self.param_dims,
889
+ coords=self.coords,
890
+ param_info=self.param_info,
891
+ data_info=self.data_info,
892
+ component_info=self._component_info,
893
+ measurement_error=self.measurement_error,
894
+ exog_names=self.exog_names,
895
+ name_to_variable=self._name_to_variable,
896
+ name_to_data=self._name_to_data,
897
+ filter_type=filter_type,
898
+ verbose=verbose,
899
+ mode=mode,
900
+ )