Skip to content

Multivariate Structural Statespace Components #529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 61 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
a70b733
Reorganize structural model module
jessegrabowski Jun 24, 2025
b970a6c
Allow combination of components with different numbers of observed st…
jessegrabowski Jun 24, 2025
7cae487
Allow multiple observed in LevelTrend component
jessegrabowski Jun 25, 2025
bba8431
Allow multiple observed states in measurement error component
jessegrabowski Jun 25, 2025
0a84576
Allow multiple observed in AutoRegressive component
jessegrabowski Jun 25, 2025
480f4fb
Fix typo in docstrings
AlexAndorra Jul 1, 2025
a898eb6
Allow multiple observed in Cycle component
aandorra-mia Jul 1, 2025
62d0750
Fix Cycle docstring examples
AlexAndorra Jul 1, 2025
152e962
Use pytensor block_diag for Cycle
AlexAndorra Jul 2, 2025
7e9bb07
1. updated level_trend component coord/param labels
Dekermanjian Jul 5, 2025
c0a4a47
1. removed incorrectly comitted file test_structural.py
Dekermanjian Jul 5, 2025
1f3dc3a
removed incorrectly committed file structural.py
Dekermanjian Jul 5, 2025
530f530
Merge pull request #3 from Dekermanjian/multivariate-structural
jessegrabowski Jul 5, 2025
0c4590e
Always count names to determine k_endog
jessegrabowski Jul 6, 2025
3c5124d
LevelTrend state/shock names depend on component name
jessegrabowski Jul 6, 2025
b932255
Update tests to new names
jessegrabowski Jul 6, 2025
6debd23
More test updates
jessegrabowski Jul 6, 2025
fbc61a1
Delay dropping data names from states/coords until `.build`
jessegrabowski Jul 6, 2025
85b78fe
Remove docstring typo
jessegrabowski Jul 6, 2025
a6327b7
Update autoregressive component and tests
jessegrabowski Jul 6, 2025
5630256
1. updated regression component to revert to univariate shape schema …
Dekermanjian Jul 6, 2025
0b20dbc
Add component name to shock state names
jessegrabowski Jul 6, 2025
a8564b7
Allow multiple observed in TimeSeasonality component
jessegrabowski Jul 6, 2025
bf38a4c
updated coord dimension names in regression component to always use t…
Dekermanjian Jul 7, 2025
46e4a39
updated level_trend component so that component name is added as a pr…
Dekermanjian Jul 7, 2025
7581f04
Allow multiple observed in FrequencySeasonality component
jessegrabowski Jul 7, 2025
a102e3c
Propagate static shape information in join_tensors_by_dim_labels wher…
jessegrabowski Jul 7, 2025
c694646
Regression component bugfix and tests
jessegrabowski Jul 7, 2025
f584e79
update default name in test
jessegrabowski Jul 7, 2025
92eae7a
merged resolved conflicts
Dekermanjian Jul 7, 2025
ab98abe
Improve cycle code with Jesse's feedback
AlexAndorra Jul 7, 2025
08818ad
Explicitly test matrices in test_cycle
AlexAndorra Jul 7, 2025
505b7d0
Add test addition of two Cycles with different observed names
AlexAndorra Jul 7, 2025
4fc8db2
Make code for state cov when no innov clearer
AlexAndorra Jul 7, 2025
4b6ffcb
1. updated coords for level trend component and regression component
Dekermanjian Jul 8, 2025
1085e24
1. added seasonality params coords fix from Alex Andorra
Dekermanjian Jul 9, 2025
669bd10
1. updated param dims in regression component
Dekermanjian Jul 9, 2025
486fa14
Add exog names to shock_names in LevelTrend
jessegrabowski Jul 10, 2025
de143ad
Update tests to respect new naming convention
jessegrabowski Jul 10, 2025
d0b6fd6
added multivariate forecast with exogenous variables test
Dekermanjian Jul 10, 2025
d123987
Merge branch 'multivariate-structural' of https://github.com/Dekerman…
Dekermanjian Jul 10, 2025
7ca00d7
level_trend shock coords should be base_shock_names, not self.shock_n…
jessegrabowski Jul 10, 2025
1c8b61a
Merge branch 'multivariate-structural' of https://github.com/Dekerman…
Dekermanjian Jul 10, 2025
c9458e7
Merge pull request #4 from Dekermanjian/multivariate-structural
jessegrabowski Jul 10, 2025
c15f965
Save static shape of last data dim
jessegrabowski Jul 11, 2025
f8e7729
More static shapes
jessegrabowski Jul 11, 2025
d38c71b
Broken test of decomposition with multiple observed
jessegrabowski Jul 11, 2025
ce14343
Don't use `pad`
jessegrabowski Jul 11, 2025
503eec5
fix decompose test
jessegrabowski Jul 11, 2025
77c27f4
updated leveltrend, seasonal, cycle components to adhere to naming sc…
Dekermanjian Jul 12, 2025
08085c7
Merge pull request #5 from Dekermanjian/multivariate-structural
jessegrabowski Jul 13, 2025
083e786
Delete notebook
jessegrabowski Jul 13, 2025
66e0252
Use nwe name order in autoregressive component
jessegrabowski Jul 14, 2025
21c64c6
Improve docstrings
AlexAndorra Jul 14, 2025
b94c9a3
Refactor test_regression to cover innotations = True
AlexAndorra Jul 14, 2025
ec98064
Fix comment in test_cycle
AlexAndorra Jul 14, 2025
124e1c3
Add docstrings to core and measurement_error
AlexAndorra Jul 14, 2025
9c14472
Improve cycle and seasonal docstrings
AlexAndorra Jul 15, 2025
16761c7
Fix shape of AR params
AlexAndorra Jul 15, 2025
b71354a
Some other AR dims fixes
AlexAndorra Jul 15, 2025
fafc4c2
fix: handle numpy arrays and missing return cases in _combine_property
AlexAndorra Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,679 changes: 0 additions & 1,679 deletions pymc_extras/statespace/models/structural.py

This file was deleted.

21 changes: 21 additions & 0 deletions pymc_extras/statespace/models/structural/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pymc_extras.statespace.models.structural.components.autoregressive import (
AutoregressiveComponent,
)
from pymc_extras.statespace.models.structural.components.cycle import CycleComponent
from pymc_extras.statespace.models.structural.components.level_trend import LevelTrendComponent
from pymc_extras.statespace.models.structural.components.measurement_error import MeasurementError
from pymc_extras.statespace.models.structural.components.regression import RegressionComponent
from pymc_extras.statespace.models.structural.components.seasonality import (
FrequencySeasonality,
TimeSeasonality,
)

__all__ = [
"LevelTrendComponent",
"MeasurementError",
"AutoregressiveComponent",
"TimeSeasonality",
"FrequencySeasonality",
"RegressionComponent",
"CycleComponent",
]
Empty file.
188 changes: 188 additions & 0 deletions pymc_extras/statespace/models/structural/components/autoregressive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import numpy as np
import pytensor.tensor as pt

from pymc_extras.statespace.models.structural.core import Component
from pymc_extras.statespace.models.structural.utils import order_to_mask
from pymc_extras.statespace.utils.constants import AR_PARAM_DIM


class AutoregressiveComponent(Component):
r"""
Autoregressive timeseries component

Parameters
----------
order: int or sequence of int

If int, the number of lags to include in the model.
If a sequence, an array-like of zeros and ones indicating which lags to include in the model.

name: str, default "auto_regressive"
A name for this autoregressive component. Used to label dimensions and coordinates.

observed_state_names: list[str] | None, default None
List of strings for observed state labels. If None, defaults to ["data"].

Notes
-----
An autoregressive component can be thought of as a way o introducing serially correlated errors into the model.
The process is modeled:

.. math::
x_t = \sum_{i=1}^p \rho_i x_{t-i}

Where ``p``, the number of autoregressive terms to model, is the order of the process. By default, all lags up to
``p`` are included in the model. To disable lags, pass a list of zeros and ones to the ``order`` argumnet. For
example, ``order=[1, 1, 0, 1]`` would become:

.. math::
x_t = \rho_1 x_{t-1} + \rho_2 x_{t-1} + \rho_4 x_{t-1}

The coefficient :math:`\rho_3` has been constrained to zero.

.. warning:: This class is meant to be used as a component in a structural time series model. For modeling of
stationary processes with ARIMA, use ``statespace.BayesianSARIMA``.

Examples
--------
Model a timeseries as an AR(2) process with non-zero mean:

.. code:: python

from pymc_extras.statespace import structural as st
import pymc as pm
import pytensor.tensor as pt

trend = st.LevelTrendComponent(order=1, innovations_order=0)
ar = st.AutoregressiveComponent(2)
ss_mod = (trend + ar).build()

with pm.Model(coords=ss_mod.coords) as model:
P0 = pm.Deterministic('P0', pt.eye(ss_mod.k_states) * 10, dims=ss_mod.param_dims['P0'])
intitial_trend = pm.Normal('initial_trend', sigma=10, dims=ss_mod.param_dims['initial_trend'])
ar_params = pm.Normal('ar_params', dims=ss_mod.param_dims['ar_params'])
sigma_ar = pm.Exponential('sigma_ar', 1, dims=ss_mod.param_dims['sigma_ar'])

ss_mod.build_statespace_graph(data)
idata = pm.sample(nuts_sampler='numpyro')

"""

def __init__(
self,
order: int = 1,
name: str = "auto_regressive",
observed_state_names: list[str] | None = None,
):
if observed_state_names is None:
observed_state_names = ["data"]

k_posdef = k_endog = len(observed_state_names)

order = order_to_mask(order)
ar_lags = np.flatnonzero(order).ravel().astype(int) + 1
k_states = len(order)

self.order = order
self.ar_lags = ar_lags

super().__init__(
name=name,
k_endog=k_endog,
k_states=k_states * k_endog,
k_posdef=k_posdef,
measurement_error=True,
combine_hidden_states=True,
observed_state_names=observed_state_names,
obs_state_idxs=np.tile(np.r_[[1.0], np.zeros(k_states - 1)], k_endog),
)

def populate_component_properties(self):
k_states = self.k_states // self.k_endog # this is also the number of AR lags

self.state_names = [
f"L{i + 1}[{state_name}]"
for state_name in self.observed_state_names
for i in range(k_states)
]

self.shock_names = [f"{self.name}[{obs_name}]" for obs_name in self.observed_state_names]
self.param_names = [f"params_{self.name}", f"sigma_{self.name}"]
self.param_dims = {f"params_{self.name}": (f"lag_{self.name}",)}
self.coords = {f"lag_{self.name}": self.ar_lags.tolist()}

if self.k_endog > 1:
self.param_dims[f"params_{self.name}"] = (
f"endog_{self.name}",
AR_PARAM_DIM,
)
self.param_dims[f"sigma_{self.name}"] = (f"endog_{self.name}",)

self.coords[f"endog_{self.name}"] = self.observed_state_names

self.param_info = {
f"params_{self.name}": {
"shape": (k_states,) if self.k_endog == 1 else (self.k_endog, k_states),
"constraints": None,
"dims": (AR_PARAM_DIM,)
if self.k_endog == 1
else (
f"endog_{self.name}",
f"lag_{self.name}",
),
},
f"sigma_{self.name}": {
"shape": () if self.k_endog == 1 else (self.k_endog,),
"constraints": "Positive",
"dims": None if self.k_endog == 1 else (f"endog_{self.name}",),
},
}

def make_symbolic_graph(self) -> None:
k_endog = self.k_endog
k_states = self.k_states // k_endog
k_posdef = self.k_posdef

k_nonzero = int(sum(self.order))
ar_params = self.make_and_register_variable(
f"params_{self.name}", shape=(k_nonzero,) if k_endog == 1 else (k_endog, k_nonzero)
)
sigma_ar = self.make_and_register_variable(
f"sigma_{self.name}", shape=() if k_endog == 1 else (k_endog,)
)

if k_endog == 1:
T = pt.eye(k_states, k=-1)
ar_idx = (np.zeros(k_nonzero, dtype="int"), np.nonzero(self.order)[0])
T = T[ar_idx].set(ar_params)

else:
transition_matrices = []

for i in range(k_endog):
T = pt.eye(k_states, k=-1)
ar_idx = (np.zeros(k_nonzero, dtype="int"), np.nonzero(self.order)[0])
T = T[ar_idx].set(ar_params[i])
transition_matrices.append(T)
T = pt.specify_shape(
pt.linalg.block_diag(*transition_matrices), (self.k_states, self.k_states)
)

self.ssm["transition", :, :] = T

R = np.eye(k_states)
R_mask = np.full((k_states), False)
R_mask[0] = True
R = R[:, R_mask]

self.ssm["selection", :, :] = pt.specify_shape(
pt.linalg.block_diag(*[R for _ in range(k_endog)]), (self.k_states, self.k_posdef)
)

Z = pt.zeros((1, k_states))[0, 0].set(1.0)
self.ssm["design", :, :] = pt.specify_shape(
pt.linalg.block_diag(*[Z for _ in range(k_endog)]), (self.k_endog, self.k_states)
)

cov_idx = ("state_cov", *np.diag_indices(k_posdef))
self.ssm[cov_idx] = sigma_ar**2
Loading