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