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