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.
@@ -0,0 +1,449 @@
1
+ """Data loading and transformation utilities for DFM estimation.
2
+
3
+ This module provides comprehensive data handling for Dynamic Factor Models:
4
+ - Configuration loading from YAML and CSV files
5
+ - Data loading from CSV with automatic date parsing
6
+ - Time series transformations (differences, percent changes, etc.)
7
+ - Data sorting and alignment with configuration
8
+
9
+ The module supports flexible configuration formats and handles common data
10
+ issues such as missing dates, inconsistent formats, and transformation errors.
11
+ """
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import io
16
+ from pathlib import Path
17
+ from typing import List, Optional, Tuple, Union
18
+ import warnings
19
+ import logging
20
+
21
+ from .config import DFMConfig
22
+
23
+ # Backward compatibility alias (deprecated - use DFMConfig)
24
+ ModelConfig = DFMConfig
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ try:
29
+ from omegaconf import OmegaConf
30
+ OMEGACONF_AVAILABLE = True
31
+ except ImportError:
32
+ OMEGACONF_AVAILABLE = False
33
+
34
+
35
+ def load_config_from_yaml(configfile: Union[str, Path]) -> DFMConfig:
36
+ """Load model configuration from YAML file.
37
+
38
+ Parameters
39
+ ----------
40
+ configfile : str or Path
41
+ Path to YAML configuration file
42
+
43
+ Returns
44
+ -------
45
+ ModelConfig
46
+ Model configuration (dataclass with validation)
47
+
48
+ Raises
49
+ ------
50
+ FileNotFoundError
51
+ If configfile does not exist
52
+ ImportError
53
+ If omegaconf is not available
54
+ ValueError
55
+ If configuration is invalid
56
+ """
57
+ if not OMEGACONF_AVAILABLE:
58
+ raise ImportError("omegaconf is required for YAML config loading. Install with: pip install omegaconf")
59
+
60
+ configfile = Path(configfile)
61
+ if not configfile.exists():
62
+ raise FileNotFoundError(f"Configuration file not found: {configfile}")
63
+
64
+ cfg = OmegaConf.load(configfile)
65
+ cfg_dict = OmegaConf.to_container(cfg, resolve=True)
66
+
67
+ # Extract model config and dfm parameters
68
+ model_dict = None
69
+ dfm_params = {}
70
+
71
+ # Handle nested structure (if config has @package model: directive)
72
+ if 'series' in cfg_dict and 'block_names' in cfg_dict:
73
+ # Direct model config structure
74
+ model_dict = cfg_dict
75
+ elif 'model' in cfg_dict:
76
+ # Nested under 'model' key (from @package model:)
77
+ model_dict = cfg_dict['model']
78
+ else:
79
+ # Try to construct from top-level keys
80
+ model_dict = cfg_dict
81
+
82
+ # Extract dfm parameters (estimation parameters like clock)
83
+ if 'dfm' in cfg_dict:
84
+ dfm_params = cfg_dict['dfm']
85
+
86
+ # Merge dfm parameters into model config
87
+ if dfm_params:
88
+ if isinstance(model_dict, dict):
89
+ model_dict = {**model_dict, **dfm_params}
90
+ else:
91
+ # If model_dict is not a dict, create new dict
92
+ model_dict = {'series': model_dict, **dfm_params} if model_dict else dfm_params
93
+
94
+ return DFMConfig.from_dict(model_dict)
95
+
96
+
97
+ def load_config_from_csv(configfile: Union[str, Path]) -> DFMConfig:
98
+ """Load model configuration from CSV file.
99
+
100
+ CSV format should have columns:
101
+ - series_id (or 'id' as alias), series_name, frequency, transformation, category, units
102
+ - Block columns (named after block names, e.g., Global, Consumption, Investment, External, or Block_Global, Block_Consumption, etc.)
103
+ - Block columns contain 0 or 1 (1 = series loads on that block)
104
+
105
+ Note: API/database-specific columns (data_code, item_id, api_source, country, is_kpi, etc.)
106
+ are ignored by the generic DFM module. They should be handled by application-specific adapters.
107
+
108
+ Example:
109
+ id,series_name,frequency,transformation,category,units,Block_Global,Block_Consumption,Block_Invest,Block_Extern
110
+ 0,Real GDP (Quarterly),q,pca,GDP,Billion Won,1,1,0,0
111
+ 1,Nominal GDP (Quarterly),q,pca,GDP,Billion Won,1,1,0,0
112
+
113
+ Parameters
114
+ ----------
115
+ configfile : str or Path
116
+ Path to CSV specification file
117
+
118
+ Returns
119
+ -------
120
+ ModelConfig
121
+ Model configuration object
122
+
123
+ Raises
124
+ ------
125
+ FileNotFoundError
126
+ If configfile does not exist
127
+ ValueError
128
+ If required columns are missing or data is invalid
129
+ """
130
+ configfile = Path(configfile)
131
+ if not configfile.exists():
132
+ raise FileNotFoundError(f"Configuration file not found: {configfile}")
133
+
134
+ try:
135
+ df = pd.read_csv(configfile)
136
+ except Exception as e:
137
+ raise ValueError(f"Failed to read CSV file {configfile}: {e}")
138
+
139
+ return _load_config_from_dataframe(df)
140
+
141
+
142
+ def _load_config_from_dataframe(df: pd.DataFrame) -> DFMConfig:
143
+ """Load DFMConfig from a pandas DataFrame (shared by CSV and BytesIO loading).
144
+
145
+ Parameters
146
+ ----------
147
+ df : pd.DataFrame
148
+ DataFrame with CSV columns
149
+
150
+ Returns
151
+ -------
152
+ DFMConfig
153
+ Model configuration
154
+ """
155
+ # Handle 'id' as alias for 'series_id' (for backward compatibility)
156
+ # If CSV has data_code, item_id, api_source, generate series_id from them
157
+ # Otherwise, use 'id' as fallback
158
+ if 'id' in df.columns and 'series_id' not in df.columns:
159
+ # Try to generate series_id from data_code, item_id, api_source if available
160
+ if all(col in df.columns for col in ['data_code', 'item_id', 'api_source']):
161
+ # Generate series_id: {api_source}_{data_code}_{item_id}
162
+ df['series_id'] = df.apply(
163
+ lambda row: f"{row.get('api_source', '')}_{row.get('data_code', '')}_{row.get('item_id', '')}",
164
+ axis=1
165
+ )
166
+ else:
167
+ # Fallback: use 'id' as series_id if series_id column doesn't exist
168
+ df['series_id'] = df['id'].astype(str)
169
+ elif 'id' in df.columns and 'series_id' in df.columns:
170
+ # Both exist - prefer series_id, but if it's empty, use id
171
+ df['series_id'] = df['series_id'].fillna(df['id'].astype(str))
172
+
173
+ # Required fields (DFM-relevant only - no API/database fields)
174
+ required_fields = ['series_id', 'series_name', 'frequency', 'transformation', 'category', 'units']
175
+ optional_fields = [] # No optional fields in generic DFM module
176
+
177
+ missing = [f for f in required_fields if f not in df.columns]
178
+ if missing:
179
+ raise ValueError(f"Missing required columns: {', '.join(missing)}")
180
+
181
+ # Detect block columns (all columns that are not in required_fields or optional_fields)
182
+ # Also exclude 'id' (if it was used as alias, we already created series_id from it)
183
+ # and other non-DFM metadata columns (API/database fields are ignored by generic DFM module)
184
+ # Preserve original column order from DataFrame - first block should be Global
185
+ excluded_fields = set(required_fields) | set(optional_fields) | {
186
+ 'id', 'country', 'data_code', 'item_id', 'api_source', 'api_code',
187
+ 'api_group_id', 'is_kpi', 'description', 'priority', 'is_active', 'metadata'
188
+ }
189
+ # Use DataFrame column order (not sorted) to preserve CSV structure
190
+ block_columns = [col for col in df.columns if col not in excluded_fields]
191
+
192
+ if not block_columns:
193
+ raise ValueError("No block columns found. Expected columns like 'Global', 'Consumption', etc.")
194
+
195
+ # Validate block columns contain only 0 or 1
196
+ for block_col in block_columns:
197
+ if not df[block_col].isin([0, 1]).all():
198
+ raise ValueError(f"Block column '{block_col}' must contain only 0 or 1")
199
+
200
+ # Ensure all series load on at least one block (first block should always be 1)
201
+ if block_columns[0] not in df.columns:
202
+ raise ValueError(f"First block column '{block_columns[0]}' is required")
203
+
204
+ if not (df[block_columns[0]] == 1).all():
205
+ raise ValueError(f"All series must load on the first block '{block_columns[0]}' (Global)")
206
+
207
+ # Build blocks array (N x n_blocks)
208
+ blocks_data = df[block_columns].values.astype(int)
209
+
210
+ # Convert to ModelConfig format
211
+ from .config import SeriesConfig
212
+
213
+ series_list = []
214
+ for idx, row in df.iterrows():
215
+ # Build block array from block columns
216
+ blocks = [int(row[col]) for col in block_columns]
217
+
218
+ # Create SeriesConfig with only DFM-relevant fields
219
+ # API/database fields (data_code, item_id, api_source, etc.) are ignored by generic DFM module
220
+ series_list.append(SeriesConfig(
221
+ series_id=str(row['series_id']), # Ensure string type
222
+ series_name=row['series_name'],
223
+ frequency=row['frequency'],
224
+ transformation=row['transformation'],
225
+ category=row['category'],
226
+ units=row['units'],
227
+ blocks=blocks
228
+ ))
229
+
230
+ return DFMConfig(series=series_list, block_names=block_columns)
231
+
232
+
233
+ # Note: Excel support removed - use CSV or YAML configs instead
234
+
235
+
236
+ def load_config(configfile: Union[str, Path, io.BytesIO]) -> DFMConfig:
237
+ """Load model configuration from file (auto-detects YAML or CSV).
238
+
239
+ Parameters
240
+ ----------
241
+ configfile : str, Path, or BytesIO
242
+ Path to configuration file (.yaml, .yml, or .csv), or BytesIO object with CSV content
243
+
244
+ Returns
245
+ -------
246
+ ModelConfig
247
+ Model configuration (dataclass with validation)
248
+
249
+ Raises
250
+ ------
251
+ FileNotFoundError
252
+ If configfile does not exist (for file paths)
253
+ ValueError
254
+ If file format is not supported or configuration is invalid
255
+ """
256
+ # Handle BytesIO (for database storage downloads)
257
+ if isinstance(configfile, io.BytesIO):
258
+ # Read CSV from BytesIO
259
+ configfile.seek(0) # Reset to beginning
260
+ df = pd.read_csv(configfile)
261
+ # Use the same logic as load_config_from_csv but with DataFrame
262
+ return _load_config_from_dataframe(df)
263
+
264
+ configfile = Path(configfile)
265
+ if not configfile.exists():
266
+ raise FileNotFoundError(f"Configuration file not found: {configfile}")
267
+
268
+ suffix = configfile.suffix.lower()
269
+ if suffix in ['.yaml', '.yml']:
270
+ return load_config_from_yaml(configfile)
271
+ elif suffix == '.csv':
272
+ return load_config_from_csv(configfile)
273
+ else:
274
+ raise ValueError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .csv")
275
+
276
+
277
+ def _transform_series(Z: np.ndarray, formula: str, freq: str, step: int) -> np.ndarray:
278
+ """Apply transformation to a single series."""
279
+ T = Z.shape[0]
280
+ X = np.full(T, np.nan)
281
+ t1 = step
282
+ n = step / 12
283
+
284
+ if formula == 'lin':
285
+ X[:] = Z
286
+ elif formula == 'chg':
287
+ idx = np.arange(t1, T, step)
288
+ if len(idx) > 1:
289
+ X[idx[0]] = np.nan
290
+ X[idx[1:]] = Z[idx[1:]] - Z[idx[:-1]]
291
+ elif formula == 'ch1':
292
+ idx = np.arange(12 + t1, T, step)
293
+ if len(idx) > 0:
294
+ X[idx] = Z[idx] - Z[idx - 12]
295
+ elif formula == 'pch':
296
+ idx = np.arange(t1, T, step)
297
+ if len(idx) > 1:
298
+ X[idx[0]] = np.nan
299
+ X[idx[1:]] = 100 * (Z[idx[1:]] / Z[idx[:-1]] - 1)
300
+ elif formula == 'pc1':
301
+ idx = np.arange(12 + t1, T, step)
302
+ if len(idx) > 0:
303
+ with np.errstate(divide='ignore', invalid='ignore'):
304
+ X[idx] = 100 * (Z[idx] / Z[idx - 12] - 1)
305
+ X[np.isinf(X)] = np.nan
306
+ elif formula == 'pca':
307
+ idx = np.arange(t1, T, step)
308
+ if len(idx) > 1:
309
+ X[idx[0]] = np.nan
310
+ with np.errstate(divide='ignore', invalid='ignore'):
311
+ X[idx[1:]] = 100 * ((Z[idx[1:]] / Z[idx[:-1]]) ** (1/n) - 1)
312
+ X[np.isinf(X)] = np.nan
313
+ elif formula == 'log':
314
+ with np.errstate(invalid='ignore'):
315
+ X[:] = np.log(Z)
316
+ else:
317
+ X[:] = Z
318
+
319
+ return X
320
+
321
+
322
+ def transform_data(Z: np.ndarray, Time: pd.DatetimeIndex, config: DFMConfig) -> Tuple[np.ndarray, pd.DatetimeIndex, np.ndarray]:
323
+ """Transform each data series based on specification.
324
+
325
+ Handles all frequencies: daily (d), weekly (w), monthly (m),
326
+ quarterly (q), semi-annual (sa), annual (a).
327
+ """
328
+ T, N = Z.shape
329
+ X = np.full((T, N), np.nan)
330
+
331
+ # Frequency to step mapping (step = number of base periods per observation)
332
+ # Base frequency is monthly, so step is months per observation
333
+ freq_to_step = {
334
+ 'd': 1, # Daily: 1 month per observation (aggregated to monthly)
335
+ 'w': 1, # Weekly: 1 month per observation (aggregated to monthly)
336
+ 'm': 1, # Monthly: 1 month per observation
337
+ 'q': 3, # Quarterly: 3 months per observation
338
+ 'sa': 6, # Semi-annual: 6 months per observation
339
+ 'a': 12, # Annual: 12 months per observation
340
+ }
341
+
342
+ for i in range(N):
343
+ freq = config.Frequency[i]
344
+ step = freq_to_step.get(freq, 1) # Default to 1 if unknown frequency
345
+ X[:, i] = _transform_series(Z[:, i], config.Transformation[i], freq, step)
346
+
347
+ # Drop initial observations based on highest frequency in data
348
+ # Find maximum step (longest period) to determine drop period
349
+ max_step = max([freq_to_step.get(f, 1) for f in config.Frequency])
350
+ # Drop period should be at least max_step + 1 to handle transformations
351
+ drop = max(4, max_step + 1) # At least 4 for quarterly, more for annual
352
+
353
+ if T > drop:
354
+ return X[drop:], Time[drop:], Z[drop:]
355
+ return X, Time, Z
356
+
357
+
358
+ def read_data(datafile: Union[str, Path]) -> Tuple[np.ndarray, pd.DatetimeIndex, List[str]]:
359
+ """Read data from CSV file.
360
+
361
+ CSV file should have:
362
+ - First column: Date (YYYY-MM-DD format or pandas parseable)
363
+ - Subsequent columns: Series data (one column per series)
364
+ - Header row: Series IDs (matching config.SeriesID)
365
+ """
366
+ datafile = Path(datafile)
367
+ if not datafile.exists():
368
+ raise FileNotFoundError(f"Data file not found: {datafile}")
369
+
370
+ # Read CSV file
371
+ try:
372
+ df = pd.read_csv(datafile, index_col=0, parse_dates=True)
373
+ except Exception as e:
374
+ raise ValueError(f"Failed to read CSV file {datafile}: {e}")
375
+
376
+ mnemonics = df.columns.tolist()
377
+ Time = df.index
378
+ Z = df.values.astype(float)
379
+
380
+ return Z, Time, mnemonics
381
+
382
+
383
+ def sort_data(Z: np.ndarray, Mnem: List[str], config: DFMConfig) -> Tuple[np.ndarray, List[str]]:
384
+ """Sort series to match configuration order."""
385
+ in_config = [m in config.SeriesID for m in Mnem]
386
+ Mnem_filt = [m for m, in_c in zip(Mnem, in_config) if in_c]
387
+ Z_filt = Z[:, in_config]
388
+
389
+ perm = [Mnem_filt.index(sid) for sid in config.SeriesID]
390
+ return Z_filt[:, perm], [Mnem_filt[i] for i in perm]
391
+
392
+
393
+ def load_data(datafile: Union[str, Path], config: DFMConfig,
394
+ sample_start: Optional[Union[pd.Timestamp, str]] = None) -> Tuple[np.ndarray, pd.DatetimeIndex, np.ndarray]:
395
+ """Load and transform data from CSV file.
396
+
397
+ This function reads data from a CSV file, sorts it to match the model
398
+ configuration (from CSV or YAML), and applies the specified transformations.
399
+
400
+ Note: For database-backed applications, create adapters that implement
401
+ the same interface (return X, Time, Z arrays).
402
+
403
+ Parameters
404
+ ----------
405
+ datafile : str or Path
406
+ Path to CSV data file (.csv)
407
+ config : ModelConfig
408
+ Model configuration object (from CSV or YAML)
409
+ sample_start : pd.Timestamp or str, optional
410
+ Start date for sample (YYYY-MM-DD). If None, uses all available data.
411
+ Data before this date will be dropped.
412
+
413
+ Returns
414
+ -------
415
+ X : np.ndarray
416
+ Transformed data matrix (T x N), ready for DFM estimation
417
+ Time : pd.DatetimeIndex
418
+ Time index for the data
419
+ Z : np.ndarray
420
+ Original untransformed data (T x N), for reference
421
+ """
422
+ print('Loading data...')
423
+
424
+ datafile = Path(datafile)
425
+ if datafile.suffix.lower() != '.csv':
426
+ raise ValueError('Only CSV files supported. Use database for production data loading.')
427
+
428
+ if not datafile.exists():
429
+ raise FileNotFoundError(f"Data file not found: {datafile}")
430
+
431
+ # Read data from CSV file
432
+ Z, Time, Mnem = read_data(datafile)
433
+
434
+ # Process data: sort to match config order, then transform
435
+ Z, _ = sort_data(Z, Mnem, config)
436
+ X, Time, Z = transform_data(Z, Time, config)
437
+
438
+ # Apply sample_start filtering
439
+ if sample_start is not None:
440
+ if isinstance(sample_start, str):
441
+ sample_start = pd.to_datetime(sample_start)
442
+ mask = Time >= sample_start
443
+ Time, X, Z = Time[mask], X[mask], Z[mask]
444
+
445
+ return X, Time, Z
446
+
447
+
448
+ # Database adapters are application-specific and should be implemented separately.
449
+ # This keeps the DFM module generic and database-agnostic.