@@ -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