dfm-python 0.1.8__py3-none-any.whl → 0.2.1__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 CHANGED
@@ -27,11 +27,14 @@ Example (High-level API - Recommended):
27
27
  >>> import dfm_python as dfm
28
28
  >>> dfm.load_config('config/default.yaml')
29
29
  >>> dfm.load_data('data/sample_data.csv')
30
- >>> dfm.train()
31
- >>> factors = dfm.result.Z # Direct access to results
30
+ >>> dfm.train(max_iter=1)
31
+ >>> Xf, Zf = dfm.predict(horizon=6)
32
+ >>> dfm.plot(kind='factor', factor_index=0, forecast_horizon=6, save_path='outputs/factor_forecast.png')
33
+ >>> factors = dfm.get_result().Z # Access factors via result
32
34
 
33
35
  Example (Low-level API - For advanced usage):
34
- >>> from dfm_python import load_config, load_data, dfm, DFMConfig, SeriesConfig
36
+ >>> from dfm_python import DFM, DFMConfig, SeriesConfig
37
+ >>> from dfm_python.data_loader import load_data
35
38
  >>> # Option 1: Load from YAML
36
39
  >>> config = load_config('config.yaml')
37
40
  >>> # Option 2: Create directly
@@ -40,41 +43,61 @@ Example (Low-level API - For advanced usage):
40
43
  ... block_names=['Global']
41
44
  ... )
42
45
  >>> X, Time, _ = load_data('data.csv', config) # or use database adapter
43
- >>> result = dfm(X, config)
46
+ >>> model = DFM()
47
+ >>> result = model.fit(X, config)
44
48
  >>> factors = result.Z # Extract estimated factors
45
49
 
46
- For detailed documentation, see the README.md file.
50
+ For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
47
51
  """
48
52
 
49
- __version__ = "0.1.7"
50
-
51
- from .config import DFMConfig, SeriesConfig, BlockConfig
52
- from .data_loader import (
53
- load_config_from_yaml,
54
- load_config_from_spec,
55
- load_data,
56
- transform_data
53
+ __version__ = "0.2.1"
54
+
55
+ from .config import DFMConfig, SeriesConfig, BlockConfig, Params, DEFAULT_GLOBAL_BLOCK_NAME
56
+ from .config import (
57
+ ConfigSource,
58
+ YamlSource,
59
+ DictSource,
60
+ SpecCSVSource,
61
+ HydraSource,
62
+ MergedConfigSource,
63
+ make_config_source,
57
64
  )
58
- from .dfm import DFMResult, dfm, calculate_rmse, diagnose_series, print_series_diagnosis
65
+ from .data_loader import transform_data
66
+ from .dfm import DFMResult, calculate_rmse, diagnose_series, print_series_diagnosis
67
+ from .dfm import DFM as _DFMCore # Core DFM class from dfm.py
59
68
  from .kalman import run_kf, skf, fis, miss_data
60
69
  from .news import update_nowcast, news_dfm, para_const
61
70
 
62
- # Import high-level API
63
- from .dfm_api import DFM, _dfm_instance
71
+ # Import high-level API (extends core DFM with convenience methods)
72
+ from .api import DFM, _dfm_instance, from_yaml, from_spec, from_spec_df, from_dict
64
73
 
65
74
  # Module-level API: expose singleton instance methods and properties
66
75
  # This allows: import dfm_python as dfm; dfm.load_config(...); dfm.result
67
- def load_config(config):
68
- """Load configuration (module-level convenience function)."""
69
- return _dfm_instance.load_config(config)
70
-
71
- def load_config_from_yaml(yaml_path):
72
- """Load configuration from YAML (module-level convenience function)."""
73
- return _dfm_instance.load_config_from_yaml(yaml_path)
74
-
75
- def load_config_from_spec(spec_path):
76
- """Load configuration from spec CSV (module-level convenience function)."""
77
- return _dfm_instance.load_config_from_spec(spec_path)
76
+ def load_config(
77
+ source=None,
78
+ *,
79
+ yaml=None,
80
+ mapping=None,
81
+ spec=None,
82
+ hydra=None,
83
+ base=None,
84
+ override=None,
85
+ config=None,
86
+ ):
87
+ """Load configuration (module-level convenience function).
88
+
89
+ See DFM.load_config() for parameter documentation.
90
+ """
91
+ return _dfm_instance.load_config(
92
+ source=source,
93
+ yaml=yaml,
94
+ mapping=mapping,
95
+ spec=spec,
96
+ hydra=hydra,
97
+ base=base,
98
+ override=override,
99
+ config=config,
100
+ )
78
101
 
79
102
  def load_data(data_path=None, data=None, **kwargs):
80
103
  """Load data (module-level convenience function)."""
@@ -84,6 +107,14 @@ def train(threshold=None, max_iter=None, **kwargs):
84
107
  """Train the model (module-level convenience function)."""
85
108
  return _dfm_instance.train(threshold=threshold, max_iter=max_iter, **kwargs)
86
109
 
110
+ def predict(horizon=12, **kwargs):
111
+ """Forecast using the trained model (module-level convenience function)."""
112
+ return _dfm_instance.predict(horizon=horizon, **kwargs)
113
+
114
+ def plot(**kwargs):
115
+ """Plot common visualizations (module-level convenience function)."""
116
+ return _dfm_instance.plot(**kwargs)
117
+
87
118
  def reset():
88
119
  """Reset state (module-level convenience function)."""
89
120
  return _dfm_instance.reset()
@@ -132,14 +163,21 @@ def __getattr__(name):
132
163
 
133
164
  __all__ = [
134
165
  # Core classes
135
- 'DFMConfig', 'SeriesConfig', 'BlockConfig', 'DFM',
166
+ 'DFMConfig', 'SeriesConfig', 'BlockConfig', 'Params', 'DFM',
167
+ # Constants
168
+ 'DEFAULT_GLOBAL_BLOCK_NAME',
169
+ # Config sources
170
+ 'ConfigSource', 'YamlSource', 'DictSource', 'SpecCSVSource', 'HydraSource',
171
+ 'MergedConfigSource', 'make_config_source',
136
172
  # High-level API (module-level - recommended)
137
- 'load_config', 'load_config_from_yaml', 'load_config_from_spec',
138
- 'load_data', 'train', 'reset',
173
+ 'load_config',
174
+ 'load_data', 'train', 'predict', 'plot', 'reset',
139
175
  'get_config', 'get_data', 'get_time', 'get_result', 'get_original_data',
176
+ # Convenience constructors (cleaner API)
177
+ 'from_yaml', 'from_spec', 'from_spec_df', 'from_dict',
140
178
  # Low-level API (functional interface - advanced usage)
141
179
  'transform_data',
142
- 'DFMResult', 'dfm', 'calculate_rmse', 'diagnose_series', 'print_series_diagnosis',
180
+ 'DFMResult', 'calculate_rmse', 'diagnose_series', 'print_series_diagnosis',
143
181
  'run_kf', 'skf', 'fis', 'miss_data',
144
182
  'update_nowcast', 'news_dfm', 'para_const',
145
183
  ]
dfm_python/api.py ADDED
@@ -0,0 +1,420 @@
1
+ """High-level API for Dynamic Factor Model (convenience layer).
2
+
3
+ This module provides an object-oriented interface for DFM estimation
4
+ on top of the core `DFM` in `dfm.py`. Use this for a simpler, more
5
+ intuitive workflow:
6
+
7
+ Example:
8
+ >>> import dfm_python as dfm
9
+ >>> dfm.load_config('config/default.yaml')
10
+ >>> dfm.load_data('data/sample_data.csv')
11
+ >>> dfm.train(max_iter=1)
12
+ >>> # Forecast and plot
13
+ >>> Xf, Zf = dfm.predict(horizon=6)
14
+ >>> dfm.plot(kind='factor', factor_index=0, forecast_horizon=6, save_path='outputs/factor_forecast.png')
15
+ >>> # Access results
16
+ >>> result = dfm.get_result()
17
+ >>> factors = result.Z
18
+ """
19
+
20
+ from typing import Optional, Union, Dict, Any, Tuple
21
+ from pathlib import Path
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from .config import DFMConfig, Params
26
+ from .data_loader import load_data as _load_data
27
+ from .config import (
28
+ make_config_source,
29
+ ConfigSource,
30
+ MergedConfigSource,
31
+ )
32
+ from .dfm import DFM as _DFMCore, DFMResult
33
+ from .core.helpers import safe_get_method, safe_get_attr
34
+
35
+
36
+ class DFM(_DFMCore):
37
+ """High-level API for Dynamic Factor Model estimation.
38
+
39
+ This class provides a simple, object-oriented interface for DFM operations.
40
+ It maintains state (config, data, results) and provides convenient methods
41
+ for loading configuration, data, training, prediction, and plotting.
42
+
43
+ Example:
44
+ >>> import dfm_python as dfm
45
+ >>> dfm.load_config('config/default.yaml')
46
+ >>> dfm.load_data('data/sample_data.csv')
47
+ >>> dfm.train(max_iter=1)
48
+ >>> Xf, Zf = dfm.predict(horizon=6)
49
+ >>> dfm.plot(kind='factor', factor_index=0, forecast_horizon=6, save_path='outputs/factor_forecast.png')
50
+ >>> print(dfm.get_result().converged)
51
+ >>> factors = dfm.get_result().Z
52
+ """
53
+
54
+ def __init__(self):
55
+ """Initialize DFM instance with empty state."""
56
+ super().__init__()
57
+ self._time: Optional[np.ndarray] = None
58
+ self._original_data: Optional[np.ndarray] = None
59
+
60
+ @property
61
+ def data(self) -> Optional[np.ndarray]:
62
+ """Get current data matrix (T x N)."""
63
+ return self._data
64
+
65
+ @property
66
+ def time(self) -> Optional[np.ndarray]:
67
+ """Get time index for data."""
68
+ return self._time
69
+
70
+ @property
71
+ def original_data(self) -> Optional[np.ndarray]:
72
+ """Get original (untransformed) data matrix."""
73
+ return self._original_data
74
+
75
+ def load_config(
76
+ self,
77
+ source: Optional[Union[str, Path, Dict[str, Any], DFMConfig, ConfigSource, Any]] = None,
78
+ *,
79
+ yaml: Optional[Union[str, Path]] = None,
80
+ mapping: Optional[Union[Dict[str, Any], Any]] = None,
81
+ spec: Optional[Union[str, Path]] = None,
82
+ hydra: Optional[Union[Dict[str, Any], 'DictConfig']] = None,
83
+ base: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
84
+ override: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
85
+ config: Optional[Any] = None,
86
+ ) -> 'DFM':
87
+ """Load configuration from various sources.
88
+
89
+ Unified interface for YAML files, dictionaries, spec CSV, or Hydra configs.
90
+ Supports merging configurations (e.g., YAML base + Spec CSV series).
91
+ """
92
+ # Handle direct DFMConfig
93
+ if isinstance(source, DFMConfig):
94
+ self._config = source
95
+ return self
96
+
97
+ # Support (mapping + spec) convenience: treat as base+override
98
+ if (mapping is not None or config is not None) and spec is not None:
99
+ base_source = make_config_source(mapping=mapping if mapping is not None else config)
100
+ override_source = make_config_source(spec=spec)
101
+ merged_source = MergedConfigSource(base_source, override_source)
102
+ self._config = merged_source.load()
103
+ return self
104
+
105
+ # Handle merging
106
+ if base is not None or override is not None:
107
+ if base is None or override is None:
108
+ raise ValueError("Both 'base' and 'override' must be provided for merging.")
109
+ base_source = make_config_source(base)
110
+ override_source = make_config_source(override)
111
+ merged_source = MergedConfigSource(base_source, override_source)
112
+ self._config = merged_source.load()
113
+ return self
114
+
115
+ # Create appropriate source adapter
116
+ source_adapter = make_config_source(
117
+ source=source,
118
+ yaml=yaml,
119
+ mapping=mapping if mapping is not None else config,
120
+ spec=spec,
121
+ hydra=hydra,
122
+ )
123
+
124
+ self._config = source_adapter.load()
125
+ return self
126
+
127
+ def load_data(self,
128
+ data_path: Optional[Union[str, Path]] = None,
129
+ data: Optional[np.ndarray] = None,
130
+ **kwargs) -> 'DFM':
131
+ """Load data from file or use provided array."""
132
+ if self._config is None:
133
+ raise ValueError("Configuration must be loaded before loading data. "
134
+ "Call load_config() first.")
135
+
136
+ if data_path is not None:
137
+ self._data, self._time, self._original_data = _load_data(
138
+ data_path, self._config, **kwargs
139
+ )
140
+ elif data is not None:
141
+ # If data is provided directly, we still need a time index
142
+ self._data = data
143
+ if 'time' in kwargs:
144
+ self._time = kwargs['time']
145
+ else:
146
+ # Generate default monthly-end index
147
+ self._time = pd.date_range(
148
+ start='2000-01-01',
149
+ periods=len(data),
150
+ freq='ME'
151
+ )
152
+ self._original_data = data
153
+ else:
154
+ raise ValueError("Either data_path or data must be provided.")
155
+
156
+ return self
157
+
158
+ def train(self,
159
+ threshold: Optional[float] = None,
160
+ max_iter: Optional[int] = None,
161
+ **kwargs) -> 'DFM':
162
+ """Train the DFM model using EM algorithm."""
163
+ if self._config is None:
164
+ raise ValueError("Configuration must be loaded before training. "
165
+ "Call load_config() first.")
166
+ if self._data is None:
167
+ raise ValueError("Data must be loaded before training. "
168
+ "Call load_data() first.")
169
+
170
+ self._result = self.fit(
171
+ self._data,
172
+ self._config,
173
+ threshold=threshold,
174
+ max_iter=max_iter,
175
+ **kwargs
176
+ )
177
+ # Attach metadata for convenient OOP access
178
+ if self._time is not None:
179
+ self._result.time_index = self._time
180
+ series_ids = safe_get_method(self._config, 'get_series_ids')
181
+ if series_ids is not None:
182
+ self._result.series_ids = series_ids
183
+ block_names = safe_get_attr(self._config, 'block_names')
184
+ if block_names is not None:
185
+ self._result.block_names = block_names
186
+ return self
187
+
188
+ def reset(self) -> 'DFM':
189
+ """Reset all state (config, data, results)."""
190
+ super().__init__()
191
+ self._time = None
192
+ self._original_data = None
193
+ return self
194
+
195
+ # ---------------------------------------------------------------------
196
+ # Inference API
197
+ # ---------------------------------------------------------------------
198
+ def predict(self, horizon: int = 12, *, return_series: bool = True, return_factors: bool = True) -> Union[Tuple[np.ndarray, np.ndarray], np.ndarray]:
199
+ """Forecast series and/or factors for a given horizon using the trained model.
200
+
201
+ Parameters
202
+ ----------
203
+ horizon : int
204
+ Number of periods ahead to forecast (default: 12)
205
+ return_series : bool
206
+ Whether to return forecasted series (default: True)
207
+ return_factors : bool
208
+ Whether to return forecasted factors (default: True)
209
+
210
+ Returns
211
+ -------
212
+ If both return_series and return_factors are True:
213
+ Tuple[np.ndarray, np.ndarray]
214
+ (X_forecast, Z_forecast) where X_forecast is (horizon x N) and Z_forecast is (horizon x m)
215
+ If only return_series is True:
216
+ np.ndarray
217
+ X_forecast (horizon x N)
218
+ If only return_factors is True:
219
+ np.ndarray
220
+ Z_forecast (horizon x m)
221
+ """
222
+ if self._result is None:
223
+ raise ValueError("Model must be trained before calling predict(). Call train() first.")
224
+ if horizon <= 0:
225
+ raise ValueError("horizon must be a positive integer.")
226
+
227
+ A = self._result.A
228
+ C = self._result.C
229
+ Wx = self._result.Wx
230
+ Mx = self._result.Mx
231
+ Z_last = self._result.Z[-1, :]
232
+
233
+ # Forecast latent factors: Z_{t+h} = A^h Z_t (deterministic)
234
+ Z_forecast = np.zeros((horizon, Z_last.shape[0]))
235
+ Z_forecast[0, :] = A @ Z_last
236
+ for h in range(1, horizon):
237
+ Z_forecast[h, :] = A @ Z_forecast[h - 1, :]
238
+
239
+ # Forecast observed standardized series then denormalize
240
+ X_forecast_std = Z_forecast @ C.T
241
+ X_forecast = X_forecast_std * Wx + Mx
242
+
243
+ if return_series and return_factors:
244
+ return X_forecast, Z_forecast
245
+ if return_series:
246
+ return X_forecast
247
+ return Z_forecast
248
+
249
+ def plot(self, *, kind: str = 'factor', factor_index: int = 0, forecast_horizon: Optional[int] = None,
250
+ save_path: Optional[Union[str, Path]] = None, show: bool = False):
251
+ """Quick plotting utility for common visualizations."""
252
+ if self._result is None:
253
+ raise ValueError("No results to plot. Train the model first.")
254
+ try:
255
+ import matplotlib.pyplot as plt # Local import to avoid hard dependency at import time
256
+ except Exception as e:
257
+ raise RuntimeError(f"matplotlib is required for plotting: {e}")
258
+
259
+ Z = self._result.Z
260
+ if factor_index < 0 or factor_index >= Z.shape[1]:
261
+ raise ValueError(f"factor_index out of range: {factor_index} (num factors: {Z.shape[1]})")
262
+
263
+ # Build time axis
264
+ if self._time is not None:
265
+ time_hist = pd.to_datetime(self._time)
266
+ else:
267
+ time_hist = pd.date_range(start='2000-01-01', periods=Z.shape[0], freq='ME')
268
+
269
+ plt.figure(figsize=(12, 4))
270
+ plt.plot(time_hist, Z[:, factor_index], 'b-', linewidth=2, label='Historical factor')
271
+
272
+ if forecast_horizon is not None and forecast_horizon > 0:
273
+ Z_fore = self.predict(forecast_horizon, return_series=False, return_factors=True)
274
+ # Build forecast dates
275
+ last_date = time_hist[-1]
276
+ # Use same monthly-end spacing; if freq missing, fallback to monthly-end
277
+ try:
278
+ forecast_dates = pd.date_range(start=last_date + pd.tseries.frequencies.to_offset('ME'),
279
+ periods=forecast_horizon, freq='ME')
280
+ except Exception:
281
+ forecast_dates = pd.date_range(start=last_date + pd.Timedelta(days=30),
282
+ periods=forecast_horizon, freq='ME')
283
+ plt.plot(forecast_dates, Z_fore[:, factor_index], 'r--', linewidth=2, label='Forecast factor')
284
+ plt.axvline(x=last_date, color='gray', linestyle=':', linewidth=1, label='Forecast start')
285
+
286
+ title_block = None
287
+ if hasattr(self._result, 'block_names') and self._result.block_names:
288
+ title_block = self._result.block_names[0] if factor_index == 0 else None
289
+ plt.title(f"Factor {factor_index}" + (f" ({title_block})" if title_block else ""))
290
+ plt.xlabel("Date")
291
+ plt.ylabel("Factor value")
292
+ plt.grid(True, alpha=0.3)
293
+ plt.legend()
294
+ plt.tight_layout()
295
+
296
+ out_path = None
297
+ if save_path is not None:
298
+ save_path = Path(save_path)
299
+ save_path.parent.mkdir(parents=True, exist_ok=True)
300
+ plt.savefig(str(save_path), dpi=150, bbox_inches='tight')
301
+ out_path = save_path
302
+ if show:
303
+ plt.show()
304
+ else:
305
+ plt.close()
306
+ return out_path
307
+
308
+
309
+ # Create a singleton instance for module-level usage
310
+ _dfm_instance = DFM()
311
+
312
+
313
+ # Module-level convenience functions that delegate to the singleton
314
+ def load_config(
315
+ source: Optional[Union[str, Path, Dict[str, Any], DFMConfig, ConfigSource]] = None,
316
+ *,
317
+ yaml: Optional[Union[str, Path]] = None,
318
+ mapping: Optional[Dict[str, Any]] = None,
319
+ spec: Optional[Union[str, Path]] = None,
320
+ hydra: Optional[Union[Dict[str, Any], 'DictConfig']] = None,
321
+ base: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
322
+ override: Optional[Union[str, Path, Dict[str, Any], ConfigSource]] = None,
323
+ ) -> DFM:
324
+ """Load configuration (module-level convenience function)."""
325
+ return _dfm_instance.load_config(
326
+ source=source,
327
+ yaml=yaml,
328
+ mapping=mapping,
329
+ spec=spec,
330
+ hydra=hydra,
331
+ base=base,
332
+ override=override,
333
+ )
334
+
335
+
336
+ def load_data(data_path: Optional[Union[str, Path]] = None,
337
+ data: Optional[np.ndarray] = None,
338
+ **kwargs) -> DFM:
339
+ """Load data (module-level convenience function)."""
340
+ return _dfm_instance.load_data(data_path=data_path, data=data, **kwargs)
341
+
342
+
343
+ def train(threshold: Optional[float] = None,
344
+ max_iter: Optional[int] = None,
345
+ **kwargs) -> DFM:
346
+ """Train the model (module-level convenience function)."""
347
+ return _dfm_instance.train(threshold=threshold, max_iter=max_iter, **kwargs)
348
+
349
+
350
+ def predict(horizon: int = 12, **kwargs):
351
+ """Forecast using the trained model (module-level convenience function)."""
352
+ return _dfm_instance.predict(horizon=horizon, **kwargs)
353
+
354
+
355
+ def plot(**kwargs):
356
+ """Plot common visualizations (module-level convenience function)."""
357
+ return _dfm_instance.plot(**kwargs)
358
+
359
+
360
+ def reset() -> DFM:
361
+ """Reset state (module-level convenience function)."""
362
+ return _dfm_instance.reset()
363
+
364
+
365
+ # Convenience constructors for cleaner API
366
+ def from_yaml(yaml_path: Union[str, Path]) -> DFM:
367
+ """Load configuration from YAML file (convenience constructor)."""
368
+ return _dfm_instance.load_config(yaml=yaml_path)
369
+
370
+
371
+ def from_spec(spec_path: Union[str, Path], params: Optional[Params] = None) -> DFM:
372
+ """Load configuration from spec CSV file (convenience constructor)."""
373
+ from .config import SpecCSVSource
374
+
375
+ if params is None:
376
+ params = Params()
377
+
378
+ # Load config from spec CSV using ConfigSource pattern
379
+ spec_source = SpecCSVSource(spec_path)
380
+ config = spec_source.load()
381
+
382
+ # Override estimation parameters from params
383
+ for key, value in params.__dict__.items():
384
+ if hasattr(config, key):
385
+ setattr(config, key, value)
386
+
387
+ _dfm_instance._config = config
388
+ return _dfm_instance
389
+
390
+
391
+ def from_spec_df(spec_df: pd.DataFrame, params: Optional[Params] = None) -> DFM:
392
+ """Load configuration from spec DataFrame (convenience constructor)."""
393
+ from .data_loader import _load_config_from_dataframe
394
+
395
+ if params is None:
396
+ params = Params()
397
+
398
+ # Load config from DataFrame (creates DFMConfig with series/blocks from spec)
399
+ config = _load_config_from_dataframe(spec_df)
400
+
401
+ # Override estimation parameters from params
402
+ for key, value in params.__dict__.items():
403
+ if hasattr(config, key):
404
+ setattr(config, key, value)
405
+
406
+ _dfm_instance._config = config
407
+ return _dfm_instance
408
+
409
+
410
+ def from_dict(mapping: Dict[str, Any]) -> DFM:
411
+ """Load configuration from dictionary (convenience constructor)."""
412
+ return _dfm_instance.load_config(mapping=mapping)
413
+
414
+
415
+ # Expose singleton instance for direct access
416
+ # Users can access: dfm.config, dfm.data, dfm.result, etc.
417
+ __all__ = ['DFM', 'load_config', 'load_data', 'train', 'reset',
418
+ 'from_yaml', 'from_spec', 'from_spec_df', 'from_dict']
419
+
420
+