dfm-python 0.4.7__py3-none-any.whl → 0.4.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
@@ -65,7 +65,7 @@ Note: DFMConfig and SeriesConfig are internal implementation details.
65
65
  For detailed documentation, see the README.md file and the tutorial notebooks/scripts.
66
66
  """
67
67
 
68
- __version__ = "0.4.7"
68
+ __version__ = "0.4.8"
69
69
 
70
70
  # ============================================================================
71
71
  # PUBLIC API DEFINITION
dfm_python/ssm/em.py CHANGED
@@ -48,7 +48,7 @@ from typing import Tuple, Optional, Dict, Any
48
48
  from dataclasses import dataclass
49
49
  from ..logger import get_logger
50
50
  from .kalman import KalmanFilter
51
- from .utils import ensure_positive_definite, ensure_symmetric
51
+ from .utils import ensure_positive_definite, ensure_symmetric, cap_max_eigenval
52
52
 
53
53
  _logger = get_logger(__name__)
54
54
 
@@ -108,6 +108,10 @@ class EMAlgorithm(nn.Module):
108
108
  self.kalman = kalman
109
109
  self.register_buffer('regularization_scale', torch.tensor(regularization_scale))
110
110
 
111
+ def _cap_max_eigenval(self, M: torch.Tensor, max_eigenval: float = 1e6) -> torch.Tensor:
112
+ """Cap maximum eigenvalue of matrix to prevent numerical explosion."""
113
+ return cap_max_eigenval(M, max_eigenval=max_eigenval, warn=False)
114
+
111
115
  def forward(
112
116
  self,
113
117
  params: EMStepParams
@@ -241,8 +245,27 @@ class EMAlgorithm(nn.Module):
241
245
  # Transpose y to (T, N) for batch operations
242
246
  # Use y_for_mstep (NaN replaced with 0) for M-step
243
247
  sum_yEZ = torch.sum(y_for_mstep.T[:, :, None] * EZ[:, None, :], dim=0) # (N, m)
244
- # Compute sum_EZZ = sum_t E[Z_t Z_t^T]
245
- sum_EZZ = torch.sum(EZZ, dim=0)
248
+
249
+ # CRITICAL FIX: Remove NaN/Inf from EZZ before computing sum_EZZ
250
+ # This prevents ill-conditioned sum_EZZ caused by corrupted EZZ from smoother
251
+ # MATLAB doesn't check for NaN/Inf, but we need to handle it explicitly
252
+ EZZ_clean = EZZ.clone()
253
+ nan_inf_mask = torch.isnan(EZZ_clean) | torch.isinf(EZZ_clean)
254
+ if torch.any(nan_inf_mask):
255
+ # Replace NaN/Inf with zero (exclude corrupted time steps from sum)
256
+ EZZ_clean[nan_inf_mask] = 0.0
257
+ corrupted_count = torch.sum(nan_inf_mask).item()
258
+ total_count = EZZ_clean.numel()
259
+ _logger.warning(
260
+ f"EM algorithm: EZZ contains {corrupted_count}/{total_count} NaN/Inf values "
261
+ f"({corrupted_count/total_count:.1%}). Excluding corrupted time steps from sum_EZZ calculation."
262
+ )
263
+
264
+ # Compute sum_EZZ = sum_t E[Z_t Z_t^T] (only from valid time steps)
265
+ sum_EZZ = torch.sum(EZZ_clean, dim=0)
266
+
267
+ # Cap maximum eigenvalue to prevent condition number explosion
268
+ sum_EZZ = self._cap_max_eigenval(sum_EZZ, max_eigenval=1e6)
246
269
 
247
270
  # ROOT CAUSE FIX: Adaptive regularization based on condition number
248
271
  # Fixed regularization (1e-6) becomes insufficient as condition number grows
@@ -268,22 +291,38 @@ class EMAlgorithm(nn.Module):
268
291
  reg_scale = base_reg_scale
269
292
  else:
270
293
  # All eigenvalues are near-zero, use stronger regularization
271
- reg_scale = base_reg_scale * 100
294
+ # Ensure minimum regularization (1e-3) for numerical stability
295
+ reg_scale = max(base_reg_scale * 100, 1e-3)
272
296
  _logger.warning(
273
297
  f"EM algorithm: sum_EZZ has no valid eigenvalues, "
274
- f"using strong regularization: {reg_scale:.2e}"
298
+ f"using strong regularization: {reg_scale:.2e} (min=1e-3, base={base_reg_scale:.2e})"
275
299
  )
276
300
  except (RuntimeError, ValueError) as e:
277
- # Eigendecomposition failed, use stronger regularization
278
- reg_scale = base_reg_scale * 10
301
+ # CRITICAL FIX: Eigendecomposition failed, use stronger regularization
302
+ # Ensure minimum regularization (1e-3) even if base_reg_scale * 10 is smaller
303
+ # This is the Ridge Regression approach: eigendecomposition failure means
304
+ # the matrix is too ill-conditioned, so we need strong regularization
305
+ reg_scale = max(base_reg_scale * 10, 1e-3)
279
306
  _logger.warning(
280
307
  f"EM algorithm: Failed to compute condition number for sum_EZZ ({e}), "
281
- f"using increased regularization: {reg_scale:.2e}"
308
+ f"using strong regularization: {reg_scale:.2e} (min=1e-3, base={base_reg_scale:.2e})"
282
309
  )
283
310
 
284
311
  # Apply adaptive regularization
285
312
  sum_EZZ_reg = sum_EZZ + torch.eye(m, device=device, dtype=dtype) * reg_scale
286
- C_new = torch.linalg.solve(sum_EZZ_reg.T, sum_yEZ.T).T
313
+
314
+ # CRITICAL FIX: Use pseudo-inverse as fallback when solve fails
315
+ # Original MATLAB uses pinv() in smoother (line 1039, 1064)
316
+ # This handles singular/ill-conditioned matrices more robustly
317
+ try:
318
+ C_new = torch.linalg.solve(sum_EZZ_reg.T, sum_yEZ.T).T
319
+ except RuntimeError as e:
320
+ # Fallback to pseudo-inverse (original MATLAB: pinv() in smoother)
321
+ _logger.warning(
322
+ f"EM algorithm: torch.linalg.solve failed for C matrix update ({e}), "
323
+ f"using pseudo-inverse fallback (original MATLAB: pinv() in smoother)"
324
+ )
325
+ C_new = (torch.linalg.pinv(sum_EZZ_reg.T) @ sum_yEZ.T).T
287
326
 
288
327
  # Check for NaN in C_new after solve (indicates numerical instability)
289
328
  if torch.any(torch.isnan(C_new)):
@@ -294,11 +333,25 @@ class EMAlgorithm(nn.Module):
294
333
  f"after solve operation. This indicates numerical instability. "
295
334
  f"Possible causes: singular matrix, extreme values, or insufficient regularization."
296
335
  )
297
- # Set NaN columns to zero instead of leaving them as NaN
298
- nan_cols = torch.any(torch.isnan(C_new), dim=0)
299
- if torch.any(nan_cols):
300
- C_new[:, nan_cols] = 0.0
301
- _logger.warning(f"Set {torch.sum(nan_cols).item()} NaN columns in C matrix to zero.")
336
+ # CRITICAL FIX: Preserve previous iteration values instead of setting to zero
337
+ # This allows gradual improvement and preserves information from previous iterations
338
+ nan_mask = torch.isnan(C_new)
339
+ if torch.any(nan_mask):
340
+ # Check if previous C matrix is valid (no NaN)
341
+ if not torch.any(torch.isnan(params.C)):
342
+ C_new[nan_mask] = params.C[nan_mask]
343
+ # Only log if significant NaN ratio to reduce log spam
344
+ if nan_ratio > 0.1: # Only log if >10% NaN
345
+ _logger.warning(
346
+ f"Preserved {torch.sum(nan_mask).item()} NaN values in C matrix from previous iteration "
347
+ f"(total NaN: {nan_count}/{C_new.numel()}, {nan_ratio:.1%})"
348
+ )
349
+ else:
350
+ # If previous C also has NaN, set to zero as last resort
351
+ C_new[nan_mask] = 0.0
352
+ _logger.warning(
353
+ f"Previous C matrix also contains NaN. Set {torch.sum(nan_mask).item()} NaN values to zero."
354
+ )
302
355
 
303
356
  # Normalize C columns (factor loadings)
304
357
  for j in range(m):
dfm_python/ssm/kalman.py CHANGED
@@ -36,6 +36,7 @@ from .utils import (
36
36
  clean_matrix,
37
37
  safe_inverse,
38
38
  safe_determinant,
39
+ cap_max_eigenval,
39
40
  )
40
41
 
41
42
  _logger = get_logger(__name__)
@@ -343,6 +344,8 @@ class KalmanFilter(nn.Module):
343
344
  # Fallback: use previous covariance with regularization
344
345
  V = Vu + torch.eye(V.shape[0], device=device, dtype=dtype) * 1e-6
345
346
 
347
+ # Cap maximum eigenvalue to prevent explosion
348
+ V = self._cap_max_eigenval(V, max_eigenval=1e6)
346
349
  # Ensure V is real, symmetric, and positive semi-definite (MATLAB: V = 0.5 * (V+V'))
347
350
  V = ensure_real_and_symmetric(V)
348
351
  V = self._ensure_cov_stable(V, min_eigenval=self.min_eigenval.item(), ensure_real=True)
@@ -356,13 +359,24 @@ class KalmanFilter(nn.Module):
356
359
  Zu = Z
357
360
  Vu = V
358
361
  else:
359
- # CRITICAL FIX: Adaptive regularization based on missing data ratio
362
+ # CRITICAL FIX: Adaptive regularization based on missing data ratio and n_obs
360
363
  # When data is sparse (many masked values), increase regularization to prevent numerical instability
361
364
  missing_ratio = 1.0 - (len(Y_t) / k) # Ratio of missing observations
362
365
  # Scale regularization: base regularization * (1 + missing_ratio * 10)
363
366
  # This means: 0% missing -> base reg, 50% missing -> 6x reg, 100% missing -> 11x reg
364
367
  adaptive_reg = self.inv_regularization.item() * (1.0 + missing_ratio * 10.0)
365
368
 
369
+ # CRITICAL FIX: Additional regularization based on n_obs (number of observed series)
370
+ # Log analysis shows F matrix inversion becomes unstable when n_obs >= 30
371
+ # Scale regularization: n_obs >= 30 -> additional factor (more generic threshold)
372
+ n_obs = len(Y_t)
373
+ if n_obs >= 30:
374
+ n_obs_factor = 1.0 + (n_obs - 29) * 0.1 # 30 -> 1.1x, 33 -> 1.4x, 39 -> 2.0x
375
+ adaptive_reg = adaptive_reg * n_obs_factor
376
+ # Only log once per iteration (first time step) to reduce log spam
377
+ if t == 0:
378
+ _logger.debug(f"kalman_filter_forward: n_obs={n_obs} >= 30, applying n_obs_factor={n_obs_factor:.2f}, adaptive_reg={adaptive_reg:.2e}")
379
+
366
380
  # Steps for variance and population regression coefficients:
367
381
  # Var(c_t*Z_t + e_t) = c_t Var(Z) c_t' + Var(e) = c_t*V*c_t' + R
368
382
  VC = V @ C_t.T
@@ -378,6 +392,26 @@ class KalmanFilter(nn.Module):
378
392
  diag_R_t = torch.clamp(diag_R_t, min=self.min_diagonal_variance.item() * (1.0 + missing_ratio * 5.0))
379
393
  R_t = torch.diag(diag_R_t)
380
394
 
395
+ # ROOT CAUSE ANALYSIS: Compute and log F matrix condition number before capping
396
+ # This helps identify when F becomes ill-conditioned
397
+ try:
398
+ eigenvals_F = torch.linalg.eigvalsh(F)
399
+ eigenvals_F = eigenvals_F[eigenvals_F > 1e-12]
400
+ if len(eigenvals_F) > 0:
401
+ max_eig_F = torch.max(eigenvals_F).item()
402
+ min_eig_F = torch.min(eigenvals_F).item()
403
+ cond_F = max_eig_F / min_eig_F if min_eig_F > 1e-12 else float('inf')
404
+ # Only log first 5 steps or when severely ill-conditioned to reduce log spam
405
+ if cond_F > 1e10 or t < 5:
406
+ _logger.debug(
407
+ f"kalman_filter_forward: t={t}, n_obs={n_obs}, F condition={cond_F:.2e}, "
408
+ f"eigenvals=[{min_eig_F:.2e}, {max_eig_F:.2e}], adaptive_reg={adaptive_reg:.2e}"
409
+ )
410
+ except (RuntimeError, ValueError):
411
+ pass # Skip if eigendecomposition fails
412
+
413
+ # Cap maximum eigenvalue to prevent condition number explosion
414
+ F = self._cap_max_eigenval(F, max_eigenval=1e6)
381
415
  # Ensure F is real, symmetric, and positive semi-definite
382
416
  # Use adaptive minimum eigenvalue based on missing data ratio
383
417
  min_eig_adaptive = self.min_eigenval.item() * (1.0 + missing_ratio * 5.0)
@@ -394,6 +428,15 @@ class KalmanFilter(nn.Module):
394
428
  # This handles GPU numerical stability issues (singular matrix errors)
395
429
  iF = self._safe_inv(F, regularization=adaptive_reg, use_pinv_fallback=True)
396
430
 
431
+ # ROOT CAUSE ANALYSIS: Check if inversion was successful (only log warnings, not every occurrence)
432
+ if not self._check_finite(iF, f"iF (inverse of F) at t={t}"):
433
+ # Only log first occurrence to reduce log spam
434
+ if t < 5:
435
+ _logger.warning(
436
+ f"kalman_filter_forward: iF contains NaN/Inf at t={t}, n_obs={n_obs}, "
437
+ f"adaptive_reg={adaptive_reg:.2e}, missing_ratio={missing_ratio:.2f}"
438
+ )
439
+
397
440
  # Matrix of population regression coefficients (Kalman gain)
398
441
  VCF = VC @ iF
399
442
 
@@ -431,6 +474,8 @@ class KalmanFilter(nn.Module):
431
474
 
432
475
  # MATLAB-style: Ensure symmetry immediately after update (V = 0.5 * (V+V'))
433
476
  Vu = ensure_real_and_symmetric(Vu)
477
+ # Cap maximum eigenvalue to prevent explosion
478
+ Vu = self._cap_max_eigenval(Vu, max_eigenval=1e6)
434
479
 
435
480
  # Clean NaN/Inf before stabilization
436
481
  if not self._check_finite(Vu, f"Vu at t={t}"):
@@ -830,6 +875,10 @@ class KalmanFilter(nn.Module):
830
875
  min_eigenval = self.min_eigenval.item()
831
876
  return ensure_covariance_stable(M, min_eigenval=min_eigenval, ensure_real=ensure_real)
832
877
 
878
+ def _cap_max_eigenval(self, M: torch.Tensor, max_eigenval: float = 1e6) -> torch.Tensor:
879
+ """Cap maximum eigenvalue of matrix to prevent numerical explosion."""
880
+ return cap_max_eigenval(M, max_eigenval=max_eigenval, warn=False)
881
+
833
882
  def _clean_matrix(
834
883
  self,
835
884
  M: torch.Tensor,
dfm_python/ssm/utils.py CHANGED
@@ -97,6 +97,51 @@ def ensure_symmetric(tensor: "torch.Tensor") -> "torch.Tensor":
97
97
  return 0.5 * (tensor + tensor.T)
98
98
 
99
99
 
100
+ def cap_max_eigenval(
101
+ M: "torch.Tensor",
102
+ max_eigenval: float = 1e6,
103
+ warn: bool = True
104
+ ) -> "torch.Tensor":
105
+ """Cap maximum eigenvalue of matrix to prevent numerical explosion.
106
+
107
+ Parameters
108
+ ----------
109
+ M : torch.Tensor
110
+ Matrix to cap (square matrix)
111
+ max_eigenval : float, default 1e6
112
+ Maximum allowed eigenvalue
113
+ warn : bool, default True
114
+ Whether to log warnings
115
+
116
+ Returns
117
+ -------
118
+ torch.Tensor
119
+ Matrix with capped eigenvalues
120
+ """
121
+ if M.numel() == 0 or M.shape[0] == 0:
122
+ return M
123
+
124
+ try:
125
+ eigenvals = torch.linalg.eigvalsh(M)
126
+ max_eig = torch.max(eigenvals)
127
+
128
+ if max_eig > max_eigenval:
129
+ # Scale matrix to cap maximum eigenvalue
130
+ scale_factor = max_eigenval / max_eig
131
+ M = M * scale_factor
132
+ M = ensure_symmetric(M)
133
+ if warn:
134
+ _logger.warning(
135
+ f"Matrix maximum eigenvalue capped: {max_eig:.2e} -> {max_eigenval:.2e} "
136
+ f"(scale_factor={scale_factor:.2e})"
137
+ )
138
+ except (RuntimeError, ValueError):
139
+ # If eigendecomposition fails, return matrix as-is
140
+ pass
141
+
142
+ return M
143
+
144
+
100
145
  def ensure_real_and_symmetric(tensor: "torch.Tensor") -> "torch.Tensor":
101
146
  """Ensure matrix is real and symmetric.
102
147
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dfm-python
3
- Version: 0.4.7
3
+ Version: 0.4.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
@@ -1,4 +1,4 @@
1
- dfm_python/__init__.py,sha256=Y_NxrfnM2ZzAnYbnIGJGPPK5nOe9Kxs13BWud_k7Nfc,5762
1
+ dfm_python/__init__.py,sha256=sYlsdpgl_rSmKwDjsMHigDud_P4jxm8GdvFhnbm_k_4,5762
2
2
  dfm_python/config/__init__.py,sha256=NrTwjNzQRcYlk5Ht0hTF8o-6CCl5e4S_JxQAp1mNyCs,1507
3
3
  dfm_python/config/adapter.py,sha256=X-mjWlg6ebWp-VAxjAUssgHnmr8AXOvSxhTTVSHOIIk,27475
4
4
  dfm_python/config/results.py,sha256=k_WY8v_rJyA8e5zr6Pq6KwNLcq1CbCpavndgBYyaqLs,13778
@@ -24,9 +24,9 @@ dfm_python/models/base.py,sha256=dwI6ZsVfL1gQy9A-BfZ-5Z9Qc00aIgKeCPIxVBLqas4,196
24
24
  dfm_python/models/ddfm.py,sha256=1A0z0Msfjf1Bak7z_Ifh-oP-e_jJ9Be6Ci6nHV3sCtE,115935
25
25
  dfm_python/models/dfm.py,sha256=oKD8Nf1RXweehTnbcf1vWez2ezNZ8-LuDCEcE0xJ_aU,31423
26
26
  dfm_python/ssm/__init__.py,sha256=dFpt8_3jb_foISDNhma_JUAzch0VrKZcaeWP9zwpwoM,1161
27
- dfm_python/ssm/em.py,sha256=GowasT4ldp5CaBUNaPJSWQp9K_AQoclGC_v8pwV02Rg,63775
28
- dfm_python/ssm/kalman.py,sha256=y-MqKcdIJXgYHRhLbGCXGnNw9DbrBAz0a-DkMW2blT4,44662
29
- dfm_python/ssm/utils.py,sha256=tk8p-uPhgKWeTRt-QjNn3RKvJuZrJQWsjpikftuJExE,12339
27
+ dfm_python/ssm/em.py,sha256=o15aPM5CKV2GVjjJ8xavwcRuRewvhRlwivZxUUFxJgg,67242
28
+ dfm_python/ssm/kalman.py,sha256=RRoIBqWfSriFbpUlHKkRPdC0S9KXPdczRyYjwc9gPXc,47881
29
+ dfm_python/ssm/utils.py,sha256=WpHCCXlAacuNo1e01X-mDbrxDZ_ztsC39PiaKkbnzKs,13584
30
30
  dfm_python/trainer/__init__.py,sha256=gc8jRjM-y2XN911LldwYUYHAAuFEvzUJJvXhG6B3Sv4,25654
31
31
  dfm_python/trainer/ddfm.py,sha256=pqaLzxppJnCDXxPPpFRP7CGMMBVO12veaik6XofgBKU,7646
32
32
  dfm_python/trainer/dfm.py,sha256=FQy82Jgkt0u5ecsQ3UtbktDmGc8hCET67OimGh0HmOE,6098
@@ -36,7 +36,7 @@ dfm_python/utils/diagnostics.py,sha256=UwrJfnn8VACATfQEkTcWw9eEJlTik-VCgTZ1dJABZ
36
36
  dfm_python/utils/helpers.py,sha256=t-hJNgmPbk5KV5nGP7z_MF0sVh-VJMdQyoDvRBzwTV8,13020
37
37
  dfm_python/utils/statespace.py,sha256=pyKy1D8bLMmd7sDvAVSb2n74U0IfH2YuYreIfyg3MAA,43823
38
38
  dfm_python/utils/time.py,sha256=o6NRPuk4Ynk8kPaUnzR6xq3pPAO6aLUTR9N_3hn_N88,35651
39
- dfm_python-0.4.7.dist-info/METADATA,sha256=Q_SDSG1h8q1EI3U-gj0rnsjLwASqLxRPBeodvjsLLZc,45885
40
- dfm_python-0.4.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
41
- dfm_python-0.4.7.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
42
- dfm_python-0.4.7.dist-info/RECORD,,
39
+ dfm_python-0.4.8.dist-info/METADATA,sha256=SvoT9ntBAaIoUeUFI5aCCbe1RchKC0PlbWr4oEQlsNA,45885
40
+ dfm_python-0.4.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
41
+ dfm_python-0.4.8.dist-info/licenses/LICENSE,sha256=6e87br6LCDHjy2hdIR1-sdBQy351qdmSEbOg6jQ7ZXY,1063
42
+ dfm_python-0.4.8.dist-info/RECORD,,