dfm-python 0.1.5__py3-none-any.whl → 0.1.6__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
@@ -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
dfm_python/dfm.py CHANGED
@@ -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,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
  }
@@ -1,13 +1,13 @@
1
- dfm_python/__init__.py,sha256=XePi1tPUqRiHwSJeJ7D-Dqz_qdFtIVBxKBB23d85cu0,2333
1
+ dfm_python/__init__.py,sha256=f7MFOpB9MmPPjX58bvlLWB4ujuIAWmc_whPN1np3be4,2419
2
2
  dfm_python/config.py,sha256=QI-kdEQIkiDahN7lwoGr6PUVeNXW4MiZQkOEr5xypzs,26283
3
3
  dfm_python/data_loader.py,sha256=0pNH4YyOgGsgcYZJL60OhC4QA-iTvjFrsq8I-WEF1rk,20585
4
- dfm_python/dfm.py,sha256=2qlSq6tAWvkaS6X713Ktycu3gfFlwKDoFrHyNKusmPE,143215
4
+ dfm_python/dfm.py,sha256=zNkT-iVEqNvMyjVmfGdCn5Txa9w8ehGGhV2ujNkieps,155260
5
5
  dfm_python/kalman.py,sha256=vaIpTiaV3EcZW3SIa0gxkKtYqboffu1wAr3-7yhKdIc,17308
6
6
  dfm_python/news.py,sha256=AEs-Z7UoOzvw7TN22AKsglVnYlI4JgNB7ittEcdGU2M,31583
7
7
  dfm_python/utils/__init__.py,sha256=nfFHSKJGUzXoeleoqrWqzuQriDU79o9xUhfG3VYy2sI,659
8
8
  dfm_python/utils/aggregation.py,sha256=Z0gbGticxMs2GLxG4pD-eOR-7aHd-S5aeHo5S4OlrKc,10270
9
9
  dfm_python/utils/data_utils.py,sha256=zUpIBD1_ydyZLXvVxl6OVW4AM43edEgGs3thE8687e0,6805
10
- dfm_python-0.1.5.dist-info/METADATA,sha256=KEbsNS-TFaBOvlXQy4cDmcLS6Nl5VBiEX8rmZ3z2gMA,19086
11
- dfm_python-0.1.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
- dfm_python-0.1.5.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
13
- dfm_python-0.1.5.dist-info/RECORD,,
10
+ dfm_python-0.1.6.dist-info/METADATA,sha256=WFMfZB8RwXbmUKjBUBYVoAwM1zloKRjB6LxJmFtW7_0,19086
11
+ dfm_python-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ dfm_python-0.1.6.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
13
+ dfm_python-0.1.6.dist-info/RECORD,,