dfm-python 0.2.6__py3-none-any.whl → 0.2.8__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
@@ -50,7 +50,7 @@ Example (Low-level API - For advanced usage):
50
50
  For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
51
51
  """
52
52
 
53
- __version__ = "0.2.6"
53
+ __version__ = "0.2.8"
54
54
 
55
55
  from .config import DFMConfig, SeriesConfig, BlockConfig, Params, DEFAULT_GLOBAL_BLOCK_NAME
56
56
  from .config import (
@@ -4,12 +4,10 @@ from typing import Optional, Dict, Any, Tuple, TYPE_CHECKING
4
4
  import numpy as np
5
5
  import logging
6
6
 
7
- try:
8
- import pandas as pd
9
- PANDAS_AVAILABLE = True
10
- except ImportError:
11
- PANDAS_AVAILABLE = False
12
- pd = None
7
+ import pandas as pd
8
+ from scipy.linalg import orthogonal_procrustes
9
+
10
+ PANDAS_AVAILABLE = True
13
11
 
14
12
  from ..config import DFMConfig
15
13
 
@@ -96,6 +94,217 @@ def calculate_rmse(actual: np.ndarray, predicted: np.ndarray,
96
94
  return rmse_overall, rmse_per_series
97
95
 
98
96
 
97
+ def evaluate_factor_estimation(
98
+ true_factors: np.ndarray,
99
+ estimated_factors: np.ndarray,
100
+ use_procrustes: bool = True
101
+ ) -> Dict[str, Any]:
102
+ """Evaluate how well factors are estimated (synthetic-data diagnostic).
103
+
104
+ This function compares known ``true_factors`` with ``estimated_factors``
105
+ (typically ``result.Z[:, :num_factors]``) and returns:
106
+
107
+ - Per-factor correlations (before any rotation)
108
+ - Optionally Procrustes-rotated factors and an overall correlation
109
+
110
+ Parameters
111
+ ----------
112
+ true_factors : np.ndarray
113
+ Array of shape (T, k) with generated factors used to build the data.
114
+ estimated_factors : np.ndarray
115
+ Array of shape (T, k_est) with estimated factors. Only the first
116
+ ``k = min(k, k_est)`` columns are compared.
117
+ use_procrustes : bool, default True
118
+ If True and SciPy is available, apply orthogonal Procrustes rotation
119
+ to align estimated factors with true factors before computing the
120
+ overall correlation.
121
+
122
+ Returns
123
+ -------
124
+ Dict[str, Any]
125
+ {
126
+ 'num_factors': int,
127
+ 'correlation_per_factor': np.ndarray (k,),
128
+ 'overall_correlation': float or None,
129
+ 'rotation_matrix': np.ndarray (k, k) or None,
130
+ 'aligned_factors': np.ndarray (T, k) or None,
131
+ }
132
+
133
+ Notes
134
+ -----
135
+ - This is primarily intended for synthetic-data experiments where the
136
+ true latent factors are known.
137
+ - Correlations are computed factor-by-factor, ignoring any time points
138
+ with NaN values.
139
+ - Procrustes rotation is useful because factors are only identified up
140
+ to sign, scale (if not constrained), and rotation in general.
141
+ """
142
+ if true_factors.ndim != 2 or estimated_factors.ndim != 2:
143
+ raise ValueError(
144
+ f"true_factors and estimated_factors must be 2D, "
145
+ f"got shapes {true_factors.shape} and {estimated_factors.shape}"
146
+ )
147
+
148
+ T_true, k_true = true_factors.shape
149
+ T_est, k_est = estimated_factors.shape
150
+ if T_true != T_est:
151
+ raise ValueError(
152
+ f"Time dimension must match, got {T_true} and {T_est}"
153
+ )
154
+
155
+ k = min(k_true, k_est)
156
+ if k == 0:
157
+ raise ValueError("At least one factor is required for comparison.")
158
+
159
+ F_true = np.asarray(true_factors[:, :k], dtype=float)
160
+ F_est = np.asarray(estimated_factors[:, :k], dtype=float)
161
+
162
+ # Per-factor correlations (no rotation)
163
+ corr_per_factor = np.full(k, np.nan, dtype=float)
164
+ for j in range(k):
165
+ mask_j = np.isfinite(F_true[:, j]) & np.isfinite(F_est[:, j])
166
+ if np.sum(mask_j) > 1:
167
+ corr = np.corrcoef(F_true[mask_j, j], F_est[mask_j, j])[0, 1]
168
+ corr_per_factor[j] = float(corr)
169
+
170
+ rotation_matrix = None
171
+ aligned_factors = None
172
+ overall_corr = None
173
+
174
+ if use_procrustes:
175
+ # Center factors before Procrustes to focus on shape, not level.
176
+ F_true_centered = F_true - np.nanmean(F_true, axis=0, keepdims=True)
177
+ F_est_centered = F_est - np.nanmean(F_est, axis=0, keepdims=True)
178
+
179
+ # Replace any remaining NaNs with 0 before Procrustes (rare in synthetic tests)
180
+ F_true_clean = np.where(np.isfinite(F_true_centered), F_true_centered, 0.0)
181
+ F_est_clean = np.where(np.isfinite(F_est_centered), F_est_centered, 0.0)
182
+
183
+ try:
184
+ # Find R such that F_est_clean @ R ≈ F_true_clean
185
+ R, _ = orthogonal_procrustes(F_est_clean, F_true_clean) # type: ignore[arg-type]
186
+ rotation_matrix = R
187
+ aligned_factors = F_est @ R
188
+
189
+ mask_all = np.isfinite(F_true) & np.isfinite(aligned_factors)
190
+ if np.any(mask_all):
191
+ overall_corr = float(
192
+ np.corrcoef(
193
+ F_true[mask_all].ravel(),
194
+ aligned_factors[mask_all].ravel()
195
+ )[0, 1]
196
+ )
197
+ except Exception as e:
198
+ _logger.debug(f"Procrustes rotation failed in evaluate_factor_estimation: {e}")
199
+
200
+ return {
201
+ "num_factors": k,
202
+ "correlation_per_factor": corr_per_factor,
203
+ "overall_correlation": overall_corr,
204
+ "rotation_matrix": rotation_matrix,
205
+ "aligned_factors": aligned_factors,
206
+ }
207
+
208
+
209
+ def evaluate_loading_estimation(
210
+ true_loadings: np.ndarray,
211
+ estimated_loadings: np.ndarray,
212
+ rotation_matrix: Optional[np.ndarray] = None
213
+ ) -> Dict[str, Any]:
214
+ """Evaluate loading matrix estimation accuracy (synthetic-data diagnostic).
215
+
216
+ Parameters
217
+ ----------
218
+ true_loadings : np.ndarray
219
+ Array of shape (N, k_true) with the loadings used to generate data.
220
+ estimated_loadings : np.ndarray
221
+ Array of shape (N, k_est) with estimated loadings from the model
222
+ (typically ``result.C[:, :num_factors]``).
223
+ rotation_matrix : np.ndarray, optional
224
+ Orthogonal rotation matrix of shape (k_est, k_rot) returned by
225
+ :func:`evaluate_factor_estimation`. If provided, the estimated
226
+ loadings are rotated as ``estimated_loadings @ rotation_matrix``
227
+ before comparison.
228
+
229
+ Returns
230
+ -------
231
+ Dict[str, Any]
232
+ {
233
+ 'num_factors': int,
234
+ 'correlation_per_factor': np.ndarray (k,),
235
+ 'rmse_per_series': np.ndarray (N,),
236
+ 'overall_rmse': float,
237
+ 'aligned_loadings': np.ndarray (N, k),
238
+ }
239
+
240
+ Notes
241
+ -----
242
+ - RMSE is computed per series (row-wise) across factors.
243
+ - This function assumes synthetic data where ``true_loadings`` are known.
244
+ """
245
+ if true_loadings.ndim != 2 or estimated_loadings.ndim != 2:
246
+ raise ValueError(
247
+ f"true_loadings and estimated_loadings must be 2D, "
248
+ f"got shapes {true_loadings.shape} and {estimated_loadings.shape}"
249
+ )
250
+
251
+ N_true, k_true = true_loadings.shape
252
+ N_est, k_est = estimated_loadings.shape
253
+ if N_true != N_est:
254
+ raise ValueError(
255
+ f"Number of series (rows) must match, got {N_true} and {N_est}"
256
+ )
257
+
258
+ # Apply rotation if provided (align loadings with rotated factors)
259
+ L_est = np.asarray(estimated_loadings, dtype=float)
260
+ if rotation_matrix is not None:
261
+ if rotation_matrix.ndim != 2 or rotation_matrix.shape[0] != k_est:
262
+ raise ValueError(
263
+ f"rotation_matrix shape {rotation_matrix.shape} incompatible "
264
+ f"with estimated_loadings shape {estimated_loadings.shape}"
265
+ )
266
+ L_est = L_est @ rotation_matrix
267
+
268
+ k = min(k_true, L_est.shape[1])
269
+ if k == 0:
270
+ raise ValueError("At least one factor/loading column is required.")
271
+
272
+ L_true = np.asarray(true_loadings[:, :k], dtype=float)
273
+ L_est_k = np.asarray(L_est[:, :k], dtype=float)
274
+
275
+ # Per-factor loading correlations
276
+ corr_per_factor = np.full(k, np.nan, dtype=float)
277
+ for j in range(k):
278
+ mask_j = np.isfinite(L_true[:, j]) & np.isfinite(L_est_k[:, j])
279
+ if np.sum(mask_j) > 1:
280
+ corr = np.corrcoef(L_true[mask_j, j], L_est_k[mask_j, j])[0, 1]
281
+ corr_per_factor[j] = float(corr)
282
+
283
+ # RMSE per series (row-wise across factors)
284
+ diff = L_true - L_est_k
285
+ mask = np.isfinite(L_true) & np.isfinite(L_est_k)
286
+ rmse_per_series = np.full(N_true, np.nan, dtype=float)
287
+ for i in range(N_true):
288
+ mask_i = mask[i, :]
289
+ if np.any(mask_i):
290
+ rmse_per_series[i] = float(
291
+ np.sqrt(np.mean(diff[i, mask_i] ** 2))
292
+ )
293
+
294
+ if np.any(mask):
295
+ overall_rmse = float(np.sqrt(np.mean(diff[mask] ** 2)))
296
+ else:
297
+ overall_rmse = float("nan")
298
+
299
+ return {
300
+ "num_factors": k,
301
+ "correlation_per_factor": corr_per_factor,
302
+ "rmse_per_series": rmse_per_series,
303
+ "overall_rmse": overall_rmse,
304
+ "aligned_loadings": L_est_k,
305
+ }
306
+
307
+
99
308
  def _display_dfm_tables(Res: DFMResult, config: DFMConfig, nQ: int) -> None:
100
309
  """Display DFM estimation output tables.
101
310
 
dfm_python/core/em.py CHANGED
@@ -809,8 +809,8 @@ def em_step(params: EMStepParams) -> Tuple[np.ndarray, np.ndarray, np.ndarray, n
809
809
  lambda_ridge = (trace_val / size_val) * 1e-5 # Scale factor
810
810
  EZZ_lag_sub = EZZ_lag_sub + lambda_ridge * np.eye(size_val)
811
811
  # Recompute condition number after regularization
812
- eigenvals = np.linalg.eigvals(EZZ_lag_sub)
813
- cond_num = (np.max(eigenvals) / max(np.min(eigenvals), 1e-12)) if np.max(eigenvals) > 0 else 1.0
812
+ eigenvals = np.linalg.eigvals(EZZ_lag_sub)
813
+ cond_num = (np.max(eigenvals) / max(np.min(eigenvals), 1e-12)) if np.max(eigenvals) > 0 else 1.0
814
814
  EZZ_lag_inv = pinv(EZZ_lag_sub, rcond=1e-8) if cond_num > 1e12 else inv(EZZ_lag_sub)
815
815
  except _NUMERICAL_EXCEPTIONS:
816
816
  EZZ_lag_inv = pinv(EZZ_lag_sub)
@@ -1247,33 +1247,39 @@ def em_step(params: EMStepParams) -> Tuple[np.ndarray, np.ndarray, np.ndarray, n
1247
1247
  # Q cap to prevent explosion: maximum allowed innovation variance for factors
1248
1248
  Q_cap_factor = safe_get_attr(config, "max_innovation_variance", 1.0) if config is not None else 1.0
1249
1249
 
1250
- # Always normalize C for clock-frequency factors to prevent scale issues
1251
- # Even if there are slower-frequency series, we can still normalize clock-frequency factors
1252
- # (tent weight constraints only apply to slower-frequency parts, not clock-frequency parts)
1250
+ # Normalize C for clock-frequency factors to prevent scale issues
1251
+ # Only normalize if C norm is in reasonable range (not too small, not too large)
1252
+ # This prevents Q from becoming too small (when C norm is small) or too large (when C norm is large)
1253
1253
  if num_clock_factors > 0:
1254
- # Only normalize if there are no slower-frequency series
1255
- # (to avoid violating tent weight constraints)
1256
- for j in range(num_clock_factors):
1257
- norm = np.linalg.norm(C_new[:, j])
1258
- if norm > 1e-8:
1259
- # Improved normalization: clip C norm before normalization to prevent Q explosion
1260
- # If C norm is too large, we cap it to prevent Q from becoming extreme
1261
- C_norm_cap = safe_get_attr(config, "max_loading_norm", 10.0) if config is not None else 10.0
1262
- norm_clipped = min(norm, C_norm_cap)
1263
-
1264
- # Normalize C column (using clipped norm to prevent extreme Q)
1265
- C_new[:, j] = C_new[:, j] / norm
1266
-
1267
- # Adjust Q to preserve variance, but use clipped norm to prevent explosion
1268
- # This is a compromise: we normalize C fully but limit Q adjustment
1269
- Q_adjustment = norm_clipped ** 2
1270
- Q_new[j, j] = Q_new[j, j] * Q_adjustment
1271
-
1272
- # Off-diagonal: Q[j,k] and Q[k,j] *= norm_clipped (for k != j)
1273
- for k in range(Q_new.shape[0]):
1274
- if k != j:
1275
- Q_new[j, k] = Q_new[j, k] * norm_clipped
1276
- Q_new[k, j] = Q_new[k, j] * norm_clipped
1254
+ # Check if there are slower-frequency series (tent weight constraints)
1255
+ has_slower_freq = (frequencies is not None and
1256
+ any(freq != clock for freq in frequencies if freq is not None))
1257
+
1258
+ # Only normalize if no slower-frequency series (to avoid violating tent weight constraints)
1259
+ if not has_slower_freq:
1260
+ for j in range(num_clock_factors):
1261
+ norm = np.linalg.norm(C_new[:, j])
1262
+ if norm > 1e-8:
1263
+ # Only normalize if norm is in reasonable range
1264
+ # Too small norm (< 0.1) would make Q too small
1265
+ # Too large norm (> 10) would make Q too large
1266
+ C_norm_min = 0.1 # Minimum norm to normalize
1267
+ C_norm_cap = safe_get_attr(config, "max_loading_norm", 10.0) if config is not None else 10.0
1268
+
1269
+ if C_norm_min <= norm <= C_norm_cap:
1270
+ # Normalize C column
1271
+ C_new[:, j] = C_new[:, j] / norm
1272
+
1273
+ # Adjust Q to preserve variance: Q[j,j] *= norm^2
1274
+ Q_adjustment = norm ** 2
1275
+ Q_new[j, j] = Q_new[j, j] * Q_adjustment
1276
+
1277
+ # Off-diagonal: Q[j,k] and Q[k,j] *= norm (for k != j)
1278
+ for k in range(Q_new.shape[0]):
1279
+ if k != j:
1280
+ Q_new[j, k] = Q_new[j, k] * norm
1281
+ Q_new[k, j] = Q_new[k, j] * norm
1282
+ # If norm is too small or too large, skip normalization to preserve stability
1277
1283
 
1278
1284
  # Re-apply Q floor and cap after C normalization to prevent extreme values
1279
1285
  # C normalization can make Q very large (if C norm was large) or very small (if C norm was small)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dfm-python
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -709,12 +709,20 @@ Demonstrates:
709
709
 
710
710
  ## Project Status
711
711
 
712
- **Version**: 0.2.6
712
+ **Version**: 0.2.7
713
713
  **Status**: Stable and production-ready
714
714
  **Python**: 3.10+
715
715
  **PyPI**: https://pypi.org/project/dfm-python/
716
716
 
717
- ### Recent Improvements (v0.2.6)
717
+ ### Recent Improvements (v0.2.7)
718
+
719
+ - ✅ **Fixed C normalization issue**: C normalization now only applies when C norm is in reasonable range (0.1 ~ 10)
720
+ - Prevents Q from becoming too small when C norm is very small
721
+ - Prevents factor values from becoming extreme
722
+ - ✅ **Improved stability**: Conditional C normalization prevents pathological factor paths
723
+ - ✅ **Better diagnostics**: Added variance guard binding checks in tutorial
724
+
725
+ ### Previous Improvements (v0.2.6)
718
726
 
719
727
  - ✅ **Q matrix cap**: Factor innovation variances bounded above by 1.0 to prevent explosion
720
728
  - ✅ **R matrix cap**: Observation error variances bounded above by 10.0 for standardized data
@@ -758,7 +766,7 @@ If you use `dfm-python` in your research, please cite:
758
766
  author = {DFM Python Contributors},
759
767
  year = {2025},
760
768
  url = {https://pypi.org/project/dfm-python/},
761
- version = {0.2.6}
769
+ version = {0.2.7}
762
770
  }
763
771
  ```
764
772
 
@@ -1,4 +1,4 @@
1
- dfm_python/__init__.py,sha256=arYgbDHYx9jgEjCZ1JMKZO4cZewBc1j-iXi_g6SDu4o,5589
1
+ dfm_python/__init__.py,sha256=oz0p9JreUg6D9SfU2vUAFMRwf7JwHaa3dQDy87YHtIw,5589
2
2
  dfm_python/api.py,sha256=-mmV7aaXOAqVW9HPhTEut7T6wN1EyW0rS3J0y-z-MRs,16221
3
3
  dfm_python/config.py,sha256=bBiKfoETohNVJ3brRbY1330C1nQROybCR3co9iagIKs,46978
4
4
  dfm_python/config_sources.py,sha256=6LTK7k5WJpnayMmtxtYwZ9SQi_hGXoz-Vg4sRGTabI0,21798
@@ -8,11 +8,11 @@ dfm_python/kalman.py,sha256=EXhloLbpzy7GKG9KFrSZt9pRp0y3CZyvycPVbvUWxq8,18074
8
8
  dfm_python/news.py,sha256=iXbJnsvH5FgKToPzKnqIpTosrZu-QHXBNHjsqwMYrHo,32583
9
9
  dfm_python/utils.py,sha256=qOAJm2KG71yP2a29iJVDGOC-ZUS_HPL5oAo16EgNXA4,15709
10
10
  dfm_python/core/__init__.py,sha256=oDrhAoc1W_ODohgufQ-gN0uri8-vZI0EXsF0dysth_A,331
11
- dfm_python/core/diagnostics.py,sha256=2Cm8XIN5jE9jL3FssEUmZNQy12FPuKvYs1k3bYKAySw,20224
12
- dfm_python/core/em.py,sha256=nNLL179l_SYSowxtnZBHFpEDR_54FOU_tNyn8XaVwnU,72357
11
+ dfm_python/core/diagnostics.py,sha256=1ninWcQlWhJmfUZq9kky7IQoRf_6fI_IlVzzSb7apRQ,28085
12
+ dfm_python/core/em.py,sha256=jbtsFHynMDtblZTtlzgodX04e86BBB9hPn20MMnV3w0,72705
13
13
  dfm_python/core/helpers.py,sha256=XJvWWEZlUqozBfFNcENKgbOOFkwlm-tGwO-u0LnAf-E,31541
14
14
  dfm_python/core/numeric.py,sha256=IcIwcKtMhIoJ5-7Mz6UNYwTUtEa5W_ZMnQtF87v0SSc,26431
15
- dfm_python-0.2.6.dist-info/METADATA,sha256=DnwocbXu0WC8ruBF1GSU2PQINCNSYjxMlGrQlRXVWs0,28159
16
- dfm_python-0.2.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
- dfm_python-0.2.6.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
18
- dfm_python-0.2.6.dist-info/RECORD,,
15
+ dfm_python-0.2.8.dist-info/METADATA,sha256=wY69TZa4fK9I4zY4t7hQUJksWuMCyxtGUS0Nr_C3SPk,28599
16
+ dfm_python-0.2.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
+ dfm_python-0.2.8.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
18
+ dfm_python-0.2.8.dist-info/RECORD,,