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.
- {dfm_python-0.1.5 → dfm_python-0.1.6}/PKG-INFO +2 -2
- {dfm_python-0.1.5 → dfm_python-0.1.6}/README.md +1 -1
- {dfm_python-0.1.5 → dfm_python-0.1.6}/pyproject.toml +1 -1
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/__init__.py +3 -3
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/dfm.py +255 -11
- dfm_python-0.1.5/test_comprehensive.py +0 -510
- {dfm_python-0.1.5 → dfm_python-0.1.6}/.gitignore +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/.python-version +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/CHANGELOG.md +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/LICENSE +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/MANIFEST.in +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/config/example_config.yaml +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/config/example_config_macro_data.yaml +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/data/example_macro_data.csv +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/data/sample_data.csv +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/scripts/inference.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/scripts/train.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/spec/example_spec.csv +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/__init__.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/config.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/data_loader.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/kalman.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/news.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/__init__.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/aggregation.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/dfm_python/utils/data_utils.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/__init__.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_dfm.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_kalman.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_news.py +0 -0
- {dfm_python-0.1.5 → dfm_python-0.1.6}/src/test/test_synthetic.py +0 -0
- {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.
|
|
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 = {
|
|
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 = {
|
|
516
|
+
year = {2025},
|
|
517
517
|
url = {https://pypi.org/project/dfm-python/},
|
|
518
518
|
version = {0.1.5}
|
|
519
519
|
}
|
|
@@ -30,13 +30,13 @@ Example:
|
|
|
30
30
|
For detailed documentation, see the README.md file.
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
__version__ = "0.1.
|
|
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 #
|
|
677
|
-
rmse_per_series: Optional[np.ndarray] = None # RMSE per series (
|
|
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
|
-
|
|
1734
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|