pymc-extras 0.4.1__py3-none-any.whl → 0.6.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.
Files changed (37) hide show
  1. pymc_extras/deserialize.py +10 -4
  2. pymc_extras/distributions/continuous.py +1 -1
  3. pymc_extras/distributions/histogram_utils.py +6 -4
  4. pymc_extras/distributions/multivariate/r2d2m2cp.py +4 -3
  5. pymc_extras/distributions/timeseries.py +4 -2
  6. pymc_extras/inference/__init__.py +8 -1
  7. pymc_extras/inference/dadvi/__init__.py +0 -0
  8. pymc_extras/inference/dadvi/dadvi.py +351 -0
  9. pymc_extras/inference/fit.py +5 -0
  10. pymc_extras/inference/laplace_approx/find_map.py +32 -47
  11. pymc_extras/inference/laplace_approx/idata.py +27 -6
  12. pymc_extras/inference/laplace_approx/laplace.py +24 -6
  13. pymc_extras/inference/laplace_approx/scipy_interface.py +47 -7
  14. pymc_extras/inference/pathfinder/idata.py +517 -0
  15. pymc_extras/inference/pathfinder/pathfinder.py +61 -7
  16. pymc_extras/model/marginal/graph_analysis.py +2 -2
  17. pymc_extras/model_builder.py +9 -4
  18. pymc_extras/prior.py +203 -8
  19. pymc_extras/statespace/core/compile.py +1 -1
  20. pymc_extras/statespace/filters/kalman_filter.py +12 -11
  21. pymc_extras/statespace/filters/kalman_smoother.py +1 -3
  22. pymc_extras/statespace/filters/utilities.py +2 -5
  23. pymc_extras/statespace/models/DFM.py +834 -0
  24. pymc_extras/statespace/models/ETS.py +190 -198
  25. pymc_extras/statespace/models/SARIMAX.py +9 -21
  26. pymc_extras/statespace/models/VARMAX.py +22 -74
  27. pymc_extras/statespace/models/structural/components/autoregressive.py +4 -4
  28. pymc_extras/statespace/models/structural/components/regression.py +4 -26
  29. pymc_extras/statespace/models/utilities.py +7 -0
  30. pymc_extras/statespace/utils/constants.py +3 -1
  31. pymc_extras/utils/model_equivalence.py +2 -2
  32. pymc_extras/utils/prior.py +10 -14
  33. pymc_extras/utils/spline.py +4 -10
  34. {pymc_extras-0.4.1.dist-info → pymc_extras-0.6.0.dist-info}/METADATA +3 -3
  35. {pymc_extras-0.4.1.dist-info → pymc_extras-0.6.0.dist-info}/RECORD +37 -33
  36. {pymc_extras-0.4.1.dist-info → pymc_extras-0.6.0.dist-info}/WHEEL +1 -1
  37. {pymc_extras-0.4.1.dist-info → pymc_extras-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,834 @@
1
+ from collections.abc import Sequence
2
+ from typing import Any
3
+
4
+ import pytensor
5
+ import pytensor.tensor as pt
6
+
7
+ from pymc_extras.statespace.core.statespace import PyMCStateSpace
8
+ from pymc_extras.statespace.models.utilities import make_default_coords, validate_names
9
+ from pymc_extras.statespace.utils.constants import (
10
+ ALL_STATE_AUX_DIM,
11
+ ALL_STATE_DIM,
12
+ AR_PARAM_DIM,
13
+ ERROR_AR_PARAM_DIM,
14
+ EXOG_STATE_DIM,
15
+ FACTOR_DIM,
16
+ OBS_STATE_AUX_DIM,
17
+ OBS_STATE_DIM,
18
+ TIME_DIM,
19
+ )
20
+
21
+ floatX = pytensor.config.floatX
22
+
23
+
24
+ class BayesianDynamicFactor(PyMCStateSpace):
25
+ r"""
26
+ Dynamic Factor Models
27
+
28
+ Notes
29
+ -----
30
+ The Dynamic Factor Model (DFM) is a multivariate state-space model used to represent high-dimensional time series
31
+ as being driven by a smaller set of unobserved dynamic factors.
32
+
33
+ Given a set of observed time series :math:`\{y_t\}_{t=0}^T`, where
34
+
35
+ .. math::
36
+ y_t = \begin{bmatrix} y_{1,t} & y_{2,t} & \cdots & y_{k_{\text{endog}},t} \end{bmatrix}^T,
37
+
38
+ the DFM assumes that each series is a linear combination of a few latent factors and (optionally) autoregressive errors.
39
+
40
+ Let:
41
+ - :math:`k` be the number of dynamic factors (k_factors),
42
+ - :math:`p` be the order of the latent factor process (factor_order),
43
+ - :math:`q` be the order of the observation error process (error_order).
44
+
45
+ The model equations are in reduced form is:
46
+
47
+ .. math::
48
+ y_t &= \Lambda f_t + B x_t + u_t + \eta_t \\
49
+ f_t &= A_1 f_{t-1} + \cdots + A_p f_{t-p} + \varepsilon_{f,t} \\
50
+ u_t &= C_1 u_{t-1} + \cdots + C_q u_{t-q} + \varepsilon_{u,t}
51
+
52
+ Where:
53
+ - :math:`f_t` is the vector of latent dynamic factors (size :math:`k`),
54
+ - :math:`x_t` is an optional vector of exogenous variables
55
+ - :math:`u_t` is a vector of autoregressive observation errors (if `error_var=True` with a VAR(q) structure, else treated as independent AR processes),
56
+ - :math:`\eta_t \sim \mathcal{N}(0, H_t)` is an optional measurement error (if `measurement_error=True`),
57
+ - :math:`\varepsilon_{f,t} \sim \mathcal{N}(0, I)` and :math:`\varepsilon_{u,t} \sim \mathcal{N}(0, \Sigma_u)` are independent noise terms.
58
+ To identify the factors, the innovations to the factor process are standardized with identity covariance.
59
+
60
+ Internally, the model is represented in state-space form by stacking all current and lagged latent factors and (if present)
61
+ AR observation errors into a single state vector of dimension: :math:: k_{\text{states}} = k \cdot p + k_{\text{endog}} \cdot q,
62
+ where :math:`k_{\text{endog}}` is the number of observed time series.
63
+
64
+ The state vector is defined as:
65
+
66
+ .. math::
67
+ s_t = \begin{bmatrix}
68
+ f_t(1) \\
69
+ \vdots \\
70
+ f_t(k) \\
71
+ f_{t-p+1}(1) \\
72
+ \vdots \\
73
+ f_{t-p+1}(k) \\
74
+ u_t(1) \\
75
+ \vdots \\
76
+ u_t(k_{\text{endog}}) \\
77
+ \vdots \\
78
+ u_{t-q+1}(1) \\
79
+ \vdots \\
80
+ u_{t-q+1}(k_{\text{endog}})
81
+ \end{bmatrix}
82
+ \in \mathbb{R}^{k_{\text{states}}}
83
+
84
+ The transition equation is given by:
85
+
86
+ .. math::
87
+ s_{t+1} = T s_t + R \epsilon_t
88
+
89
+ Where:
90
+ - :math:`T` is the state transition matrix, composed of:
91
+ - VAR coefficients :math:`A_1, \dots, A_{p*k_factors}` for the factors,
92
+ - (if enabled) AR coefficients :math:`C_1, \dots, C_q` for the observation errors.
93
+ .. math::
94
+ T = \begin{bmatrix}
95
+ A_{1,1} & A_{1,2} & \cdots & A_{1,p} & 0 & 0 & \cdots & 0 \\
96
+ A_{2,1} & A_{2,2} & \cdots & A_{2,p} & 0 & 0 & \cdots & 0 \\
97
+ 1 & 0 & \cdots & 0 & 0 & 0 & \cdots & 0 \\
98
+ 0 & 1 & \cdots & 0 & 0 & 0 & \cdots & 0 \\
99
+ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \ddots & \vdots \\
100
+ \hline
101
+ 0 & 0 & \cdots & 0 & C_{1,1} & \cdots & C_{1,2} & C_{1,q} \\
102
+ 0 & 0 & \cdots & 0 & 1 & 0 & \cdots & 0 \\
103
+ 0 & 0 & \cdots & 0 & 0 & 1 & \cdots & 0 \\
104
+ \vdots & \vdots & & \vdots & \vdots & \vdots & \ddots & \vdots
105
+ \end{bmatrix}
106
+ \in \mathbb{R}^{k_{\text{states}} \times k_{\text{states}}}
107
+
108
+ - :math:`\epsilon_t` contains the independent shocks (innovations) and has dimension :math:`k + k_{\text{endog}}` if AR errors are included.
109
+ .. math::
110
+ \epsilon_t = \begin{bmatrix}
111
+ \epsilon_{f,t} \\
112
+ \epsilon_{u,t}
113
+ \end{bmatrix}
114
+ \in \mathbb{R}^{k + k_{\text{endog}}}
115
+
116
+ - :math:`R` is a selection matrix mapping shocks to state transitions.
117
+ .. math::
118
+ R = \begin{bmatrix}
119
+ 1 & 0 & \cdots & 0 & 0 & 0 & \cdots & 0 \\
120
+ 0 & 1 & \cdots & 0 & 0 & 0 & \cdots & 0 \\
121
+ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \ddots & \vdots \\
122
+ 0 & 0 & \cdots & 1 & 0 & 0 & \cdots & 0 \\
123
+ 0 & 0 & \cdots & 0 & 1 & 0 & \cdots & 0 \\
124
+ 0 & 0 & \cdots & 0 & 0 & 1 & \cdots & 0 \\
125
+ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \ddots & \vdots \\
126
+ \end{bmatrix}
127
+ \in \mathbb{R}^{k_{\text{states}} \times (k + k_{\text{endog}})}
128
+
129
+ The observation equation is given by:
130
+
131
+ .. math::
132
+
133
+ y_t = Z s_t + \eta_t
134
+
135
+ where
136
+
137
+ - :math:`y_t` is the vector of observed variables at time :math:`t`
138
+
139
+ - :math:`Z` is the design matrix of the state space representation
140
+ .. math::
141
+ Z = \begin{bmatrix}
142
+ \lambda_{1,1} & \lambda_{1,k} & \vdots & 1 & 0 & \cdots & 0 & 0 & \cdots & 0 \\
143
+ \lambda_{2,1} & \lambda_{2,k} & \vdots & 0 & 1 & \cdots & 0 & \cdots & 0 \\
144
+ \vdots & \vdots & \vdots & \vdots & \ddots & \vdots & \vdots & \ddots & \vdots \\
145
+ \lambda_{k_{\text{endog}},1} & \cdots & \lambda_{k_{\text{endog}},k} & 0 & 0 & \cdots & 1 & 0 & \cdots & 0 \\
146
+ \end{bmatrix}
147
+ \in \mathbb{R}^{k_{\text{endog}} \times k_{\text{states}}}
148
+
149
+ - :math:`\eta_t` is the vector of observation errors at time :math:`t`
150
+
151
+ When exogenous variables :math:`x_t` are present, the implementation follows `pymc_extras/statespace/models/structural/components/regression.py`.
152
+ In this case, the state vector is extended to include the beta parameters, and the design matrix is modified accordingly,
153
+ becoming 3-dimensional to handle time-varying exogenous regressors.
154
+ This approach provides greater flexibility, controlled by the boolean flags `shared_exog_state` and `exog_innovations`.
155
+ Unlike Statsmodels, where exogenous variables are included only in the observation equation, here they are fully integrated into the state-space
156
+ representation.
157
+
158
+ .. warning::
159
+
160
+ Identification can be an issue, particularly when many observed series load onto only a few latent factors.
161
+ These models are only identified up to a sign flip in the factor loadings. Proper prior specification is crucial
162
+ for good estimation and inference.
163
+
164
+ Examples
165
+ --------
166
+ The following code snippet estimates a dynamic factor model with 1 latent factors,
167
+ a AR(2) structure on the factor and a AR(1) structure on the errors:
168
+
169
+ .. code:: python
170
+
171
+ import pymc_extras.statespace as pmss
172
+ import pymc as pm
173
+
174
+ # Create DFM Statespace Model
175
+ dfm_mod = pmss.BayesianDynamicFactor(
176
+ k_factors=1, # Number of latent dynamic factors
177
+ factor_order=2, # Number of lags for the latent factor process
178
+ endog_names=data.columns, # Names of the observed time series (endogenous variables) (we could also use k_endog = len(data.columns))
179
+ error_order=1, # Order of the autoregressive process for the observation noise (i.e., AR(q) error, here q=1)
180
+ error_var=False, # If False, models errors as separate AR processes
181
+ error_cov_type="diagonal", # Structure of the observation error covariance matrix: uncorrelated noise across series
182
+ measurement_error=True, # Whether to include a measurement error term in the model
183
+ verbose=True
184
+ )
185
+
186
+ # Unpack coords
187
+ coords = dfm_mod.coords
188
+
189
+
190
+ with pm.Model(coords=coords) as pymc_mod:
191
+ # Priors for the initial state mean and covariance
192
+ x0 = pm.Normal("x0", dims=["state_dim"])
193
+ P0 = pm.HalfNormal("P0", dims=["state_dim", "state_dim"])
194
+
195
+ # Factor loadings: shape (k_endog, k_factors)
196
+ factor_loadings = pm.Normal("factor_loadings", sigma=1, dims=["k_endog", "k_factors"])
197
+
198
+ # AR coefficients for factor dynamics: shape (k_factors, factor_order)
199
+ factor_ar = pm.Normal("factor_ar", sigma=1, dims=["k_factors", "k_factors" * "factor_order"])
200
+
201
+ # AR coefficients for observation noise: shape (k_endog, error_order)
202
+ error_ar = pm.Normal("error_ar", sigma=1, dims=["k_endog", "error_order"])
203
+
204
+ # Std devs for observation noise: shape (k_endog,)
205
+ error_sigma = pm.HalfNormal("error_sigma", dims=["k_endog"])
206
+
207
+ # Observation noise covariance matrix
208
+ obs_sigma = pm.HalfNormal("sigma_obs", dims=["k_endog"])
209
+
210
+ # Build the symbolic graph and attach it to the model
211
+ dfm_mod.build_statespace_graph(data=data, mode="JAX")
212
+
213
+ # Sampling
214
+ idata = pm.sample(
215
+ draws=500,
216
+ chains=2,
217
+ nuts_sampler="nutpie",
218
+ nuts_sampler_kwargs={"backend": "jax", "gradient_backend": "jax"},
219
+ )
220
+
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ k_factors: int,
226
+ factor_order: int,
227
+ endog_names: Sequence[str] | None = None,
228
+ exog_names: Sequence[str] | None = None,
229
+ shared_exog_states: bool = False,
230
+ exog_innovations: bool = False,
231
+ error_order: int = 0,
232
+ error_var: bool = False,
233
+ error_cov_type: str = "diagonal",
234
+ measurement_error: bool = False,
235
+ verbose: bool = True,
236
+ ):
237
+ """
238
+ Create a Bayesian Dynamic Factor Model.
239
+
240
+ Parameters
241
+ ----------
242
+ k_factors : int
243
+ Number of latent factors.
244
+
245
+ factor_order : int
246
+ Order of the VAR process for the latent factors. If set to 0, the factors have no autoregressive dynamics
247
+ and are modeled as a white noise process, i.e., :math:`f_t = \varepsilon_{f,t}`.
248
+ Therefore, the state vector will include one state per factor and "factor_ar" will not exist.
249
+
250
+ endog_names : list of str, optional
251
+ Names of the observed time series.
252
+
253
+ exog_names : Sequence[str], optional
254
+ Names of the exogenous variables.
255
+
256
+ shared_exog_states: bool, optional
257
+ Whether exogenous latent states are shared across the observed states. If True, there will be only one set of exogenous latent
258
+ states, which are observed by all observed states. If False, each observed state has its own set of exogenous latent states.
259
+
260
+ exog_innovations : bool, optional
261
+ Whether to allow time-varying regression coefficients. If True, coefficients follow a random walk.
262
+
263
+ error_order : int, optional
264
+ Order of the AR process for the observation error component.
265
+ Default is 0, corresponding to white noise errors.
266
+
267
+ error_var : bool, optional
268
+ If True, errors are modeled jointly via a VAR process;
269
+ otherwise, each error is modeled separately.
270
+
271
+ error_cov_type : {'scalar', 'diagonal', 'unstructured'}, optional
272
+ Structure of the covariance matrix of the observation errors.
273
+
274
+ measurement_error: bool, default True
275
+ If true, a measurement error term is added to the model.
276
+
277
+ verbose: bool, default True
278
+ If true, a message will be logged to the terminal explaining the variable names, dimensions, and supports.
279
+
280
+ """
281
+
282
+ validate_names(endog_names, var_name="endog_names", optional=False)
283
+ k_endog = len(endog_names)
284
+ self.endog_names = endog_names
285
+ self.k_endog = k_endog
286
+ self.k_factors = k_factors
287
+ self.factor_order = factor_order
288
+ self.error_order = error_order
289
+ self.error_var = error_var
290
+ self.error_cov_type = error_cov_type
291
+
292
+ if exog_names is not None:
293
+ self.shared_exog_states = shared_exog_states
294
+ self.exog_innovations = exog_innovations
295
+ validate_names(
296
+ exog_names, var_name="exog_names", optional=True
297
+ ) # Not sure if this adds anything
298
+ k_exog = len(exog_names)
299
+ self.k_exog = k_exog
300
+ self.exog_names = exog_names
301
+ else:
302
+ self.k_exog = 0
303
+
304
+ self.k_exog_states = self.k_exog * self.k_endog if not shared_exog_states else self.k_exog
305
+ self.exog_flag = self.k_exog > 0
306
+
307
+ # Determine the dimension for the latent factor states.
308
+ # For static factors, one use k_factors.
309
+ # For dynamic factors with lags, the state include current factors and past lags.
310
+ # If factor_order is 0, we treat the factor as static (no dynamics),
311
+ # but it is still included in the state vector with one state per factor. Factor_ar paramter will not exist in this case.
312
+ k_factor_states = max(self.factor_order, 1) * k_factors
313
+
314
+ # Determine the dimension for the error component.
315
+ # If error_order > 0 then we add additional states for error dynamics, otherwise white noise error.
316
+ k_error_states = k_endog * error_order if error_order > 0 else 0
317
+
318
+ # Total state dimension
319
+ k_states = k_factor_states + k_error_states + self.k_exog_states
320
+
321
+ # Number of independent shocks.
322
+ # Typically, the latent factors introduce k_factors shocks.
323
+ # If error_order > 0 and errors are modeled jointly or separately, add appropriate count.
324
+ k_posdef = k_factors + (k_endog if error_order > 0 else 0) + self.k_exog_states
325
+ # k_posdef = (k_factors + (k_endog if error_order > 0 else 0) + self.k_exog_states if self.exog_innovations else 0)
326
+
327
+ # Initialize the PyMCStateSpace base class.
328
+ super().__init__(
329
+ k_endog=k_endog,
330
+ k_states=k_states,
331
+ k_posdef=k_posdef,
332
+ verbose=verbose,
333
+ measurement_error=measurement_error,
334
+ )
335
+
336
+ @property
337
+ def param_names(self):
338
+ names = [
339
+ "x0",
340
+ "P0",
341
+ "factor_loadings",
342
+ "factor_ar",
343
+ "error_ar",
344
+ "error_sigma",
345
+ "error_cov",
346
+ "sigma_obs",
347
+ "beta",
348
+ "beta_sigma",
349
+ ]
350
+
351
+ # Handle cases where parameters should be excluded based on model settings
352
+ if self.factor_order == 0:
353
+ names.remove("factor_ar")
354
+ if self.error_order == 0:
355
+ names.remove("error_ar")
356
+ if self.error_cov_type in ["scalar", "diagonal"]:
357
+ names.remove("error_cov")
358
+ if self.error_cov_type == "unstructured":
359
+ names.remove("error_sigma")
360
+ if not self.measurement_error:
361
+ names.remove("sigma_obs")
362
+ if not self.exog_flag:
363
+ names.remove("beta")
364
+ names.remove("beta_sigma")
365
+ if self.exog_flag and not self.exog_innovations:
366
+ names.remove("beta_sigma")
367
+
368
+ return names
369
+
370
+ @property
371
+ def param_info(self) -> dict[str, dict[str, Any]]:
372
+ info = {
373
+ "x0": {
374
+ "shape": (self.k_states,),
375
+ "constraints": None,
376
+ },
377
+ "P0": {
378
+ "shape": (self.k_states, self.k_states),
379
+ "constraints": "Positive Semi-definite",
380
+ },
381
+ "factor_loadings": {
382
+ "shape": (self.k_endog, self.k_factors),
383
+ "constraints": None,
384
+ },
385
+ "factor_ar": {
386
+ "shape": (self.k_factors, self.factor_order * self.k_factors),
387
+ "constraints": None,
388
+ },
389
+ "error_ar": {
390
+ "shape": (
391
+ self.k_endog,
392
+ self.error_order * self.k_endog if self.error_var else self.error_order,
393
+ ),
394
+ "constraints": None,
395
+ },
396
+ "error_sigma": {
397
+ "shape": (self.k_endog,) if self.error_cov_type == "diagonal" else (),
398
+ "constraints": "Positive",
399
+ },
400
+ "error_cov": {
401
+ "shape": (self.k_endog, self.k_endog),
402
+ "constraints": "Positive Semi-definite",
403
+ },
404
+ "sigma_obs": {
405
+ "shape": (self.k_endog,),
406
+ "constraints": "Positive",
407
+ },
408
+ "beta": {
409
+ "shape": (self.k_exog_states,),
410
+ "constraints": None,
411
+ },
412
+ "beta_sigma": {
413
+ "shape": (self.k_exog_states,),
414
+ "constraints": "Positive",
415
+ },
416
+ }
417
+
418
+ for name in self.param_names:
419
+ info[name]["dims"] = self.param_dims[name]
420
+
421
+ return {name: info[name] for name in self.param_names}
422
+
423
+ @property
424
+ def state_names(self) -> list[str]:
425
+ """
426
+ Returns the names of the hidden states: first factor states (with lags),
427
+ idiosyncratic error states (with lags), then exogenous states.
428
+ """
429
+ names = [
430
+ f"L{lag}.factor_{i}"
431
+ for i in range(self.k_factors)
432
+ for lag in range(max(self.factor_order, 1))
433
+ ]
434
+
435
+ if self.error_order > 0:
436
+ names.extend(
437
+ f"L{lag}.error_{i}" for i in range(self.k_endog) for lag in range(self.error_order)
438
+ )
439
+
440
+ if self.exog_flag:
441
+ if self.shared_exog_states:
442
+ names.extend([f"beta_{exog_name}[shared]" for exog_name in self.exog_names])
443
+ else:
444
+ names.extend(
445
+ f"beta_{exog_name}[{endog_name}]"
446
+ for exog_name in self.exog_names
447
+ for endog_name in self.endog_names
448
+ )
449
+ return names
450
+
451
+ @property
452
+ def observed_states(self) -> list[str]:
453
+ """
454
+ Returns the names of the observed states (i.e., the endogenous variables).
455
+ """
456
+ return self.endog_names
457
+
458
+ @property
459
+ def coords(self) -> dict[str, Sequence]:
460
+ coords = make_default_coords(self)
461
+
462
+ coords[FACTOR_DIM] = [f"factor_{i+1}" for i in range(self.k_factors)]
463
+
464
+ if self.factor_order > 0:
465
+ coords[AR_PARAM_DIM] = list(range(1, (self.factor_order * self.k_factors) + 1))
466
+
467
+ if self.error_order > 0:
468
+ if self.error_var:
469
+ coords[ERROR_AR_PARAM_DIM] = list(range(1, (self.error_order * self.k_endog) + 1))
470
+ else:
471
+ coords[ERROR_AR_PARAM_DIM] = list(range(1, self.error_order + 1))
472
+
473
+ if self.exog_flag:
474
+ coords[EXOG_STATE_DIM] = list(range(1, self.k_exog_states + 1))
475
+
476
+ return coords
477
+
478
+ @property
479
+ def shock_names(self) -> list[str]:
480
+ shock_names = [f"factor_shock_{i}" for i in range(self.k_factors)]
481
+
482
+ if self.error_order > 0:
483
+ shock_names.extend(f"error_shock_{i}" for i in range(self.k_endog))
484
+
485
+ if self.exog_flag:
486
+ if self.shared_exog_states:
487
+ shock_names.extend(f"exog_shock_{i}.shared" for i in range(self.k_exog))
488
+ else:
489
+ shock_names.extend(
490
+ f"exog_shock_{i}.endog_{j}"
491
+ for i in range(self.k_exog)
492
+ for j in range(self.k_endog)
493
+ )
494
+
495
+ return shock_names
496
+
497
+ @property
498
+ def param_dims(self):
499
+ coord_map = {
500
+ "x0": (ALL_STATE_DIM,),
501
+ "P0": (ALL_STATE_DIM, ALL_STATE_AUX_DIM),
502
+ "factor_loadings": (OBS_STATE_DIM, FACTOR_DIM),
503
+ }
504
+ if self.factor_order > 0:
505
+ coord_map["factor_ar"] = (FACTOR_DIM, AR_PARAM_DIM)
506
+
507
+ if self.error_order > 0:
508
+ coord_map["error_ar"] = (OBS_STATE_DIM, ERROR_AR_PARAM_DIM)
509
+
510
+ if self.error_cov_type in ["scalar"]:
511
+ coord_map["error_sigma"] = ()
512
+
513
+ elif self.error_cov_type in ["diagonal"]:
514
+ coord_map["error_sigma"] = (OBS_STATE_DIM,)
515
+
516
+ if self.error_cov_type == "unstructured":
517
+ coord_map["error_cov"] = (OBS_STATE_DIM, OBS_STATE_AUX_DIM)
518
+
519
+ if self.measurement_error:
520
+ coord_map["sigma_obs"] = (OBS_STATE_DIM,)
521
+
522
+ if self.exog_flag:
523
+ coord_map["beta"] = (EXOG_STATE_DIM,)
524
+ if self.exog_innovations:
525
+ coord_map["beta_sigma"] = (EXOG_STATE_DIM,)
526
+
527
+ return coord_map
528
+
529
+ @property
530
+ def data_info(self):
531
+ if self.exog_flag:
532
+ return {
533
+ "exog_data": {
534
+ "shape": (None, self.k_exog),
535
+ "dims": (TIME_DIM, EXOG_STATE_DIM),
536
+ },
537
+ }
538
+ return {}
539
+
540
+ @property
541
+ def data_names(self):
542
+ if self.exog_flag:
543
+ return ["exog_data"]
544
+ return []
545
+
546
+ def make_symbolic_graph(self):
547
+ if not self.exog_flag:
548
+ x0 = self.make_and_register_variable("x0", shape=(self.k_states,), dtype=floatX)
549
+ else:
550
+ initial_factor_loadings = self.make_and_register_variable(
551
+ "x0", shape=(self.k_states - self.k_exog_states,), dtype=floatX
552
+ )
553
+ initial_betas = self.make_and_register_variable(
554
+ "beta", shape=(self.k_exog_states,), dtype=floatX
555
+ )
556
+ x0 = pt.concatenate([initial_factor_loadings, initial_betas], axis=0)
557
+
558
+ self.ssm["initial_state", :] = x0
559
+
560
+ # Initial covariance
561
+ P0 = self.make_and_register_variable(
562
+ "P0", shape=(self.k_states, self.k_states), dtype=floatX
563
+ )
564
+ self.ssm["initial_state_cov", :, :] = P0
565
+
566
+ # Design matrix (Z)
567
+ # Construction with block structure:
568
+ # When factor_order <= 1 and error_order = 0:
569
+ # [ A ] A is the factor loadings matrix with shape (k_endog, k_factors)
570
+ #
571
+ # When factor_order > 1, add block of zeros for the factors lags:
572
+ # [ A | 0 ] the zero block has shape (k_endog, k_factors * (factor_order - 1))
573
+ #
574
+ # When error_order > 0, add identity matrix and additional zero block for errors lags:
575
+ # [ A | 0 | I | 0 ] I is the identity matrix (k_endog, k_endog) and the final zero block
576
+ # has shape (k_endog, k_endog * (error_order - 1))
577
+ #
578
+ # When exog_flag=True, exogenous data (exog_data) is included and the design
579
+ # matrix becomes 3D with the first dimension indexing time:
580
+ # - shared_exog_states=True: exog_data is broadcast across all endogenous series
581
+ # → shape (n_timepoints, k_endog, k_exog)
582
+ # - shared_exog_states=False: each endogenous series gets its own exog block
583
+ # → block-diagonal structure with shape (n_timepoints, k_endog, k_exog * k_endog)
584
+ # In this case, the base design matrix (factors + errors) is repeated over
585
+ # time and concatenated with the exogenous block. The final design matrix
586
+ # has shape (n_timepoints, k_endog, n_columns) and combines all components.
587
+ factor_loadings = self.make_and_register_variable(
588
+ "factor_loadings", shape=(self.k_endog, self.k_factors), dtype=floatX
589
+ )
590
+ # Add factor loadings (A matrix)
591
+ matrix_parts = [factor_loadings]
592
+
593
+ # Add zero block for the factors lags when factor_order > 1
594
+ if self.factor_order > 1:
595
+ matrix_parts.append(
596
+ pt.zeros((self.k_endog, self.k_factors * (self.factor_order - 1)), dtype=floatX)
597
+ )
598
+ # Add identity and zero blocks for error lags when error_order > 0
599
+ if self.error_order > 0:
600
+ error_matrix = pt.eye(self.k_endog, dtype=floatX)
601
+ matrix_parts.append(error_matrix)
602
+ matrix_parts.append(
603
+ pt.zeros((self.k_endog, self.k_endog * (self.error_order - 1)), dtype=floatX)
604
+ )
605
+ if len(matrix_parts) == 1:
606
+ design_matrix = factor_loadings * 1.0 # copy to ensure a new PyTensor variable
607
+ design_matrix.name = "design"
608
+ # TODO: This is a hack to ensure the design matrix isn't identically equal to the factor_loadings when error_order=0 and factor_order=0
609
+ else:
610
+ design_matrix = pt.concatenate(matrix_parts, axis=1)
611
+ design_matrix.name = "design"
612
+ # Handle exogenous variables (if any)
613
+ if self.exog_flag:
614
+ exog_data = self.make_and_register_data("exog_data", shape=(None, self.k_exog))
615
+ if self.shared_exog_states:
616
+ # Shared exogenous states: same exog data is used across all endogenous variables
617
+ # Shape becomes (n_timepoints, k_endog, k_exog)
618
+ Z_exog = pt.specify_shape(
619
+ pt.join(1, *[pt.expand_dims(exog_data, 1) for _ in range(self.k_endog)]),
620
+ (None, self.k_endog, self.k_exog),
621
+ )
622
+ else:
623
+ # Separate exogenous states: each endogenous variable gets its own exog block
624
+ # Create block-diagonal structure and reshape to (n_timepoints, k_endog, k_exog * k_endog)
625
+ Z_exog = pt.linalg.block_diag(
626
+ *[pt.expand_dims(exog_data, 1) for _ in range(self.k_endog)]
627
+ )
628
+ Z_exog = pt.specify_shape(Z_exog, (None, self.k_endog, self.k_exog * self.k_endog))
629
+
630
+ # Repeat base design_matrix over time dimension to match exogenous time series
631
+ n_timepoints = Z_exog.shape[0]
632
+ design_matrix_time = pt.tile(design_matrix, (n_timepoints, 1, 1))
633
+ # Concatenate the repeated design matrix with exogenous matrix along the last axis
634
+ # Final shape: (n_timepoints, k_endog, n_columns + n_exog_columns)
635
+ design_matrix = pt.concatenate([design_matrix_time, Z_exog], axis=2)
636
+
637
+ self.ssm["design"] = design_matrix
638
+
639
+ # Transition matrix (T)
640
+ # Construction with block-diagonal structure:
641
+ # Each latent component (factors, errors, exogenous states) contributes its own transition block,
642
+ # and the full transition matrix is assembled with block_diag.
643
+ # T = block_diag(A, B, C)
644
+ #
645
+ # - Factors (block A):
646
+ # If factor_order > 0, the factor AR coefficients are organized into a
647
+ # VAR(p) companion matrix of size (k_factors * factor_order, k_factors * factor_order).
648
+ # This block shifts lagged factor states and applies AR coefficients.
649
+ # If factor_order = 0, a zero matrix is used instead.
650
+ #
651
+ # - Errors (block B):
652
+ # If error_order > 0:
653
+ # * error_var=True → build a full VAR(p) companion matrix (cross-series correlations allowed).
654
+ # * error_var=False → build independent AR(p) companion matrices (no cross-series effects).
655
+ #
656
+ # - Exogenous states (block C):
657
+ # If exog_flag=True, exogenous states are either constant or follow a random walk, modeled with an identity
658
+ # transition block of size (k_exog_states, k_exog_states).
659
+ #
660
+ # The final transition matrix is block-diagonal, combining all active components:
661
+ # Transition = block_diag(Factors, Errors, Exogenous)
662
+
663
+ # auxiliary functions to build transition matrix block
664
+ def build_var_block_matrix(ar_coeffs, k_series, p):
665
+ """
666
+ Build the VAR(p) companion matrix for the factors.
667
+
668
+ ar_coeffs: PyTensor matrix of shape (k_series, p * k_series)
669
+ [A1 | A2 | ... | Ap] horizontally concatenated.
670
+ k_series: number of series
671
+ p: lag order
672
+ """
673
+ size = k_series * p
674
+ block = pt.zeros((size, size), dtype=floatX)
675
+
676
+ # First block row: the AR coefficient matrices for each lag
677
+ block = block[0:k_series, 0 : k_series * p].set(ar_coeffs)
678
+
679
+ # Sub-diagonal identity blocks (shift structure)
680
+ if p > 1:
681
+ # Create the identity pattern for all sub-diagonal blocks
682
+ identity_pattern = pt.eye(k_series * (p - 1), dtype=floatX)
683
+ block = block[k_series:, : k_series * (p - 1)].set(identity_pattern)
684
+
685
+ return block
686
+
687
+ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
688
+ """
689
+ Build a VAR(p)-style companion matrix for independent AR(p) processes
690
+ with interleaved state ordering:
691
+ (x1(t), x2(t), ..., x1(t-1), x2(t-1), ...).
692
+
693
+ ar_coeffs: PyTensor matrix of shape (k_series, p)
694
+ k_series: number of independent series
695
+ p: lag order
696
+ """
697
+ size = k_series * p
698
+ block = pt.zeros((size, size), dtype=floatX)
699
+
700
+ # First block row: AR coefficients per series (block diagonal)
701
+ for j in range(k_series):
702
+ for lag in range(p):
703
+ col_idx = lag * k_series + j
704
+ block = pt.set_subtensor(block[j, col_idx], ar_coeffs[j, lag])
705
+
706
+ # Sub-diagonal identity blocks (shift)
707
+ if p > 1:
708
+ identity_pattern = pt.eye(k_series * (p - 1), dtype=floatX)
709
+ block = pt.set_subtensor(block[k_series:, : k_series * (p - 1)], identity_pattern)
710
+ return block
711
+
712
+ transition_blocks = []
713
+ # Block A: Factors
714
+ if self.factor_order > 0:
715
+ factor_ar = self.make_and_register_variable(
716
+ "factor_ar",
717
+ shape=(self.k_factors, self.factor_order * self.k_factors),
718
+ dtype=floatX,
719
+ )
720
+ transition_blocks.append(
721
+ build_var_block_matrix(factor_ar, self.k_factors, self.factor_order)
722
+ )
723
+ else:
724
+ transition_blocks.append(pt.zeros((self.k_factors, self.k_factors), dtype=floatX))
725
+ # Block B: Errors
726
+ if self.error_order > 0 and self.error_var:
727
+ error_ar = self.make_and_register_variable(
728
+ "error_ar", shape=(self.k_endog, self.error_order * self.k_endog), dtype=floatX
729
+ )
730
+ transition_blocks.append(
731
+ build_var_block_matrix(error_ar, self.k_endog, self.error_order)
732
+ )
733
+ elif self.error_order > 0 and not self.error_var:
734
+ error_ar = self.make_and_register_variable(
735
+ "error_ar", shape=(self.k_endog, self.error_order), dtype=floatX
736
+ )
737
+ transition_blocks.append(
738
+ build_independent_var_block_matrix(error_ar, self.k_endog, self.error_order)
739
+ )
740
+ # Block C: Exogenous states
741
+ if self.exog_flag:
742
+ transition_blocks.append(pt.eye(self.k_exog_states, dtype=floatX))
743
+
744
+ self.ssm["transition", :, :] = pt.linalg.block_diag(*transition_blocks)
745
+
746
+ # Selection matrix (R)
747
+ for i in range(self.k_factors):
748
+ self.ssm["selection", i, i] = 1.0
749
+
750
+ if self.error_order > 0:
751
+ for i in range(self.k_endog):
752
+ row = max(self.factor_order, 1) * self.k_factors + i
753
+ col = self.k_factors + i
754
+ self.ssm["selection", row, col] = 1.0
755
+
756
+ if self.exog_flag and self.exog_innovations:
757
+ row_start = self.k_states - self.k_exog_states
758
+ row_end = self.k_states
759
+
760
+ if self.error_order > 0:
761
+ col_start = self.k_factors + self.k_endog
762
+ col_end = self.k_factors + self.k_endog + self.k_exog_states
763
+ else:
764
+ col_start = self.k_factors
765
+ col_end = self.k_factors + self.k_exog_states
766
+
767
+ self.ssm["selection", row_start:row_end, col_start:col_end] = pt.eye(
768
+ self.k_exog_states, dtype=floatX
769
+ )
770
+
771
+ factor_cov = pt.eye(self.k_factors, dtype=floatX)
772
+
773
+ # Handle error_sigma and error_cov depending on error_cov_type
774
+ if self.error_cov_type == "scalar":
775
+ error_sigma = self.make_and_register_variable("error_sigma", shape=(), dtype=floatX)
776
+ error_cov = pt.eye(self.k_endog) * error_sigma
777
+ elif self.error_cov_type == "diagonal":
778
+ error_sigma = self.make_and_register_variable(
779
+ "error_sigma", shape=(self.k_endog,), dtype=floatX
780
+ )
781
+ error_cov = pt.diag(error_sigma)
782
+ elif self.error_cov_type == "unstructured":
783
+ error_cov = self.make_and_register_variable(
784
+ "error_cov", shape=(self.k_endog, self.k_endog), dtype=floatX
785
+ )
786
+
787
+ # State covariance matrix (Q)
788
+ if self.error_order > 0:
789
+ if self.exog_flag and self.exog_innovations:
790
+ # Include AR noise in state vector
791
+ beta_sigma = self.make_and_register_variable(
792
+ "beta_sigma", shape=(self.k_exog_states,), dtype=floatX
793
+ )
794
+ exog_cov = pt.diag(beta_sigma)
795
+ self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov, exog_cov)
796
+ elif self.exog_flag and not self.exog_innovations:
797
+ exog_cov = pt.zeros((self.k_exog_states, self.k_exog_states), dtype=floatX)
798
+ self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov, exog_cov)
799
+ elif not self.exog_flag:
800
+ self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov)
801
+ else:
802
+ if self.exog_flag and self.exog_innovations:
803
+ beta_sigma = self.make_and_register_variable(
804
+ "beta_sigma", shape=(self.k_exog_states,), dtype=floatX
805
+ )
806
+ exog_cov = pt.diag(beta_sigma)
807
+ self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, exog_cov)
808
+ elif self.exog_flag and not self.exog_innovations:
809
+ exog_cov = pt.zeros((self.k_exog_states, self.k_exog_states), dtype=floatX)
810
+ self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, exog_cov)
811
+ elif not self.exog_flag:
812
+ # Only latent factor in the state
813
+ self.ssm["state_cov", :, :] = factor_cov
814
+
815
+ # Observation covariance matrix (H)
816
+ if self.error_order > 0:
817
+ if self.measurement_error:
818
+ sigma_obs = self.make_and_register_variable(
819
+ "sigma_obs", shape=(self.k_endog,), dtype=floatX
820
+ )
821
+ self.ssm["obs_cov", :, :] = pt.diag(sigma_obs)
822
+ # else: obs_cov remains zero (no measurement noise and idiosyncratic noise captured in state)
823
+ else:
824
+ if self.measurement_error:
825
+ # TODO: check this decision
826
+ # in this case error_order = 0, so there is no error term in the state, so the sigma error could not be added there
827
+ # Idiosyncratic + measurement error
828
+ sigma_obs = self.make_and_register_variable(
829
+ "sigma_obs", shape=(self.k_endog,), dtype=floatX
830
+ )
831
+ total_obs_var = error_sigma**2 + sigma_obs**2
832
+ self.ssm["obs_cov", :, :] = pt.diag(pt.sqrt(total_obs_var))
833
+ else:
834
+ self.ssm["obs_cov", :, :] = pt.diag(error_sigma)