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 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 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
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.
179 changes: 179 additions & 0 deletions pymc_extras/statespace/models/structural/components/autoregressive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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.

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 = "AutoRegressive",
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):
self.state_names = [
f"L{i + 1}.{state_name}"
for i in range(self.k_states)
for state_name in self.observed_state_names
]
self.shock_names = [f"{name}_{self.name}_innovation" for name in self.observed_state_names]
self.param_names = ["ar_params", "sigma_ar"]
self.param_dims = {"ar_params": (AR_PARAM_DIM,)}
self.coords = {AR_PARAM_DIM: self.ar_lags.tolist()}

if self.k_endog > 1:
self.param_dims["ar_params"] = (
f"{self.name}_endog",
AR_PARAM_DIM,
)
self.param_dims["sigma_ar"] = (f"{self.name}_endog",)

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

self.param_info = {
"ar_params": {
"shape": (self.k_states,) if self.k_endog == 1 else (self.k_endog, self.k_states),
"constraints": None,
"dims": (AR_PARAM_DIM,)
if self.k_endog == 1
else (
f"{self.name}_endog",
AR_PARAM_DIM,
),
},
"sigma_ar": {
"shape": () if self.k_endog == 1 else (self.k_endog,),
"constraints": "Positive",
"dims": None if self.k_endog == 1 else (f"{self.name}_endog",),
},
}

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(
"ar_params", shape=(k_nonzero,) if k_endog == 1 else (k_endog, k_nonzero)
)
sigma_ar = self.make_and_register_variable(
"sigma_ar", 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