dfm-python 0.2.0__py3-none-any.whl → 0.2.2__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 +2 -2
- dfm_python/{dfm_api.py → api.py} +41 -251
- dfm_python/config.py +72 -7
- dfm_python/core/__init__.py +59 -0
- dfm_python/core/diagnostics.py +274 -0
- dfm_python/core/em.py +1022 -0
- dfm_python/core/grouping.py +54 -0
- dfm_python/core/helpers.py +56 -0
- dfm_python/core/numeric.py +346 -0
- dfm_python/core/results.py +70 -0
- dfm_python/dfm.py +497 -3538
- dfm_python/kalman.py +18 -0
- dfm_python/utils/data_utils.py +3 -5
- dfm_python-0.2.2.dist-info/METADATA +312 -0
- dfm_python-0.2.2.dist-info/RECORD +21 -0
- dfm_python-0.2.0.dist-info/METADATA +0 -647
- dfm_python-0.2.0.dist-info/RECORD +0 -14
- {dfm_python-0.2.0.dist-info → dfm_python-0.2.2.dist-info}/WHEEL +0 -0
- {dfm_python-0.2.0.dist-info → dfm_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any, TYPE_CHECKING
|
|
2
|
+
import numpy as np
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import pandas as pd
|
|
7
|
+
PANDAS_AVAILABLE = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
PANDAS_AVAILABLE = False
|
|
10
|
+
pd = None
|
|
11
|
+
|
|
12
|
+
from ..config import DFMConfig
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..dfm import DFMResult
|
|
18
|
+
else:
|
|
19
|
+
# Avoid circular import at runtime
|
|
20
|
+
DFMResult = Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _display_dfm_tables(Res: DFMResult, config: DFMConfig, nQ: int) -> None:
|
|
24
|
+
"""Display DFM estimation output tables."""
|
|
25
|
+
if not _logger.isEnabledFor(logging.INFO):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
series_ids = config.get_series_ids()
|
|
29
|
+
series_names = config.get_series_names()
|
|
30
|
+
block_names = config.block_names
|
|
31
|
+
n_same_freq = len(series_ids) - nQ
|
|
32
|
+
nLags = max(Res.p, 5)
|
|
33
|
+
nFactors = int(np.sum(Res.r))
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
_logger.info('\n\n\n')
|
|
37
|
+
# Factor Loadings for Same-Frequency Series
|
|
38
|
+
_logger.info('Factor Loadings for Same-Frequency Series')
|
|
39
|
+
C_same_freq = Res.C[:n_same_freq, ::5][:, :nFactors]
|
|
40
|
+
if PANDAS_AVAILABLE:
|
|
41
|
+
try:
|
|
42
|
+
df = pd.DataFrame(C_same_freq,
|
|
43
|
+
index=[name.replace(' ', '_') for name in series_names[:n_same_freq]],
|
|
44
|
+
columns=block_names[:nFactors] if len(block_names) >= nFactors else [f'Block{i+1}' for i in range(nFactors)])
|
|
45
|
+
_logger.info(f'\n{df.to_string()}')
|
|
46
|
+
except Exception as e:
|
|
47
|
+
_logger.debug(f'Failed to format same-frequency loadings table: {e}')
|
|
48
|
+
_logger.info(f'Same-frequency loadings shape: {C_same_freq.shape}')
|
|
49
|
+
else:
|
|
50
|
+
_logger.info(f'Same-frequency loadings shape: {C_same_freq.shape}')
|
|
51
|
+
|
|
52
|
+
_logger.info('\n\n\n')
|
|
53
|
+
# Slower-Frequency Loadings Sample (First Factor)
|
|
54
|
+
_logger.info('Slower-Frequency Loadings Sample (First Factor)')
|
|
55
|
+
C_slower_freq = Res.C[-nQ:, :5]
|
|
56
|
+
if PANDAS_AVAILABLE:
|
|
57
|
+
try:
|
|
58
|
+
n_lags = min(5, C_slower_freq.shape[1])
|
|
59
|
+
lag_cols = [f'factor1_lag{i}' for i in range(n_lags)]
|
|
60
|
+
df = pd.DataFrame(C_slower_freq,
|
|
61
|
+
index=[name.replace(' ', '_') for name in series_names[-nQ:]],
|
|
62
|
+
columns=lag_cols)
|
|
63
|
+
_logger.info(f'\n{df.to_string()}')
|
|
64
|
+
except Exception as e:
|
|
65
|
+
_logger.debug(f'Failed to format slower-frequency loadings table: {e}')
|
|
66
|
+
_logger.info(f'Slower-frequency loadings shape: {C_slower_freq.shape}')
|
|
67
|
+
else:
|
|
68
|
+
_logger.info(f'Slower-frequency loadings shape: {C_slower_freq.shape}')
|
|
69
|
+
|
|
70
|
+
_logger.info('\n\n\n')
|
|
71
|
+
# Autoregressive Coefficients on Factors
|
|
72
|
+
_logger.info('Autoregressive Coefficients on Factors')
|
|
73
|
+
A_terms = np.diag(Res.A)
|
|
74
|
+
Q_terms = np.diag(Res.Q)
|
|
75
|
+
A_terms_factors = A_terms[::5][:nFactors]
|
|
76
|
+
Q_terms_factors = Q_terms[::5][:nFactors]
|
|
77
|
+
if PANDAS_AVAILABLE:
|
|
78
|
+
try:
|
|
79
|
+
df = pd.DataFrame({
|
|
80
|
+
'AR_Coefficient': A_terms_factors,
|
|
81
|
+
'Variance_Residual': Q_terms_factors
|
|
82
|
+
}, index=[name.replace(' ', '_') for name in block_names[:nFactors]])
|
|
83
|
+
_logger.info(f'\n{df.to_string()}')
|
|
84
|
+
except Exception as e:
|
|
85
|
+
_logger.debug(f'Failed to format AR coefficients table: {e}')
|
|
86
|
+
_logger.info(f'Factor AR coefficients: {A_terms_factors}')
|
|
87
|
+
else:
|
|
88
|
+
_logger.info(f'Factor AR coefficients: {A_terms_factors}')
|
|
89
|
+
|
|
90
|
+
_logger.info('\n\n\n')
|
|
91
|
+
# Autoregressive Coefficients on Idiosyncratic Component
|
|
92
|
+
_logger.info('Autoregressive Coefficients on Idiosyncratic Component')
|
|
93
|
+
rp1 = nFactors * 5
|
|
94
|
+
same_freq_idx = np.arange(rp1, rp1 + n_same_freq)
|
|
95
|
+
slower_freq_idx = np.arange(rp1 + n_same_freq, len(A_terms), 5)
|
|
96
|
+
combined_idx = np.concatenate([same_freq_idx, slower_freq_idx])
|
|
97
|
+
combined_idx = combined_idx[combined_idx < len(A_terms)]
|
|
98
|
+
A_idio = A_terms[combined_idx]
|
|
99
|
+
Q_idio = Q_terms[combined_idx]
|
|
100
|
+
if PANDAS_AVAILABLE:
|
|
101
|
+
try:
|
|
102
|
+
series_names_list = []
|
|
103
|
+
for idx in combined_idx:
|
|
104
|
+
if idx < rp1 + n_same_freq:
|
|
105
|
+
series_idx = idx - rp1
|
|
106
|
+
if series_idx < n_same_freq:
|
|
107
|
+
series_names_list.append(series_names[series_idx].replace(' ', '_'))
|
|
108
|
+
else:
|
|
109
|
+
slower_idx = (idx - (rp1 + n_same_freq)) // 5
|
|
110
|
+
if slower_idx < nQ:
|
|
111
|
+
series_names_list.append(series_names[n_same_freq + slower_idx].replace(' ', '_'))
|
|
112
|
+
df = pd.DataFrame({
|
|
113
|
+
'AR_Coefficient': A_idio[:len(series_names_list)],
|
|
114
|
+
'Variance_Residual': Q_idio[:len(series_names_list)]
|
|
115
|
+
}, index=series_names_list)
|
|
116
|
+
_logger.info(f'\n{df.to_string()}')
|
|
117
|
+
except Exception as e:
|
|
118
|
+
_logger.debug(f'Failed to format idiosyncratic AR coefficients table: {e}')
|
|
119
|
+
_logger.info(f'Idiosyncratic AR coefficients (first 10): {A_idio[:min(10, len(A_idio))]}')
|
|
120
|
+
if len(A_idio) > 10:
|
|
121
|
+
_logger.info(f'... (total {len(A_idio)} coefficients)')
|
|
122
|
+
else:
|
|
123
|
+
_logger.info(f'Idiosyncratic AR coefficients (first 10): {A_idio[:min(10, len(A_idio))]}')
|
|
124
|
+
if len(A_idio) > 10:
|
|
125
|
+
_logger.info(f'... (total {len(A_idio)} coefficients)')
|
|
126
|
+
|
|
127
|
+
_logger.info('\n\n\n')
|
|
128
|
+
# Model Fit Statistics (RMSE)
|
|
129
|
+
if Res.rmse is not None and not np.isnan(Res.rmse):
|
|
130
|
+
_logger.info('Model Fit Statistics')
|
|
131
|
+
_logger.info(f' Overall RMSE (original scale): {Res.rmse:.6f}')
|
|
132
|
+
if Res.rmse_std is not None and not np.isnan(Res.rmse_std):
|
|
133
|
+
_logger.info(f' Overall RMSE (standardized scale): {Res.rmse_std:.6f}')
|
|
134
|
+
if Res.rmse_per_series is not None and len(Res.rmse_per_series) > 0:
|
|
135
|
+
_logger.info('\n RMSE per Series (Original Scale):')
|
|
136
|
+
try:
|
|
137
|
+
for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
|
|
138
|
+
if not np.isnan(rmse_val):
|
|
139
|
+
mean_val = Res.Mx[i] if i < len(Res.Mx) else np.nan
|
|
140
|
+
if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
|
|
141
|
+
pct = 100.0 * rmse_val / abs(mean_val)
|
|
142
|
+
_logger.info(f' {name:40s}: {rmse_val:.6f} ({pct:.2f}% of mean)')
|
|
143
|
+
else:
|
|
144
|
+
_logger.info(f' {name:40s}: {rmse_val:.6f}')
|
|
145
|
+
except Exception as e:
|
|
146
|
+
_logger.debug(f'Failed to format RMSE per series: {e}')
|
|
147
|
+
for i, rmse_val in enumerate(Res.rmse_per_series):
|
|
148
|
+
if not np.isnan(rmse_val):
|
|
149
|
+
_logger.info(f' Series {i:3d}: {rmse_val:.6f}')
|
|
150
|
+
if Res.rmse_std_per_series is not None and len(Res.rmse_std_per_series) > 0:
|
|
151
|
+
_logger.info('\n RMSE per Series (Standardized Scale):')
|
|
152
|
+
try:
|
|
153
|
+
for i, (name, rmse_std_val) in enumerate(zip(series_names, Res.rmse_std_per_series)):
|
|
154
|
+
if not np.isnan(rmse_std_val):
|
|
155
|
+
_logger.info(f' {name:40s}: {rmse_std_val:.6f} std dev')
|
|
156
|
+
except Exception as e:
|
|
157
|
+
_logger.debug(f'Failed to format RMSE std per series: {e}')
|
|
158
|
+
for i, rmse_std_val in enumerate(Res.rmse_std_per_series):
|
|
159
|
+
if not np.isnan(rmse_std_val):
|
|
160
|
+
_logger.info(f' Series {i:3d}: {rmse_std_val:.6f} std dev')
|
|
161
|
+
if Res.rmse_per_series is not None and Res.Mx is not None:
|
|
162
|
+
_logger.info('\n Diagnostic Warnings:')
|
|
163
|
+
try:
|
|
164
|
+
warnings_count = 0
|
|
165
|
+
for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
|
|
166
|
+
if not np.isnan(rmse_val) and i < len(Res.Mx):
|
|
167
|
+
mean_val = Res.Mx[i]
|
|
168
|
+
std_val = Res.Wx[i] if i < len(Res.Wx) else np.nan
|
|
169
|
+
if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
|
|
170
|
+
pct_of_mean = 100.0 * rmse_val / abs(mean_val)
|
|
171
|
+
if pct_of_mean > 50.0 or (not np.isnan(std_val) and rmse_val > 10.0 * std_val):
|
|
172
|
+
warnings_count += 1
|
|
173
|
+
if warnings_count <= 5:
|
|
174
|
+
_logger.warning(f' ⚠ {name:40s}: RMSE is {pct_of_mean:.1f}% of mean')
|
|
175
|
+
if not np.isnan(std_val):
|
|
176
|
+
_logger.warning(f' (RMSE={rmse_val:.2e}, Mean={mean_val:.2e}, Std={std_val:.2e})')
|
|
177
|
+
if warnings_count > 5:
|
|
178
|
+
_logger.warning(f' ... and {warnings_count - 5} more series with high RMSE')
|
|
179
|
+
except Exception as e:
|
|
180
|
+
_logger.debug(f'Failed to format diagnostic warnings: {e}')
|
|
181
|
+
_logger.info('\n\n\n')
|
|
182
|
+
except Exception as e:
|
|
183
|
+
_logger.debug(f'Failed to display DFM tables: {e}')
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def diagnose_series(Res: DFMResult, config: DFMConfig, series_name: Optional[str] = None,
|
|
187
|
+
series_idx: Optional[int] = None) -> Dict[str, Any]:
|
|
188
|
+
"""Diagnose model fit issues for a specific series."""
|
|
189
|
+
if series_name is not None:
|
|
190
|
+
try:
|
|
191
|
+
series_names = config.get_series_names() if config.series else []
|
|
192
|
+
if series_name in series_names:
|
|
193
|
+
series_idx = series_names.index(series_name)
|
|
194
|
+
else:
|
|
195
|
+
series_idx = next((i for i, name in enumerate(series_names)
|
|
196
|
+
if name.lower() == series_name.lower()), None)
|
|
197
|
+
if series_idx is None:
|
|
198
|
+
raise ValueError(f"Series '{series_name}' not found in configuration")
|
|
199
|
+
except (AttributeError, ValueError) as e:
|
|
200
|
+
raise ValueError(f"Cannot find series '{series_name}': {e}")
|
|
201
|
+
if series_idx is None:
|
|
202
|
+
raise ValueError("Must provide either series_name or series_idx")
|
|
203
|
+
if series_idx < 0 or series_idx >= Res.C.shape[0]:
|
|
204
|
+
raise ValueError(f"Series index {series_idx} out of range [0, {Res.C.shape[0]})")
|
|
205
|
+
try:
|
|
206
|
+
series_names = config.get_series_names() if config.series else []
|
|
207
|
+
name = series_names[series_idx] if series_idx < len(series_names) else f"Series_{series_idx}"
|
|
208
|
+
except (AttributeError, IndexError, KeyError):
|
|
209
|
+
name = f"Series_{series_idx}"
|
|
210
|
+
rmse_original = None
|
|
211
|
+
rmse_standardized = None
|
|
212
|
+
if Res.rmse_per_series is not None and series_idx < len(Res.rmse_per_series):
|
|
213
|
+
rmse_original = Res.rmse_per_series[series_idx]
|
|
214
|
+
if Res.rmse_std_per_series is not None and series_idx < len(Res.rmse_std_per_series):
|
|
215
|
+
rmse_standardized = Res.rmse_std_per_series[series_idx]
|
|
216
|
+
mean_val = Res.Mx[series_idx] if series_idx < len(Res.Mx) else np.nan
|
|
217
|
+
std_val = Res.Wx[series_idx] if series_idx < len(Res.Wx) else np.nan
|
|
218
|
+
rmse_pct_of_mean = 100.0 * rmse_original / abs(mean_val) if (rmse_original is not None and not np.isnan(mean_val) and abs(mean_val) > 1e-6) else None
|
|
219
|
+
rmse_in_std_devs = rmse_original / std_val if (rmse_original is not None and not np.isnan(std_val) and std_val > 1e-6) else None
|
|
220
|
+
factor_loadings = Res.C[series_idx, :] if series_idx < Res.C.shape[0] else np.array([])
|
|
221
|
+
max_loading = np.max(np.abs(factor_loadings)) if len(factor_loadings) > 0 else np.nan
|
|
222
|
+
loading_sum_sq = np.sum(factor_loadings ** 2) if len(factor_loadings) > 0 else np.nan
|
|
223
|
+
return {
|
|
224
|
+
'series_name': name,
|
|
225
|
+
'series_idx': series_idx,
|
|
226
|
+
'rmse_original': rmse_original,
|
|
227
|
+
'rmse_standardized': rmse_standardized,
|
|
228
|
+
'mean': mean_val,
|
|
229
|
+
'std': std_val,
|
|
230
|
+
'rmse_pct_of_mean': rmse_pct_of_mean,
|
|
231
|
+
'rmse_in_std_devs': rmse_in_std_devs,
|
|
232
|
+
'factor_loadings': factor_loadings,
|
|
233
|
+
'max_loading': max_loading,
|
|
234
|
+
'loading_sum_sq': loading_sum_sq,
|
|
235
|
+
'reconstruction_error_mean': None,
|
|
236
|
+
'reconstruction_error_std': None
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def print_series_diagnosis(Res: DFMResult, config: DFMConfig,
|
|
241
|
+
series_name: Optional[str] = None,
|
|
242
|
+
series_idx: Optional[int] = None) -> None:
|
|
243
|
+
"""Print a formatted diagnosis report for a specific series.
|
|
244
|
+
|
|
245
|
+
Note: This function uses print() for user-facing output, as it's intended
|
|
246
|
+
to be called directly by users who want to see diagnostic information.
|
|
247
|
+
"""
|
|
248
|
+
diag = diagnose_series(Res, config, series_name=series_name, series_idx=series_idx)
|
|
249
|
+
print(f"\n{'='*70}")
|
|
250
|
+
print(f"DIAGNOSTIC REPORT: {diag['series_name']}")
|
|
251
|
+
print(f"{'='*70}\n")
|
|
252
|
+
print("RMSE Statistics:")
|
|
253
|
+
print(f" Original scale: {diag['rmse_original']:.6e}" if diag['rmse_original'] is not None else " Original scale: N/A")
|
|
254
|
+
print(f" Standardized scale: {diag['rmse_standardized']:.6f} std dev" if diag['rmse_standardized'] is not None else " Standardized scale: N/A")
|
|
255
|
+
if diag['rmse_pct_of_mean'] is not None:
|
|
256
|
+
print(f" As % of mean: {diag['rmse_pct_of_mean']:.2f}%")
|
|
257
|
+
if diag['rmse_in_std_devs'] is not None:
|
|
258
|
+
print(f" In std deviations: {diag['rmse_in_std_devs']:.2f}x")
|
|
259
|
+
print("\nStandardization Values:")
|
|
260
|
+
print(f" Mean: {diag['mean']:.6e}" if not np.isnan(diag['mean']) else " Mean: N/A")
|
|
261
|
+
print(f" Std: {diag['std']:.6e}" if not np.isnan(diag['std']) else " Std: N/A")
|
|
262
|
+
print("\nFactor Loadings:")
|
|
263
|
+
if len(diag['factor_loadings']) > 0:
|
|
264
|
+
print(f" Number of loadings: {len(diag['factor_loadings'])}")
|
|
265
|
+
print(f" Max absolute: {diag['max_loading']:.6f}" if not np.isnan(diag['max_loading']) else " Max absolute: N/A")
|
|
266
|
+
print(f" Sum of squares: {diag['loading_sum_sq']:.6f}" if not np.isnan(diag['loading_sum_sq']) else " Sum of squares: N/A")
|
|
267
|
+
abs_loadings = np.abs(diag['factor_loadings'])
|
|
268
|
+
top_indices = np.argsort(abs_loadings)[-5:][::-1]
|
|
269
|
+
print(f" Top 5 loadings:")
|
|
270
|
+
for idx in top_indices:
|
|
271
|
+
print(f" Factor {idx:3d}: {diag['factor_loadings'][idx]:8.4f}")
|
|
272
|
+
else:
|
|
273
|
+
print(" No loadings available")
|
|
274
|
+
print(f"\n{'='*70}\n")
|