dfm-python 0.1.4__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 ADDED
@@ -0,0 +1,60 @@
1
+ """Dynamic Factor Model (DFM) package for Python.
2
+
3
+ This package implements a comprehensive Dynamic Factor Model framework with support for:
4
+ - Mixed-frequency time series data (daily, weekly, monthly, quarterly, semi-annual, annual)
5
+ - Clock-based synchronization of latent factors
6
+ - Tent kernel aggregation for low-to-high frequency mapping
7
+ - Expectation-Maximization (EM) algorithm for parameter estimation
8
+ - Kalman filtering and smoothing for factor extraction
9
+ - News decomposition for nowcasting
10
+
11
+ The package follows the FRBNY (Federal Reserve Bank of New York) approach to mixed-frequency
12
+ DFMs, where all latent factors (global and block-level) are synchronized to a common "clock"
13
+ frequency, typically monthly. Lower-frequency observed variables are mapped to higher-frequency
14
+ latent states using deterministic tent kernels in the observation equation.
15
+
16
+ Key Features:
17
+ - Unified configuration system (YAML/CSV)
18
+ - Flexible block structure for factor modeling
19
+ - Robust handling of missing data
20
+ - Comprehensive transformation support
21
+ - News decomposition for forecast updates
22
+
23
+ Example:
24
+ >>> from dfm_python import load_config, load_data, dfm
25
+ >>> config = load_config('config.yaml')
26
+ >>> X, Time, _ = load_data('data.csv', config)
27
+ >>> result = dfm(X, config)
28
+ >>> factors = result.Z # Extract estimated factors
29
+
30
+ For detailed documentation, see the README.md file.
31
+ """
32
+
33
+ __version__ = "0.1.4"
34
+
35
+ from .config import DFMConfig
36
+ from .data_loader import (
37
+ load_config, load_config_from_yaml, load_config_from_csv, load_data, transform_data
38
+ )
39
+ from .dfm import DFMResult, dfm
40
+ from .kalman import run_kf, skf, fis, miss_data
41
+ from .news import update_nowcast, news_dfm, para_const
42
+
43
+ # Backward compatibility aliases
44
+ ModelConfig = DFMConfig # Deprecated: ModelConfig merged into DFMConfig
45
+ ModelSpec = DFMConfig # Deprecated: use DFMConfig
46
+ load_spec = load_config # Deprecated: use load_config
47
+
48
+ __all__ = [
49
+ 'DFMConfig',
50
+ # Backward compatibility
51
+ 'ModelConfig', # Alias for DFMConfig
52
+ 'load_config', 'load_config_from_yaml', 'load_config_from_csv',
53
+ 'load_data', 'transform_data',
54
+ 'DFMResult', 'dfm',
55
+ 'run_kf', 'skf', 'fis', 'miss_data',
56
+ 'update_nowcast', 'news_dfm', 'para_const',
57
+ # Deprecated aliases
58
+ 'ModelSpec', 'load_spec',
59
+ ]
60
+
dfm_python/config.py ADDED
@@ -0,0 +1,482 @@
1
+ """Configuration models for DFM nowcasting using OmegaConf and Hydra.
2
+
3
+ This module defines the configuration structure for Dynamic Factor Models,
4
+ providing a unified interface for specifying:
5
+ - Series definitions (frequency, transformation, block loadings)
6
+ - Block structure and factor dimensions
7
+ - Estimation parameters (EM algorithm settings, missing data handling)
8
+ - Clock frequency for mixed-frequency synchronization
9
+
10
+ The configuration system supports both YAML and CSV formats, with validation
11
+ and type checking to ensure model specifications are correct before estimation.
12
+ """
13
+
14
+ import numpy as np
15
+ from typing import List, Optional, Dict, Any
16
+ from pathlib import Path
17
+ import warnings
18
+ from dataclasses import dataclass, field
19
+
20
+ try:
21
+ from hydra.core.config_store import ConfigStore
22
+ from omegaconf import DictConfig, OmegaConf
23
+ HYDRA_AVAILABLE = True
24
+ except ImportError:
25
+ HYDRA_AVAILABLE = False
26
+ ConfigStore = None
27
+ DictConfig = None
28
+ OmegaConf = None
29
+
30
+ # Valid frequency codes
31
+ _VALID_FREQUENCIES = {'d', 'w', 'm', 'q', 'sa', 'a'}
32
+
33
+ # Valid transformation codes
34
+ _VALID_TRANSFORMATIONS = {
35
+ 'lin', 'chg', 'ch1', 'pch', 'pc1', 'pca',
36
+ 'cch', 'cca', 'log'
37
+ }
38
+
39
+ # Transformation to readable units mapping
40
+ _TRANSFORM_UNITS_MAP = {
41
+ 'lin': 'Levels (No Transformation)',
42
+ 'chg': 'Change (Difference)',
43
+ 'ch1': 'Year over Year Change (Difference)',
44
+ 'pch': 'Percent Change',
45
+ 'pc1': 'Year over Year Percent Change',
46
+ 'pca': 'Percent Change (Annual Rate)',
47
+ 'cch': 'Continuously Compounded Rate of Change',
48
+ 'cca': 'Continuously Compounded Annual Rate of Change',
49
+ 'log': 'Natural Log'
50
+ }
51
+
52
+
53
+ def validate_frequency(frequency: str) -> str:
54
+ """Validate frequency code."""
55
+ if frequency not in _VALID_FREQUENCIES:
56
+ raise ValueError(f"Invalid frequency: {frequency}. Must be one of {_VALID_FREQUENCIES}")
57
+ return frequency
58
+
59
+
60
+ def validate_transformation(transformation: str) -> str:
61
+ """Validate transformation code."""
62
+ if transformation not in _VALID_TRANSFORMATIONS:
63
+ warnings.warn(f"Unknown transformation code: {transformation}. Will use untransformed data.")
64
+ return transformation
65
+
66
+
67
+ @dataclass
68
+ class SeriesConfig:
69
+ """Configuration for a single time series.
70
+
71
+ This is a generic DFM configuration - no API or database-specific fields.
72
+ For API/database integration, implement adapters in your application layer.
73
+ """
74
+ # Required fields (no defaults)
75
+ frequency: str
76
+ transformation: str
77
+ blocks: List[int]
78
+ # Optional fields (with defaults - must come after required fields)
79
+ series_id: Optional[str] = None # Auto-generated if None: "series_0", "series_1", etc.
80
+ series_name: Optional[str] = None # Optional metadata for display
81
+ units: str = "" # Optional metadata
82
+ category: str = "" # Optional metadata
83
+
84
+ def __post_init__(self):
85
+ """Validate fields after initialization."""
86
+ self.frequency = validate_frequency(self.frequency)
87
+ self.transformation = validate_transformation(self.transformation)
88
+ # Auto-generate series_id if not provided
89
+ if self.series_id is None:
90
+ # Will be set when SeriesConfig is created in a list
91
+ pass
92
+ # Auto-generate series_name if not provided
93
+ if self.series_name is None and self.series_id:
94
+ self.series_name = self.series_id
95
+
96
+
97
+ @dataclass
98
+ class DFMConfig:
99
+ """Unified DFM configuration - model structure + estimation parameters.
100
+
101
+ This is the single configuration class for the DFM module, combining:
102
+ - Model structure (what series, blocks, factors)
103
+ - Estimation parameters (how to run EM algorithm)
104
+ """
105
+ # ========================================================================
106
+ # Model Structure (WHAT - defines the model)
107
+ # ========================================================================
108
+ series: List[SeriesConfig] # Series specifications
109
+ block_names: List[str] # Block names (e.g., ["Global", "Consumption", "Investment"])
110
+ factors_per_block: Optional[List[int]] = None # Number of factors per block. If None, defaults to 1 per block
111
+
112
+ # ========================================================================
113
+ # Estimation Parameters (HOW - controls the algorithm)
114
+ # ========================================================================
115
+ ar_lag: int = 1 # Number of lags in AR transition equation (lookback window)
116
+ threshold: float = 1e-5 # EM convergence threshold
117
+ max_iter: int = 5000 # Maximum EM iterations
118
+ nan_method: int = 2 # Missing data handling method (1-5)
119
+ nan_k: int = 3 # Spline parameter for NaN interpolation
120
+ clock: str = 'm' # Base frequency for nowcasting (global clock): 'd', 'w', 'm', 'q', 'sa', 'a' (defaults to 'm' for monthly)
121
+
122
+ # ========================================================================
123
+ # Internal cache (not user-configurable)
124
+ # ========================================================================
125
+ _cached_blocks: Optional[np.ndarray] = field(default=None, init=False, repr=False)
126
+
127
+ def __post_init__(self):
128
+ """Validate blocks structure and consistency.
129
+
130
+ This method performs comprehensive validation of the DFM configuration:
131
+ - Ensures at least one series is specified
132
+ - Validates block structure consistency across all series
133
+ - Ensures all series load on the global block
134
+ - Validates factor dimensions match block structure
135
+ - Validates clock frequency
136
+
137
+ Raises
138
+ ------
139
+ ValueError
140
+ If any validation check fails, with a descriptive error message
141
+ indicating what needs to be fixed.
142
+ """
143
+ if not self.series:
144
+ raise ValueError(
145
+ "DFM configuration must contain at least one series. "
146
+ "Please add series definitions to your configuration."
147
+ )
148
+
149
+ # Auto-generate series_id if not provided
150
+ for i, s in enumerate(self.series):
151
+ if s.series_id is None:
152
+ s.series_id = f"series_{i}"
153
+ if s.series_name is None:
154
+ s.series_name = s.series_id
155
+
156
+ # Extract blocks matrix
157
+ n_series = len(self.series)
158
+ n_blocks = len(self.block_names)
159
+
160
+ # Check all series have same number of blocks
161
+ for i, s in enumerate(self.series):
162
+ if len(s.blocks) != n_blocks:
163
+ raise ValueError(
164
+ f"Series {i} ('{s.series_id}') has {len(s.blocks)} block loadings, "
165
+ f"but expected {n_blocks} (from block_names: {self.block_names}). "
166
+ f"Each series must specify a loading (0 or 1) for each block."
167
+ )
168
+
169
+ # Check first column (global block) is all 1s
170
+ for i, s in enumerate(self.series):
171
+ if s.blocks[0] != 1:
172
+ raise ValueError(
173
+ f"Series {i} ('{s.series_id}') must load on the global block "
174
+ f"(first block '{self.block_names[0]}'). "
175
+ f"All series must have blocks[0] = 1. "
176
+ f"Current value: {s.blocks[0]}"
177
+ )
178
+
179
+ # Validate factors_per_block if provided
180
+ if self.factors_per_block is not None:
181
+ if len(self.factors_per_block) != n_blocks:
182
+ raise ValueError(
183
+ f"factors_per_block length ({len(self.factors_per_block)}) must match "
184
+ f"number of blocks ({n_blocks}). "
185
+ f"Block names: {self.block_names}. "
186
+ f"Please provide one factor count per block."
187
+ )
188
+ if any(f < 1 for f in self.factors_per_block):
189
+ invalid_blocks = [i for i, f in enumerate(self.factors_per_block) if f < 1]
190
+ raise ValueError(
191
+ f"factors_per_block must contain positive integers (>= 1). "
192
+ f"Invalid values found at block indices {invalid_blocks}: "
193
+ f"{[self.factors_per_block[i] for i in invalid_blocks]}. "
194
+ f"Each block must have at least one factor."
195
+ )
196
+
197
+ # Validate clock
198
+ self.clock = validate_frequency(self.clock)
199
+
200
+ # Convenience properties for backward compatibility
201
+ @property
202
+ def SeriesID(self) -> List[str]:
203
+ """Backward compatibility: SeriesID property."""
204
+ return [s.series_id if s.series_id is not None else f"series_{i}"
205
+ for i, s in enumerate(self.series)]
206
+
207
+ @property
208
+ def SeriesName(self) -> List[str]:
209
+ """Backward compatibility: SeriesName property."""
210
+ return [s.series_name if s.series_name is not None else (s.series_id or f"series_{i}")
211
+ for i, s in enumerate(self.series)]
212
+
213
+ @property
214
+ def Frequency(self) -> List[str]:
215
+ """Backward compatibility: Frequency property."""
216
+ return [s.frequency for s in self.series]
217
+
218
+ @property
219
+ def Units(self) -> List[str]:
220
+ """Backward compatibility: Units property."""
221
+ return [s.units for s in self.series]
222
+
223
+ @property
224
+ def Transformation(self) -> List[str]:
225
+ """Backward compatibility: Transformation property."""
226
+ return [s.transformation for s in self.series]
227
+
228
+ @property
229
+ def Category(self) -> List[str]:
230
+ """Backward compatibility: Category property."""
231
+ return [s.category for s in self.series]
232
+
233
+ @property
234
+ def Blocks(self) -> np.ndarray:
235
+ """Backward compatibility: Blocks property as numpy array (cached)."""
236
+ if self._cached_blocks is None:
237
+ blocks_list = [s.blocks for s in self.series]
238
+ self._cached_blocks = np.array(blocks_list, dtype=int)
239
+ return self._cached_blocks
240
+
241
+ @property
242
+ def BlockNames(self) -> List[str]:
243
+ """Backward compatibility: BlockNames property (alias for block_names)."""
244
+ return self.block_names
245
+
246
+ @property
247
+ def UnitsTransformed(self) -> List[str]:
248
+ """Backward compatibility: UnitsTransformed property."""
249
+ return [_TRANSFORM_UNITS_MAP.get(t, t) for t in self.Transformation]
250
+
251
+ @classmethod
252
+ def _from_legacy_dict(cls, data: Dict[str, Any]) -> 'DFMConfig':
253
+ """Convert legacy format (separate lists) to new format (series list)."""
254
+ series_list = []
255
+ n = len(data.get('SeriesID', data.get('series_id', [])))
256
+
257
+ # Handle Blocks - can be numpy array or list of lists
258
+ blocks_data = data.get('Blocks', data.get('blocks', []))
259
+ if isinstance(blocks_data, np.ndarray):
260
+ blocks_data = blocks_data.tolist()
261
+ elif not isinstance(blocks_data, list):
262
+ blocks_data = []
263
+
264
+ # Helper to get list value with index fallback
265
+ def get_list_value(key: str, index: int, default=None):
266
+ """Get value from list, handling both camelCase and snake_case keys."""
267
+ val = data.get(key, data.get(key.lower(), default))
268
+ if isinstance(val, list) and index < len(val):
269
+ return val[index]
270
+ return default
271
+
272
+ for i in range(n):
273
+ # Extract blocks for this series
274
+ if blocks_data and i < len(blocks_data):
275
+ if isinstance(blocks_data[i], (list, np.ndarray)):
276
+ series_blocks = list(blocks_data[i]) if isinstance(blocks_data[i], np.ndarray) else blocks_data[i]
277
+ else:
278
+ series_blocks = [blocks_data[i]]
279
+ else:
280
+ series_blocks = []
281
+
282
+ freq_val = get_list_value('Frequency', i, 'm')
283
+ trans_val = get_list_value('Transformation', i, 'lin')
284
+ units_val = get_list_value('Units', i, '')
285
+ cat_val = get_list_value('Category', i, '')
286
+ series_list.append(SeriesConfig(
287
+ frequency=str(freq_val) if freq_val is not None else 'm',
288
+ transformation=str(trans_val) if trans_val is not None else 'lin',
289
+ blocks=series_blocks,
290
+ series_id=get_list_value('SeriesID', i, None),
291
+ series_name=get_list_value('SeriesName', i, None),
292
+ units=str(units_val) if units_val is not None else '',
293
+ category=str(cat_val) if cat_val is not None else ''
294
+ ))
295
+
296
+ # Extract estimation parameters if provided
297
+ return cls(
298
+ series=series_list,
299
+ block_names=data.get('BlockNames', data.get('block_names', [])),
300
+ factors_per_block=data.get('factors_per_block', None),
301
+ # Estimation parameters
302
+ ar_lag=data.get('ar_lag', 1),
303
+ threshold=data.get('threshold', 1e-5),
304
+ max_iter=data.get('max_iter', 5000),
305
+ nan_method=data.get('nan_method', 2),
306
+ nan_k=data.get('nan_k', 3),
307
+ clock=data.get('clock', 'm')
308
+ )
309
+
310
+ @classmethod
311
+ def from_dict(cls, data: Dict[str, Any]) -> 'DFMConfig':
312
+ """Create DFMConfig from dictionary.
313
+
314
+ Handles multiple formats:
315
+ 1. Legacy format: {'SeriesID': [...], 'Frequency': [...], 'Blocks': [[...]], ...}
316
+ 2. New format (list): {'series': [{'series_id': ..., ...}], 'block_names': [...]}
317
+ 3. New format (Hydra): {'series': {'series_id': {...}}, 'blocks': {'block_name': {'factors': N}}}
318
+
319
+ Also accepts estimation parameters: ar_lag, threshold, max_iter, nan_method, nan_k
320
+ """
321
+ # Detect legacy format (has SeriesID or series_id as lists)
322
+ if 'SeriesID' in data or ('series_id' in data and isinstance(data.get('series_id'), list)):
323
+ return cls._from_legacy_dict(data)
324
+
325
+ # New Hydra format: series is a dict (series_id -> config), blocks is a dict (block_name -> {factors: N})
326
+ if 'series' in data and isinstance(data['series'], dict):
327
+ # First, parse blocks dict to get block_names (needed for conversion)
328
+ blocks_dict = data.get('blocks', {})
329
+ if isinstance(blocks_dict, dict):
330
+ block_names = list(blocks_dict.keys())
331
+ factors_per_block = [
332
+ blocks_dict[bn].get('factors', 1) if isinstance(blocks_dict[bn], dict) else blocks_dict[bn]
333
+ for bn in block_names
334
+ ]
335
+ else:
336
+ block_names = data.get('block_names', [])
337
+ factors_per_block = data.get('factors_per_block', None)
338
+
339
+ # Parse series dict: {series_id: {frequency: ..., blocks: [block_names], ...}}
340
+ series_list = []
341
+ for series_id, series_cfg in data['series'].items():
342
+ if isinstance(series_cfg, dict):
343
+ # Convert block names to binary array (0/1 for each block)
344
+ series_blocks_names = series_cfg.get('blocks', [])
345
+ if isinstance(series_blocks_names, list) and series_blocks_names:
346
+ # Create binary array: 1 if series loads on block, 0 otherwise
347
+ # First block (Global) must always be 1
348
+ series_blocks = [0] * len(block_names)
349
+ series_blocks[0] = 1 # Global block is always 1
350
+ for block_name in series_blocks_names:
351
+ if block_name in block_names:
352
+ block_idx = block_names.index(block_name)
353
+ series_blocks[block_idx] = 1
354
+ else:
355
+ # Default: only global block
356
+ series_blocks = [1] + [0] * (len(block_names) - 1)
357
+
358
+ series_list.append(SeriesConfig(
359
+ series_id=series_id,
360
+ series_name=series_cfg.get('series_name', series_id),
361
+ frequency=series_cfg.get('frequency', 'm'),
362
+ transformation=series_cfg.get('transformation', 'lin'),
363
+ blocks=series_blocks,
364
+ units=series_cfg.get('units', ''),
365
+ category=series_cfg.get('category', '')
366
+ ))
367
+
368
+ # Extract estimation parameters if provided
369
+ return cls(
370
+ series=series_list,
371
+ block_names=block_names,
372
+ factors_per_block=factors_per_block if factors_per_block else None,
373
+ # Estimation parameters
374
+ ar_lag=data.get('ar_lag', 1),
375
+ threshold=data.get('threshold', 1e-5),
376
+ max_iter=data.get('max_iter', 5000),
377
+ nan_method=data.get('nan_method', 2),
378
+ nan_k=data.get('nan_k', 3),
379
+ clock=data.get('clock', 'm')
380
+ )
381
+
382
+ # New format with series list
383
+ if 'series' in data and isinstance(data['series'], list):
384
+ series_list = [
385
+ SeriesConfig(**s) if isinstance(s, dict) else s
386
+ for s in data['series']
387
+ ]
388
+ # Extract estimation parameters if provided
389
+ return cls(
390
+ series=series_list,
391
+ block_names=data.get('block_names', []),
392
+ factors_per_block=data.get('factors_per_block', None),
393
+ # Estimation parameters
394
+ ar_lag=data.get('ar_lag', 1),
395
+ threshold=data.get('threshold', 1e-5),
396
+ max_iter=data.get('max_iter', 5000),
397
+ nan_method=data.get('nan_method', 2),
398
+ nan_k=data.get('nan_k', 3),
399
+ clock=data.get('clock', 'm')
400
+ )
401
+
402
+ # Direct instantiation (shouldn't happen often, but handle it)
403
+ return cls(**data)
404
+
405
+
406
+ # DataConfig removed - data loading is application-specific, not part of generic DFM module
407
+ # Use adapters for data loading configuration
408
+
409
+ @dataclass
410
+ class AppConfig:
411
+ """Root application configuration (for backward compatibility only).
412
+
413
+ Note: DataConfig has been removed from generic DFM module.
414
+ Data loading configuration should be handled by adapters/application layer.
415
+ """
416
+ model: DFMConfig # Renamed from ModelConfig to DFMConfig
417
+ # data: DataConfig # Removed - not part of generic DFM module
418
+ dfm: Optional[DFMConfig] = None # Deprecated - use model config directly
419
+
420
+
421
+ # Register with Hydra ConfigStore following the Structured Config schema pattern
422
+ # This enables validation of YAML config files while keeping our full dataclass
423
+ # with @property methods for runtime use.
424
+ #
425
+ # Pattern: Schema validation (from Hydra docs)
426
+ # - YAML files extend schemas via defaults list
427
+ # - Schemas provide type checking and validation
428
+ # - Runtime uses full ModelConfig with @property methods
429
+ if HYDRA_AVAILABLE and ConfigStore is not None:
430
+ try:
431
+ cs = ConfigStore.instance()
432
+ if cs is None:
433
+ raise RuntimeError("ConfigStore.instance() returned None")
434
+ # Create schema versions without @property methods for Hydra validation
435
+ # These match our dataclass structure exactly for schema validation.
436
+ # We'll still use the full ModelConfig/DataConfig/DFMConfig classes with
437
+ # @property methods at runtime via from_dict() conversion.
438
+ from dataclasses import dataclass as schema_dataclass
439
+
440
+ @schema_dataclass
441
+ class SeriesConfigSchema:
442
+ """Schema for SeriesConfig validation in Hydra."""
443
+ series_id: str
444
+ series_name: str
445
+ frequency: str
446
+ units: str
447
+ transformation: str
448
+ category: str
449
+ blocks: List[int]
450
+
451
+ @schema_dataclass
452
+ class DFMConfigSchema:
453
+ """Schema for unified DFMConfig validation in Hydra."""
454
+ # Model structure
455
+ series: List[SeriesConfigSchema]
456
+ block_names: List[str]
457
+ factors_per_block: Optional[List[int]] = None
458
+ # Estimation parameters
459
+ ar_lag: int = 1
460
+ threshold: float = 1e-5
461
+ max_iter: int = 5000
462
+ nan_method: int = 2
463
+ nan_k: int = 3
464
+ clock: str = 'm'
465
+
466
+ # Register schemas in config groups (following Hydra docs pattern)
467
+ # These can be referenced in YAML defaults lists for validation
468
+ # Format: defaults: [base_dfm_config, _self_]
469
+ cs.store(group="dfm", name="base_dfm_config", node=DFMConfigSchema)
470
+ # Also register as "model" for backward compatibility
471
+ cs.store(group="model", name="base_model_config", node=DFMConfigSchema)
472
+
473
+ # Also register standalone for direct use
474
+ cs.store(name="dfm_config_schema", node=DFMConfigSchema)
475
+ # Backward compatibility alias
476
+ cs.store(name="model_config_schema", node=DFMConfigSchema)
477
+
478
+ except Exception as e:
479
+ # If registration fails, continue without schema validation
480
+ # Configs will still work via from_dict() in scripts
481
+ warnings.warn(f"Could not register Hydra structured config schemas: {e}. "
482
+ f"Configs will still work via from_dict() but without schema validation.")