dfm-python 0.5.0__py3-none-any.whl → 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dfm_python/__init__.py CHANGED
@@ -65,7 +65,7 @@ Note: DFMConfig and SeriesConfig are internal implementation details.
65
65
  For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
66
66
  """
67
67
 
68
- __version__ = "0.5.0"
68
+ __version__ = "0.5.2"
69
69
 
70
70
  # ============================================================================
71
71
  # PUBLIC API DEFINITION
@@ -313,12 +313,14 @@ class NowcastResult:
313
313
  Examples
314
314
  --------
315
315
  >>> from dfm_python import DFM
316
+ >>> import numpy as np
316
317
  >>> model = DFM()
317
318
  >>> trainer.fit(model, data_module)
318
- >>> # Get nowcast with full result
319
- >>> result = model.nowcast('gdp', view_date='2024-01-15', return_result=True)
320
- >>> print(f"Nowcast: {result.nowcast_value}")
321
- >>> print(f"Available data: {result.data_availability['n_available']}")
319
+ >>> # Update state with new data, then predict
320
+ >>> X_std = np.random.randn(10, 5) # Standardized data
321
+ >>> model.update(X_std)
322
+ >>> forecast = model.predict(horizon=1)
323
+ >>> print(f"Forecast: {forecast[0, 0]}")
322
324
  """
323
325
  target_series: str
324
326
  target_period: datetime
@@ -398,6 +398,11 @@ class BaseModelConfig:
398
398
  f"Block '{block_name}' validation failed: AR lag must be at least 1, got {ar_lag}. "
399
399
  f"Please set ar_lag >= 1 for block '{block_name}'."
400
400
  )
401
+ if ar_lag > 2:
402
+ raise ValueError(
403
+ f"Block '{block_name}' validation failed: AR lag must be at most 2 (maximum supported VAR order is VAR(2)), got {ar_lag}. "
404
+ f"Please set ar_lag to 1 (VAR(1)) or 2 (VAR(2)) for block '{block_name}'."
405
+ )
401
406
 
402
407
  # Auto-generate series_id if not provided and convert blocks to indices
403
408
  n_blocks = len(self.block_names)
@@ -608,7 +613,7 @@ class DFMConfig(BaseModelConfig):
608
613
  # ========================================================================
609
614
  # EM Algorithm Parameters (HOW - controls the algorithm)
610
615
  # ========================================================================
611
- ar_lag: int = 1 # Number of lags in AR transition equation (lookback window)
616
+ ar_lag: int = 1 # Number of lags in AR transition equation (lookback window). Must be 1 or 2 (maximum supported order is VAR(2))
612
617
  threshold: float = 1e-5 # EM convergence threshold
613
618
  max_iter: int = 5000 # Maximum EM iterations
614
619
 
@@ -717,7 +722,7 @@ class DDFMConfig(BaseModelConfig):
717
722
  learning_rate: float = 0.001 # Learning rate for Adam optimizer (default: 0.001)
718
723
  epochs: int = 100 # Number of training epochs (default: 100)
719
724
  batch_size: int = 100 # Batch size for training (default: 100 to match original DDFM)
720
- factor_order: int = 1 # VAR lag order for factor dynamics (1 or 2, default: 1)
725
+ factor_order: int = 1 # VAR lag order for factor dynamics. Must be 1 or 2 (maximum supported order is VAR(2), default: 1)
721
726
  use_idiosyncratic: bool = True # Model idio components with AR(1) dynamics (default: True)
722
727
  min_obs_idio: int = 5 # Minimum observations for idio AR(1) estimation (default: 5)
723
728
 
@@ -124,7 +124,10 @@ class NoScalingStrategy:
124
124
  # Scaler Factory
125
125
  # ============================================================================
126
126
 
127
- def _create_scaler_transformer(scaler_type: Optional[str]) -> Any:
127
+ def _create_scaler_transformer(
128
+ scaler_type: Optional[str],
129
+ use_robust_as_default: bool = False
130
+ ) -> Any:
128
131
  """Create a raw sklearn scaler for unified scaling.
129
132
 
130
133
  Returns a raw sklearn scaler that can be used directly in TransformerPipeline.
@@ -160,10 +163,13 @@ def _create_scaler_transformer(scaler_type: Optional[str]) -> Any:
160
163
  )
161
164
 
162
165
  if scaler_type is None or scaler_type == 'none':
163
- # Passthrough: no scaling
164
- return FunctionTransformer(func=lambda x: x, inverse_func=lambda x: x)
165
-
166
- scaler_type_lower = scaler_type.lower()
166
+ if use_robust_as_default:
167
+ scaler_type_lower = 'robust'
168
+ else:
169
+ # Passthrough: no scaling
170
+ return FunctionTransformer(func=lambda x: x, inverse_func=lambda x: x)
171
+ else:
172
+ scaler_type_lower = scaler_type.lower()
167
173
 
168
174
  if scaler_type_lower == 'standard':
169
175
  return StandardScaler()
dfm_python/models/base.py CHANGED
@@ -16,16 +16,10 @@ import torch
16
16
  import pytorch_lightning as pl
17
17
  import pandas as pd
18
18
 
19
- if TYPE_CHECKING:
20
- from ..config.results import NowcastResult
21
-
22
19
  from ..config import DFMConfig, make_config_source, ConfigSource, MergedConfigSource
23
20
  from ..config.results import BaseResult
24
21
  from ..config.schema import SeriesConfig, DEFAULT_BLOCK_NAME
25
22
  from ..logger import get_logger
26
- from .utils import create_nowcast_result
27
- from ..utils.time import parse_timestamp, get_latest_time
28
- from ..utils.data import create_data_view
29
23
 
30
24
  _logger = get_logger(__name__)
31
25
 
@@ -194,28 +188,6 @@ class BaseFactorModel(pl.LightningModule):
194
188
 
195
189
  return config
196
190
 
197
- def _get_data_from_datamodule(self) -> Tuple[Any, Optional[np.ndarray], Optional[np.ndarray]]:
198
- """Get processed data and standardization parameters from DataModule.
199
-
200
- Returns
201
- -------
202
- X_torch : torch.Tensor
203
- Processed data tensor (T x N)
204
- Mx : np.ndarray or None
205
- Mean values for unstandardization (N,)
206
- Wx : np.ndarray or None
207
- Standard deviation values for unstandardization (N,)
208
-
209
- Raises
210
- ------
211
- ValueError
212
- If DataModule is not available
213
- """
214
- data_module = self._get_datamodule()
215
- X_torch = data_module.get_processed_data()
216
- Mx, Wx = data_module.get_std_params()
217
- return X_torch, Mx, Wx
218
-
219
191
  def _get_datamodule(self):
220
192
  """Get DataModule from model or trainer.
221
193
 
@@ -250,7 +222,7 @@ class BaseFactorModel(pl.LightningModule):
250
222
  ) -> np.ndarray:
251
223
  """Forecast factors using VAR dynamics.
252
224
 
253
- Supports VAR(1) and VAR(2) factor dynamics.
225
+ Supports VAR(1) and VAR(2) factor dynamics (maximum supported order is VAR(2)).
254
226
 
255
227
  Parameters
256
228
  ----------
@@ -259,7 +231,7 @@ class BaseFactorModel(pl.LightningModule):
259
231
  A : np.ndarray
260
232
  Transition matrix. For VAR(1): (m x m), for VAR(2): (m x 2m)
261
233
  p : int
262
- VAR order (1 or 2)
234
+ VAR order. Must be 1 or 2 (maximum supported order is VAR(2))
263
235
  horizon : int
264
236
  Number of periods to forecast
265
237
  Z_prev : np.ndarray, optional
@@ -297,7 +269,7 @@ class BaseFactorModel(pl.LightningModule):
297
269
  else:
298
270
  raise ValueError(
299
271
  f"{self.__class__.__name__} prediction failed: unsupported VAR order {p}. "
300
- f"Only VAR(1) and VAR(2) are supported. Please use factor_order=1 or factor_order=2"
272
+ f"Maximum supported VAR order is VAR(2). Please use factor_order=1 (VAR(1)) or factor_order=2 (VAR(2))"
301
273
  )
302
274
  return Z_forecast
303
275
 
@@ -428,13 +400,22 @@ class BaseFactorModel(pl.LightningModule):
428
400
  Dict[str, torch.Tensor]
429
401
  Dictionary of torch tensors: A, C, Q, R, Z_0, V_0
430
402
  """
403
+ # Ensure V_0 is always 2D (defensive check for edge cases)
404
+ V_0 = result.V_0
405
+ if isinstance(V_0, np.ndarray):
406
+ if V_0.ndim == 0:
407
+ V_0 = np.atleast_2d(V_0)
408
+ elif V_0.ndim == 1:
409
+ # If 1D, reshape to (m x m) - assume it's a single value for m=1 case
410
+ V_0 = np.atleast_2d(V_0).T if V_0.shape[0] == 1 else np.atleast_2d(V_0)
411
+
431
412
  return {
432
413
  'A': torch.tensor(result.A, dtype=torch.float32),
433
414
  'C': torch.tensor(C if C is not None else result.C, dtype=torch.float32),
434
415
  'Q': torch.tensor(result.Q, dtype=torch.float32),
435
416
  'R': torch.tensor(R if R is not None else result.R, dtype=torch.float32),
436
417
  'Z_0': torch.tensor(result.Z_0, dtype=torch.float32),
437
- 'V_0': torch.tensor(result.V_0, dtype=torch.float32)
418
+ 'V_0': torch.tensor(V_0, dtype=torch.float32)
438
419
  }
439
420
 
440
421
  def _update_factor_state_dfm(
@@ -494,33 +475,66 @@ class BaseFactorModel(pl.LightningModule):
494
475
  """
495
476
  if not hasattr(self, 'encoder') or self.encoder is None:
496
477
  _logger.warning(
497
- f"{self.__class__.__name__} predict(): Encoder not available, "
498
- "using training state instead"
478
+ f"{self.__class__.__name__} update(): Encoder not available, "
479
+ "cannot update factor state"
499
480
  )
500
481
  return None
501
482
 
502
- # Extract factors using encoder
503
- device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
504
- self.encoder.eval()
505
- X_tensor = torch.tensor(X_recent_std, device=device, dtype=torch.float32)
506
- with torch.no_grad():
507
- factors_raw = self.encoder(X_tensor).cpu().numpy() # (T x m)
508
-
509
- # For DDFM: C = I (identity), R = small noise
510
- m = factors_raw.shape[1]
511
- C = np.eye(m)
512
- R = np.eye(m) * 1e-8
513
-
514
- # Convert to torch: (m x T) format for Kalman filter
515
- Y = torch.tensor(factors_raw.T, dtype=torch.float32)
516
- params = self._result_to_torch_params(result, C=C, R=R)
517
-
518
- # Run Kalman smoother
519
- kf = self._get_kalman_filter(kalman_filter)
520
- zsmooth, _, _, _ = kf(Y, params['A'], params['C'], params['Q'],
521
- params['R'], params['Z_0'], params['V_0'])
522
-
523
- return zsmooth.T[-1, :].cpu().numpy()
483
+ try:
484
+ # Extract factors using encoder
485
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
486
+ self.encoder.eval()
487
+ X_tensor = torch.tensor(X_recent_std, device=device, dtype=torch.float32)
488
+ with torch.no_grad():
489
+ factors_raw = self.encoder(X_tensor).cpu().numpy() # (T x m)
490
+
491
+ # Validate factors shape
492
+ if factors_raw.ndim != 2 or factors_raw.shape[0] == 0:
493
+ _logger.warning(
494
+ f"{self.__class__.__name__} update(): Invalid factors shape {factors_raw.shape}, "
495
+ "cannot update factor state"
496
+ )
497
+ return None
498
+
499
+ # For DDFM: C = I (identity), R = small noise
500
+ m = factors_raw.shape[1]
501
+ C = np.eye(m)
502
+ R = np.eye(m) * 1e-8
503
+
504
+ # Convert to torch: (m x T) format for Kalman filter
505
+ Y = torch.tensor(factors_raw.T, dtype=torch.float32)
506
+ params = self._result_to_torch_params(result, C=C, R=R)
507
+
508
+ # Run Kalman smoother
509
+ kf = self._get_kalman_filter(kalman_filter)
510
+ kf_result = kf(Y, params['A'], params['C'], params['Q'],
511
+ params['R'], params['Z_0'], params['V_0'])
512
+
513
+ # Handle return value - Kalman filter returns (zsmooth, Vsmooth, VVsmooth, loglik)
514
+ if isinstance(kf_result, tuple) and len(kf_result) >= 1:
515
+ zsmooth = kf_result[0]
516
+ else:
517
+ zsmooth = kf_result
518
+
519
+ # zsmooth is (m x (T+1)), transpose to ((T+1) x m), then get last row
520
+ if not isinstance(zsmooth, torch.Tensor):
521
+ zsmooth = torch.tensor(zsmooth, dtype=torch.float32)
522
+
523
+ zsmooth_T = zsmooth.T # ((T+1) x m)
524
+ if zsmooth_T.ndim == 2 and zsmooth_T.shape[0] > 0:
525
+ return zsmooth_T[-1, :].cpu().numpy()
526
+ else:
527
+ _logger.warning(
528
+ f"{self.__class__.__name__} update(): Invalid zsmooth shape {zsmooth_T.shape}, "
529
+ "cannot extract last state"
530
+ )
531
+ return None
532
+
533
+ except Exception as e:
534
+ _logger.warning(
535
+ f"{self.__class__.__name__} update(): Failed to update factor state: {e}"
536
+ )
537
+ return None
524
538
 
525
539
  @property
526
540
  def result(self) -> BaseResult:
@@ -576,149 +590,52 @@ class BaseFactorModel(pl.LightningModule):
576
590
  """
577
591
  pass
578
592
 
579
- def nowcast(
593
+ @abstractmethod
594
+ def update(
580
595
  self,
581
- target_series: str,
582
- view_date: Optional[Union[datetime, str]] = None,
583
- target_period: Optional[Union[datetime, str]] = None,
596
+ X_std: np.ndarray,
584
597
  *,
585
- return_result: bool = False,
586
- horizon: int = 1
587
- ) -> Union[float, 'NowcastResult']:
588
- """Calculate nowcast for target series.
589
-
590
- This method performs nowcasting by:
591
- 1. Creating a data view with masking based on release dates
592
- 2. Re-running Kalman filter with masked data to update factor state
593
- 3. Calling predict() with the updated state
598
+ history: Optional[int] = None,
599
+ kalman_filter: Optional[Any] = None,
600
+ scaler: Optional[Any] = None
601
+ ) -> 'BaseFactorModel':
602
+ """Update factor state with standardized data.
594
603
 
595
- This is equivalent to: masking → Kalman filter update → predict()
604
+ This method permanently updates the last factor state (result.Z[-1, :])
605
+ using the provided standardized data. Users should handle all preprocessing
606
+ (masking, imputation, standardization) before calling this method.
596
607
 
597
608
  Parameters
598
609
  ----------
599
- target_series : str
600
- Target series ID to nowcast
601
- view_date : datetime or str, optional
602
- Data view date (when data is available). If None, uses latest available.
603
- target_period : datetime or str, optional
604
- Target period for nowcast. If None, uses latest available period.
605
- return_result : bool, default False
606
- If True, returns NowcastResult with additional information.
607
- If False, returns only the nowcast value (float).
608
- horizon : int, default 1
609
- Forecast horizon. For nowcasting, typically 1 (current period).
610
+ X_std : np.ndarray
611
+ Standardized data array (T x N), where T is number of time periods
612
+ and N is number of series. Data should already be standardized using
613
+ result.Mx and result.Wx.
614
+ history : int, optional
615
+ Number of recent periods to use for factor state update. If None, uses
616
+ all provided data (default). If specified (e.g., 60), uses only the most
617
+ recent N periods. Initial state (Z_0, V_0) is always estimated from
618
+ full training data, but the update uses only recent history for efficiency.
619
+ kalman_filter : Any, optional
620
+ Kalman filter instance. If None, uses default or model's kalman filter.
621
+ scaler : Any, optional
622
+ If provided, attach/replace the model's scaler (e.g., refit on new regime).
623
+ When None, keep the existing scaler from training.
610
624
 
611
625
  Returns
612
626
  -------
613
- float or NowcastResult
614
- Nowcast value if return_result=False, or NowcastResult if return_result=True
627
+ BaseFactorModel
628
+ Self for method chaining
615
629
 
616
630
  Examples
617
631
  --------
618
- >>> model = DFM()
619
- >>> trainer.fit(model, data_module)
620
- >>> # Simple nowcast
621
- >>> value = model.nowcast('gdp', view_date='2024-01-15', target_period='2024Q1')
622
- >>> # With full result
623
- >>> result = model.nowcast('gdp', view_date='2024-01-15', return_result=True)
632
+ >>> # Update state with new data, then predict
633
+ >>> model.update(X_std).predict(horizon=1)
634
+ >>> # Or update with only recent 12 periods
635
+ >>> model.update(X_std, history=12)
636
+ >>> forecast = model.predict(horizon=6)
624
637
  """
625
- self._check_trained()
626
-
627
- # Get DataModule
628
- data_module = self._get_datamodule()
629
- raw_data = data_module.data
630
- time_index = data_module.time_index
631
-
632
- # Convert to numpy
633
- X = raw_data.to_numpy() if hasattr(raw_data, 'to_numpy') else np.asarray(raw_data)
634
-
635
- # Parse view_date
636
- if view_date is None:
637
- view_date_dt = get_latest_time(time_index)
638
- elif isinstance(view_date, str):
639
- view_date_dt = parse_timestamp(view_date)
640
- else:
641
- view_date_dt = view_date
642
-
643
- # Create data view with masking
644
- X_view, Time_view, _ = create_data_view(
645
- X=X,
646
- Time=time_index,
647
- Z=None,
648
- config=self.config,
649
- view_date=view_date_dt,
650
- X_frame=raw_data if isinstance(raw_data, pd.DataFrame) else None
651
- )
652
-
653
- # Standardize masked data
654
- result = self._result
655
- X_view_std = self._standardize_data(X_view, result.Mx, result.Wx)
656
- X_view_std = np.where(np.isfinite(X_view_std), X_view_std, np.nan)
657
-
658
- # Update factor state using masked data (model-specific)
659
- if hasattr(self, 'encoder') and self.encoder is not None:
660
- # DDFM: Extract factors via encoder, then use Kalman filter
661
- Z_last_updated = self._update_factor_state_ddfm(X_view_std, result, None)
662
- else:
663
- # DFM: Use Kalman filter directly on standardized data
664
- Z_last_updated = self._update_factor_state_dfm(
665
- X_view_std, result, getattr(self, 'kalman', None)
666
- )
667
-
668
- if Z_last_updated is None:
669
- Z_last_updated = result.Z[-1, :]
670
- _logger.warning(
671
- f"{self.__class__.__name__} nowcast(): Failed to update factor state, "
672
- f"using training state instead"
673
- )
674
-
675
- # Temporarily update result.Z[-1, :] with updated state
676
- original_Z_last = result.Z[-1, :].copy()
677
- result.Z[-1, :] = Z_last_updated
678
-
679
- try:
680
- # Call predict() with updated state
681
- X_forecast = self.predict(horizon=horizon, return_series=True, return_factors=False)
682
-
683
- # Extract nowcast value for target series
684
- series_ids = [s.series_id for s in self.config.series if s.series_id]
685
- try:
686
- target_idx = series_ids.index(target_series)
687
- except ValueError:
688
- available = ', '.join(str(sid) for sid in series_ids[:5])
689
- raise ValueError(
690
- f"{self.__class__.__name__} nowcast failed: target_series '{target_series}' not found. "
691
- f"Available: {available}..."
692
- )
693
-
694
- # Get nowcast value (first period of forecast)
695
- if isinstance(X_forecast, np.ndarray):
696
- nowcast_value = float(X_forecast[0, target_idx])
697
- else:
698
- nowcast_value = float(X_forecast[0][target_idx])
699
-
700
- if return_result:
701
- # Calculate data availability
702
- data_availability = {
703
- 'n_available': int(np.sum(np.isfinite(X_view))),
704
- 'n_missing': int(np.sum(np.isnan(X_view)))
705
- }
706
-
707
- # Create NowcastResult using utility function
708
- return create_nowcast_result(
709
- target_series=target_series,
710
- target_period=target_period,
711
- view_date=view_date_dt,
712
- nowcast_value=nowcast_value,
713
- factors_at_view=Z_last_updated,
714
- data_availability=data_availability,
715
- dfm_result=result
716
- )
717
- else:
718
- return nowcast_value
719
- finally:
720
- # Restore original state
721
- result.Z[-1, :] = original_Z_last
638
+ pass
722
639
 
723
640
  def get_result(self) -> BaseResult:
724
641
  """Extract result from trained model.