dfm-python 0.5.60__py3-none-any.whl → 0.6.0__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.
@@ -508,18 +508,16 @@ def make_config_source(
508
508
  # ============================================================================
509
509
 
510
510
  # Model type constants (exported for use in schema/model.py)
511
- MODEL_TYPE_KDFM = 'kdfm'
511
+ # NOTE: KDFM is legacy and has been removed from this codebase.
512
512
  MODEL_TYPE_DDFM = 'ddfm'
513
513
  MODEL_TYPE_DFM = 'dfm'
514
514
 
515
515
  # Model type detection patterns
516
- _KDFM_TYPE_ALIASES = {'kdfm', 'kernelized'}
517
516
  _DDFM_TYPE_ALIASES = {'ddfm', 'deep'}
518
- _KDFM_PARAMS = {'ar_order', 'ma_order', 'structural_method', 'structural_reg_weight'}
519
517
  _DDFM_PARAMS = {'encoder_layers', 'epochs', 'learning_rate', 'batch_size'}
520
518
 
521
519
  def detect_config_type(data: Dict[str, Any]) -> str:
522
- """Detect config type (DFM, DDFM, or KDFM) from data dictionary.
520
+ """Detect config type (DFM or DDFM) from data dictionary.
523
521
 
524
522
  Parameters
525
523
  ----------
@@ -529,7 +527,7 @@ def detect_config_type(data: Dict[str, Any]) -> str:
529
527
  Returns
530
528
  -------
531
529
  str
532
- 'kdfm', 'ddfm', or 'dfm'
530
+ 'ddfm' or 'dfm'
533
531
 
534
532
  Raises
535
533
  ------
@@ -544,8 +542,6 @@ def detect_config_type(data: Dict[str, Any]) -> str:
544
542
  model_type = data.get('model_type', '').lower()
545
543
 
546
544
  # Check explicit model type
547
- if model_type in _KDFM_TYPE_ALIASES:
548
- return MODEL_TYPE_KDFM
549
545
  if model_type in _DDFM_TYPE_ALIASES:
550
546
  return MODEL_TYPE_DDFM
551
547
  if model_type == MODEL_TYPE_DFM:
@@ -558,8 +554,6 @@ def detect_config_type(data: Dict[str, Any]) -> str:
558
554
 
559
555
  # Check for model-specific parameters
560
556
  keys = set(data.keys())
561
- if keys & _KDFM_PARAMS:
562
- return MODEL_TYPE_KDFM
563
557
  if any(k.startswith('ddfm_') for k in keys) or (keys & _DDFM_PARAMS):
564
558
  return MODEL_TYPE_DDFM
565
559
 
@@ -35,6 +35,29 @@ DEFAULT_VARIANCE_FALLBACK = 1.0 # Default variance fallback value for numerical
35
35
  # Maximum eigenvalues
36
36
  MAX_EIGENVALUE = 1e3 # Maximum eigenvalue cap (reduced to 1e3 to improve condition number: 1e3/1e-6 = 1e9)
37
37
 
38
+ # ============================================================================
39
+ # Performance and Numerical Health Guardrails
40
+ # ============================================================================
41
+
42
+ # Time budgets (hard limits to prevent pathological runs)
43
+ # Single constraint: 10 minutes for all datasets
44
+ MAX_ESTEP_SECONDS = 600.0 # Maximum E-step time (Kalman filter + smoother) - 10 minutes
45
+ MAX_MSTEP_SECONDS = 600.0 # Maximum M-step time (parameter updates) - 10 minutes
46
+ MAX_EM_ITERATION_SECONDS = 1200.0 # Maximum total EM iteration time - 20 minutes
47
+ MAX_FIRST_ITERATION_SECONDS = 600.0 # Maximum first iteration E-step time (diagnostic) - 10 minutes
48
+
49
+ # Numerical health thresholds (fail fast on pathological regimes)
50
+ # Note: Condition number check is more lenient during initialization (first iteration)
51
+ MAX_CONDITION_NUMBER_SMOOTHER = 1e8 # Maximum condition number before refusing to smooth (increased from 1e7)
52
+ MAX_CONDITION_NUMBER_INIT = 1e10 # Maximum condition number during initialization (more lenient)
53
+ MAX_STABILIZATION_AMOUNT = 1e-3 # Maximum stabilization amount before error (increased from 1e-4 to 0.1% diagonal loading)
54
+ MAX_STABILIZATION_AMOUNT_INIT = 5e-3 # Maximum stabilization during initialization (more lenient)
55
+ MAX_STD_UNSCALED = 100.0 # Maximum standard deviation for unscaled data (indicates missing preprocessing)
56
+ MIN_STD_SCALED = 1e-4 # Minimum standard deviation for scaled data (indicates degenerate series)
57
+
58
+ # Fallback tracking
59
+ MAX_FALLBACKS_PER_ITERATION = 0 # Maximum allowed fallbacks per iteration (0 = fail on any fallback)
60
+
38
61
  # Eigenvalue stability thresholds
39
62
  DEFAULT_EIGENVALUE_MAX_MAGNITUDE = 1.0 # Default maximum eigenvalue magnitude for stability checks
40
63
  DEFAULT_EIGENVALUE_WARN_THRESHOLD = 0.99 # Default warning threshold for near-unstable eigenvalues
@@ -566,5 +589,17 @@ __all__ = [
566
589
  # Frequency hierarchy and tent kernels
567
590
  'FREQUENCY_HIERARCHY',
568
591
  'MAX_TENT_SIZE',
592
+ # Performance and numerical health guardrails
593
+ 'MAX_ESTEP_SECONDS',
594
+ 'MAX_MSTEP_SECONDS',
595
+ 'MAX_EM_ITERATION_SECONDS',
596
+ 'MAX_FIRST_ITERATION_SECONDS',
597
+ 'MAX_CONDITION_NUMBER_SMOOTHER',
598
+ 'MAX_CONDITION_NUMBER_INIT',
599
+ 'MAX_STABILIZATION_AMOUNT',
600
+ 'MAX_STABILIZATION_AMOUNT_INIT',
601
+ 'MAX_STD_UNSCALED',
602
+ 'MIN_STD_SCALED',
603
+ 'MAX_FALLBACKS_PER_ITERATION',
569
604
  ]
570
605
 
@@ -418,16 +418,32 @@ class DFMConfig(BaseModelConfig):
418
418
  series_ids = list(self.frequency.keys()) if columns is None else columns
419
419
 
420
420
  # Build blocks array from block series lists (N x B matrix)
421
- block_series_sets = {
422
- name: set(self.blocks[name].get('series', []))
423
- for name in self.block_names
424
- }
421
+ # Default global block behavior:
422
+ # If config omitted blocks.series (empty list) and we have runtime columns,
423
+ # interpret a single empty Block_Global as "all series in this block".
424
+ block_series_sets: Dict[str, set] = {}
425
+ default_global_applied = False
426
+ for name in self.block_names:
427
+ series_list = self.blocks[name].get('series', [])
428
+ if (
429
+ columns is not None
430
+ and (series_list is None or len(series_list) == 0)
431
+ and len(self.block_names) == 1
432
+ and name == "Block_Global"
433
+ ):
434
+ block_series_sets[name] = set(series_ids)
435
+ default_global_applied = True
436
+ else:
437
+ block_series_sets[name] = set(series_list or [])
438
+
425
439
  blocks_list = [
426
440
  [1 if series_id in block_series_sets[name] else 0 for name in self.block_names]
427
441
  for series_id in series_ids
428
442
  ]
429
443
 
430
444
  self._cached_blocks = np.array(blocks_list, dtype=int)
445
+
446
+ # Quick sanity on membership counts for the first block (if any)
431
447
  return self._cached_blocks
432
448
 
433
449
  @classmethod
@@ -463,14 +479,14 @@ class DFMConfig(BaseModelConfig):
463
479
  return result
464
480
 
465
481
  @classmethod
466
- def from_dict(cls, data: Dict[str, Any]) -> Union['DFMConfig', 'DDFMConfig', 'KDFMConfig']:
467
- """Create DFMConfig, DDFMConfig, or KDFMConfig from dictionary.
482
+ def from_dict(cls, data: Dict[str, Any]) -> Union['DFMConfig', 'DDFMConfig']:
483
+ """Create DFMConfig or DDFMConfig from dictionary.
468
484
 
469
485
  Expected format: {'frequency': {'column_name': 'frequency'}, 'blocks': {...}, ...}
470
486
 
471
487
  Also accepts estimation parameters: threshold, max_iter, etc.
472
488
  """
473
- from ...config.adapter import detect_config_type, MODEL_TYPE_DDFM, MODEL_TYPE_KDFM, _normalize_blocks_dict
489
+ from ...config.adapter import detect_config_type, MODEL_TYPE_DDFM, _normalize_blocks_dict
474
490
  from ...utils.errors import ConfigurationError
475
491
 
476
492
  # Extract base params (handles frequency conversion from series if needed)
@@ -482,14 +498,24 @@ class DFMConfig(BaseModelConfig):
482
498
  if config_type == MODEL_TYPE_DDFM:
483
499
  return DDFMConfig(**base_params, **DDFMConfig._extract_ddfm(data))
484
500
 
485
- if config_type == MODEL_TYPE_KDFM:
486
- return KDFMConfig(**base_params, **KDFMConfig._extract_kdfm(data))
487
-
488
501
  # Handle blocks for DFM
489
502
  from ...config.adapter import _raise_config_error, _is_dict_like
490
503
  blocks_dict = data.get('blocks', {})
491
504
  if not blocks_dict:
492
- _raise_config_error("blocks dict is required for DFM config")
505
+ # Default behavior: single global block.
506
+ # This enables minimal configs like:
507
+ # clock: d
508
+ # num_factors: 2
509
+ # and lets the model bind series names from the dataset at runtime.
510
+ default_num_factors = int(data.get('num_factors', 1) or 1)
511
+ if default_num_factors < 1:
512
+ _raise_config_error(f"num_factors must be >= 1, got {default_num_factors}")
513
+ blocks_dict = {
514
+ "Block_Global": {
515
+ "num_factors": default_num_factors,
516
+ "series": [], # to be bound from dataset columns
517
+ }
518
+ }
493
519
  if not _is_dict_like(blocks_dict):
494
520
  _raise_config_error(f"blocks must be a dict, got {type(blocks_dict)}")
495
521
 
@@ -600,72 +626,6 @@ class DDFMConfig(BaseModelConfig):
600
626
  )
601
627
 
602
628
 
603
- @dataclass
604
- class KDFMConfig(BaseModelConfig):
605
- """KDFM configuration dataclass.
606
-
607
- This dataclass contains all configuration parameters for the KDFM model.
608
- It inherits from BaseModelConfig and adds KDFM-specific parameters.
609
-
610
- Note: KDFM does not use blocks structure (unlike DFM). Only frequency dict is needed.
611
- """
612
- # VARMA parameters
613
- ar_order: int = DEFAULT_KDFM_AR_ORDER # VAR order p
614
- ma_order: int = DEFAULT_KDFM_MA_ORDER # MA order q (0 = pure VAR)
615
-
616
- # Structural identification
617
- structural_method: str = 'cholesky' # 'cholesky', 'full', 'low_rank'
618
- structural_rank: Optional[int] = None # For low-rank parameterization
619
-
620
- # Training parameters (use constants for defaults)
621
- learning_rate: float = DEFAULT_LEARNING_RATE
622
- max_epochs: int = DEFAULT_MAX_EPOCHS
623
- batch_size: int = DEFAULT_BATCH_SIZE
624
- weight_decay: float = DEFAULT_REGULARIZATION_SCALE
625
- grad_clip_val: float = DEFAULT_GRAD_CLIP_VAL
626
-
627
- # Regularization
628
- structural_reg_weight: float = DEFAULT_STRUCTURAL_REG_WEIGHT # Weight for structural loss
629
- use_regularization: bool = True
630
- regularization_scale: float = DEFAULT_REGULARIZATION_SCALE
631
-
632
- # ========================================================================
633
- # Factory Methods
634
- # ========================================================================
635
-
636
- @classmethod
637
- def _extract_kdfm(cls, data: Dict[str, Any]) -> Dict[str, Any]:
638
- """Extract KDFM-specific parameters from config dict."""
639
- # Don't extract base params here - they're already in base_params from from_dict
640
- kdfm_params = cls._extract_params(data, {
641
- 'ar_order': DEFAULT_KDFM_AR_ORDER,
642
- 'ma_order': DEFAULT_KDFM_MA_ORDER,
643
- 'structural_method': 'cholesky',
644
- 'structural_rank': None,
645
- 'learning_rate': DEFAULT_LEARNING_RATE,
646
- 'max_epochs': DEFAULT_MAX_EPOCHS,
647
- 'batch_size': DEFAULT_BATCH_SIZE,
648
- 'weight_decay': DEFAULT_REGULARIZATION_SCALE,
649
- 'grad_clip_val': DEFAULT_GRAD_CLIP_VAL,
650
- 'structural_reg_weight': DEFAULT_STRUCTURAL_REG_WEIGHT,
651
- 'use_regularization': True,
652
- 'regularization_scale': DEFAULT_REGULARIZATION_SCALE,
653
- })
654
- return kdfm_params
655
-
656
- @classmethod
657
- def from_dict(cls, data: Dict[str, Any]) -> 'KDFMConfig':
658
- """Create KDFMConfig from dictionary (delegates to DFMConfig.from_dict for type detection)."""
659
- result = DFMConfig.from_dict(data)
660
- if isinstance(result, KDFMConfig):
661
- return result
662
- from ..utils.errors import ConfigurationError
663
- raise ConfigurationError(
664
- f"Expected KDFMConfig but got {type(result).__name__}",
665
- details=f"Result type: {type(result).__name__}, expected: KDFMConfig"
666
- )
667
-
668
-
669
629
  # ============================================================================
670
630
  # Validation Functions
671
631
  # ============================================================================
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
22
22
 
23
23
  from ..logger import get_logger
24
24
  from ..config import DFMConfig
25
+ from ..config.constants import FREQUENCY_HIERARCHY, DEFAULT_HIERARCHY_VALUE
25
26
  from ..dataset.time import TimeIndex
26
27
  from ..utils.errors import DataValidationError, ConfigurationError
27
28
 
@@ -139,9 +140,13 @@ class DFMDataset:
139
140
 
140
141
  # Get all available columns (numeric only)
141
142
  numeric_columns = list(X_df.select_dtypes(include=[np.number]).columns)
143
+
144
+ # Reorder columns for mixed-frequency models: clock-frequency first, then slower-frequency
145
+ numeric_columns = self._reorder_columns_for_mixed_freq(numeric_columns, X_df)
146
+
142
147
  self._columns = numeric_columns
143
148
 
144
- # Store DataFrame with time index (numeric columns only)
149
+ # Store DataFrame with time index (numeric columns only, properly ordered)
145
150
  self.data_df = X_df[numeric_columns]
146
151
 
147
152
  @property
@@ -154,6 +159,88 @@ class DFMDataset:
154
159
  """Get time index."""
155
160
  return self._time_index
156
161
 
162
+ def _reorder_columns_for_mixed_freq(
163
+ self,
164
+ columns: List[str],
165
+ data_df: pd.DataFrame
166
+ ) -> List[str]:
167
+ """Reorder columns for mixed-frequency models.
168
+
169
+ Ensures columns are ordered as [clock-frequency series | slower-frequency series]
170
+ as required by the mixed-frequency model implementation.
171
+
172
+ Parameters
173
+ ----------
174
+ columns : List[str]
175
+ Current column list
176
+ data_df : pd.DataFrame
177
+ DataFrame with data (for validation)
178
+
179
+ Returns
180
+ -------
181
+ List[str]
182
+ Reordered column list (clock-frequency first, then slower-frequency)
183
+ """
184
+ # Check if config has frequency mapping
185
+ if not self.config.frequency:
186
+ return columns # No frequency mapping, return as-is
187
+
188
+ # Get clock frequency
189
+ clock = self.config.clock
190
+
191
+ # Build frequency dict from config
192
+ # Handle both grouped format {'w': [series1, ...], 'm': [series2, ...]}
193
+ # and individual format {'series1': 'w', 'series2': 'm', ...}
194
+ frequency_dict = {}
195
+ if isinstance(self.config.frequency, dict):
196
+ for key, value in self.config.frequency.items():
197
+ if isinstance(value, list):
198
+ # Grouped format: {'w': [series1, ...], 'm': [series2, ...]}
199
+ for series in value:
200
+ if series in columns:
201
+ frequency_dict[series] = key
202
+ else:
203
+ # Individual format: {'series1': 'w', 'series2': 'm', ...}
204
+ if key in columns:
205
+ frequency_dict[key] = value
206
+
207
+ # If no frequency mapping found for any columns, return as-is
208
+ if not frequency_dict:
209
+ return columns
210
+
211
+ # Get clock hierarchy value
212
+ clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, DEFAULT_HIERARCHY_VALUE)
213
+
214
+ # Separate columns by frequency
215
+ clock_cols = []
216
+ slower_cols = []
217
+
218
+ for col in columns:
219
+ freq = frequency_dict.get(col)
220
+ if freq is None:
221
+ # Column not in frequency mapping - assume clock frequency
222
+ clock_cols.append(col)
223
+ else:
224
+ freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, DEFAULT_HIERARCHY_VALUE)
225
+ if freq_hierarchy <= clock_hierarchy:
226
+ # Clock frequency or same as clock
227
+ clock_cols.append(col)
228
+ else:
229
+ # Slower frequency
230
+ slower_cols.append(col)
231
+
232
+ # Reorder: clock-frequency first, then slower-frequency
233
+ reordered = clock_cols + slower_cols
234
+
235
+ # Log if reordering occurred
236
+ if reordered != columns:
237
+ _logger.info(
238
+ f"Reordered columns for mixed-frequency model: "
239
+ f"{len(clock_cols)} clock-frequency, {len(slower_cols)} slower-frequency"
240
+ )
241
+
242
+ return reordered
243
+
157
244
  def _validate(self) -> None:
158
245
  """Validate dataset state and data integrity.
159
246
 
dfm_python/encoder/pca.py CHANGED
@@ -56,8 +56,11 @@ def compute_principal_components(
56
56
 
57
57
  n_series = cov_matrix.shape[0]
58
58
 
59
+
60
+
59
61
  try:
60
62
  eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
63
+
61
64
  # Sort by absolute value, descending
62
65
  sort_idx = np.argsort(np.abs(eigenvalues))[::-1][:n_components]
63
66
  eigenvalues_sorted = eigenvalues[sort_idx]