dfm-python 0.5.57__tar.gz → 0.5.58__tar.gz

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.
Files changed (135) hide show
  1. {dfm_python-0.5.57 → dfm_python-0.5.58}/PKG-INFO +1 -1
  2. {dfm_python-0.5.57 → dfm_python-0.5.58}/pyproject.toml +1 -1
  3. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/__init__.py +0 -1
  4. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/__init__.py +1 -3
  5. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/adapter.py +5 -0
  6. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/constants.py +0 -17
  7. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/schema/block.py +2 -0
  8. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/schema/model.py +20 -3
  9. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/dataset/ddfm_dataset.py +73 -7
  10. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/dataset/dfm_dataset.py +71 -13
  11. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/encoder/pca.py +59 -9
  12. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/functional/dfm_block.py +20 -5
  13. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/functional/em.py +69 -47
  14. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/models/ddfm.py +157 -28
  15. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/models/dfm.py +175 -30
  16. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/builder.py +1 -5
  17. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/estimator.py +71 -2
  18. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/stability.py +44 -0
  19. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/tent.py +59 -38
  20. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/validator.py +85 -0
  21. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/ssm/kalman.py +144 -80
  22. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/__init__.py +2 -5
  23. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/helper.py +64 -8
  24. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/misc.py +62 -9
  25. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/schema/test_model.py +7 -1
  26. dfm_python-0.5.58/src/test/config/test_tent_weights_config.py +240 -0
  27. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/dataset/test_process.py +2 -2
  28. dfm_python-0.5.58/src/test/functional/test_dfm_block.py +140 -0
  29. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/functional/test_em.py +6 -6
  30. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/test_ddfm.py +81 -6
  31. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/test_dfm.py +6 -6
  32. dfm_python-0.5.58/src/test/models/test_dfm_fixes.py +261 -0
  33. dfm_python-0.5.58/src/test/models/test_dfm_result_caching.py +309 -0
  34. dfm_python-0.5.58/src/test/models/test_dfm_save_load.py +261 -0
  35. dfm_python-0.5.58/src/test/models/test_dfm_save_load_integration.py +136 -0
  36. dfm_python-0.5.58/src/test/models/test_dfm_save_load_mock.py +340 -0
  37. dfm_python-0.5.58/src/test/models/test_dfm_training_pipeline_save.py +246 -0
  38. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/test_builder.py +3 -3
  39. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/test_tent.py +28 -50
  40. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/ssm/test_kalman.py +40 -0
  41. dfm_python-0.5.58/src/test/ssm/test_kalman_progress.py +161 -0
  42. dfm_python-0.5.58/src/test/ssm/test_progress_bar_exact_scenario.py +226 -0
  43. dfm_python-0.5.58/src/test/ssm/test_progress_bar_spam_fix.py +297 -0
  44. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_helper.py +21 -0
  45. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_misc.py +51 -1
  46. dfm_python-0.5.57/src/test/functional/test_dfm_block.py +0 -219
  47. {dfm_python-0.5.57 → dfm_python-0.5.58}/.git +0 -0
  48. {dfm_python-0.5.57 → dfm_python-0.5.58}/.gitignore +0 -0
  49. {dfm_python-0.5.57 → dfm_python-0.5.58}/.python-version +0 -0
  50. {dfm_python-0.5.57 → dfm_python-0.5.58}/MANIFEST.in +0 -0
  51. {dfm_python-0.5.57 → dfm_python-0.5.58}/README.md +0 -0
  52. {dfm_python-0.5.57 → dfm_python-0.5.58}/config/ddfm_exchange.yaml +0 -0
  53. {dfm_python-0.5.57 → dfm_python-0.5.58}/config/ddfm_finance.yaml +0 -0
  54. {dfm_python-0.5.57 → dfm_python-0.5.58}/config/ddfm_macro.yaml +0 -0
  55. {dfm_python-0.5.57 → dfm_python-0.5.58}/config/dfm_finance.yaml +0 -0
  56. {dfm_python-0.5.57 → dfm_python-0.5.58}/config/dfm_macro.yaml +0 -0
  57. {dfm_python-0.5.57 → dfm_python-0.5.58}/data/exchange_rate.csv +0 -0
  58. {dfm_python-0.5.57 → dfm_python-0.5.58}/data/finance.csv +0 -0
  59. {dfm_python-0.5.57 → dfm_python-0.5.58}/data/macro.csv +0 -0
  60. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/schema/__init__.py +0 -0
  61. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/schema/params.py +0 -0
  62. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/schema/results.py +0 -0
  63. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/config/types.py +0 -0
  64. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/dataset/__init__.py +0 -0
  65. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/dataset/time.py +0 -0
  66. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/decoder/__init__.py +0 -0
  67. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/decoder/base.py +0 -0
  68. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/decoder/linear.py +0 -0
  69. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/decoder/mlp.py +0 -0
  70. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/encoder/__init__.py +0 -0
  71. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/encoder/base.py +0 -0
  72. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/encoder/simple_autoencoder.py +0 -0
  73. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/functional/__init__.py +0 -0
  74. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/logger/__init__.py +0 -0
  75. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/logger/inference_logger.py +0 -0
  76. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/logger/logger.py +0 -0
  77. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/logger/train_logger.py +0 -0
  78. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/models/__init__.py +0 -0
  79. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/models/base.py +0 -0
  80. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/numeric/statistic.py +0 -0
  81. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/ssm/__init__.py +0 -0
  82. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/checkpoint.py +0 -0
  83. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/errors.py +0 -0
  84. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/loss.py +0 -0
  85. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/metric.py +0 -0
  86. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/dfm_python/utils/validation.py +0 -0
  87. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/__init__.py +0 -0
  88. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/__init__.py +0 -0
  89. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/schema/__init__.py +0 -0
  90. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/schema/test_block.py +0 -0
  91. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/schema/test_results.py +0 -0
  92. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/test_adapter.py +0 -0
  93. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/test_constants.py +0 -0
  94. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/config/test_types.py +0 -0
  95. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/conftest.py +0 -0
  96. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/dataset/__init__.py +0 -0
  97. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/dataset/test_dataloader.py +0 -0
  98. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/dataset/test_dataset.py +0 -0
  99. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/decoder/__init__.py +0 -0
  100. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/decoder/test_linear.py +0 -0
  101. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/decoder/test_mlp.py +0 -0
  102. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/encoder/__init__.py +0 -0
  103. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/encoder/test_base.py +0 -0
  104. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/encoder/test_pca.py +0 -0
  105. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/encoder/test_simple_encoder.py +0 -0
  106. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/functional/__init__.py +0 -0
  107. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/logger/__init__.py +0 -0
  108. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/logger/test_inference_logger.py +0 -0
  109. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/logger/test_logger.py +0 -0
  110. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/logger/test_train_logger.py +0 -0
  111. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/__init__.py +0 -0
  112. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/test_base.py +0 -0
  113. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/test_ddfm_batchnorm_placement.py +0 -0
  114. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/models/test_tensorflow_batchnorm_comparison.py +0 -0
  115. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/__init__.py +0 -0
  116. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/test_estimator.py +0 -0
  117. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/test_stability.py +0 -0
  118. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/numeric/test_validator.py +0 -0
  119. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/ssm/__init__.py +0 -0
  120. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/ssm/test_arma.py +0 -0
  121. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/ssm/test_companion.py +0 -0
  122. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/ssm/test_structural.py +0 -0
  123. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/__init__.py +0 -0
  124. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_common.py +0 -0
  125. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_errors.py +0 -0
  126. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_metric.py +0 -0
  127. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_tensor_utils.py +0 -0
  128. {dfm_python-0.5.57 → dfm_python-0.5.58}/src/test/utils/test_validation.py +0 -0
  129. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_exchange_ddfm.py +0 -0
  130. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_finance_ddfm.py +0 -0
  131. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_finance_dfm.py +0 -0
  132. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_illness_ddfm.py +0 -0
  133. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_macro_ddfm.py +0 -0
  134. {dfm_python-0.5.57 → dfm_python-0.5.58}/tutorial/tutorial_macro_dfm.py +0 -0
  135. {dfm_python-0.5.57 → dfm_python-0.5.58}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dfm-python
3
- Version: 0.5.57
3
+ Version: 0.5.58
4
4
  Summary: Dynamic Factor Model (DFM) estimation and forecasting in Python
5
5
  Project-URL: Homepage, https://github.com/yourusername/dfm-python
6
6
  Project-URL: Documentation, https://github.com/yourusername/dfm-python#readme
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dfm-python"
3
- version = "0.5.57"
3
+ version = "0.5.58"
4
4
  description = "Dynamic Factor Model (DFM) estimation and forecasting in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -80,7 +80,6 @@ __version__ = "0.5.56"
80
80
 
81
81
  # Configuration (from config/ subpackage)
82
82
  from .config import (
83
- # DEFAULT_BLOCK_NAME, # Removed to avoid circular import - import directly from functional.dfm_block
84
83
  ConfigSource, YamlSource,
85
84
  make_config_source,
86
85
  )
@@ -86,9 +86,8 @@ except ImportError:
86
86
  compute_idio_lengths = None
87
87
 
88
88
  try:
89
- from ..numeric.tent import get_tent_weights, get_agg_structure, group_by_freq
89
+ from ..numeric.tent import get_agg_structure, group_by_freq
90
90
  except ImportError:
91
- get_tent_weights = None
92
91
  get_agg_structure = None
93
92
  group_by_freq = None
94
93
  from .constants import FREQUENCY_HIERARCHY, PERIODS_PER_YEAR
@@ -119,7 +118,6 @@ __all__ = [
119
118
  'PERIODS_PER_YEAR',
120
119
  'get_periods_per_year',
121
120
  'compute_idio_lengths',
122
- 'get_tent_weights',
123
121
  'get_agg_structure',
124
122
  'group_by_freq',
125
123
  # Type definitions (from types.py)
@@ -552,6 +552,11 @@ def detect_config_type(data: Dict[str, Any]) -> str:
552
552
  if model_type == MODEL_TYPE_DFM:
553
553
  return MODEL_TYPE_DFM
554
554
 
555
+ # Priority check: blocks parameter indicates DFM (highest priority)
556
+ # DFM is the only model type that uses blocks structure
557
+ if 'blocks' in data and data.get('blocks'):
558
+ return MODEL_TYPE_DFM
559
+
555
560
  # Check for model-specific parameters
556
561
  keys = set(data.keys())
557
562
  if keys & _KDFM_PARAMS:
@@ -387,22 +387,6 @@ FREQUENCY_HIERARCHY: Dict[str, int] = {
387
387
  # For frequency gaps larger than this, the missing data approach is used instead
388
388
  MAX_TENT_SIZE: int = 12
389
389
 
390
- # Deterministic tent weights lookup for supported frequency pairs
391
- # Format: (slower_freq, faster_freq) -> tent_weights_array
392
- # These weights define how slower-frequency series aggregate clock-frequency factors
393
- TENT_WEIGHTS_LOOKUP: Dict[Tuple[str, str], np.ndarray] = {
394
- ('q', 'm'): np.array([1, 2, 3, 2, 1]), # 5 periods: quarterly -> monthly
395
- ('sa', 'm'): np.array([1, 2, 3, 4, 3, 2, 1]), # 7 periods: semi-annual -> monthly
396
- ('a', 'm'): np.array([1, 2, 3, 4, 5, 4, 3, 2, 1]), # 9 periods: annual -> monthly
397
- ('m', 'w'): np.array([1, 2, 3, 2, 1]), # 5 periods: monthly -> weekly
398
- ('q', 'w'): np.array([1, 2, 3, 4, 5, 4, 3, 2, 1]), # 9 periods: quarterly -> weekly
399
- ('sa', 'w'): np.array([1, 2, 3, 4, 3, 2, 1]), # 7 periods: semi-annual -> weekly
400
- ('a', 'w'): np.array([1, 2, 3, 4, 5, 4, 3, 2, 1]), # 9 periods: annual -> weekly
401
- ('sa', 'q'): np.array([1, 2, 1]), # 3 periods: semi-annual -> quarterly
402
- ('a', 'q'): np.array([1, 2, 3, 2, 1]), # 5 periods: annual -> quarterly
403
- ('a', 'sa'): np.array([1, 2, 1]), # 3 periods: annual -> semi-annual
404
- }
405
-
406
390
  # ============================================================================
407
391
  # Export all constants
408
392
  # ============================================================================
@@ -582,6 +566,5 @@ __all__ = [
582
566
  # Frequency hierarchy and tent kernels
583
567
  'FREQUENCY_HIERARCHY',
584
568
  'MAX_TENT_SIZE',
585
- 'TENT_WEIGHTS_LOOKUP',
586
569
  ]
587
570
 
@@ -90,6 +90,7 @@ class BlockStructure:
90
90
  _cached_n_idio_M: Optional[int] = field(default=None, init=False, repr=False)
91
91
  _cached_c_idio_indicator: Optional[np.ndarray] = field(default=None, init=False, repr=False)
92
92
  _cached_rp1: Optional[int] = field(default=None, init=False, repr=False)
93
+ _cached_N: Optional[int] = field(default=None, init=False, repr=False) # N value used for caching
93
94
 
94
95
  def is_valid(self) -> bool:
95
96
  """Check if block structure is valid (all required fields are not None)."""
@@ -119,4 +120,5 @@ class BlockStructure:
119
120
  self._cached_n_idio_M = None
120
121
  self._cached_c_idio_indicator = None
121
122
  self._cached_rp1 = None
123
+ self._cached_N = None
122
124
 
@@ -286,12 +286,19 @@ class DFMConfig(BaseModelConfig):
286
286
  # Damped Updates: If provided (not None), automatically enables damping and always warns
287
287
  damping_factor: Optional[float] = None # Damping factor (0.8 = 80% new, 20% old). If None, no damping. If provided, damping enabled and warnings always shown.
288
288
 
289
- # Idiosyncratic Component Parameters (auto-detected from frequencies)
289
+ # Idiosyncratic Component Parameters
290
290
  idio_rho0: float = DEFAULT_IDIO_RHO0 # Initial AR coefficient for idiosyncratic components (default: 0.1)
291
291
  idio_min_var: float = MIN_DIAGONAL_VARIANCE # Minimum variance for idiosyncratic innovation covariance (defaults to MIN_DIAGONAL_VARIANCE)
292
- # Note: augment_idio and augment_idio_slow are auto-detected from frequency configuration
292
+ # Note: augment_idio and augment_idio_slow are INTERNALLY auto-detected (not configurable)
293
293
  # - If all series use clock frequency: augment_idio=False, augment_idio_slow=False
294
- # - If mixed frequencies detected: augment_idio=True, augment_idio_slow=True (auto-enabled)
294
+ # - If mixed frequencies detected: augment_idio=True, augment_idio_slow=True (required for tent kernel)
295
+ # These are properties, not config fields - automatically determined from frequency configuration
296
+
297
+ # Kalman Filter Type
298
+ use_cholesky_filter: bool = False # Use CholeskyKalmanFilter for better numerical stability (default: False)
299
+
300
+ # Tent Kernel Weights (for mixed-frequency aggregation)
301
+ tent_weights: Optional[Union[Dict[str, List[float]], Dict[str, np.ndarray]]] = None # Required for mixed-frequency data: tent weights for frequency pairs. Format: {'freq_pair': [weights]} or {'freq': [weights]}. Example: {'m:w': [1, 2, 1]} or {'m': [1, 2, 1]}. Must be specified in config for all slower-frequency pairs.
295
302
 
296
303
  def __post_init__(self):
297
304
  """Validate blocks structure and derive block properties."""
@@ -446,6 +453,8 @@ class DFMConfig(BaseModelConfig):
446
453
  'data_clip': data.get('data_clip', None),
447
454
  'regularization': data.get('regularization', None),
448
455
  'damping_factor': data.get('damping_factor', None),
456
+ 'use_cholesky_filter': data.get('use_cholesky_filter', False),
457
+ 'tent_weights': data.get('tent_weights', None),
449
458
  }
450
459
 
451
460
  result = base_params.copy()
@@ -524,6 +533,11 @@ class DDFMConfig(BaseModelConfig):
524
533
  seed: Optional[int] = None # Random seed for reproducibility
525
534
  lags_input: int = 0 # Number of lags of inputs on encoder (default 0, matching original TensorFlow DDFM)
526
535
 
536
+ # Interpolation parameters (for handling missing values)
537
+ interpolation_method: str = 'linear' # Interpolation method: 'linear', 'spline', 'cubic', etc. (default: 'linear' for stability)
538
+ interpolation_limit: Optional[int] = 10 # Maximum consecutive NaNs to interpolate (default: 10, prevents extreme extrapolation)
539
+ interpolation_limit_direction: str = 'both' # Direction to fill: 'forward', 'backward', or 'both' (default: 'both')
540
+
527
541
 
528
542
  # ========================================================================
529
543
  # Factory Methods
@@ -550,6 +564,9 @@ class DDFMConfig(BaseModelConfig):
550
564
  'disp': DEFAULT_DISP,
551
565
  'seed': None,
552
566
  'lags_input': 0, # Number of lags (default 0, matching original TensorFlow)
567
+ 'interpolation_method': 'linear', # Interpolation method (default: 'linear' for stability)
568
+ 'interpolation_limit': 10, # Maximum consecutive NaNs to interpolate (default: 10)
569
+ 'interpolation_limit_direction': 'both', # Direction to fill (default: 'both')
553
570
  })
554
571
  # Map 'epochs' from config to 'n_mc_samples' for clarity (backward compatibility)
555
572
  # Always remove 'epochs' if present (even if n_mc_samples is also present)
@@ -21,7 +21,7 @@ from ..config.constants import DEFAULT_TORCH_DTYPE
21
21
  class DDFMDataset(Dataset):
22
22
  """Dataset for DDFM training.
23
23
 
24
- Scales target series if scaler provided. Feature series are used as-is.
24
+ Scales target series if scaler provided. Feature series can also be scaled if provided.
25
25
 
26
26
  Parameters
27
27
  ----------
@@ -33,6 +33,8 @@ class DDFMDataset(Dataset):
33
33
  Target series column names to scale. If None or empty, all columns will be used as targets.
34
34
  target_scaler : StandardScaler | RobustScaler | MinMaxScaler, optional
35
35
  Scaler instance to scale target series. If None, no scaling.
36
+ feature_scaler : StandardScaler | RobustScaler | MinMaxScaler, optional
37
+ Scaler instance to scale feature series. If None, no scaling.
36
38
  """
37
39
 
38
40
  def __init__(
@@ -41,6 +43,7 @@ class DDFMDataset(Dataset):
41
43
  time_idx: str,
42
44
  target_series: Optional[List[str]] = None,
43
45
  target_scaler: Optional[Union[StandardScaler, RobustScaler, MinMaxScaler]] = None,
46
+ feature_scaler: Optional[Union[StandardScaler, RobustScaler, MinMaxScaler]] = None,
44
47
  ):
45
48
  if _has_polars and isinstance(data, pl.DataFrame):
46
49
  data = data.to_pandas()
@@ -70,10 +73,31 @@ class DDFMDataset(Dataset):
70
73
  self.target_series = target_series_list
71
74
  self.data_original = data.copy()
72
75
  self.target_scaler = target_scaler
76
+ self.feature_scaler = feature_scaler
73
77
 
74
78
  y = data[target_series_list]
75
79
  X = data.drop(columns=target_series_list)
76
80
 
81
+ # Optionally scale feature series to avoid scale dominance in encoder input
82
+ if feature_scaler is not None and not X.empty:
83
+ x_mean = X.mean().abs().max()
84
+ x_std = (X.std() - 1.0).abs().max()
85
+ x_is_already_scaled = x_mean < 0.1 and x_std < 0.1
86
+ x_scaler_is_fitted = (
87
+ hasattr(feature_scaler, 'mean_') and
88
+ feature_scaler.mean_ is not None and
89
+ hasattr(feature_scaler, 'scale_') and
90
+ feature_scaler.scale_ is not None
91
+ )
92
+ if not x_is_already_scaled:
93
+ if not x_scaler_is_fitted:
94
+ self.feature_scaler.fit(X.values)
95
+ X_scaled = self.feature_scaler.transform(X.values)
96
+ X = pd.DataFrame(X_scaled, index=X.index, columns=X.columns)
97
+ else:
98
+ if not x_scaler_is_fitted:
99
+ self.feature_scaler.fit(X.values)
100
+
77
101
  # Check if data is already standardized (mean≈0, std≈1) to avoid double scaling
78
102
  # If data is already scaled, we still need target_scaler for inverse transformation
79
103
  # but we should NOT scale again
@@ -83,17 +107,58 @@ class DDFMDataset(Dataset):
83
107
  y_std = (y.std() - 1.0).abs().max()
84
108
  is_already_scaled = y_mean < 0.1 and y_std < 0.1
85
109
 
110
+ # Check if scaler is already fitted
111
+ scaler_is_fitted = (
112
+ hasattr(target_scaler, 'mean_') and
113
+ target_scaler.mean_ is not None and
114
+ hasattr(target_scaler, 'scale_') and
115
+ target_scaler.scale_ is not None
116
+ )
117
+
86
118
  if is_already_scaled:
87
119
  # Data is already standardized - don't scale again
88
- # But still fit the scaler if not already fitted (for inverse transformation)
120
+ # IMPORTANT: If scaler is already fitted, do NOT refit it on already-scaled data
89
121
  # The scaler should have been fitted on original unscaled data by the caller
90
- if not hasattr(target_scaler, 'mean_') or target_scaler.mean_ is None:
91
- # If scaler not fitted, fit it on current data (assumes caller passed unscaled data)
122
+ # Refitting on already-scaled data would break inverse transformation
123
+ if not scaler_is_fitted:
124
+ # Only fit if scaler is not already fitted
125
+ # This assumes caller passed unscaled data (edge case)
126
+ import warnings
127
+ warnings.warn(
128
+ "DDFMDataset: Data appears already scaled but scaler is not fitted. "
129
+ "Fitting scaler on already-scaled data may break inverse transformation. "
130
+ "Ensure scaler is fitted on original unscaled data before passing to DDFMDataset.",
131
+ UserWarning
132
+ )
92
133
  self.target_scaler.fit(y.values)
93
134
  # Don't transform - data is already scaled
94
135
  else:
95
136
  # Data is not standardized - apply scaling
96
- self.target_scaler.fit(y.values)
137
+ # If scaler is already fitted, DO NOT refit (preserve training scaler).
138
+ if scaler_is_fitted:
139
+ # Check if scaler was fitted on data with similar scale
140
+ # If mean/scale are very different, warn user but keep scaler.
141
+ scaler_mean = np.abs(target_scaler.mean_)
142
+ scaler_scale = target_scaler.scale_
143
+ data_mean = np.abs(y.mean().values)
144
+ data_std = y.std().values
145
+
146
+ # Check for significant mismatch (more than 2x difference)
147
+ mean_mismatch = np.any((data_mean > 2 * scaler_mean) | (scaler_mean > 2 * data_mean))
148
+ scale_mismatch = np.any((data_std > 2 * scaler_scale) | (scaler_scale > 2 * data_std))
149
+
150
+ if mean_mismatch or scale_mismatch:
151
+ import warnings
152
+ warnings.warn(
153
+ "DDFMDataset: Scaler appears to be fitted on data with different scale. "
154
+ "Using existing scaler to preserve inverse transformation. "
155
+ "Ensure scaler consistency across train/test.",
156
+ UserWarning
157
+ )
158
+ else:
159
+ # Scaler not fitted - fit on current data
160
+ self.target_scaler.fit(y.values)
161
+
97
162
  y_scaled = self.target_scaler.transform(y.values)
98
163
  y = pd.DataFrame(y_scaled, index=y.index, columns=y.columns)
99
164
 
@@ -184,13 +249,14 @@ class DDFMDataset(Dataset):
184
249
  Returns
185
250
  -------
186
251
  DDFMDataset
187
- New dataset with same time_idx, target_series, target_scaler.
252
+ New dataset with same time_idx, target_series, target_scaler, feature_scaler.
188
253
  """
189
254
  return cls(
190
255
  data=new_data,
191
256
  time_idx=dataset.time_idx,
192
257
  target_series=dataset.target_series,
193
- target_scaler=dataset.target_scaler
258
+ target_scaler=dataset.target_scaler,
259
+ feature_scaler=getattr(dataset, 'feature_scaler', None)
194
260
  )
195
261
 
196
262
  def create_autoencoder_dataset(
@@ -14,10 +14,9 @@ if TYPE_CHECKING:
14
14
 
15
15
  from ..logger import get_logger
16
16
  from ..config import DFMConfig
17
- from ..numeric.tent import get_tent_weights, generate_R_mat
17
+ from ..numeric.tent import generate_R_mat
18
18
  from ..config.constants import (
19
19
  FREQUENCY_HIERARCHY,
20
- TENT_WEIGHTS_LOOKUP,
21
20
  DEFAULT_HIERARCHY_VALUE,
22
21
  DEFAULT_CLOCK_FREQUENCY,
23
22
  DEFAULT_NAN_METHOD,
@@ -368,26 +367,72 @@ class DFMDataset:
368
367
  self.config._update_idio_flags_from_frequencies(frequencies_list)
369
368
  clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, DEFAULT_HIERARCHY_VALUE)
370
369
 
371
- # Validate frequency pairs
372
- missing_pairs = [
373
- (freq, clock) for freq in frequencies_set
374
- if FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE) > clock_hierarchy and get_tent_weights(freq, clock) is None
375
- ]
370
+ # Validate frequency pairs (tent weights must be in config)
371
+ config_tent_weights = getattr(self.config, 'tent_weights', None)
372
+ missing_pairs = []
373
+ for freq in frequencies_set:
374
+ if FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE) > clock_hierarchy:
375
+ # Check if tent weights available in config
376
+ has_config_weights = False
377
+ if config_tent_weights is not None:
378
+ pair_key = f"{freq}:{clock}"
379
+ has_config_weights = (pair_key in config_tent_weights) or (freq in config_tent_weights)
380
+
381
+ if not has_config_weights:
382
+ missing_pairs.append((freq, clock))
383
+
376
384
  if missing_pairs:
377
385
  raise DataValidationError(
378
- f"Mixed-frequency data detected but the following frequency pairs are not in TENT_WEIGHTS_LOOKUP: {missing_pairs}. "
379
- f"Available pairs: {list(TENT_WEIGHTS_LOOKUP.keys())}. "
380
- f"Either add the missing pairs to TENT_WEIGHTS_LOOKUP or ensure all series use clock frequency.",
386
+ f"Mixed-frequency data detected but no tent weights found in config for frequency pairs: {missing_pairs}. "
387
+ f"Please specify tent_weights in config (e.g., tent_weights: {{'m:w': [1, 2, 1]}}). "
388
+ f"Alternatively, ensure all series use clock frequency.",
381
389
  details=f"Frequency pairs without tent weights: {missing_pairs}"
382
390
  )
383
391
 
384
- # Get aggregation structure
392
+ # Get aggregation structure (tent weights must be in config)
385
393
  tent_weights_dict = {}
394
+ config_tent_weights = getattr(self.config, 'tent_weights', None)
395
+
396
+ if config_tent_weights is None:
397
+ slower_freqs = [freq for freq in frequencies_set
398
+ if FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE) > clock_hierarchy]
399
+ if slower_freqs:
400
+ raise DataValidationError(
401
+ f"Mixed-frequency data detected (slower frequencies: {slower_freqs}) but no tent_weights "
402
+ f"specified in config. Please specify tent_weights in config for all frequency pairs.",
403
+ details=f"Slower frequencies: {slower_freqs}, clock: {clock}"
404
+ )
405
+
386
406
  for freq in frequencies_set:
387
407
  if FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE) > clock_hierarchy:
388
- tent_w = get_tent_weights(freq, clock)
408
+ tent_w = None
409
+
410
+ # Get tent weights from config (required)
411
+ if config_tent_weights is not None:
412
+ # Try both formats: 'freq:clock' (e.g., 'm:w') or just 'freq' (e.g., 'm')
413
+ pair_key = f"{freq}:{clock}"
414
+ if pair_key in config_tent_weights:
415
+ tent_w = config_tent_weights[pair_key]
416
+ elif freq in config_tent_weights:
417
+ tent_w = config_tent_weights[freq]
418
+
419
+ # Tent weights must be in config
420
+ if tent_w is None:
421
+ pair_key = f"{freq}:{clock}"
422
+ raise DataValidationError(
423
+ f"Tent weights not found in config for frequency pair ({freq}, {clock}). "
424
+ f"Please specify tent_weights in config, e.g., tent_weights: {{'{pair_key}': [1, 2, 1]}}",
425
+ details=f"Frequency pair: ({freq}, {clock})"
426
+ )
427
+
428
+ # Convert to numpy array if found
389
429
  if tent_w is not None:
390
- tent_weights_dict[freq] = np.array(tent_w, dtype=np.float32)
430
+ if isinstance(tent_w, list):
431
+ tent_weights_dict[freq] = np.array(tent_w, dtype=np.float32)
432
+ elif isinstance(tent_w, np.ndarray):
433
+ tent_weights_dict[freq] = tent_w.astype(np.float32)
434
+ else:
435
+ tent_weights_dict[freq] = np.array(tent_w, dtype=np.float32)
391
436
 
392
437
  # Validate: DFM supports only clock + one slower frequency
393
438
  if len(tent_weights_dict) > 1:
@@ -398,6 +443,19 @@ class DFMDataset:
398
443
  details=f"Slower frequencies detected: {slower_freqs}, clock: {clock}"
399
444
  )
400
445
 
446
+ # Warn if slower frequency detected but no tent weights found
447
+ slower_freqs_detected = [freq for freq in frequencies_set
448
+ if FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE) > clock_hierarchy]
449
+ if slower_freqs_detected and not tent_weights_dict:
450
+ missing_pairs = [(freq, clock) for freq in slower_freqs_detected]
451
+ from ..logger import get_logger
452
+ _logger = get_logger(__name__)
453
+ _logger.warning(
454
+ f"Mixed-frequency data detected but no tent weights found in config for pairs: {missing_pairs}. "
455
+ f"Tent weights must be specified in config. "
456
+ f"This may cause errors in model initialization."
457
+ )
458
+
401
459
  # Generate constraint matrices if needed
402
460
  R_mat = None
403
461
  q = None
@@ -153,26 +153,76 @@ class PCAEncoder(BaseEncoder):
153
153
 
154
154
  # Use SVD for efficient low-rank PCA (NumPy equivalent of torch.pca_lowrank)
155
155
  try:
156
+ # Check for NaN/Inf values before SVD
157
+ if np.any(~np.isfinite(X_centered)):
158
+ raise ValueError("Input contains non-finite values")
159
+
160
+ # Check for zero variance columns (can cause SVD issues)
161
+ col_stds = np.std(X_centered, axis=0)
162
+ if np.any(col_stds < 1e-10):
163
+ _logger.warning(
164
+ f"PCA: Some columns have near-zero variance (min_std={col_stds.min():.2e}). "
165
+ "This may cause SVD convergence issues. Consider removing constant columns."
166
+ )
167
+
156
168
  # Use truncated SVD for efficiency
157
- U, S, Vt = np.linalg.svd(X_centered, full_matrices=False)
169
+ # Use hermitian=False explicitly for clarity
170
+ U, S, Vt = np.linalg.svd(X_centered, full_matrices=False, hermitian=False)
171
+
172
+ # Validate SVD results
173
+ if not np.all(np.isfinite(S)):
174
+ raise ValueError("SVD produced non-finite singular values")
175
+
158
176
  # Take top n_components
159
- U = U[:, :self.n_components]
160
- S = S[:self.n_components]
161
- Vt = Vt[:self.n_components, :]
177
+ n_components_actual = min(self.n_components, len(S))
178
+ if n_components_actual < self.n_components:
179
+ _logger.warning(
180
+ f"PCA: Requested {self.n_components} components but only {len(S)} available. "
181
+ f"Using {n_components_actual} components."
182
+ )
183
+
184
+ U = U[:, :n_components_actual]
185
+ S = S[:n_components_actual]
186
+ Vt = Vt[:n_components_actual, :]
162
187
 
163
188
  # Vt contains the principal components (eigenvectors) as rows
164
189
  # Transpose to get (N x n_components)
165
190
  self.eigenvectors = Vt.T
166
191
  # Convert singular values to eigenvalues
167
192
  self.eigenvalues = S ** 2
193
+
168
194
  except (ValueError, np.linalg.LinAlgError) as e:
169
195
  # Fallback: compute covariance and use eigendecomposition
170
- _logger.warning(f"PCA SVD failed, falling back to eigendecomposition. Error: {type(e).__name__}")
171
- T = X_centered.shape[0]
172
- self.cov_matrix = (X_centered.T @ X_centered) / (T - 1)
173
- self.eigenvalues, self.eigenvectors = compute_principal_components(
174
- self.cov_matrix, self.n_components, block_idx=self.block_idx
196
+ error_msg = str(e) if str(e) else type(e).__name__
197
+ _logger.warning(
198
+ f"PCA SVD failed ({error_msg}), falling back to eigendecomposition. "
199
+ "This may be due to numerical instability or ill-conditioned data."
175
200
  )
201
+
202
+ # Clean data for covariance computation
203
+ X_centered_clean = np.nan_to_num(X_centered, nan=0.0, posinf=0.0, neginf=0.0)
204
+ T = X_centered_clean.shape[0]
205
+
206
+ # Compute covariance with regularization for stability
207
+ cov_raw = (X_centered_clean.T @ X_centered_clean) / max(T - 1, 1)
208
+
209
+ # Add small regularization to diagonal for numerical stability
210
+ reg = 1e-8 * np.eye(cov_raw.shape[0], dtype=cov_raw.dtype)
211
+ self.cov_matrix = cov_raw + reg
212
+
213
+ try:
214
+ self.eigenvalues, self.eigenvectors = compute_principal_components(
215
+ self.cov_matrix, self.n_components, block_idx=self.block_idx
216
+ )
217
+ except (ValueError, np.linalg.LinAlgError) as e2:
218
+ # If eigendecomposition also fails, raise with helpful message
219
+ raise RuntimeError(
220
+ f"PCA failed: Both SVD and eigendecomposition failed. "
221
+ f"Original error: {error_msg}, Eigendecomposition error: {str(e2)}. "
222
+ "This may indicate severely ill-conditioned data. "
223
+ "Consider: (1) Removing constant/near-constant columns, "
224
+ "(2) Checking for outliers, (3) Using fewer components."
225
+ ) from e2
176
226
 
177
227
  return self
178
228
 
@@ -8,8 +8,8 @@ This module provides functions for initializing DFM blocks, including:
8
8
  """
9
9
 
10
10
  import numpy as np
11
- from typing import Optional, Tuple, Dict, Any, List
12
- from ..numeric.tent import get_tent_weights, generate_tent_weights
11
+ from typing import Optional, Tuple, Dict, Any, List, Callable
12
+ from ..numeric.tent import generate_tent_weights
13
13
  from ..numeric.stability import create_scaled_identity
14
14
  from ..logger import get_logger
15
15
  from ..config.constants import (
@@ -176,7 +176,8 @@ def initialize_block_loadings(
176
176
  N: int,
177
177
  max_lag_size: int,
178
178
  matrix_regularization: Optional[float] = None,
179
- dtype: type = np.float32
179
+ dtype: type = np.float32,
180
+ impute_func: Optional[callable] = None
180
181
  ) -> Tuple[np.ndarray, np.ndarray]:
181
182
  """Initialize loadings for a block (clock frequency PCA + slower frequency constrained OLS).
182
183
 
@@ -210,6 +211,9 @@ def initialize_block_loadings(
210
211
  Regularization for matrix operations
211
212
  dtype : type, default np.float32
212
213
  Data type
214
+ impute_func : Optional[Callable[[np.ndarray], np.ndarray]], optional
215
+ Function to impute NaN values for initialization fallback.
216
+ Only used when insufficient non-NaN observations for regression.
213
217
 
214
218
  Returns
215
219
  -------
@@ -310,10 +314,17 @@ def initialize_block_loadings(
310
314
  series_data = data_with_nans[tent_kernel_size:, series_idx_int]
311
315
  non_nan_mask = ~np.isnan(series_data)
312
316
 
313
- # Use clean data if insufficient non-NaN values
317
+ # Use imputed data if insufficient non-NaN values (following FRBNY pattern)
314
318
  min_required = slower_freq_factors.shape[1] + 2
315
319
  if np.sum(non_nan_mask) < min_required:
316
- series_data = data_for_extraction[tent_kernel_size:, series_idx_int]
320
+ # Try data_for_extraction first (may already be imputed)
321
+ series_data_attempt = data_for_extraction[tent_kernel_size:, series_idx_int]
322
+ if np.sum(~np.isnan(series_data_attempt)) < min_required and impute_func is not None:
323
+ # Fallback: use imputation function (only for initialization)
324
+ series_data = impute_func(series_data)
325
+ _logger.debug(f" Using imputed data for series {series_idx_int} (insufficient observations)")
326
+ else:
327
+ series_data = series_data_attempt
317
328
  non_nan_mask = np.ones(len(series_data), dtype=bool)
318
329
 
319
330
  slower_freq_factors_clean = slower_freq_factors[tent_kernel_size:][non_nan_mask, :]
@@ -341,6 +352,10 @@ def initialize_block_loadings(
341
352
  regularization=reg,
342
353
  dtype=dtype
343
354
  )
355
+ # Validate loadings are finite
356
+ if np.any(~np.isfinite(loadings_constrained)):
357
+ _logger.warning(f"Computed loadings contain non-finite values for series {series_idx_int}. Skipping.")
358
+ continue
344
359
  C_i[series_idx_int, :num_factors * tent_kernel_size] = loadings_constrained
345
360
  except (np.linalg.LinAlgError, ValueError) as e:
346
361
  _logger.warning(f"Failed to compute constrained loadings for series {series_idx_int}: {e}. Skipping.")