Skip to content

Commit 6496f38

Browse files
committed
Completing the implementation of exogeneous varibales support
1 parent ca61e3b commit 6496f38

File tree

1 file changed

+117
-34
lines changed
  • pymc_extras/statespace/models

1 file changed

+117
-34
lines changed

pymc_extras/statespace/models/DFM.py

Lines changed: 117 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ class BayesianDynamicFactor(PyMCStateSpace):
5555
exog_names : Sequence[str], optional
5656
Names of the exogenous variables. If not provided, but `k_exog` is specified, default names will be generated as `exog_1`, `exog_2`, ..., `exog_k`.
5757
58+
shared_exog_states: bool, optional
59+
Whether exogenous latent states are shared across the observed states. If True, there will be only one set of exogenous latent
60+
states, which are observed by all observed states. If False, each observed state has its own set of exogenous latent states.
61+
62+
exog_innovations : bool, optional
63+
Whether to include stochastic innovations in the exogenous state (betas),
64+
allowing them to vary over time. If True, coefficients follow a random walk.
65+
5866
error_order : int, optional
5967
Order of the AR process for the observation error component.
6068
Default is 0, corresponding to white noise errors.
@@ -98,7 +106,7 @@ class BayesianDynamicFactor(PyMCStateSpace):
98106
99107
Where:
100108
- :math:`f_t` is the vector of latent dynamic factors (size :math:`k`),
101-
- :math:`x_t` is an optional vector of exogenous variables (currently **not implemented**),
109+
- :math:`x_t` is an optional vector of exogenous variables
102110
- :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),
103111
- :math:`\eta_t \sim \mathcal{N}(0, H_t)` is an optional measurement error (if `measurement_error=True`),
104112
- :math:`\varepsilon_{f,t} \sim \mathcal{N}(0, I)` and :math:`\varepsilon_{u,t} \sim \mathcal{N}(0, \Sigma_u)` are independent noise terms.
@@ -272,6 +280,8 @@ def __init__(
272280
endog_names: Sequence[str] | None = None,
273281
k_exog: int | None = None,
274282
exog_names: Sequence[str] | None = None,
283+
shared_exog_states: bool | None = None,
284+
exog_innovations: bool | None = None,
275285
error_order: int = 0,
276286
error_var: bool = False,
277287
error_cov_type: str = "diagonal",
@@ -285,9 +295,6 @@ def __init__(
285295
if endog_names is None:
286296
endog_names = [f"endog_{i}" for i in range(k_endog)]
287297

288-
# if k_exog is not None or exog_names is not None:
289-
# raise NotImplementedError("Exogenous variables (exog) are not yet implemented.")
290-
291298
self.endog_names = endog_names
292299
self.k_endog = k_endog
293300
self.k_factors = k_factors
@@ -301,17 +308,24 @@ def __init__(
301308
self.k_exog = 0
302309
else:
303310
self._exog = True
311+
self.shared_exog_states = shared_exog_states
312+
self.exog_innovations = (
313+
exog_innovations if exog_innovations is not None else False
314+
) # default if not provided is False
304315
if k_exog is None:
305316
k_exog = len(exog_names) if exog_names is not None else 0
306317
elif exog_names is None:
307318
exog_names = [f"exog_{i}" for i in range(k_exog)] if k_exog > 0 else None
319+
self.k_exog_states = k_exog * k_endog if not shared_exog_states else k_exog
308320

309321
self.exog_names = exog_names
310322
self.k_exog = k_exog
311323

312-
# TODO add exogenous variables support (statsmodel dealt with exog without touching state vector,but just working on the measurement equation)
313-
# I start implementing a version of exog support, with shared_states=False based on pymc_extras/statespace/models/structural/components/regression.py
314-
# currently the beta coefficients are time invariant, so the innovation on beta are not supported
324+
# TODO add exogenous variables support
325+
# I start implementing a version of exog support based on pymc_extras/statespace/models/structural/components/regression.py
326+
# exog_innovations control if the beta coefficient follows a random walk
327+
# shared_exog_states control if the exogenous states are shared across equations
328+
# I tested the case of shared_exog_states=True and exog_innovations=False vs the stats case by looking at trajectory and it works well
315329

316330
# Determine the dimension for the latent factor states.
317331
# For static factors, one use k_factors.
@@ -326,13 +340,17 @@ def __init__(
326340
k_error_states = k_endog * error_order if error_order > 0 else 0
327341

328342
# Total state dimension
329-
k_states = k_factor_states + k_error_states + (k_exog * k_endog if self._exog else 0)
343+
k_states = k_factor_states + k_error_states + (self.k_exog_states if self._exog else 0)
330344

331345
# Number of independent shocks.
332346
# Typically, the latent factors introduce k_factors shocks.
333347
# If error_order > 0 and errors are modeled jointly or separately, add appropriate count.
334348
# TODO currently the implementation does not support for innovation on betas coefficient
335-
k_posdef = k_factors + (k_endog if error_order > 0 else 0)
349+
k_posdef = (
350+
k_factors
351+
+ (k_endog if error_order > 0 else 0)
352+
+ (self.k_exog_states if self._exog else 0)
353+
)
336354

337355
# Initialize the PyMCStateSpace base class.
338356
super().__init__(
@@ -367,6 +385,8 @@ def param_names(self):
367385
names.remove("sigma_obs")
368386
if self._exog:
369387
names.append("beta")
388+
if self.exog_innovations:
389+
names.append("beta_sigma")
370390

371391
return names
372392

@@ -409,9 +429,13 @@ def param_info(self) -> dict[str, dict[str, Any]]:
409429
"constraints": "Positive",
410430
},
411431
"beta": {
412-
"shape": (self.k_exog * self.k_endog if self.k_exog is not None else 0,),
432+
"shape": (self.k_exog_states if self.k_exog is not None else 0,),
413433
"constraints": None,
414434
},
435+
"beta_sigma": {
436+
"shape": (self.k_exog_states if self.k_exog is not None else 0,),
437+
"constraints": "Positive",
438+
},
415439
}
416440

417441
for name in self.param_names:
@@ -438,10 +462,15 @@ def state_names(self) -> list[str]:
438462
names.append(f"L{lag}.error_{i}")
439463

440464
if self._exog:
441-
# Exogenous states
442-
for i in range(self.k_exog):
443-
for j in range(self.k_endog):
444-
names.append(f"exog_{i}.endog_{j}")
465+
if self.shared_exog_states:
466+
# Shared exogenous states
467+
for i in range(self.k_exog):
468+
names.append(f"exog_{i}.shared")
469+
else:
470+
# Exogenous states
471+
for i in range(self.k_exog):
472+
for j in range(self.k_endog):
473+
names.append(f"exog_{i}.endog_{j}")
445474

446475
return names
447476

@@ -471,7 +500,7 @@ def coords(self) -> dict[str, Sequence]:
471500

472501
if self._exog:
473502
# Exogenous states
474-
coords[EXOG_STATE_DIM] = list(range(1, (self.k_exog * self.k_endog) + 1))
503+
coords[EXOG_STATE_DIM] = list(range(1, self.k_exog_states + 1))
475504

476505
return coords
477506

@@ -517,7 +546,8 @@ def param_dims(self):
517546

518547
if self._exog:
519548
coord_map["beta"] = (EXOG_STATE_DIM,)
520-
# coord_map["exog_data"]
549+
if self.exog_innovations:
550+
coord_map["beta_sigma"] = (EXOG_STATE_DIM,)
521551

522552
return coord_map
523553

@@ -540,7 +570,23 @@ def data_names(self):
540570

541571
def make_symbolic_graph(self):
542572
# Initial states
543-
x0 = self.make_and_register_variable("x0", shape=(self.k_states,), dtype=floatX)
573+
574+
if not self._exog:
575+
x0 = self.make_and_register_variable("x0", shape=(self.k_states,), dtype=floatX)
576+
else:
577+
x0_1 = self.make_and_register_variable(
578+
"x0", shape=(self.k_states - self.k_exog_states,), dtype=floatX
579+
)
580+
x0_2 = self.make_and_register_variable(
581+
"beta", shape=(self.k_exog_states,), dtype=floatX
582+
)
583+
# if self.shared_exog_states:
584+
# x0_1 = self.make_and_register_variable("x0", shape=(self.k_states-self.k_exog,), dtype=floatX)
585+
# x0_2 = self.make_and_register_variable("beta", shape=(self.k_exog,), dtype=floatX)
586+
# else:
587+
# x0_1 = self.make_and_register_variable("x0", shape=(self.k_states-self.k_endog*self.k_exog,), dtype=floatX)
588+
# x0_2 = self.make_and_register_variable("beta", shape=(self.k_endog*self.k_exog,), dtype=floatX)
589+
x0 = pt.concatenate([x0_1, x0_2], axis=0)
544590

545591
self.ssm["initial_state", :] = x0
546592

@@ -575,16 +621,23 @@ def make_symbolic_graph(self):
575621
design_matrix = pt.concatenate(matrix_parts, axis=1)
576622

577623
if self._exog:
578-
exog_data = self.make_and_register_data("exog_data", shape=(None, self.k_exog))
579-
Z_exog = pt.linalg.block_diag(
580-
*[pt.expand_dims(exog_data, 1) for _ in range(self.k_endog)]
581-
) # (time, k_endog, k_exog)
582-
Z_exog = pt.specify_shape(Z_exog, (None, self.k_endog, self.k_exog * self.k_endog))
583-
# Repeat design_matrix over time dimension
584-
n_timepoints = Z_exog.shape[0]
585-
design_matrix_time = pt.tile(
586-
design_matrix, (n_timepoints, 1, 1)
587-
) # (time, k_endog, states_before_exog)
624+
if self.shared_exog_states:
625+
exog_data = self.make_and_register_data("exog_data", shape=(None, self.k_exog))
626+
Z_exog = pt.specify_shape(
627+
pt.join(1, *[pt.expand_dims(exog_data, 1) for _ in range(self.k_endog)]),
628+
(None, self.k_endog, self.k_exog),
629+
)
630+
n_timepoints = Z_exog.shape[0]
631+
design_matrix_time = pt.tile(design_matrix, (n_timepoints, 1, 1))
632+
else:
633+
exog_data = self.make_and_register_data("exog_data", shape=(None, self.k_exog))
634+
Z_exog = pt.linalg.block_diag(
635+
*[pt.expand_dims(exog_data, 1) for _ in range(self.k_endog)]
636+
) # (time, k_endog, k_exog)
637+
Z_exog = pt.specify_shape(Z_exog, (None, self.k_endog, self.k_exog * self.k_endog))
638+
# Repeat design_matrix over time dimension
639+
n_timepoints = Z_exog.shape[0]
640+
design_matrix_time = pt.tile(design_matrix, (n_timepoints, 1, 1))
588641

589642
# Concatenate along states dimension
590643
design_matrix = pt.concatenate([design_matrix_time, Z_exog], axis=2)
@@ -670,8 +723,7 @@ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
670723
build_independent_var_block_matrix(error_ar, self.k_endog, self.error_order)
671724
)
672725
if self._exog:
673-
transition_blocks.append(pt.eye(self.k_exog * self.k_endog, dtype=floatX))
674-
726+
transition_blocks.append(pt.eye(self.k_exog_states, dtype=floatX))
675727
# Final block diagonal transition matrix
676728
self.ssm["transition", :, :] = pt.linalg.block_diag(*transition_blocks)
677729

@@ -685,7 +737,18 @@ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
685737
col = self.k_factors + i
686738
self.ssm["selection", row, col] = 1.0
687739

688-
# No changes in selection matrix since there are not innovations related to the betas parameters
740+
if self._exog and self.exog_innovations:
741+
for i in range(self.k_exog_states):
742+
if self.error_order > 0:
743+
self.ssm[
744+
"selection",
745+
self.k_states - self.k_exog_states + i,
746+
self.k_factors + self.k_endog + i,
747+
] = 1.0
748+
else:
749+
self.ssm[
750+
"selection", self.k_states - self.k_exog_states + i, self.k_factors + i
751+
] = 1.0
689752

690753
factor_cov = pt.eye(self.k_factors, dtype=floatX)
691754

@@ -708,11 +771,31 @@ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
708771

709772
# State covariance matrix (Q)
710773
if self.error_order > 0:
711-
# Include AR noise in state vector
712-
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov)
774+
if self._exog and self.exog_innovations:
775+
# Include AR noise in state vector
776+
beta_sigma = self.make_and_register_variable(
777+
"beta_sigma", shape=(self.k_exog_states,), dtype=floatX
778+
)
779+
exog_cov = pt.diag(beta_sigma)
780+
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov, exog_cov)
781+
elif self._exog and not self.exog_innovations:
782+
exog_cov = pt.zeros((self.k_exog_states, self.k_exog_states), dtype=floatX)
783+
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov, exog_cov)
784+
elif not self._exog:
785+
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, error_cov)
713786
else:
714-
# Only latent factor in the state
715-
self.ssm["state_cov", :, :] = factor_cov
787+
if self._exog and self.exog_innovations:
788+
beta_sigma = self.make_and_register_variable(
789+
"beta_sigma", shape=(self.k_exog_states,), dtype=floatX
790+
)
791+
exog_cov = pt.diag(beta_sigma)
792+
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, exog_cov)
793+
elif self._exog and not self.exog_innovations:
794+
exog_cov = pt.zeros((self.k_exog_states, self.k_exog_states), dtype=floatX)
795+
self.ssm["state_cov", :, :] = pt.linalg.block_diag(factor_cov, exog_cov)
796+
elif not self._exog:
797+
# Only latent factor in the state
798+
self.ssm["state_cov", :, :] = factor_cov
716799

717800
# Observation covariance matrix (H)
718801
if self.error_order > 0:

0 commit comments

Comments
 (0)