Skip to content

Commit 2288606

Browse files
committed
Adjustments following Jesse suggestions and added tests for exog support
1 parent f4bb74c commit 2288606

File tree

2 files changed

+70
-43
lines changed

2 files changed

+70
-43
lines changed

pymc_extras/statespace/models/DFM.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class BayesianDynamicFactor(PyMCStateSpace):
8585
The transition equation is given by:
8686
8787
.. math::
88-
s_{t+1} = T s_t + R \eta_t
88+
s_{t+1} = T s_t + R \epsilon_t
8989
9090
Where:
9191
- :math:`T` is the state transition matrix, composed of:
@@ -106,11 +106,11 @@ class BayesianDynamicFactor(PyMCStateSpace):
106106
\end{bmatrix}
107107
\in \mathbb{R}^{k_{\text{states}} \times k_{\text{states}}}
108108
109-
- :math:`\eta_t` contains the independent shocks (innovations) and has dimension :math:`k + k_{\text{endog}}` if AR errors are included.
109+
- :math:`\epsilon_t` contains the independent shocks (innovations) and has dimension :math:`k + k_{\text{endog}}` if AR errors are included.
110110
.. math::
111-
\eta_t = \begin{bmatrix}
112-
\eta_{f,t} \\
113-
\eta_{u,t}
111+
\epsilon_t = \begin{bmatrix}
112+
\epsilon_{f,t} \\
113+
\epsilon_{u,t}
114114
\end{bmatrix}
115115
\in \mathbb{R}^{k + k_{\text{endog}}}
116116
@@ -131,7 +131,7 @@ class BayesianDynamicFactor(PyMCStateSpace):
131131
132132
.. math::
133133
134-
y_t = Z s_t + B x_t + \eta_t
134+
y_t = Z s_t + \eta_t
135135
136136
where
137137
@@ -147,18 +147,21 @@ class BayesianDynamicFactor(PyMCStateSpace):
147147
\end{bmatrix}
148148
\in \mathbb{R}^{k_{\text{endog}} \times k_{\text{states}}}
149149
150-
- :math:`\x_t` is the vector of exogenous variables at time :math:`t`
151-
152150
- :math:`\eta_t` is the vector of observation errors at time :math:`t`
153151
152+
When exogenous variables :math:`x_t` are present, the implementation follows `pymc_extras/statespace/models/structural/components/regression.py`.
153+
In this case, the state vector is extended to include the beta parameters, and the design matrix is modified accordingly,
154+
becoming 3-dimensional to handle time-varying exogenous regressors.
155+
This approach provides greater flexibility, controlled by the boolean flags `shared_exog_state` and `exog_innovations`.
156+
Unlike Statsmodels, where exogenous variables are included only in the observation equation, here they are fully integrated into the state-space
157+
representation.
158+
154159
.. warning::
155160
156161
Identification can be an issue, particularly when many observed series load onto only a few latent factors.
157162
These models are only identified up to a sign flip in the factor loadings. Proper prior specification is crucial
158163
for good estimation and inference.
159164
160-
Currently, the implementation does not yet support exogenous variables
161-
162165
Examples
163166
--------
164167
The following code snippet estimates a dynamic factor model with 1 latent factors,
@@ -188,13 +191,13 @@ class BayesianDynamicFactor(PyMCStateSpace):
188191
with pm.Model(coords=coords) as pymc_mod:
189192
# Priors for the initial state mean and covariance
190193
x0 = pm.Normal("x0", dims=["state_dim"])
191-
P0 = pm.Normal("P0", dims=["state_dim", "state_dim"])
194+
P0 = pm.HalfNormal("P0", dims=["state_dim", "state_dim"])
192195
193196
# Factor loadings: shape (k_endog, k_factors)
194197
factor_loadings = pm.Normal("factor_loadings", sigma=1, dims=["k_endog", "k_factors"])
195198
196199
# AR coefficients for factor dynamics: shape (k_factors, factor_order)
197-
factor_ar = pm.Normal("factor_ar", sigma=1, dims=["k_factors", "factor_order"])
200+
factor_ar = pm.Normal("factor_ar", sigma=1, dims=["k_factors", "k_factors" * "factor_order"])
198201
199202
# AR coefficients for observation noise: shape (k_endog, error_order)
200203
error_ar = pm.Normal("error_ar", sigma=1, dims=["k_endog", "error_order"])
@@ -241,7 +244,8 @@ def __init__(
241244
Number of latent factors.
242245
243246
factor_order : int
244-
Order of the VAR process for the latent factors. If 0, the factors are treated as static (no dynamics).
247+
Order of the VAR process for the latent factors. If set to 0, the factors have no autoregressive dynamics
248+
and are modeled as a white noise process, i.e., :math:`f_t = \varepsilon_{f,t}`.
245249
Therefore, the state vector will include one state per factor and "factor_ar" will not exist.
246250
247251
k_endog : int, optional
@@ -438,7 +442,7 @@ def state_names(self) -> list[str]:
438442
"""
439443
names = []
440444

441-
for i in range(self.factor_order):
445+
for i in range(self.k_factors):
442446
for lag in range(max(self.factor_order, 1)):
443447
names.append(f"L{lag}.factor_{i}")
444448

tests/statespace/models/test_DFM.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222

2323
floatX = pytensor.config.floatX
2424

25-
# TODO: check test for error_var=True, since there are problems with statsmodels, the matrices looks the same by some experiments done in notebooks
26-
# (FAILED tests/statespace/models/test_DFM.py::test_DFM_update_matches_statsmodels[True-2-2-2] - numpy.linalg.LinAlgError: 1-th leading minor of the array is not positive definite)
27-
2825

2926
@pytest.fixture(scope="session")
3027
def data():
@@ -43,7 +40,7 @@ def create_sm_test_values_mapping(
4340
"""Convert PyMC test values to statsmodels parameter format"""
4441
sm_test_values = {}
4542

46-
# 1. Factor loadings: PyMC shape (n_endog, k_factors) -> statsmodels individual params
43+
# Factor loadings: PyMC shape (n_endog, k_factors) -> statsmodels individual params
4744
factor_loadings = test_values["factor_loadings"]
4845
all_pairs = product(data.columns, range(1, k_factors + 1))
4946
sm_test_values.update(
@@ -53,7 +50,7 @@ def create_sm_test_values_mapping(
5350
}
5451
)
5552

56-
# 2. Factor AR coefficients: PyMC shape (k_factors, factor_order*k_factors) -> L{lag}.f{to}.f{from}
53+
# Factor AR coefficients: PyMC shape (k_factors, factor_order*k_factors) -> L{lag}.f{to}.f{from}
5754
if factor_order > 0 and "factor_ar" in test_values:
5855
factor_ar = test_values["factor_ar"]
5956
triplets = product(
@@ -68,7 +65,7 @@ def create_sm_test_values_mapping(
6865
}
6966
)
7067

71-
# 3a. Error AR coefficients: PyMC shape (n_endog, error_order) -> L{lag}.e(var).e(var)
68+
# Error AR coefficients: PyMC shape (n_endog, error_order) -> L{lag}.e(var).e(var)
7269
if error_order > 0 and not error_var and "error_ar" in test_values:
7370
error_ar = test_values["error_ar"]
7471
pairs = product(enumerate(data.columns), range(1, error_order + 1))
@@ -79,7 +76,7 @@ def create_sm_test_values_mapping(
7976
}
8077
)
8178

82-
# 3b. Error AR coefficients: PyMC shape (n_endog, error_order * n_endog) -> L{lag}.e(var).e(var)
79+
# Error AR coefficients: PyMC shape (n_endog, error_order * n_endog) -> L{lag}.e(var).e(var)
8380
elif error_order > 0 and error_var and "error_ar" in test_values:
8481
error_ar = test_values["error_ar"]
8582
triplets = product(
@@ -97,7 +94,7 @@ def create_sm_test_values_mapping(
9794
}
9895
)
9996

100-
# 4. Observation error variances:
97+
# Observation error variances:
10198
if "error_sigma" in test_values:
10299
error_sigma = test_values["error_sigma"]
103100
sm_test_values.update(
@@ -113,10 +110,15 @@ def create_sm_test_values_mapping(
113110
@pytest.mark.parametrize("k_factors", [1, 2])
114111
@pytest.mark.parametrize("factor_order", [0, 1, 2])
115112
@pytest.mark.parametrize("error_order", [0, 1, 2])
116-
@pytest.mark.parametrize("error_var", [False])
113+
@pytest.mark.parametrize("error_var", [True, False])
117114
@pytest.mark.filterwarnings("ignore::statsmodels.tools.sm_exceptions.EstimationWarning")
118115
@pytest.mark.filterwarnings("ignore::FutureWarning")
119116
def test_DFM_update_matches_statsmodels(data, k_factors, factor_order, error_order, error_var, rng):
117+
if error_var and (factor_order > 0 or error_order > 0):
118+
pytest.xfail(
119+
"Statsmodels may be doing something wrong with error_var=True and (factor_order > 0 or error_order > 0) [numpy.linalg.LinAlgError: 1-th leading minor of the array is not positive definite]"
120+
)
121+
120122
mod = BayesianDynamicFactor(
121123
k_factors=k_factors,
122124
factor_order=factor_order,
@@ -155,14 +157,13 @@ def test_DFM_update_matches_statsmodels(data, k_factors, factor_order, error_ord
155157
test_values, data, k_factors, factor_order, error_order, error_var
156158
)
157159

158-
# Initialize and constrain statsmodels model
159160
x0 = test_values["x0"]
160161
P0 = test_values["P0"]
161162

162163
sm_dfm.initialize_known(initial_state=x0, initial_state_cov=P0)
163164
sm_dfm.fit_constrained({name: sm_test_values[name] for name in sm_dfm.param_names})
164165

165-
# Get PyMC matrices using the same pattern as ETS test
166+
# Get PyMC matrices
166167
matrices = mod._unpack_statespace_with_placeholders()
167168
inputs = list(explicit_graph_inputs(matrices))
168169
input_names = [x.name for x in inputs]
@@ -238,10 +239,8 @@ def simulate_from_numpy_model(
238239

239240

240241
@pytest.mark.parametrize("n_obs,n_runs", [(100, 200)])
241-
def test_exog_betas_random_walk(n_obs, n_runs):
242+
def test_DFM_exog_betas_random_walk(n_obs, n_runs):
242243
rng = np.random.default_rng(123)
243-
244-
# Example model
245244
dfm_mod = BayesianDynamicFactor(
246245
k_factors=1,
247246
factor_order=1,
@@ -255,7 +254,7 @@ def test_exog_betas_random_walk(n_obs, n_runs):
255254
measurement_error=False,
256255
)
257256

258-
# Parameters
257+
# Arbitrary Parameters
259258
param_dict = {
260259
"factor_loadings": np.array([[0.9], [0.8]]),
261260
"factor_ar": np.array([[0.5]]),
@@ -284,14 +283,13 @@ def test_exog_betas_random_walk(n_obs, n_runs):
284283
var_t1 = betas_t1.var(axis=0)
285284
var_t100 = betas_t100.var(axis=0)
286285

287-
# ---- Assertion ----
288286
assert np.all(
289287
var_t100 > var_t1
290288
), f"Expected variance at T=100 > T=1, got {var_t1} vs {var_t100}"
291289

292290

293291
@pytest.mark.parametrize("shared", [True, False])
294-
def test_exog_shared_vs_not(shared):
292+
def test_DFM_exog_shared_vs_not(shared):
295293
rng = np.random.default_rng(123)
296294

297295
n_obs = 50
@@ -301,7 +299,6 @@ def test_exog_shared_vs_not(shared):
301299
# Dummy exogenous data
302300
exog = rng.normal(size=(n_obs, k_exog))
303301

304-
# Create the model
305302
dfm_mod = BayesianDynamicFactor(
306303
k_factors=1,
307304
factor_order=1,
@@ -313,34 +310,60 @@ def test_exog_shared_vs_not(shared):
313310
error_cov_type="diagonal",
314311
measurement_error=False,
315312
)
313+
316314
k_exog_states = dfm_mod.k_exog * dfm_mod.k_endog if not shared else dfm_mod.k_exog
317315

318-
# Dummy parameters (small values so simulation is stable)
316+
if shared:
317+
beta = np.array([0.3, 0.5])
318+
else:
319+
beta = np.array([0.3, 0.5, 1.0, 2.0])
320+
319321
param_dict = {
320322
"factor_loadings": np.array([[0.9], [0.8]]),
321323
"factor_ar": np.array([[0.5]]),
322324
"error_ar": np.array([[0.4], [0.3]]),
323325
"error_sigma": np.array([0.1, 0.2]),
324326
"P0": np.eye(dfm_mod.k_states),
325327
"x0": np.zeros(dfm_mod.k_states - k_exog_states),
326-
"beta": np.array([0.3, 0.5, 1, 2]) if not shared else np.array([0.3, 0.5]),
328+
"beta": beta,
327329
}
328330

329331
data_dict = {"exog_data": exog}
330332

331333
# Simulate trajectory
332334
x_traj, y_traj = simulate_from_numpy_model(dfm_mod, rng, param_dict, data_dict, steps=n_obs)
333335

334-
# Extract contribution from exogenous variables at time t
335-
beta = param_dict["beta"].reshape(-1, k_exog) # shape depends on shared flag
336-
exog_t = exog[10] # pick a random time point
336+
# Test 1: Check hidden states
337+
# Extract exogenous hidden states at time t=10
338+
t = 10
339+
exog_states_start = dfm_mod.k_states - k_exog_states
340+
exog_states_end = dfm_mod.k_states
341+
exog_hidden_states = x_traj[t, exog_states_start:exog_states_end]
342+
343+
if shared:
344+
# When shared=True, there should be k_exog states total
345+
assert len(exog_hidden_states) == k_exog
346+
else:
347+
# When shared=False, there should be k_exog * k_endog states
348+
assert len(exog_hidden_states) == k_exog * k_endog
349+
# Each endogenous variable has its own set of exogenous states
350+
exog_states_reshaped = exog_hidden_states.reshape(k_endog, k_exog)
351+
assert not np.allclose(exog_states_reshaped[0], exog_states_reshaped[1])
352+
353+
# Test 2: Check observed contributions
354+
exog_t = exog[t]
337355

338356
if shared:
339-
# all endogs get the same contribution
357+
# All endogenous variables get the same beta * data contribution
340358
contributions = [beta @ exog_t for _ in range(k_endog)]
341-
assert np.allclose(contributions[0], contributions[1:])
359+
assert np.allclose(
360+
contributions[0], contributions[1]
361+
), "Expected same contribution for all endog when shared=True"
342362
else:
343-
# each endog gets a different contribution
344-
contributions = [beta[i] @ exog_t for i in range(k_endog)]
345-
# check that at least one differs
346-
assert not np.allclose(contributions[0], contributions[1:])
363+
# Each endogenous variable gets different beta * data
364+
beta_reshaped = beta.reshape(k_endog, k_exog)
365+
contributions = [beta_reshaped[i] @ exog_t for i in range(k_endog)]
366+
# Check that contributions are different
367+
assert not np.allclose(
368+
contributions[0], contributions[1]
369+
), f"Expected different contributions, got {contributions}"

0 commit comments

Comments
 (0)