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 +28 -0
- dfm_python/config.py +435 -0
- dfm_python/data_loader.py +500 -0
- dfm_python/dfm.py +2356 -0
- dfm_python/kalman.py +403 -0
- dfm_python/news.py +557 -0
- dfm_python-0.1.0.dist-info/METADATA +271 -0
- dfm_python-0.1.0.dist-info/RECORD +9 -0
- dfm_python-0.1.0.dist-info/WHEEL +4 -0
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.")
|