dfm-python 0.1.5__tar.gz → 0.1.6__tar.gz

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.
Files changed (32) hide show
  1. {dfm_python-0.1.5 → dfm_python-0.1.6}/PKG-INFO +2 -2
  2. {dfm_python-0.1.5 → dfm_python-0.1.6}/README.md +1 -1
  3. {dfm_python-0.1.5 → dfm_python-0.1.6}/pyproject.toml +1 -1
  4. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/__init__.py +3 -3
  5. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/dfm.py +255 -11
  6. dfm_python-0.1.5/test_comprehensive.py +0 -510
  7. {dfm_python-0.1.5 → dfm_python-0.1.6}/.gitignore +0 -0
  8. {dfm_python-0.1.5 → dfm_python-0.1.6}/.python-version +0 -0
  9. {dfm_python-0.1.5 → dfm_python-0.1.6}/CHANGELOG.md +0 -0
  10. {dfm_python-0.1.5 → dfm_python-0.1.6}/LICENSE +0 -0
  11. {dfm_python-0.1.5 → dfm_python-0.1.6}/MANIFEST.in +0 -0
  12. {dfm_python-0.1.5 → dfm_python-0.1.6}/config/example_config.yaml +0 -0
  13. {dfm_python-0.1.5 → dfm_python-0.1.6}/config/example_config_macro_data.yaml +0 -0
  14. {dfm_python-0.1.5 → dfm_python-0.1.6}/data/example_macro_data.csv +0 -0
  15. {dfm_python-0.1.5 → dfm_python-0.1.6}/data/sample_data.csv +0 -0
  16. {dfm_python-0.1.5 → dfm_python-0.1.6}/scripts/inference.py +0 -0
  17. {dfm_python-0.1.5 → dfm_python-0.1.6}/scripts/train.py +0 -0
  18. {dfm_python-0.1.5 → dfm_python-0.1.6}/spec/example_spec.csv +0 -0
  19. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/__init__.py +0 -0
  20. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/config.py +0 -0
  21. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/data_loader.py +0 -0
  22. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/kalman.py +0 -0
  23. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/news.py +0 -0
  24. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/__init__.py +0 -0
  25. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/aggregation.py +0 -0
  26. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/data_utils.py +0 -0
  27. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/__init__.py +0 -0
  28. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_dfm.py +0 -0
  29. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_kalman.py +0 -0
  30. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_news.py +0 -0
  31. {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_synthetic.py +0 -0
  32. {dfm_python-0.1.5 → dfm_python-0.1.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dfm-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Dynamic Factor Model (DFM) estimation and nowcasting in Python
5
5
  Project-URL: Homepage, https://github.com/yourusername/dfm-python
6
6
  Project-URL: Documentation, https://github.com/yourusername/dfm-python#readme
@@ -555,7 +555,7 @@ If you use this package in your research, please cite:
555
555
  @software{dfm-python,
556
556
  title = {dfm-python: Dynamic Factor Models for Nowcasting and Forecasting},
557
557
  author = {DFM Python Contributors},
558
- year = {2024},
558
+ year = {2025},
559
559
  url = {https://pypi.org/project/dfm-python/},
560
560
  version = {0.1.5}
561
561
  }
@@ -513,7 +513,7 @@ If you use this package in your research, please cite:
513
513
  @software{dfm-python,
514
514
  title = {dfm-python: Dynamic Factor Models for Nowcasting and Forecasting},
515
515
  author = {DFM Python Contributors},
516
- year = {2024},
516
+ year = {2025},
517
517
  url = {https://pypi.org/project/dfm-python/},
518
518
  version = {0.1.5}
519
519
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dfm-python"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  description = "Dynamic Factor Model (DFM) estimation and nowcasting in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -30,13 +30,13 @@ Example:
30
30
  For detailed documentation, see the README.md file.
31
31
  """
32
32
 
33
- __version__ = "0.1.4"
33
+ __version__ = "0.1.6"
34
34
 
35
35
  from .config import DFMConfig
36
36
  from .data_loader import (
37
37
  load_config, load_config_from_yaml, load_config_from_csv, load_data, transform_data
38
38
  )
39
- from .dfm import DFMResult, dfm, calculate_rmse
39
+ from .dfm import DFMResult, dfm, calculate_rmse, diagnose_series, print_series_diagnosis
40
40
  from .kalman import run_kf, skf, fis, miss_data
41
41
  from .news import update_nowcast, news_dfm, para_const
42
42
 
@@ -51,7 +51,7 @@ __all__ = [
51
51
  'ModelConfig', # Alias for DFMConfig
52
52
  'load_config', 'load_config_from_yaml', 'load_config_from_csv',
53
53
  'load_data', 'transform_data',
54
- 'DFMResult', 'dfm', 'calculate_rmse',
54
+ 'DFMResult', 'dfm', 'calculate_rmse', 'diagnose_series', 'print_series_diagnosis',
55
55
  'run_kf', 'skf', 'fis', 'miss_data',
56
56
  'update_nowcast', 'news_dfm', 'para_const',
57
57
  # Deprecated aliases
@@ -646,6 +646,20 @@ class DFMResult:
646
646
  how many factors are in each block structure.
647
647
  p : int
648
648
  Number of lags in the autoregressive structure of factors. Typically p=1.
649
+ rmse : float, optional
650
+ Overall RMSE on original scale (averaged across all series).
651
+ rmse_per_series : np.ndarray, optional
652
+ RMSE per series on original scale (N,).
653
+ rmse_std : float, optional
654
+ Overall RMSE on standardized scale (averaged across all series).
655
+ rmse_std_per_series : np.ndarray, optional
656
+ RMSE per series on standardized scale (N,).
657
+ converged : bool, optional
658
+ Whether EM algorithm converged.
659
+ num_iter : int, optional
660
+ Number of EM iterations performed.
661
+ loglik : float, optional
662
+ Final log-likelihood value.
649
663
 
650
664
  Examples
651
665
  --------
@@ -673,8 +687,10 @@ class DFMResult:
673
687
  converged: bool = False # Whether EM algorithm converged
674
688
  num_iter: int = 0 # Number of iterations completed
675
689
  loglik: float = -np.inf # Final log-likelihood
676
- rmse: Optional[float] = None # Root Mean Squared Error (averaged across all series)
677
- rmse_per_series: Optional[np.ndarray] = None # RMSE per series (N,)
690
+ rmse: Optional[float] = None # Overall RMSE (original scale)
691
+ rmse_per_series: Optional[np.ndarray] = None # RMSE per series (original scale)
692
+ rmse_std: Optional[float] = None # Overall RMSE (standardized scale)
693
+ rmse_std_per_series: Optional[np.ndarray] = None # RMSE per series (standardized scale)
678
694
 
679
695
 
680
696
  def em_converged(loglik: float, previous_loglik: float, threshold: float = 1e-4,
@@ -1729,12 +1745,11 @@ def em_step(y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
1729
1745
  num_blocks = blocks.shape[1]
1730
1746
 
1731
1747
  # Get config defaults if not provided
1748
+ # Create a minimal config object with defaults for backward compatibility
1749
+ # We use a simple object to avoid DFMConfig validation which requires series
1732
1750
  if config is None:
1733
- # Create a minimal config with defaults for backward compatibility
1734
- from dfm_python.config import DFMConfig
1735
- config = DFMConfig(
1736
- series=[],
1737
- block_names=[],
1751
+ from types import SimpleNamespace
1752
+ config = SimpleNamespace(
1738
1753
  clip_ar_coefficients=True,
1739
1754
  ar_clip_min=-0.99,
1740
1755
  ar_clip_max=0.99,
@@ -2953,6 +2968,12 @@ def dfm(X: np.ndarray, config: DFMConfig, threshold: Optional[float] = None, max
2953
2968
  # Only use non-missing observations for comparison
2954
2969
  rmse_overall, rmse_per_series = calculate_rmse(X, X_sm, mask=None)
2955
2970
 
2971
+ # Calculate standardized RMSE for better comparison across series
2972
+ # Standardize both actual and predicted using the same Wx and Mx
2973
+ X_std = (X - Mx_clean) / np.where(Wx_clean != 0, Wx_clean, 1.0)
2974
+ x_sm_std = x_sm # Already standardized
2975
+ rmse_std_overall, rmse_std_per_series = calculate_rmse(X_std, x_sm_std, mask=None)
2976
+
2956
2977
  # Create result structure
2957
2978
  Res = DFMResult(
2958
2979
  x_sm=x_sm,
@@ -2972,7 +2993,9 @@ def dfm(X: np.ndarray, config: DFMConfig, threshold: Optional[float] = None, max
2972
2993
  num_iter=num_iter,
2973
2994
  loglik=loglik,
2974
2995
  rmse=rmse_overall,
2975
- rmse_per_series=rmse_per_series
2996
+ rmse_per_series=rmse_per_series,
2997
+ rmse_std=rmse_std_overall,
2998
+ rmse_std_per_series=rmse_std_per_series
2976
2999
  )
2977
3000
 
2978
3001
  # Display output tables (optional - can be disabled for cleaner output)
@@ -3099,22 +3122,243 @@ def _display_dfm_tables(Res: DFMResult, config, nQ: int) -> None:
3099
3122
  # Table 8: Model Fit Statistics (RMSE)
3100
3123
  if Res.rmse is not None and not np.isnan(Res.rmse):
3101
3124
  print('Table 8: Model Fit Statistics')
3102
- print(f' Overall RMSE (averaged across all series): {Res.rmse:.6f}')
3125
+ print(f' Overall RMSE (original scale): {Res.rmse:.6f}')
3126
+ if Res.rmse_std is not None and not np.isnan(Res.rmse_std):
3127
+ print(f' Overall RMSE (standardized scale): {Res.rmse_std:.6f}')
3103
3128
  if Res.rmse_per_series is not None and len(Res.rmse_per_series) > 0:
3104
- print('\n RMSE per Series:')
3129
+ print('\n RMSE per Series (Original Scale):')
3105
3130
  try:
3106
3131
  series_names = config.SeriesName if hasattr(config, 'SeriesName') else [f"Series_{i}" for i in range(len(Res.rmse_per_series))]
3107
3132
  for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
3108
3133
  if not np.isnan(rmse_val):
3109
- print(f' {name:40s}: {rmse_val:.6f}')
3134
+ # Calculate RMSE as percentage of mean if available
3135
+ mean_val = Res.Mx[i] if i < len(Res.Mx) else np.nan
3136
+ if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3137
+ pct = 100.0 * rmse_val / abs(mean_val)
3138
+ print(f' {name:40s}: {rmse_val:.6f} ({pct:.2f}% of mean)')
3139
+ else:
3140
+ print(f' {name:40s}: {rmse_val:.6f}')
3110
3141
  except Exception:
3111
3142
  # Fallback if series names not available
3112
3143
  for i, rmse_val in enumerate(Res.rmse_per_series):
3113
3144
  if not np.isnan(rmse_val):
3114
3145
  print(f' Series {i:3d}: {rmse_val:.6f}')
3146
+
3147
+ # Show standardized RMSE per series
3148
+ if Res.rmse_std_per_series is not None and len(Res.rmse_std_per_series) > 0:
3149
+ print('\n RMSE per Series (Standardized Scale):')
3150
+ try:
3151
+ series_names = config.SeriesName if hasattr(config, 'SeriesName') else [f"Series_{i}" for i in range(len(Res.rmse_std_per_series))]
3152
+ for i, (name, rmse_std_val) in enumerate(zip(series_names, Res.rmse_std_per_series)):
3153
+ if not np.isnan(rmse_std_val):
3154
+ print(f' {name:40s}: {rmse_std_val:.6f} std dev')
3155
+ except Exception:
3156
+ # Fallback if series names not available
3157
+ for i, rmse_std_val in enumerate(Res.rmse_std_per_series):
3158
+ if not np.isnan(rmse_std_val):
3159
+ print(f' Series {i:3d}: {rmse_std_val:.6f} std dev')
3160
+
3161
+ # Diagnostic warnings for high RMSE
3162
+ if Res.rmse_per_series is not None and Res.Mx is not None:
3163
+ print('\n Diagnostic Warnings:')
3164
+ try:
3165
+ series_names = config.SeriesName if hasattr(config, 'SeriesName') else [f"Series_{i}" for i in range(len(Res.rmse_per_series))]
3166
+ warnings_count = 0
3167
+ for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
3168
+ if not np.isnan(rmse_val) and i < len(Res.Mx):
3169
+ mean_val = Res.Mx[i]
3170
+ std_val = Res.Wx[i] if i < len(Res.Wx) else np.nan
3171
+ if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3172
+ pct_of_mean = 100.0 * rmse_val / abs(mean_val)
3173
+ # Warn if RMSE > 50% of mean or > 10 standard deviations
3174
+ if pct_of_mean > 50.0 or (not np.isnan(std_val) and rmse_val > 10.0 * std_val):
3175
+ warnings_count += 1
3176
+ if warnings_count <= 5: # Limit to first 5 warnings
3177
+ print(f' ⚠ {name:40s}: RMSE is {pct_of_mean:.1f}% of mean')
3178
+ if not np.isnan(std_val):
3179
+ print(f' (RMSE={rmse_val:.2e}, Mean={mean_val:.2e}, Std={std_val:.2e})')
3180
+ if warnings_count > 5:
3181
+ print(f' ... and {warnings_count - 5} more series with high RMSE')
3182
+ except Exception:
3183
+ pass
3115
3184
  print('\n\n\n')
3116
3185
 
3117
3186
  except Exception as e:
3118
3187
  print(f'Error displaying tables: {e}')
3119
3188
  pass
3120
3189
 
3190
+
3191
+ def diagnose_series(Res: DFMResult, config: DFMConfig, series_name: Optional[str] = None,
3192
+ series_idx: Optional[int] = None) -> Dict[str, Any]:
3193
+ """Diagnose model fit issues for a specific series.
3194
+
3195
+ This function provides detailed diagnostics to help identify why a series
3196
+ may have high RMSE, including factor loadings, standardization values,
3197
+ and reconstruction errors.
3198
+
3199
+ Parameters
3200
+ ----------
3201
+ Res : DFMResult
3202
+ DFM estimation results
3203
+ config : DFMConfig
3204
+ DFM configuration
3205
+ series_name : str, optional
3206
+ Name of the series to diagnose. If provided, series_idx is ignored.
3207
+ series_idx : int, optional
3208
+ Index of the series to diagnose (0-based). Used if series_name is not provided.
3209
+
3210
+ Returns
3211
+ -------
3212
+ dict
3213
+ Dictionary containing diagnostic information:
3214
+ - 'series_name': Name of the series
3215
+ - 'series_idx': Index of the series
3216
+ - 'rmse_original': RMSE on original scale
3217
+ - 'rmse_standardized': RMSE on standardized scale
3218
+ - 'mean': Series mean (Mx)
3219
+ - 'std': Series standard deviation (Wx)
3220
+ - 'rmse_pct_of_mean': RMSE as percentage of mean
3221
+ - 'rmse_in_std_devs': RMSE in standard deviations
3222
+ - 'factor_loadings': Factor loadings for this series
3223
+ - 'max_loading': Maximum absolute loading
3224
+ - 'loading_sum_sq': Sum of squared loadings
3225
+ - 'reconstruction_error_mean': Mean reconstruction error
3226
+ - 'reconstruction_error_std': Std of reconstruction error
3227
+
3228
+ Examples
3229
+ --------
3230
+ >>> Res = dfm(X, config)
3231
+ >>> diag = diagnose_series(Res, config, series_name="Real GDP")
3232
+ >>> print(f"Standardized RMSE: {diag['rmse_standardized']:.4f}")
3233
+ """
3234
+ # Find series index
3235
+ if series_name is not None:
3236
+ try:
3237
+ series_names = config.SeriesName if hasattr(config, 'SeriesName') else []
3238
+ if series_name in series_names:
3239
+ series_idx = series_names.index(series_name)
3240
+ else:
3241
+ # Try case-insensitive match
3242
+ series_idx = next((i for i, name in enumerate(series_names)
3243
+ if name.lower() == series_name.lower()), None)
3244
+ if series_idx is None:
3245
+ raise ValueError(f"Series '{series_name}' not found in configuration")
3246
+ except (AttributeError, ValueError) as e:
3247
+ raise ValueError(f"Cannot find series '{series_name}': {e}")
3248
+
3249
+ if series_idx is None:
3250
+ raise ValueError("Must provide either series_name or series_idx")
3251
+
3252
+ if series_idx < 0 or series_idx >= Res.C.shape[0]:
3253
+ raise ValueError(f"Series index {series_idx} out of range [0, {Res.C.shape[0]})")
3254
+
3255
+ # Get series name
3256
+ try:
3257
+ series_names = config.SeriesName if hasattr(config, 'SeriesName') else []
3258
+ name = series_names[series_idx] if series_idx < len(series_names) else f"Series_{series_idx}"
3259
+ except (AttributeError, IndexError):
3260
+ name = f"Series_{series_idx}"
3261
+
3262
+ # Get RMSE values
3263
+ rmse_original = None
3264
+ rmse_standardized = None
3265
+ if Res.rmse_per_series is not None and series_idx < len(Res.rmse_per_series):
3266
+ rmse_original = Res.rmse_per_series[series_idx]
3267
+ if Res.rmse_std_per_series is not None and series_idx < len(Res.rmse_std_per_series):
3268
+ rmse_standardized = Res.rmse_std_per_series[series_idx]
3269
+
3270
+ # Get standardization values
3271
+ mean_val = Res.Mx[series_idx] if series_idx < len(Res.Mx) else np.nan
3272
+ std_val = Res.Wx[series_idx] if series_idx < len(Res.Wx) else np.nan
3273
+
3274
+ # Calculate RMSE metrics
3275
+ rmse_pct_of_mean = None
3276
+ rmse_in_std_devs = None
3277
+ if not np.isnan(rmse_original) and not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3278
+ rmse_pct_of_mean = 100.0 * rmse_original / abs(mean_val)
3279
+ if not np.isnan(rmse_original) and not np.isnan(std_val) and std_val > 1e-6:
3280
+ rmse_in_std_devs = rmse_original / std_val
3281
+
3282
+ # Get factor loadings
3283
+ factor_loadings = Res.C[series_idx, :] if series_idx < Res.C.shape[0] else np.array([])
3284
+ max_loading = np.max(np.abs(factor_loadings)) if len(factor_loadings) > 0 else np.nan
3285
+ loading_sum_sq = np.sum(factor_loadings ** 2) if len(factor_loadings) > 0 else np.nan
3286
+
3287
+ # Calculate reconstruction errors (if we have original data)
3288
+ reconstruction_error_mean = None
3289
+ reconstruction_error_std = None
3290
+ # Note: We don't have access to original X here, so we can't calculate this
3291
+ # This would require passing X to the function or storing it in Res
3292
+
3293
+ return {
3294
+ 'series_name': name,
3295
+ 'series_idx': series_idx,
3296
+ 'rmse_original': rmse_original,
3297
+ 'rmse_standardized': rmse_standardized,
3298
+ 'mean': mean_val,
3299
+ 'std': std_val,
3300
+ 'rmse_pct_of_mean': rmse_pct_of_mean,
3301
+ 'rmse_in_std_devs': rmse_in_std_devs,
3302
+ 'factor_loadings': factor_loadings,
3303
+ 'max_loading': max_loading,
3304
+ 'loading_sum_sq': loading_sum_sq,
3305
+ 'reconstruction_error_mean': reconstruction_error_mean,
3306
+ 'reconstruction_error_std': reconstruction_error_std
3307
+ }
3308
+
3309
+
3310
+ def print_series_diagnosis(Res: DFMResult, config: DFMConfig,
3311
+ series_name: Optional[str] = None,
3312
+ series_idx: Optional[int] = None) -> None:
3313
+ """Print a formatted diagnosis report for a specific series.
3314
+
3315
+ Parameters
3316
+ ----------
3317
+ Res : DFMResult
3318
+ DFM estimation results
3319
+ config : DFMConfig
3320
+ DFM configuration
3321
+ series_name : str, optional
3322
+ Name of the series to diagnose
3323
+ series_idx : int, optional
3324
+ Index of the series to diagnose
3325
+
3326
+ Examples
3327
+ --------
3328
+ >>> Res = dfm(X, config)
3329
+ >>> print_series_diagnosis(Res, config, series_name="Real GDP")
3330
+ """
3331
+ diag = diagnose_series(Res, config, series_name=series_name, series_idx=series_idx)
3332
+
3333
+ print(f"\n{'='*70}")
3334
+ print(f"DIAGNOSTIC REPORT: {diag['series_name']}")
3335
+ print(f"{'='*70}\n")
3336
+
3337
+ print("RMSE Statistics:")
3338
+ print(f" Original scale: {diag['rmse_original']:.6e}" if diag['rmse_original'] is not None else " Original scale: N/A")
3339
+ print(f" Standardized scale: {diag['rmse_standardized']:.6f} std dev" if diag['rmse_standardized'] is not None else " Standardized scale: N/A")
3340
+ if diag['rmse_pct_of_mean'] is not None:
3341
+ print(f" As % of mean: {diag['rmse_pct_of_mean']:.2f}%")
3342
+ if diag['rmse_in_std_devs'] is not None:
3343
+ print(f" In std deviations: {diag['rmse_in_std_devs']:.2f}x")
3344
+
3345
+ print("\nStandardization Values:")
3346
+ print(f" Mean: {diag['mean']:.6e}" if not np.isnan(diag['mean']) else " Mean: N/A")
3347
+ print(f" Std: {diag['std']:.6e}" if not np.isnan(diag['std']) else " Std: N/A")
3348
+
3349
+ print("\nFactor Loadings:")
3350
+ if len(diag['factor_loadings']) > 0:
3351
+ print(f" Number of loadings: {len(diag['factor_loadings'])}")
3352
+ print(f" Max absolute: {diag['max_loading']:.6f}" if not np.isnan(diag['max_loading']) else " Max absolute: N/A")
3353
+ print(f" Sum of squares: {diag['loading_sum_sq']:.6f}" if not np.isnan(diag['loading_sum_sq']) else " Sum of squares: N/A")
3354
+ # Show top 5 loadings
3355
+ abs_loadings = np.abs(diag['factor_loadings'])
3356
+ top_indices = np.argsort(abs_loadings)[-5:][::-1]
3357
+ print(f" Top 5 loadings:")
3358
+ for idx in top_indices:
3359
+ print(f" Factor {idx:3d}: {diag['factor_loadings'][idx]:8.4f}")
3360
+ else:
3361
+ print(" No loadings available")
3362
+
3363
+ print(f"\n{'='*70}\n")
3364
+
@@ -1,510 +0,0 @@
1
- """Comprehensive test suite for DFM package with various parameters and situations."""
2
-
3
- import numpy as np
4
- import pandas as pd
5
- from pathlib import Path
6
- import sys
7
- import warnings
8
-
9
- # Add src to path
10
- project_root = Path(__file__).parent
11
- sys.path.insert(0, str(project_root / 'src'))
12
-
13
- from dfm_python import load_config, load_data, dfm
14
- from dfm_python.config import DFMConfig, SeriesConfig
15
- from dfm_python.dfm import init_conditions, em_step
16
- from dfm_python.kalman import run_kf
17
-
18
- warnings.filterwarnings('ignore')
19
-
20
- import logging
21
- logging.getLogger('dfm_python').setLevel(logging.ERROR) # Suppress INFO/WARNING logs
22
-
23
- def create_test_data(T=100, N=10, seed=42, missing_rate=0.1):
24
- """Create synthetic test data."""
25
- np.random.seed(seed)
26
- # Generate data with some structure
27
- factors = np.random.randn(T, 2)
28
- loadings = np.random.randn(N, 2) * 0.5
29
- X = factors @ loadings.T + np.random.randn(T, N) * 0.3
30
-
31
- # Add missing values
32
- if missing_rate > 0:
33
- missing_mask = np.random.rand(T, N) < missing_rate
34
- X[missing_mask] = np.nan
35
-
36
- return X
37
-
38
- def create_test_config(series_freqs, clock='m', block_names=None, factors_per_block=None):
39
- """Create test configuration."""
40
- if block_names is None:
41
- block_names = ['Global', 'Block1']
42
- if factors_per_block is None:
43
- factors_per_block = [1, 1]
44
-
45
- N = len(series_freqs)
46
- series_list = []
47
- for i, freq in enumerate(series_freqs):
48
- # All series load on Global, first half also load on Block1
49
- blocks = [1, 1 if i < N // 2 else 0]
50
- series_list.append(SeriesConfig(
51
- series_id=f"test_{i:02d}",
52
- series_name=f"Test Series {i}",
53
- frequency=freq,
54
- transformation='pch',
55
- category='Test',
56
- units='Index',
57
- blocks=blocks
58
- ))
59
-
60
- return DFMConfig(
61
- series=series_list,
62
- block_names=block_names,
63
- factors_per_block=factors_per_block,
64
- ar_lag=1,
65
- threshold=1e-4,
66
- max_iter=100,
67
- nan_method=2,
68
- nan_k=3,
69
- clock=clock
70
- )
71
-
72
- def test_clock_frequencies():
73
- """Test different clock frequencies."""
74
- print("\n" + "="*70)
75
- print("TEST: Different Clock Frequencies")
76
- print("="*70)
77
-
78
- results = {}
79
- # Test with monthly and quarterly clocks (daily/weekly may have issues with small data)
80
- clocks = ['m', 'q']
81
- series_freqs = ['m', 'm', 'm', 'q', 'q'] # 3 monthly, 2 quarterly
82
-
83
- for clock in clocks:
84
- try:
85
- print(f"\n Testing clock='{clock}'...")
86
- config = create_test_config(series_freqs, clock=clock)
87
- X = create_test_data(T=100, N=5, seed=42)
88
-
89
- result = dfm(X, config, max_iter=5, threshold=1e-3)
90
- results[clock] = 'PASS'
91
- print(f" ✓ clock='{clock}' passed")
92
- except Exception as e:
93
- results[clock] = f'FAIL: {str(e)[:50]}'
94
- print(f" ✗ clock='{clock}' failed: {str(e)[:50]}")
95
-
96
- return results
97
-
98
- def test_mixed_frequencies():
99
- """Test various mixed frequency combinations."""
100
- print("\n" + "="*70)
101
- print("TEST: Mixed Frequency Combinations")
102
- print("="*70)
103
-
104
- test_cases = [
105
- ('All Monthly', ['m', 'm', 'm', 'm', 'm'], 'm'),
106
- ('Monthly + Quarterly', ['m', 'm', 'm', 'q', 'q'], 'm'),
107
- ('Daily + Monthly', ['d', 'd', 'm', 'm', 'm'], 'm'),
108
- ('Weekly + Monthly', ['w', 'w', 'm', 'm', 'm'], 'm'),
109
- ('Quarterly + Semi-annual', ['q', 'q', 'sa', 'sa'], 'q'),
110
- ('All Frequencies', ['d', 'w', 'm', 'q', 'sa', 'a'], 'm'),
111
- ('Monthly + Quarterly + Annual', ['m', 'm', 'q', 'q', 'a'], 'm'),
112
- ]
113
-
114
- results = {}
115
- for name, freqs, clock in test_cases:
116
- try:
117
- print(f"\n Testing {name} (clock={clock})...")
118
- config = create_test_config(freqs, clock=clock)
119
- X = create_test_data(T=100, N=len(freqs), seed=42)
120
-
121
- result = dfm(X, config, max_iter=5, threshold=1e-3)
122
- results[name] = 'PASS'
123
- print(f" ✓ {name} passed")
124
- except Exception as e:
125
- results[name] = f'FAIL: {str(e)[:50]}'
126
- print(f" ✗ {name} failed: {str(e)[:50]}")
127
-
128
- return results
129
-
130
- def test_parameters():
131
- """Test different parameter combinations."""
132
- print("\n" + "="*70)
133
- print("TEST: Different Parameter Combinations")
134
- print("="*70)
135
-
136
- base_config = create_test_config(['m', 'm', 'm', 'q', 'q'], clock='m')
137
- X = create_test_data(T=100, N=5, seed=42)
138
-
139
- test_cases = [
140
- ('Low threshold', {'threshold': 1e-6, 'max_iter': 10}),
141
- ('High threshold', {'threshold': 1e-2, 'max_iter': 10}),
142
- ('Few iterations', {'threshold': 1e-3, 'max_iter': 3}),
143
- ('Many iterations', {'threshold': 1e-4, 'max_iter': 50}),
144
- ('nan_method=1', {'nan_method': 1, 'nan_k': 3}),
145
- ('nan_method=2', {'nan_method': 2, 'nan_k': 3}),
146
- ('nan_method=3', {'nan_method': 3, 'nan_k': 3}),
147
- ('nan_k=1', {'nan_method': 2, 'nan_k': 1}),
148
- ('nan_k=5', {'nan_method': 2, 'nan_k': 5}),
149
- ('ar_lag=2', {'ar_lag': 2}),
150
- ]
151
-
152
- results = {}
153
- for name, params in test_cases:
154
- try:
155
- print(f"\n Testing {name}...")
156
- config_dict = base_config.__dict__.copy()
157
- config_dict.update(params)
158
- config = DFMConfig(**config_dict)
159
-
160
- result = dfm(X, config, max_iter=params.get('max_iter', 10),
161
- threshold=params.get('threshold', 1e-3))
162
- results[name] = 'PASS'
163
- print(f" ✓ {name} passed")
164
- except Exception as e:
165
- results[name] = f'FAIL: {str(e)[:50]}'
166
- print(f" ✗ {name} failed: {str(e)[:50]}")
167
-
168
- return results
169
-
170
- def test_data_scenarios():
171
- """Test different data scenarios."""
172
- print("\n" + "="*70)
173
- print("TEST: Different Data Scenarios")
174
- print("="*70)
175
-
176
- test_cases = [
177
- ('No missing', {'missing_rate': 0.0, 'T': 100, 'N': 5}),
178
- ('Low missing (5%)', {'missing_rate': 0.05, 'T': 100, 'N': 5}),
179
- ('Medium missing (20%)', {'missing_rate': 0.20, 'T': 100, 'N': 5}),
180
- ('High missing (40%)', {'missing_rate': 0.40, 'T': 100, 'N': 5}),
181
- ('Short series (T=50)', {'T': 50, 'N': 5, 'missing_rate': 0.1}),
182
- ('Long series (T=200)', {'T': 200, 'N': 5, 'missing_rate': 0.1}),
183
- ('Few series (N=3)', {'N': 3, 'T': 100, 'missing_rate': 0.1}),
184
- ('Many series (N=15)', {'N': 15, 'T': 100, 'missing_rate': 0.1}),
185
- ]
186
-
187
- results = {}
188
- for name, params in test_cases:
189
- try:
190
- print(f"\n Testing {name}...")
191
- T = params.get('T', 100)
192
- N = params.get('N', 5)
193
- missing_rate = params.get('missing_rate', 0.1)
194
-
195
- # Adjust config for different N
196
- freqs = ['m'] * N
197
- config = create_test_config(freqs, clock='m')
198
-
199
- X = create_test_data(T=T, N=N, seed=42, missing_rate=missing_rate)
200
- result = dfm(X, config, max_iter=5, threshold=1e-3)
201
- results[name] = 'PASS'
202
- print(f" ✓ {name} passed")
203
- except Exception as e:
204
- results[name] = f'FAIL: {str(e)[:50]}'
205
- print(f" ✗ {name} failed: {str(e)[:50]}")
206
-
207
- return results
208
-
209
- def test_block_configurations():
210
- """Test different block configurations."""
211
- print("\n" + "="*70)
212
- print("TEST: Different Block Configurations")
213
- print("="*70)
214
-
215
- test_cases = [
216
- ('Single block', ['Global'], [1], 5),
217
- ('Two blocks', ['Global', 'Block1'], [1, 1], 5),
218
- ('Three blocks', ['Global', 'Block1', 'Block2'], [1, 1, 1], 8),
219
- ('Different factors', ['Global', 'Block1'], [2, 1], 5),
220
- ('All on Global', ['Global'], [1], 5, True), # All series only on Global
221
- ]
222
-
223
- results = {}
224
- for case in test_cases:
225
- name = case[0]
226
- block_names = case[1]
227
- factors = case[2]
228
- N = case[3]
229
- all_global = case[4] if len(case) > 4 else False
230
-
231
- try:
232
- print(f"\n Testing {name}...")
233
- freqs = ['m'] * N
234
- series_list = []
235
- for i in range(N):
236
- if all_global:
237
- blocks = [1] # Only Global
238
- else:
239
- # Distribute across blocks
240
- blocks = [1] # Always Global
241
- if len(block_names) > 1:
242
- blocks.append(1 if i < N // 2 else 0) # First half on Block1
243
- if len(block_names) > 2:
244
- blocks.append(1 if i >= N // 2 else 0) # Second half on Block2
245
-
246
- series_list.append(SeriesConfig(
247
- series_id=f"test_{i:02d}",
248
- series_name=f"Test Series {i}",
249
- frequency='m',
250
- transformation='pch',
251
- category='Test',
252
- units='Index',
253
- blocks=blocks
254
- ))
255
-
256
- config = DFMConfig(
257
- series=series_list,
258
- block_names=block_names,
259
- factors_per_block=factors,
260
- clock='m'
261
- )
262
-
263
- X = create_test_data(T=100, N=N, seed=42)
264
- result = dfm(X, config, max_iter=5, threshold=1e-3)
265
- results[name] = 'PASS'
266
- print(f" ✓ {name} passed")
267
- except Exception as e:
268
- results[name] = f'FAIL: {str(e)[:50]}'
269
- print(f" ✗ {name} failed: {str(e)[:50]}")
270
-
271
- return results
272
-
273
- def test_transformations():
274
- """Test different transformation types."""
275
- print("\n" + "="*70)
276
- print("TEST: Different Transformation Types")
277
- print("="*70)
278
-
279
- transformations = ['lin', 'chg', 'pch', 'pca', 'log']
280
- results = {}
281
-
282
- for trans in transformations:
283
- try:
284
- print(f"\n Testing transformation='{trans}'...")
285
- series_list = []
286
- for i in range(5):
287
- series_list.append(SeriesConfig(
288
- series_id=f"test_{i:02d}",
289
- series_name=f"Test Series {i}",
290
- frequency='m',
291
- transformation=trans,
292
- category='Test',
293
- units='Index',
294
- blocks=[1, 1 if i < 3 else 0]
295
- ))
296
-
297
- config = DFMConfig(
298
- series=series_list,
299
- block_names=['Global', 'Block1'],
300
- clock='m'
301
- )
302
-
303
- X = create_test_data(T=100, N=5, seed=42)
304
- result = dfm(X, config, max_iter=5, threshold=1e-3)
305
- results[trans] = 'PASS'
306
- print(f" ✓ transformation='{trans}' passed")
307
- except Exception as e:
308
- results[trans] = f'FAIL: {str(e)[:50]}'
309
- print(f" ✗ transformation='{trans}' failed: {str(e)[:50]}")
310
-
311
- return results
312
-
313
- def test_edge_cases():
314
- """Test edge cases and boundary conditions."""
315
- print("\n" + "="*70)
316
- print("TEST: Edge Cases and Boundary Conditions")
317
- print("="*70)
318
-
319
- results = {}
320
-
321
- # Test cases (skip problematic ones)
322
- test_cases = [
323
- ('Minimal data (T=30, N=3)', {'T': 30, 'N': 3}),
324
- ('Very high missing (50%)', {'missing_rate': 0.5}),
325
- ('Constant series', {'constant': True}),
326
- ('Extreme values', {'extreme': True}),
327
- ]
328
-
329
- for name, params in test_cases:
330
- try:
331
- print(f"\n Testing {name}...")
332
- T = params.get('T', 100)
333
- N = params.get('N', 5)
334
- missing_rate = params.get('missing_rate', 0.1)
335
-
336
- if params.get('constant'):
337
- X = np.ones((T, N)) * 100.0
338
- # Add some variation to first series
339
- np.random.seed(42)
340
- X[:, 0] = 100.0 + np.random.randn(T) * 0.1
341
- elif params.get('extreme'):
342
- np.random.seed(42)
343
- X = np.random.randn(T, N) * 1e6 # Very large values
344
- else:
345
- X = create_test_data(T=T, N=N, seed=42, missing_rate=missing_rate)
346
-
347
- config = create_test_config(['m'] * N, clock='m')
348
- result = dfm(X, config, max_iter=3, threshold=1e-2)
349
- results[name] = 'PASS'
350
- print(f" ✓ {name} passed")
351
- except Exception as e:
352
- results[name] = f'FAIL: {str(e)[:50]}'
353
- print(f" ✗ {name} failed: {str(e)[:50]}")
354
-
355
- return results
356
-
357
- def test_tent_weights_scenarios():
358
- """Test scenarios that trigger tent weights."""
359
- print("\n" + "="*70)
360
- print("TEST: Tent Weights Scenarios")
361
- print("="*70)
362
-
363
- results = {}
364
-
365
- # Test cases that should use tent weights (slower freq < clock)
366
- test_cases = [
367
- ('Quarterly with monthly clock', ['m', 'm', 'q', 'q'], 'm'),
368
- ('Semi-annual with monthly clock', ['m', 'm', 'sa'], 'm'),
369
- ('Annual with monthly clock', ['m', 'm', 'a'], 'm'),
370
- ('Annual with quarterly clock', ['q', 'q', 'a'], 'q'),
371
- ('Quarterly with daily clock', ['d', 'd', 'q'], 'd'),
372
- ]
373
-
374
- for name, freqs, clock in test_cases:
375
- try:
376
- print(f"\n Testing {name}...")
377
- config = create_test_config(freqs, clock=clock)
378
- X = create_test_data(T=100, N=len(freqs), seed=42)
379
-
380
- result = dfm(X, config, max_iter=5, threshold=1e-3)
381
- results[name] = 'PASS'
382
- print(f" ✓ {name} passed")
383
- except Exception as e:
384
- results[name] = f'FAIL: {str(e)[:50]}'
385
- print(f" ✗ {name} failed: {str(e)[:50]}")
386
-
387
- return results
388
-
389
- def test_convergence_scenarios():
390
- """Test convergence behavior with different settings."""
391
- print("\n" + "="*70)
392
- print("TEST: Convergence Scenarios")
393
- print("="*70)
394
-
395
- config = create_test_config(['m', 'm', 'm', 'q', 'q'], clock='m')
396
- X = create_test_data(T=100, N=5, seed=42)
397
-
398
- results = {}
399
- test_cases = [
400
- ('Converges quickly', {'threshold': 1e-2, 'max_iter': 50}),
401
- ('Strict convergence', {'threshold': 1e-6, 'max_iter': 100}),
402
- ('Max iterations reached', {'threshold': 1e-8, 'max_iter': 5}),
403
- ]
404
-
405
- for name, params in test_cases:
406
- try:
407
- print(f"\n Testing {name}...")
408
- result = dfm(X, config, max_iter=params['max_iter'],
409
- threshold=params['threshold'])
410
-
411
- # Check if converged or hit max_iter
412
- converged = hasattr(result, 'converged') and result.converged if hasattr(result, 'converged') else True
413
- results[name] = 'PASS'
414
- print(f" ✓ {name} passed (converged: {converged})")
415
- except Exception as e:
416
- results[name] = f'FAIL: {str(e)[:50]}'
417
- print(f" ✗ {name} failed: {str(e)[:50]}")
418
-
419
- return results
420
-
421
- def test_real_data_scenario():
422
- """Test with real sample data configuration."""
423
- print("\n" + "="*70)
424
- print("TEST: Real Sample Data Scenario")
425
- print("="*70)
426
-
427
- try:
428
- config_file = project_root / 'config' / 'example_config.yaml'
429
- data_file = project_root / 'data' / 'sample_data.csv'
430
-
431
- if not config_file.exists() or not data_file.exists():
432
- print(" ⚠ Sample data files not found, skipping...")
433
- return {'Real data': 'SKIP'}
434
-
435
- config = load_config(config_file)
436
- X, Time, Z = load_data(data_file, config)
437
-
438
- # Test with different parameters
439
- test_cases = [
440
- ('Default params', {}),
441
- ('Quick test', {'max_iter': 5, 'threshold': 1e-2}),
442
- ('Strict convergence', {'max_iter': 100, 'threshold': 1e-6}),
443
- ]
444
-
445
- results = {}
446
- for name, params in test_cases:
447
- try:
448
- print(f"\n Testing {name}...")
449
- result = dfm(X, config, **params)
450
- results[name] = 'PASS'
451
- print(f" ✓ {name} passed")
452
- except Exception as e:
453
- results[name] = f'FAIL: {str(e)[:50]}'
454
- print(f" ✗ {name} failed: {str(e)[:50]}")
455
-
456
- return results
457
- except Exception as e:
458
- return {'Real data': f'FAIL: {str(e)[:50]}'}
459
-
460
- def run_all_tests():
461
- """Run all comprehensive tests."""
462
- print("\n" + "="*70)
463
- print("COMPREHENSIVE TEST SUITE FOR DFM PACKAGE")
464
- print("="*70)
465
-
466
- all_results = {}
467
-
468
- # Run all test suites
469
- all_results['Clock Frequencies'] = test_clock_frequencies()
470
- all_results['Mixed Frequencies'] = test_mixed_frequencies()
471
- all_results['Parameters'] = test_parameters()
472
- all_results['Data Scenarios'] = test_data_scenarios()
473
- all_results['Block Configurations'] = test_block_configurations()
474
- all_results['Transformations'] = test_transformations()
475
- all_results['Edge Cases'] = test_edge_cases()
476
- all_results['Tent Weights'] = test_tent_weights_scenarios()
477
- all_results['Convergence'] = test_convergence_scenarios()
478
- all_results['Real Data'] = test_real_data_scenario()
479
-
480
- # Print summary
481
- print("\n" + "="*70)
482
- print("TEST SUMMARY")
483
- print("="*70)
484
-
485
- total_tests = 0
486
- passed_tests = 0
487
- failed_tests = 0
488
-
489
- for suite_name, suite_results in all_results.items():
490
- print(f"\n{suite_name}:")
491
- for test_name, result in suite_results.items():
492
- total_tests += 1
493
- if result == 'PASS':
494
- passed_tests += 1
495
- print(f" ✓ {test_name}")
496
- elif result == 'SKIP':
497
- print(f" ⊘ {test_name} (skipped)")
498
- else:
499
- failed_tests += 1
500
- print(f" ✗ {test_name}: {result}")
501
-
502
- print("\n" + "="*70)
503
- print(f"TOTAL: {total_tests} tests | PASSED: {passed_tests} | FAILED: {failed_tests} | SKIPPED: {total_tests - passed_tests - failed_tests}")
504
- print("="*70)
505
-
506
- return all_results
507
-
508
- if __name__ == '__main__':
509
- results = run_all_tests()
510
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes