dfm-python 0.2.3__py3-none-any.whl → 0.2.5__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/config.py CHANGED
@@ -1,48 +1,35 @@
1
- """Configuration models and source adapters for DFM nowcasting.
1
+ """Configuration models and factory methods for DFM nowcasting.
2
2
 
3
- This module provides a unified configuration system for Dynamic Factor Models:
3
+ This module provides the core configuration system for Dynamic Factor Models:
4
4
  - Configuration dataclasses (DFMConfig, SeriesConfig, BlockConfig, Params)
5
- - Configuration source adapters (YAML, Dict, Spec CSV, Hydra)
6
- - Factory functions for flexible config loading
5
+ - Factory methods for creating DFMConfig from dictionaries and Hydra configs
7
6
 
8
- The configuration system supports:
9
- - YAML files (with Hydra/OmegaConf support)
10
- - Direct DFMConfig object creation
11
- - Spec CSV files (series definitions)
12
- - Dictionary configurations
13
- - Merging multiple configuration sources
7
+ The configuration dataclasses define:
8
+ - Model structure (series, blocks, factors)
9
+ - Estimation parameters (EM algorithm settings)
10
+ - Numerical stability controls (regularization, clipping, damping)
14
11
 
15
- All adapters return a DFMConfig object, ensuring a consistent interface
16
- regardless of the source format.
12
+ Factory methods support:
13
+ - Dictionary configurations (legacy format, new format, Hydra format)
14
+ - Hydra DictConfig objects (via from_hydra())
15
+
16
+ For loading configurations from files (YAML, Spec CSV) or other sources,
17
+ see the config_sources module which provides source adapters.
18
+
19
+ Note: Source adapter classes (YamlSource, DictSource, etc.) are re-exported
20
+ from config_sources for backward compatibility.
17
21
  """
18
22
 
19
23
  import numpy as np
20
24
  from typing import List, Optional, Dict, Any, Union
21
- from pathlib import Path
22
- import warnings
23
- import logging
24
- from dataclasses import dataclass, field, is_dataclass, asdict
25
+ from dataclasses import dataclass, field
25
26
 
26
27
  try:
27
28
  from typing import Protocol
28
29
  except ImportError:
29
30
  from typing_extensions import Protocol
30
31
 
31
- try:
32
- from hydra.core.config_store import ConfigStore
33
- HYDRA_AVAILABLE = True
34
- except ImportError:
35
- HYDRA_AVAILABLE = False
36
- ConfigStore = None
37
-
38
- # Valid frequency codes
39
- _VALID_FREQUENCIES = {'d', 'w', 'm', 'q', 'sa', 'a'}
40
-
41
- # Valid transformation codes
42
- _VALID_TRANSFORMATIONS = {
43
- 'lin', 'chg', 'ch1', 'pch', 'pc1', 'pca',
44
- 'cch', 'cca', 'log'
45
- }
32
+ # Validation functions are defined at the end of this file
46
33
 
47
34
  # Default global block name (can be overridden in config)
48
35
  DEFAULT_GLOBAL_BLOCK_NAME = 'Block_Global'
@@ -61,20 +48,6 @@ _TRANSFORM_UNITS_MAP = {
61
48
  }
62
49
 
63
50
 
64
- def validate_frequency(frequency: str) -> str:
65
- """Validate frequency code."""
66
- if frequency not in _VALID_FREQUENCIES:
67
- raise ValueError(f"Invalid frequency: {frequency}. Must be one of {_VALID_FREQUENCIES}")
68
- return frequency
69
-
70
-
71
- def validate_transformation(transformation: str) -> str:
72
- """Validate transformation code."""
73
- if transformation not in _VALID_TRANSFORMATIONS:
74
- warnings.warn(f"Unknown transformation code: {transformation}. Will use untransformed data.")
75
- return transformation
76
-
77
-
78
51
  @dataclass
79
52
  class BlockConfig:
80
53
  """Configuration for a single factor block.
@@ -224,9 +197,12 @@ class Params:
224
197
  max_iter : int
225
198
  Maximum EM iterations (default: 5000)
226
199
  nan_method : int
227
- Missing data handling method (1-5, default: 2 = spline interpolation)
200
+ Missing data handling method (1-5, default: 2 = spline interpolation).
201
+ This preprocessing step is followed by Kalman Filter-based missing data
202
+ handling during DFM estimation, following standard practice in state-space
203
+ models (Mariano & Murasawa 2003, Harvey 1989).
228
204
  nan_k : int
229
- Spline parameter for NaN interpolation (default: 3)
205
+ Spline parameter for NaN interpolation (default: 3 for cubic spline)
230
206
  clock : str
231
207
  Base frequency for all latent factors (default: 'm' for monthly)
232
208
  clip_ar_coefficients : bool
@@ -259,6 +235,14 @@ class Params:
259
235
  Damping factor: 0.8 = 80% new, 20% old (default: 0.8)
260
236
  warn_on_damped_update : bool
261
237
  Warn when damped updates are used (default: True)
238
+ augment_idio : bool
239
+ Enable state augmentation with idiosyncratic components (default: True)
240
+ augment_idio_slow : bool
241
+ Enable tent-length chains for slower-frequency series (default: True)
242
+ idio_rho0 : float
243
+ Initial AR coefficient for idiosyncratic components (default: 0.1)
244
+ idio_min_var : float
245
+ Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
262
246
 
263
247
  Examples
264
248
  --------
@@ -297,6 +281,12 @@ class Params:
297
281
  damping_factor: float = 0.8
298
282
  warn_on_damped_update: bool = True
299
283
 
284
+ # Idiosyncratic component augmentation
285
+ augment_idio: bool = True # Enable state augmentation with idiosyncratic components (default: True)
286
+ augment_idio_slow: bool = True # Enable tent-length chains for slower-frequency series (default: True)
287
+ idio_rho0: float = 0.1 # Initial AR coefficient for idiosyncratic components (default: 0.1)
288
+ idio_min_var: float = 1e-8 # Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
289
+
300
290
  def __post_init__(self):
301
291
  """Validate parameters."""
302
292
  self.clock = validate_frequency(self.clock)
@@ -340,8 +330,8 @@ class DFMConfig:
340
330
  ar_lag: int = 1 # Number of lags in AR transition equation (lookback window)
341
331
  threshold: float = 1e-5 # EM convergence threshold
342
332
  max_iter: int = 5000 # Maximum EM iterations
343
- nan_method: int = 2 # Missing data handling method (1-5)
344
- nan_k: int = 3 # Spline parameter for NaN interpolation
333
+ nan_method: int = 2 # Missing data handling method (1-5). Preprocessing step before Kalman Filter-based handling
334
+ nan_k: int = 3 # Spline parameter for NaN interpolation (cubic spline)
345
335
  clock: str = 'm' # Base frequency for nowcasting (global clock): 'd', 'w', 'm', 'q', 'sa', 'a' (defaults to 'm' for monthly)
346
336
 
347
337
  # ========================================================================
@@ -370,6 +360,12 @@ class DFMConfig:
370
360
  damping_factor: float = 0.8 # Damping factor (0.8 = 80% new, 20% old)
371
361
  warn_on_damped_update: bool = True # Warn when damped updates are used
372
362
 
363
+ # Idiosyncratic Component Augmentation
364
+ augment_idio: bool = True # Enable state augmentation with idiosyncratic components (default: True)
365
+ augment_idio_slow: bool = True # Enable tent-length chains for slower-frequency series (default: True)
366
+ idio_rho0: float = 0.1 # Initial AR coefficient for idiosyncratic components (default: 0.1)
367
+ idio_min_var: float = 1e-8 # Minimum variance for idiosyncratic innovation covariance (default: 1e-8)
368
+
373
369
  # ========================================================================
374
370
  # Internal cache (not user-configurable)
375
371
  # ========================================================================
@@ -394,7 +390,7 @@ class DFMConfig:
394
390
  indicating what needs to be fixed.
395
391
  """
396
392
  # Import frequency hierarchy for validation
397
- from .utils.aggregation import FREQUENCY_HIERARCHY
393
+ from .utils import FREQUENCY_HIERARCHY
398
394
 
399
395
  if not self.series:
400
396
  raise ValueError(
@@ -443,7 +439,9 @@ class DFMConfig:
443
439
  if block_clock_hierarchy < global_clock_hierarchy:
444
440
  raise ValueError(
445
441
  f"Block '{block_name}' has clock '{block_cfg.clock}' which is faster than "
446
- f"global clock '{self.clock}'. Block clocks must be >= global clock."
442
+ f"global clock '{self.clock}'. Block clocks must be >= global clock. "
443
+ f"Suggested fix: change block '{block_name}' clock to '{self.clock}' or slower, "
444
+ f"or set global clock to '{block_cfg.clock}' or faster."
447
445
  )
448
446
 
449
447
  # Auto-generate series_id if not provided and convert blocks to indices
@@ -493,11 +491,17 @@ class DFMConfig:
493
491
 
494
492
  # Series frequency must be <= block clock (slower or equal)
495
493
  if series_freq_hierarchy < block_clock_hierarchy:
494
+ # Suggest valid frequencies for the series
495
+ valid_freqs = [freq for freq, hier in FREQUENCY_HIERARCHY.items()
496
+ if hier >= block_clock_hierarchy]
497
+ valid_freqs_str = ', '.join(sorted(valid_freqs))
496
498
  raise ValueError(
497
499
  f"Series '{s.series_id}' has frequency '{s.frequency}' which is faster than "
498
500
  f"block '{block_name}' clock '{block_cfg.clock}'. "
499
501
  f"Series in a block must have frequency <= block clock. "
500
- f"Either change the series frequency or move it to a faster block."
502
+ f"Suggested fix: change series frequency to one of [{valid_freqs_str}] "
503
+ f"(slower or equal to block clock '{block_cfg.clock}'), "
504
+ f"or set block clock to '{s.frequency}' or faster."
501
505
  )
502
506
 
503
507
  # Validate factors_per_block
@@ -535,6 +539,106 @@ class DFMConfig:
535
539
  self._cached_blocks = np.array(blocks_list, dtype=int)
536
540
  return self._cached_blocks
537
541
 
542
+ def validate_and_report(self) -> Dict[str, Any]:
543
+ """Validate configuration and return structured report with issues and suggestions.
544
+
545
+ This method performs validation checks without raising exceptions, returning
546
+ a structured report that can be used for debugging and user guidance.
547
+
548
+ Returns
549
+ -------
550
+ Dict[str, Any]
551
+ Report dictionary with keys:
552
+ - 'valid': bool - Whether configuration is valid
553
+ - 'errors': List[str] - List of error messages
554
+ - 'warnings': List[str] - List of warning messages
555
+ - 'suggestions': List[str] - List of actionable suggestions
556
+
557
+ Examples
558
+ --------
559
+ >>> config = DFMConfig(...)
560
+ >>> report = config.validate_and_report()
561
+ >>> if not report['valid']:
562
+ ... print("Errors:", report['errors'])
563
+ ... print("Suggestions:", report['suggestions'])
564
+ """
565
+ from .utils import FREQUENCY_HIERARCHY
566
+
567
+ report = {
568
+ 'valid': True,
569
+ 'errors': [],
570
+ 'warnings': [],
571
+ 'suggestions': []
572
+ }
573
+
574
+ # Check for empty series
575
+ if not self.series:
576
+ report['valid'] = False
577
+ report['errors'].append("DFM configuration must contain at least one series.")
578
+ report['suggestions'].append("Add series definitions to your configuration.")
579
+ return report
580
+
581
+ # Check for empty blocks
582
+ if not self.blocks:
583
+ report['valid'] = False
584
+ report['errors'].append("DFM configuration must contain at least one block.")
585
+ report['suggestions'].append("Add block definitions to your configuration.")
586
+ return report
587
+
588
+ # Check frequency constraints
589
+ global_clock_hierarchy = FREQUENCY_HIERARCHY.get(self.clock, 3)
590
+ for i, s in enumerate(self.series):
591
+ series_freq_hierarchy = FREQUENCY_HIERARCHY.get(s.frequency, 3)
592
+
593
+ for block_idx, loads_on_block in enumerate(s.blocks):
594
+ if loads_on_block == 1:
595
+ if block_idx < len(self.block_names):
596
+ block_name = self.block_names[block_idx]
597
+ block_cfg = self.blocks[block_name]
598
+ block_clock_hierarchy = FREQUENCY_HIERARCHY.get(block_cfg.clock, 3)
599
+
600
+ if series_freq_hierarchy < block_clock_hierarchy:
601
+ valid_freqs = [freq for freq, hier in FREQUENCY_HIERARCHY.items()
602
+ if hier >= block_clock_hierarchy]
603
+ valid_freqs_str = ', '.join(sorted(valid_freqs))
604
+ report['valid'] = False
605
+ report['errors'].append(
606
+ f"Series '{s.series_id}' has frequency '{s.frequency}' which is faster than "
607
+ f"block '{block_name}' clock '{block_cfg.clock}'."
608
+ )
609
+ report['suggestions'].append(
610
+ f"For series '{s.series_id}': change frequency to one of [{valid_freqs_str}], "
611
+ f"or set block '{block_name}' clock to '{s.frequency}' or faster."
612
+ )
613
+
614
+ # Check block clock constraints
615
+ for block_name, block_cfg in self.blocks.items():
616
+ block_clock_hierarchy = FREQUENCY_HIERARCHY.get(block_cfg.clock, 3)
617
+ if block_clock_hierarchy < global_clock_hierarchy:
618
+ report['valid'] = False
619
+ report['errors'].append(
620
+ f"Block '{block_name}' has clock '{block_cfg.clock}' which is faster than "
621
+ f"global clock '{self.clock}'."
622
+ )
623
+ report['suggestions'].append(
624
+ f"Change block '{block_name}' clock to '{self.clock}' or slower, "
625
+ f"or set global clock to '{block_cfg.clock}' or faster."
626
+ )
627
+
628
+ # Check factors_per_block
629
+ if any(f < 1 for f in self.factors_per_block):
630
+ invalid_blocks = [i for i, f in enumerate(self.factors_per_block) if f < 1]
631
+ report['valid'] = False
632
+ report['errors'].append(
633
+ f"factors_per_block must contain positive integers (>= 1). "
634
+ f"Invalid values found at block indices {invalid_blocks}."
635
+ )
636
+ report['suggestions'].append(
637
+ f"Set factors_per_block[{invalid_blocks[0]}] to at least 1 for block '{self.block_names[invalid_blocks[0]]}'."
638
+ )
639
+
640
+ return report
641
+
538
642
  # Note: Legacy PascalCase properties were removed to keep the API clean and generic.
539
643
  # Use the snake_case helper methods above.
540
644
 
@@ -567,7 +671,12 @@ class DFMConfig:
567
671
  'warn_on_regularization': data.get('warn_on_regularization', True),
568
672
  'use_damped_updates': data.get('use_damped_updates', True),
569
673
  'damping_factor': data.get('damping_factor', 0.8),
570
- 'warn_on_damped_update': data.get('warn_on_damped_update', True)
674
+ 'warn_on_damped_update': data.get('warn_on_damped_update', True),
675
+ # Idiosyncratic component augmentation
676
+ 'augment_idio': data.get('augment_idio', True),
677
+ 'augment_idio_slow': data.get('augment_idio_slow', True),
678
+ 'idio_rho0': data.get('idio_rho0', 0.1),
679
+ 'idio_min_var': data.get('idio_min_var', 1e-8)
571
680
  }
572
681
 
573
682
  @classmethod
@@ -825,536 +934,104 @@ class DFMConfig:
825
934
  return cls.from_dict(cfg)
826
935
 
827
936
 
828
- # Register with Hydra ConfigStore (optional - only if Hydra is available)
829
- if HYDRA_AVAILABLE and ConfigStore is not None:
830
- try:
831
- cs = ConfigStore.instance()
832
- if cs is not None:
833
- from dataclasses import dataclass as schema_dataclass
834
-
835
- @schema_dataclass
836
- class SeriesConfigSchema:
837
- """Schema for SeriesConfig validation in Hydra."""
838
- series_id: str
839
- series_name: str
840
- frequency: str
841
- units: str
842
- transformation: str
843
- category: str
844
- blocks: List[int]
845
-
846
- @schema_dataclass
847
- class DFMConfigSchema:
848
- """Schema for unified DFMConfig validation in Hydra."""
849
- series: List[SeriesConfigSchema]
850
- block_names: List[str]
851
- factors_per_block: Optional[List[int]] = None
852
- ar_lag: int = 1
853
- threshold: float = 1e-5
854
- max_iter: int = 5000
855
- nan_method: int = 2
856
- nan_k: int = 3
857
- clock: str = 'm'
858
-
859
- # Register schemas
860
- cs.store(group="dfm", name="base_dfm_config", node=DFMConfigSchema)
861
- cs.store(group="model", name="base_model_config", node=DFMConfigSchema)
862
- cs.store(name="dfm_config_schema", node=DFMConfigSchema)
863
- cs.store(name="model_config_schema", node=DFMConfigSchema)
864
-
865
- except Exception as e:
866
- warnings.warn(f"Could not register Hydra structured config schemas: {e}. "
867
- f"Configs will still work via from_dict() but without schema validation.")
868
-
869
-
870
937
  # ============================================================================
871
938
  # Configuration Source Adapters
872
939
  # ============================================================================
873
940
 
874
- logger = logging.getLogger(__name__)
941
+ # Import ConfigSource classes from separate module
942
+ from .config_sources import (
943
+ ConfigSource,
944
+ YamlSource,
945
+ DictSource,
946
+ SpecCSVSource,
947
+ HydraSource,
948
+ MergedConfigSource,
949
+ make_config_source,
950
+ )
875
951
 
952
+ # Re-export for backward compatibility
953
+ __all__ = [
954
+ 'DFMConfig', 'SeriesConfig', 'BlockConfig', 'Params',
955
+ 'DEFAULT_GLOBAL_BLOCK_NAME',
956
+ 'ConfigSource', 'YamlSource', 'DictSource', 'SpecCSVSource',
957
+ 'HydraSource', 'MergedConfigSource', 'make_config_source',
958
+ ]
876
959
 
877
- class ConfigSource(Protocol):
878
- """Protocol for configuration sources.
879
-
880
- Any object that implements a `load()` method returning a DFMConfig
881
- can be used as a configuration source.
882
- """
883
- def load(self) -> DFMConfig:
884
- """Load and return a DFMConfig object."""
885
- ...
960
+ # Legacy class definitions removed - now in config_sources.py
961
+ # For backward compatibility, classes are imported and re-exported above
886
962
 
887
963
 
888
- class YamlSource:
889
- """Load configuration from a YAML file.
890
-
891
- Supports Hydra-style configs with defaults for series and blocks.
892
- """
893
- def __init__(self, yaml_path: Union[str, Path]):
894
- """Initialize YAML source.
895
-
896
- Parameters
897
- ----------
898
- yaml_path : str or Path
899
- Path to YAML configuration file
900
- """
901
- self.yaml_path = Path(yaml_path)
902
-
903
- def load(self) -> DFMConfig:
904
- """Load configuration from YAML file."""
905
- try:
906
- from omegaconf import OmegaConf
907
- except ImportError:
908
- raise ImportError("omegaconf is required for YAML config loading. Install with: pip install omegaconf")
909
-
910
- configfile = Path(self.yaml_path)
911
- if not configfile.exists():
912
- raise FileNotFoundError(f"Configuration file not found: {configfile}")
913
-
914
- config_dir = configfile.parent
915
- cfg = OmegaConf.load(configfile)
916
- cfg_dict = OmegaConf.to_container(cfg, resolve=True)
917
-
918
- # Extract main settings (estimation parameters)
919
- excluded_keys = {'defaults', '_target_', '_recursive_', '_convert_'}
920
- main_settings = {k: v for k, v in cfg_dict.items() if k not in excluded_keys}
921
-
922
- # Load series from config/series/default.yaml
923
- series_list = []
924
- series_loaded = False
925
- series_dict = None
926
-
927
- if 'defaults' in cfg:
928
- defaults_list = cfg.defaults
929
- for default_item in defaults_list:
930
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
931
-
932
- # Handle dict format: {'series': 'default'}
933
- if isinstance(default_dict, dict) and 'series' in default_dict:
934
- series_config_name = default_dict['series']
935
- series_config_path = config_dir / 'series' / f'{series_config_name}.yaml'
936
- if series_config_path.exists():
937
- series_cfg = OmegaConf.load(series_config_path)
938
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
939
- series_loaded = True
940
- break
941
-
942
- # If not loaded from defaults, try direct path
943
- if not series_loaded:
944
- series_config_path = config_dir / 'series' / 'default.yaml'
945
- if series_config_path.exists():
946
- series_cfg = OmegaConf.load(series_config_path)
947
- series_dict = OmegaConf.to_container(series_cfg, resolve=True)
948
- series_loaded = True
949
-
950
- # Convert series dict to SeriesConfig objects
951
- if series_loaded and series_dict is not None:
952
- for series_id, series_data in series_dict.items():
953
- if isinstance(series_data, dict):
954
- series_list.append(SeriesConfig(
955
- series_id=series_id,
956
- series_name=series_data.get('series_name', series_id),
957
- frequency=series_data.get('frequency', 'm'),
958
- transformation=series_data.get('transformation', 'lin'),
959
- category=series_data.get('category', ''),
960
- units=series_data.get('units', ''),
961
- blocks=series_data.get('blocks', [])
962
- ))
963
-
964
- # If no series loaded from separate files, try to get from main config
965
- if not series_loaded and 'series' in cfg_dict:
966
- series_data = cfg_dict['series']
967
- if isinstance(series_data, list):
968
- for series_item in series_data:
969
- series_list.append(SeriesConfig(**series_item))
970
- elif isinstance(series_data, dict):
971
- for series_id, series_item in series_data.items():
972
- if isinstance(series_item, dict):
973
- series_item['series_id'] = series_id
974
- series_list.append(SeriesConfig(**series_item))
975
-
976
- # Load blocks from config/blocks/default.yaml
977
- blocks_dict = {}
978
- blocks_loaded = False
979
- blocks_dict_raw = None
980
-
981
- if 'defaults' in cfg:
982
- defaults_list = cfg.defaults
983
- for default_item in defaults_list:
984
- default_dict = OmegaConf.to_container(default_item, resolve=False) if hasattr(default_item, 'keys') else default_item
985
-
986
- # Handle dict format: {'blocks': 'default'}
987
- if isinstance(default_dict, dict) and 'blocks' in default_dict:
988
- blocks_config_name = default_dict['blocks']
989
- blocks_config_path = config_dir / 'blocks' / f'{blocks_config_name}.yaml'
990
- if blocks_config_path.exists():
991
- blocks_cfg = OmegaConf.load(blocks_config_path)
992
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
993
- blocks_loaded = True
994
- break
995
-
996
- # If not loaded from defaults, try direct path
997
- if not blocks_loaded:
998
- blocks_config_path = config_dir / 'blocks' / 'default.yaml'
999
- if blocks_config_path.exists():
1000
- blocks_cfg = OmegaConf.load(blocks_config_path)
1001
- blocks_dict_raw = OmegaConf.to_container(blocks_cfg, resolve=True)
1002
- blocks_loaded = True
1003
-
1004
- # Convert blocks dict to BlockConfig objects
1005
- if blocks_loaded and blocks_dict_raw is not None:
1006
- for block_name, block_data in blocks_dict_raw.items():
1007
- if isinstance(block_data, dict):
1008
- blocks_dict[block_name] = BlockConfig(
1009
- factors=block_data.get('factors', 1),
1010
- ar_lag=block_data.get('ar_lag', 1),
1011
- clock=block_data.get('clock', 'm'),
1012
- notes=block_data.get('notes', None)
1013
- )
1014
-
1015
- # If no blocks loaded from separate files, try to get from main config
1016
- if not blocks_loaded and 'blocks' in cfg_dict:
1017
- blocks_data = cfg_dict['blocks']
1018
- if isinstance(blocks_data, dict):
1019
- for block_name, block_item in blocks_data.items():
1020
- if isinstance(block_item, dict):
1021
- blocks_dict[block_name] = BlockConfig(**block_item)
1022
-
1023
- # Ensure at least one block exists
1024
- if not blocks_dict:
1025
- blocks_dict[DEFAULT_GLOBAL_BLOCK_NAME] = BlockConfig(factors=1, clock=main_settings.get('clock', 'm'))
1026
-
1027
- return DFMConfig(
1028
- series=series_list,
1029
- blocks=blocks_dict,
1030
- **main_settings
1031
- )
964
+ # ============================================================================
965
+ # Configuration validation functions
966
+ # ============================================================================
1032
967
 
968
+ # Valid frequency codes
969
+ _VALID_FREQUENCIES = {'d', 'w', 'm', 'q', 'sa', 'a'}
1033
970
 
1034
- class DictSource:
1035
- """Load configuration from a dictionary.
1036
-
1037
- Supports multiple dict formats:
1038
- - Legacy format: {'SeriesID': [...], 'Frequency': [...], ...}
1039
- - New format (list): {'series': [{'series_id': ..., ...}], ...}
1040
- - Hydra format: {'series': {'series_id': {...}}, 'blocks': {...}}
1041
- """
1042
- def __init__(self, mapping: Dict[str, Any]):
1043
- """Initialize dictionary source.
1044
-
1045
- Parameters
1046
- ----------
1047
- mapping : dict
1048
- Dictionary containing configuration data
1049
- """
1050
- self.mapping = mapping
1051
-
1052
- def load(self) -> DFMConfig:
1053
- """Load configuration from dictionary.
1054
-
1055
- If the dictionary is partial (e.g., only max_iter, threshold),
1056
- it will be merged with a minimal default config.
1057
- """
1058
- # Check if this is a partial config (missing series or blocks)
1059
- has_series = 'series' in self.mapping and self.mapping['series']
1060
- has_blocks = 'blocks' in self.mapping and self.mapping['blocks']
1061
-
1062
- if not has_series or not has_blocks:
1063
- # This is a partial config - create a minimal default and merge
1064
- minimal_default = {
1065
- 'series': [],
1066
- 'blocks': {},
1067
- 'clock': 'm',
1068
- 'max_iter': 5000,
1069
- 'threshold': 1e-5
1070
- }
1071
- # Merge: mapping takes precedence
1072
- merged = {**minimal_default, **self.mapping}
1073
- return DFMConfig.from_dict(merged)
1074
-
1075
- return DFMConfig.from_dict(self.mapping)
971
+ # Valid transformation codes
972
+ _VALID_TRANSFORMATIONS = {
973
+ 'lin', 'chg', 'ch1', 'pch', 'pc1', 'pca',
974
+ 'cch', 'cca', 'log'
975
+ }
1076
976
 
1077
977
 
1078
- class SpecCSVSource:
1079
- """Load series definitions from a spec CSV file.
1080
-
1081
- This source reads series definitions and block memberships from a CSV.
1082
- It constructs a valid DFMConfig with sensible defaults for other
1083
- parameters (e.g., clock='m', threshold=1e-5, max_iter=5000, factors=1).
1084
-
1085
- For custom main settings (threshold, max_iter, clock, per-block factors),
1086
- you can still merge with a base config via MergedConfigSource or
1087
- dfm.load_config(base=..., override=spec_path).
1088
- """
1089
- def __init__(self, spec_path: Union[str, Path]):
1090
- """Initialize spec CSV source.
1091
-
1092
- Parameters
1093
- ----------
1094
- spec_path : str or Path
1095
- Path to spec CSV file
1096
- """
1097
- self.spec_path = Path(spec_path)
978
+ def validate_frequency(frequency: str) -> str:
979
+ """Validate frequency code.
1098
980
 
1099
- def load(self) -> DFMConfig:
1100
- """Load series definitions from spec CSV."""
1101
- import pandas as pd
1102
- from .data_loader import _load_config_from_dataframe
1103
-
1104
- specfile = Path(self.spec_path)
1105
- if not specfile.exists():
1106
- raise FileNotFoundError(f"Spec file not found: {specfile}")
981
+ Parameters
982
+ ----------
983
+ frequency : str
984
+ Frequency code to validate
1107
985
 
1108
- try:
1109
- df_full = pd.read_csv(specfile)
1110
- except Exception as e:
1111
- raise ValueError(f"Failed to read spec file {specfile}: {e}")
986
+ Returns
987
+ -------
988
+ str
989
+ Validated frequency code (same as input if valid)
1112
990
 
1113
- return _load_config_from_dataframe(df_full)
1114
-
1115
-
1116
- class HydraSource:
1117
- """Load configuration from a Hydra DictConfig or dict.
1118
-
1119
- This adapter handles Hydra's composed configuration objects,
1120
- converting them to DFMConfig format.
1121
- """
1122
- def __init__(self, cfg: Union[Dict[str, Any], 'DictConfig']):
1123
- """Initialize Hydra source.
991
+ Raises
992
+ ------
993
+ ValueError
994
+ If frequency is not in the set of valid frequencies
1124
995
 
1125
- Parameters
1126
- ----------
1127
- cfg : DictConfig or dict
1128
- Hydra configuration object or dictionary in Hydra format
1129
- """
1130
- self.cfg = cfg
1131
-
1132
- def load(self) -> DFMConfig:
1133
- """Load configuration from Hydra DictConfig/dict."""
1134
- return DFMConfig.from_hydra(self.cfg)
1135
-
1136
-
1137
- class MergedConfigSource:
1138
- """Merge multiple configuration sources.
1139
-
1140
- This allows combining configurations from different sources,
1141
- e.g., base YAML config + series from Spec CSV.
1142
-
1143
- The merge strategy:
1144
- - Base config provides main settings (threshold, max_iter, clock, blocks)
1145
- - Override config provides series definitions (replaces base series)
1146
- - Block definitions are merged (override takes precedence)
996
+ Examples
997
+ --------
998
+ >>> validate_frequency('m')
999
+ 'm'
1000
+ >>> validate_frequency('invalid')
1001
+ ValueError: Invalid frequency: invalid. Must be one of {'d', 'w', 'm', 'q', 'sa', 'a'}
1147
1002
  """
1148
- def __init__(self, base: ConfigSource, override: ConfigSource):
1149
- """Initialize merged config source.
1150
-
1151
- Parameters
1152
- ----------
1153
- base : ConfigSource
1154
- Base configuration (provides main settings)
1155
- override : ConfigSource
1156
- Override configuration (provides series/block overrides)
1157
- """
1158
- self.base = base
1159
- self.override = override
1160
-
1161
- def load(self) -> DFMConfig:
1162
- """Load and merge configurations."""
1163
- from dataclasses import fields
1164
-
1165
- base_cfg = self.base.load()
1166
-
1167
- # Check if override is a partial config (DictSource with partial dict)
1168
- override_is_partial = False
1169
- if isinstance(self.override, DictSource):
1170
- has_series = 'series' in self.override.mapping and self.override.mapping['series']
1171
- has_blocks = 'blocks' in self.override.mapping and self.override.mapping['blocks']
1172
- override_is_partial = not (has_series and has_blocks)
1173
-
1174
- if override_is_partial:
1175
- # Handle partial override: merge fields directly without loading full DFMConfig
1176
- override_dict = self.override.mapping
1177
- override_cfg = base_cfg # Use base as template
1178
- else:
1179
- override_cfg = self.override.load()
1180
-
1181
- # Merge blocks: override takes precedence
1182
- if override_is_partial and 'blocks' in override_dict:
1183
- # Merge block dicts
1184
- merged_blocks = {**base_cfg.blocks, **override_dict['blocks']}
1185
- else:
1186
- merged_blocks = {**base_cfg.blocks, **override_cfg.blocks}
1187
-
1188
- # Use override's series if provided and non-empty, otherwise use base's series
1189
- if override_is_partial:
1190
- if 'series' in override_dict and override_dict['series']:
1191
- merged_series = override_dict['series']
1192
- else:
1193
- merged_series = base_cfg.series
1194
- else:
1195
- merged_series = override_cfg.series if (override_cfg.series and len(override_cfg.series) > 0) else base_cfg.series
1196
-
1197
- # Get all config fields (excluding derived/computed fields)
1198
- excluded_fields = {'series', 'blocks', 'block_names', 'factors_per_block', '_cached_blocks'}
1199
- base_settings = {
1200
- field.name: getattr(base_cfg, field.name)
1201
- for field in fields(DFMConfig)
1202
- if field.name not in excluded_fields
1203
- }
1204
-
1205
- # Override settings from override_cfg or override_dict
1206
- if override_is_partial:
1207
- override_settings = {
1208
- field.name: override_dict.get(field.name, getattr(base_cfg, field.name))
1209
- for field in fields(DFMConfig)
1210
- if field.name not in excluded_fields
1211
- }
1212
- else:
1213
- override_settings = {
1214
- field.name: getattr(override_cfg, field.name)
1215
- for field in fields(DFMConfig)
1216
- if field.name not in excluded_fields
1217
- and hasattr(override_cfg, field.name)
1218
- }
1219
-
1220
- # Merge settings: base + override (override takes precedence)
1221
- merged_settings = {**base_settings, **override_settings}
1222
-
1223
- # Create merged config: merged settings + merged series + merged blocks
1224
- return DFMConfig(
1225
- series=merged_series,
1226
- blocks=merged_blocks,
1227
- **merged_settings
1228
- )
1003
+ if frequency not in _VALID_FREQUENCIES:
1004
+ raise ValueError(f"Invalid frequency: {frequency}. Must be one of {_VALID_FREQUENCIES}")
1005
+ return frequency
1229
1006
 
1230
1007
 
1231
- def make_config_source(
1232
- source: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
1233
- *,
1234
- yaml: Optional[Union[str, Path]] = None,
1235
- mapping: Optional[Union[Dict[str, Any], Any]] = None,
1236
- spec: Optional[Union[str, Path]] = None,
1237
- hydra: Optional[Union[Dict[str, Any], 'DictConfig']] = None,
1238
- ) -> ConfigSource:
1239
- """Create a ConfigSource adapter from various input formats.
1240
-
1241
- This factory function automatically selects the appropriate adapter
1242
- based on the input type or explicit keyword arguments.
1008
+ def validate_transformation(transformation: str) -> str:
1009
+ """Validate transformation code.
1243
1010
 
1244
1011
  Parameters
1245
1012
  ----------
1246
- source : str, Path, dict, or ConfigSource, optional
1247
- Configuration source. If a ConfigSource, returned as-is.
1248
- If str/Path, treated as YAML file path.
1249
- If dict, treated as dictionary config.
1250
- yaml : str or Path, optional
1251
- Explicit YAML file path
1252
- mapping : dict, optional
1253
- Explicit dictionary config
1254
- spec : str or Path, optional
1255
- Explicit spec CSV file path
1256
- hydra : DictConfig or dict, optional
1257
- Explicit Hydra config
1013
+ transformation : str
1014
+ Transformation code to validate
1258
1015
 
1259
1016
  Returns
1260
1017
  -------
1261
- ConfigSource
1262
- Appropriate adapter for the input
1263
-
1018
+ str
1019
+ Validated transformation code (same as input, even if unknown)
1020
+
1021
+ Notes
1022
+ -----
1023
+ Unknown transformation codes trigger a warning but are not rejected,
1024
+ allowing for extensibility. The code will be used as-is, and the
1025
+ transformation logic should handle unknown codes appropriately.
1026
+
1264
1027
  Examples
1265
1028
  --------
1266
- >>> # From YAML file
1267
- >>> source = make_config_source('config/default.yaml')
1268
- >>>
1269
- >>> # From dictionary
1270
- >>> source = make_config_source({'series': [...], 'clock': 'm'})
1271
- >>>
1272
- >>> # Explicit keyword arguments
1273
- >>> source = make_config_source(yaml='config/default.yaml')
1274
- >>> source = make_config_source(spec='data/spec.csv')
1275
- >>>
1276
- >>> # Merge YAML base + Spec CSV series
1277
- >>> base = make_config_source(yaml='config/default.yaml')
1278
- >>> override = make_config_source(spec='data/spec.csv')
1279
- >>> merged = MergedConfigSource(base, override)
1029
+ >>> validate_transformation('lin')
1030
+ 'lin'
1031
+ >>> validate_transformation('unknown') # Issues warning but returns value
1032
+ 'unknown'
1280
1033
  """
1281
- # Check for explicit keyword arguments (only one allowed)
1282
- explicit_kwargs = [k for k, v in [('yaml', yaml), ('mapping', mapping), ('spec', spec), ('hydra', hydra)] if v is not None]
1283
- if len(explicit_kwargs) > 1:
1284
- raise ValueError(
1285
- f"Only one of yaml, mapping, spec, or hydra can be specified. "
1286
- f"Got: {', '.join(explicit_kwargs)}. "
1287
- f"For merging configs, use MergedConfigSource."
1288
- )
1289
-
1290
- # Helper: coerce arbitrary object with attributes into dict
1291
- def _coerce_to_mapping(obj: Any) -> Dict[str, Any]:
1292
- if isinstance(obj, dict):
1293
- return obj
1294
- if is_dataclass(obj):
1295
- return asdict(obj)
1296
- if hasattr(obj, "__dict__"):
1297
- try:
1298
- return dict(vars(obj))
1299
- except Exception:
1300
- pass
1301
- raise TypeError(
1302
- f"Unsupported mapping type: {type(obj)}. "
1303
- f"Provide a dict, dataclass instance, or an object with attributes."
1304
- )
1305
-
1306
- # Handle explicit keyword arguments
1307
- if yaml is not None:
1308
- return YamlSource(yaml)
1309
- if mapping is not None:
1310
- return DictSource(_coerce_to_mapping(mapping))
1311
- if spec is not None:
1312
- return SpecCSVSource(spec)
1313
- if hydra is not None:
1314
- return HydraSource(hydra)
1315
-
1316
- # Infer from source argument
1317
- if source is None:
1318
- raise ValueError(
1319
- "No configuration source provided. "
1320
- "Specify source, yaml, mapping, spec, or hydra."
1321
- )
1322
-
1323
- # If already a ConfigSource, return as-is
1324
- if hasattr(source, 'load') and callable(getattr(source, 'load')):
1325
- return source # type: ignore
1326
-
1327
- # Infer type from source
1328
- if isinstance(source, DFMConfig):
1329
- # Wrap DFMConfig in a simple adapter
1330
- class DFMConfigAdapter:
1331
- def __init__(self, cfg: DFMConfig):
1332
- self._cfg = cfg
1333
- def load(self) -> DFMConfig:
1334
- return self._cfg
1335
- return DFMConfigAdapter(source)
1336
-
1337
- if isinstance(source, (str, Path)):
1338
- path = Path(source)
1339
- suffix = path.suffix.lower()
1340
- if suffix in ['.yaml', '.yml']:
1341
- return YamlSource(path)
1342
- elif suffix == '.csv':
1343
- return SpecCSVSource(path)
1344
- else:
1345
- # Default to YAML if extension unclear
1346
- return YamlSource(path)
1347
-
1348
- if isinstance(source, dict):
1349
- return DictSource(source)
1350
- # Accept objects that can be coerced into dict (dataclass or attribute bag)
1351
- try:
1352
- coerced = _coerce_to_mapping(source)
1353
- return DictSource(coerced)
1354
- except Exception:
1355
- pass
1356
-
1357
- raise TypeError(
1358
- f"Unsupported source type: {type(source)}. "
1359
- f"Expected str, Path, dict, ConfigSource, or DFMConfig."
1360
- )
1034
+ import warnings
1035
+ if transformation not in _VALID_TRANSFORMATIONS:
1036
+ warnings.warn(f"Unknown transformation code: {transformation}. Will use untransformed data.")
1037
+ return transformation