@@ -55,6 +55,14 @@ class BayesianDynamicFactor(PyMCStateSpace):
55
55
exog_names : Sequence[str], optional
56
56
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`.
57
57
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
+
58
66
error_order : int, optional
59
67
Order of the AR process for the observation error component.
60
68
Default is 0, corresponding to white noise errors.
@@ -98,7 +106,7 @@ class BayesianDynamicFactor(PyMCStateSpace):
98
106
99
107
Where:
100
108
- :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
102
110
- :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),
103
111
- :math:`\eta_t \sim \mathcal{N}(0, H_t)` is an optional measurement error (if `measurement_error=True`),
104
112
- :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__(
272
280
endog_names : Sequence [str ] | None = None ,
273
281
k_exog : int | None = None ,
274
282
exog_names : Sequence [str ] | None = None ,
283
+ shared_exog_states : bool | None = None ,
284
+ exog_innovations : bool | None = None ,
275
285
error_order : int = 0 ,
276
286
error_var : bool = False ,
277
287
error_cov_type : str = "diagonal" ,
@@ -285,9 +295,6 @@ def __init__(
285
295
if endog_names is None :
286
296
endog_names = [f"endog_{ i } " for i in range (k_endog )]
287
297
288
- # if k_exog is not None or exog_names is not None:
289
- # raise NotImplementedError("Exogenous variables (exog) are not yet implemented.")
290
-
291
298
self .endog_names = endog_names
292
299
self .k_endog = k_endog
293
300
self .k_factors = k_factors
@@ -301,17 +308,24 @@ def __init__(
301
308
self .k_exog = 0
302
309
else :
303
310
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
304
315
if k_exog is None :
305
316
k_exog = len (exog_names ) if exog_names is not None else 0
306
317
elif exog_names is None :
307
318
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
308
320
309
321
self .exog_names = exog_names
310
322
self .k_exog = k_exog
311
323
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
315
329
316
330
# Determine the dimension for the latent factor states.
317
331
# For static factors, one use k_factors.
@@ -326,13 +340,17 @@ def __init__(
326
340
k_error_states = k_endog * error_order if error_order > 0 else 0
327
341
328
342
# 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 )
330
344
331
345
# Number of independent shocks.
332
346
# Typically, the latent factors introduce k_factors shocks.
333
347
# If error_order > 0 and errors are modeled jointly or separately, add appropriate count.
334
348
# 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
+ )
336
354
337
355
# Initialize the PyMCStateSpace base class.
338
356
super ().__init__ (
@@ -367,6 +385,8 @@ def param_names(self):
367
385
names .remove ("sigma_obs" )
368
386
if self ._exog :
369
387
names .append ("beta" )
388
+ if self .exog_innovations :
389
+ names .append ("beta_sigma" )
370
390
371
391
return names
372
392
@@ -409,9 +429,13 @@ def param_info(self) -> dict[str, dict[str, Any]]:
409
429
"constraints" : "Positive" ,
410
430
},
411
431
"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 ,),
413
433
"constraints" : None ,
414
434
},
435
+ "beta_sigma" : {
436
+ "shape" : (self .k_exog_states if self .k_exog is not None else 0 ,),
437
+ "constraints" : "Positive" ,
438
+ },
415
439
}
416
440
417
441
for name in self .param_names :
@@ -438,10 +462,15 @@ def state_names(self) -> list[str]:
438
462
names .append (f"L{ lag } .error_{ i } " )
439
463
440
464
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 } " )
445
474
446
475
return names
447
476
@@ -471,7 +500,7 @@ def coords(self) -> dict[str, Sequence]:
471
500
472
501
if self ._exog :
473
502
# 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 ))
475
504
476
505
return coords
477
506
@@ -517,7 +546,8 @@ def param_dims(self):
517
546
518
547
if self ._exog :
519
548
coord_map ["beta" ] = (EXOG_STATE_DIM ,)
520
- # coord_map["exog_data"]
549
+ if self .exog_innovations :
550
+ coord_map ["beta_sigma" ] = (EXOG_STATE_DIM ,)
521
551
522
552
return coord_map
523
553
@@ -540,7 +570,23 @@ def data_names(self):
540
570
541
571
def make_symbolic_graph (self ):
542
572
# 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 )
544
590
545
591
self .ssm ["initial_state" , :] = x0
546
592
@@ -575,16 +621,23 @@ def make_symbolic_graph(self):
575
621
design_matrix = pt .concatenate (matrix_parts , axis = 1 )
576
622
577
623
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 ))
588
641
589
642
# Concatenate along states dimension
590
643
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):
670
723
build_independent_var_block_matrix (error_ar , self .k_endog , self .error_order )
671
724
)
672
725
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 ))
675
727
# Final block diagonal transition matrix
676
728
self .ssm ["transition" , :, :] = pt .linalg .block_diag (* transition_blocks )
677
729
@@ -685,7 +737,18 @@ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
685
737
col = self .k_factors + i
686
738
self .ssm ["selection" , row , col ] = 1.0
687
739
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
689
752
690
753
factor_cov = pt .eye (self .k_factors , dtype = floatX )
691
754
@@ -708,11 +771,31 @@ def build_independent_var_block_matrix(ar_coeffs, k_series, p):
708
771
709
772
# State covariance matrix (Q)
710
773
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 )
713
786
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
716
799
717
800
# Observation covariance matrix (H)
718
801
if self .error_order > 0 :
0 commit comments