dfm-python 0.2.3__py3-none-any.whl → 0.2.4__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 +1 -1
- dfm_python/config.py +7 -4
- dfm_python/core/__init__.py +11 -0
- dfm_python/core/em.py +266 -108
- dfm_python/core/numeric.py +302 -1
- dfm_python/dfm.py +4 -2
- dfm_python/kalman.py +46 -47
- dfm_python/utils/data_utils.py +34 -1
- {dfm_python-0.2.3.dist-info → dfm_python-0.2.4.dist-info}/METADATA +58 -15
- dfm_python-0.2.4.dist-info/RECORD +21 -0
- dfm_python-0.2.3.dist-info/RECORD +0 -21
- {dfm_python-0.2.3.dist-info → dfm_python-0.2.4.dist-info}/WHEEL +0 -0
- {dfm_python-0.2.3.dist-info → dfm_python-0.2.4.dist-info}/licenses/LICENSE +0 -0
dfm_python/core/numeric.py
CHANGED
|
@@ -85,10 +85,252 @@ def _ensure_square_matrix(M: np.ndarray, method: str = 'diag') -> np.ndarray:
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def _ensure_symmetric(M: np.ndarray) -> np.ndarray:
|
|
88
|
-
"""Ensure matrix is symmetric by averaging with its transpose.
|
|
88
|
+
"""Ensure matrix is symmetric by averaging with its transpose.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
M : np.ndarray
|
|
93
|
+
Matrix to symmetrize
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
np.ndarray
|
|
98
|
+
Symmetric matrix (M + M.T) / 2
|
|
99
|
+
"""
|
|
89
100
|
return 0.5 * (M + M.T)
|
|
90
101
|
|
|
91
102
|
|
|
103
|
+
def _ensure_real(M: np.ndarray) -> np.ndarray:
|
|
104
|
+
"""Ensure matrix is real by extracting real part if complex.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
M : np.ndarray
|
|
109
|
+
Matrix that may be complex due to numerical errors
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
np.ndarray
|
|
114
|
+
Real matrix (extracts real part if complex)
|
|
115
|
+
"""
|
|
116
|
+
if np.iscomplexobj(M):
|
|
117
|
+
return np.real(M)
|
|
118
|
+
return M
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ensure_real_and_symmetric(M: np.ndarray) -> np.ndarray:
|
|
122
|
+
"""Ensure matrix is real and symmetric.
|
|
123
|
+
|
|
124
|
+
This is a common operation in Kalman filtering where covariance matrices
|
|
125
|
+
should be real and symmetric, but numerical errors can introduce complex
|
|
126
|
+
values or asymmetry.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
M : np.ndarray
|
|
131
|
+
Matrix to process (may be complex or asymmetric due to numerical errors)
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
np.ndarray
|
|
136
|
+
Real, symmetric matrix
|
|
137
|
+
"""
|
|
138
|
+
M = _ensure_real(M)
|
|
139
|
+
M = _ensure_symmetric(M)
|
|
140
|
+
return M
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ensure_covariance_stable(M: np.ndarray, min_eigenval: float = 1e-8,
|
|
144
|
+
ensure_real: bool = True) -> np.ndarray:
|
|
145
|
+
"""Ensure covariance matrix is real, symmetric, and positive semi-definite.
|
|
146
|
+
|
|
147
|
+
This is a comprehensive function for stabilizing covariance matrices in
|
|
148
|
+
numerical computations, commonly used in Kalman filtering.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
M : np.ndarray
|
|
153
|
+
Covariance matrix to stabilize
|
|
154
|
+
min_eigenval : float
|
|
155
|
+
Minimum eigenvalue threshold for positive semi-definiteness
|
|
156
|
+
ensure_real : bool
|
|
157
|
+
If True, extract real part if matrix is complex
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
np.ndarray
|
|
162
|
+
Stabilized covariance matrix (real, symmetric, PSD)
|
|
163
|
+
"""
|
|
164
|
+
if M.size == 0 or M.shape[0] == 0:
|
|
165
|
+
return M
|
|
166
|
+
|
|
167
|
+
# Step 1: Ensure real (if needed)
|
|
168
|
+
if ensure_real:
|
|
169
|
+
M = _ensure_real(M)
|
|
170
|
+
|
|
171
|
+
# Step 2: Ensure symmetric
|
|
172
|
+
M = _ensure_symmetric(M)
|
|
173
|
+
|
|
174
|
+
# Step 3: Ensure positive semi-definite
|
|
175
|
+
try:
|
|
176
|
+
eigenvals = np.linalg.eigvalsh(M) # Use eigvalsh for symmetric matrices
|
|
177
|
+
min_eig = np.min(eigenvals)
|
|
178
|
+
if min_eig < min_eigenval:
|
|
179
|
+
reg_amount = min_eigenval - min_eig
|
|
180
|
+
M = M + np.eye(M.shape[0]) * reg_amount
|
|
181
|
+
M = _ensure_symmetric(M)
|
|
182
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
183
|
+
# Eigendecomposition failed - add regularization
|
|
184
|
+
M = M + np.eye(M.shape[0]) * min_eigenval
|
|
185
|
+
M = _ensure_symmetric(M)
|
|
186
|
+
|
|
187
|
+
return M
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _compute_covariance_safe(data: np.ndarray, rowvar: bool = True,
|
|
191
|
+
pairwise_complete: bool = False,
|
|
192
|
+
min_eigenval: float = 1e-8,
|
|
193
|
+
fallback_to_identity: bool = True) -> np.ndarray:
|
|
194
|
+
"""Compute covariance matrix safely with robust error handling.
|
|
195
|
+
|
|
196
|
+
This function computes covariance matrices with automatic handling of:
|
|
197
|
+
- Missing data (NaN values)
|
|
198
|
+
- Edge cases (empty data, single series, etc.)
|
|
199
|
+
- Numerical instability (negative eigenvalues)
|
|
200
|
+
- Fallback strategies for failed computations
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
data : np.ndarray
|
|
205
|
+
Data matrix (T x N) where T is time periods and N is number of series.
|
|
206
|
+
If rowvar=True, each row is a variable (standard). If rowvar=False, each column is a variable.
|
|
207
|
+
rowvar : bool
|
|
208
|
+
If True (default), each row represents a variable, each column an observation.
|
|
209
|
+
If False, each column represents a variable, each row an observation.
|
|
210
|
+
pairwise_complete : bool
|
|
211
|
+
If True, use pairwise complete observations (more robust to missing data).
|
|
212
|
+
If False, use listwise deletion (all variables must be observed simultaneously).
|
|
213
|
+
min_eigenval : float
|
|
214
|
+
Minimum eigenvalue threshold for positive semi-definiteness.
|
|
215
|
+
fallback_to_identity : bool
|
|
216
|
+
If True, return identity matrix if covariance computation fails.
|
|
217
|
+
If False, raise an exception.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
np.ndarray
|
|
222
|
+
Covariance matrix (N x N) where N is number of variables.
|
|
223
|
+
Guaranteed to be positive semi-definite.
|
|
224
|
+
|
|
225
|
+
Notes
|
|
226
|
+
-----
|
|
227
|
+
- For single series, returns variance as 1x1 matrix
|
|
228
|
+
- For empty data, returns identity matrix if fallback_to_identity=True
|
|
229
|
+
- Automatically regularizes if negative eigenvalues are found
|
|
230
|
+
- Uses pairwise complete observations when pairwise_complete=True for robustness
|
|
231
|
+
|
|
232
|
+
Examples
|
|
233
|
+
--------
|
|
234
|
+
>>> data = np.array([[1.0, 2.0], [2.0, np.nan], [3.0, 4.0]])
|
|
235
|
+
>>> cov = _compute_covariance_safe(data, pairwise_complete=True)
|
|
236
|
+
>>> assert cov.shape == (2, 2)
|
|
237
|
+
>>> assert np.all(np.linalg.eigvalsh(cov) >= 0) # PSD
|
|
238
|
+
"""
|
|
239
|
+
if data.size == 0:
|
|
240
|
+
if fallback_to_identity:
|
|
241
|
+
return np.eye(1) if data.ndim == 1 else np.eye(data.shape[1] if rowvar else data.shape[0])
|
|
242
|
+
raise ValueError("Cannot compute covariance: data is empty")
|
|
243
|
+
|
|
244
|
+
# Handle 1D case
|
|
245
|
+
if data.ndim == 1:
|
|
246
|
+
var_val = np.nanvar(data, ddof=0)
|
|
247
|
+
var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
|
|
248
|
+
return np.array([[var_val]])
|
|
249
|
+
|
|
250
|
+
# Determine number of variables
|
|
251
|
+
n_vars = data.shape[1] if rowvar else data.shape[0]
|
|
252
|
+
|
|
253
|
+
# Handle single variable case
|
|
254
|
+
if n_vars == 1:
|
|
255
|
+
series_data = data.flatten()
|
|
256
|
+
var_val = np.nanvar(series_data, ddof=0)
|
|
257
|
+
var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
|
|
258
|
+
return np.array([[var_val]])
|
|
259
|
+
|
|
260
|
+
# Compute covariance
|
|
261
|
+
try:
|
|
262
|
+
if pairwise_complete:
|
|
263
|
+
# Use pairwise complete observations (more robust)
|
|
264
|
+
cov = np.cov(data.T, rowvar=False) if rowvar else np.cov(data, rowvar=True)
|
|
265
|
+
else:
|
|
266
|
+
# Standard covariance (listwise deletion)
|
|
267
|
+
cov = np.cov(data, rowvar=rowvar)
|
|
268
|
+
|
|
269
|
+
# Ensure positive semi-definite
|
|
270
|
+
if np.any(~np.isfinite(cov)):
|
|
271
|
+
raise ValueError("Covariance contains non-finite values")
|
|
272
|
+
|
|
273
|
+
eigenvals = np.linalg.eigvalsh(cov)
|
|
274
|
+
if np.any(eigenvals < 0):
|
|
275
|
+
# Regularize if needed
|
|
276
|
+
reg_amount = abs(np.min(eigenvals)) + min_eigenval
|
|
277
|
+
cov = cov + np.eye(n_vars) * reg_amount
|
|
278
|
+
|
|
279
|
+
return cov
|
|
280
|
+
except (ValueError, np.linalg.LinAlgError) as e:
|
|
281
|
+
if fallback_to_identity:
|
|
282
|
+
_logger.warning(
|
|
283
|
+
f"Covariance computation failed ({type(e).__name__}), "
|
|
284
|
+
f"falling back to identity matrix. Error: {str(e)[:100]}"
|
|
285
|
+
)
|
|
286
|
+
return np.eye(n_vars)
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _ensure_innovation_variance_minimum(Q: np.ndarray, min_variance: float = 1e-8) -> np.ndarray:
|
|
291
|
+
"""Ensure innovation covariance matrix Q has minimum diagonal values.
|
|
292
|
+
|
|
293
|
+
This is critical for factor evolution: if Q[i,i] = 0, factor i cannot evolve
|
|
294
|
+
(innovation variance is zero). This function enforces a minimum variance
|
|
295
|
+
threshold on the diagonal elements while preserving off-diagonal structure.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
Q : np.ndarray
|
|
300
|
+
Innovation covariance matrix (m x m) where m is the state dimension.
|
|
301
|
+
Can be any square matrix representing innovation variances.
|
|
302
|
+
min_variance : float
|
|
303
|
+
Minimum allowed variance for each diagonal element. Default is 1e-8.
|
|
304
|
+
This ensures factors can evolve even with very small innovations.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
np.ndarray
|
|
309
|
+
Q matrix with guaranteed minimum diagonal values. Off-diagonal elements
|
|
310
|
+
are preserved unchanged.
|
|
311
|
+
|
|
312
|
+
Notes
|
|
313
|
+
-----
|
|
314
|
+
- Only modifies diagonal elements, preserving correlation structure
|
|
315
|
+
- If Q is empty or non-square, returns Q unchanged
|
|
316
|
+
- This is a common operation in DFM initialization and EM steps
|
|
317
|
+
|
|
318
|
+
Examples
|
|
319
|
+
--------
|
|
320
|
+
>>> Q = np.array([[0.0, 0.1], [0.1, 0.0]]) # Zero diagonal
|
|
321
|
+
>>> Q_safe = _ensure_innovation_variance_minimum(Q, min_variance=1e-8)
|
|
322
|
+
>>> assert np.all(np.diag(Q_safe) >= 1e-8) # All diagonals >= 1e-8
|
|
323
|
+
"""
|
|
324
|
+
if Q.size == 0 or Q.shape[0] == 0 or Q.shape[0] != Q.shape[1]:
|
|
325
|
+
return Q
|
|
326
|
+
|
|
327
|
+
Q_diag = np.diag(Q)
|
|
328
|
+
Q_diag = np.maximum(Q_diag, min_variance)
|
|
329
|
+
# Preserve off-diagonal elements: Q_new = diag(Q_diag) + (Q - diag(Q))
|
|
330
|
+
Q = np.diag(Q_diag) + Q - np.diag(np.diag(Q))
|
|
331
|
+
return Q
|
|
332
|
+
|
|
333
|
+
|
|
92
334
|
def _compute_principal_components(cov_matrix: np.ndarray, n_components: int,
|
|
93
335
|
block_idx: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]:
|
|
94
336
|
"""Compute top principal components via eigendecomposition with fallbacks."""
|
|
@@ -335,6 +577,65 @@ def _estimate_ar_coefficient(EZZ_FB: np.ndarray, EZZ_BB: np.ndarray,
|
|
|
335
577
|
return A_diag, Q_diag
|
|
336
578
|
|
|
337
579
|
|
|
580
|
+
def _compute_variance_safe(data: np.ndarray, ddof: int = 0,
|
|
581
|
+
min_variance: float = 1e-10,
|
|
582
|
+
default_variance: float = 1.0) -> float:
|
|
583
|
+
"""Compute variance safely with robust error handling.
|
|
584
|
+
|
|
585
|
+
This function computes variance with automatic handling of:
|
|
586
|
+
- Missing data (NaN values)
|
|
587
|
+
- Edge cases (empty data, insufficient samples)
|
|
588
|
+
- Numerical instability (non-finite results)
|
|
589
|
+
- Minimum variance threshold enforcement
|
|
590
|
+
|
|
591
|
+
Parameters
|
|
592
|
+
----------
|
|
593
|
+
data : np.ndarray
|
|
594
|
+
Data array (1D or 2D). If 2D, variance is computed over all elements.
|
|
595
|
+
ddof : int
|
|
596
|
+
Delta degrees of freedom. Default is 0 (population variance).
|
|
597
|
+
min_variance : float
|
|
598
|
+
Minimum allowed variance threshold. Default is 1e-10.
|
|
599
|
+
Values below this are replaced with default_variance.
|
|
600
|
+
default_variance : float
|
|
601
|
+
Default variance value to use when computation fails or result is invalid.
|
|
602
|
+
Default is 1.0.
|
|
603
|
+
|
|
604
|
+
Returns
|
|
605
|
+
-------
|
|
606
|
+
float
|
|
607
|
+
Variance value, guaranteed to be finite and >= min_variance.
|
|
608
|
+
|
|
609
|
+
Notes
|
|
610
|
+
-----
|
|
611
|
+
- Uses np.nanvar for automatic NaN handling
|
|
612
|
+
- Returns default_variance if result is NaN, Inf, or < min_variance
|
|
613
|
+
- Flattens 2D arrays before computation
|
|
614
|
+
|
|
615
|
+
Examples
|
|
616
|
+
--------
|
|
617
|
+
>>> data = np.array([1.0, 2.0, np.nan, 4.0])
|
|
618
|
+
>>> var = _compute_variance_safe(data)
|
|
619
|
+
>>> assert var >= 1e-10 # Minimum threshold
|
|
620
|
+
>>> assert np.isfinite(var) # Always finite
|
|
621
|
+
"""
|
|
622
|
+
if data.size == 0:
|
|
623
|
+
return default_variance
|
|
624
|
+
|
|
625
|
+
# Flatten if 2D
|
|
626
|
+
if data.ndim > 1:
|
|
627
|
+
data = data.flatten()
|
|
628
|
+
|
|
629
|
+
# Compute variance with NaN handling
|
|
630
|
+
var_val = np.nanvar(data, ddof=ddof)
|
|
631
|
+
|
|
632
|
+
# Validate and enforce minimum
|
|
633
|
+
if np.isnan(var_val) or np.isinf(var_val) or var_val < min_variance:
|
|
634
|
+
return default_variance
|
|
635
|
+
|
|
636
|
+
return float(var_val)
|
|
637
|
+
|
|
638
|
+
|
|
338
639
|
def _safe_divide(numerator: np.ndarray, denominator: float, default: float = 0.0) -> np.ndarray:
|
|
339
640
|
"""Safely divide numerator by denominator, handling zero and invalid values."""
|
|
340
641
|
if denominator == 0 or np.isnan(denominator) or np.isinf(denominator):
|
dfm_python/dfm.py
CHANGED
|
@@ -822,8 +822,10 @@ def _dfm_core(X: np.ndarray, config: DFMConfig,
|
|
|
822
822
|
_logger.warning("Initial conditions contain NaN/Inf - this should not happen after init_conditions()")
|
|
823
823
|
|
|
824
824
|
# Step 5: Prepare data for EM (with and without missing values)
|
|
825
|
-
y
|
|
826
|
-
|
|
825
|
+
# y contains missing data (NaNs) - handled by Kalman Filter during estimation
|
|
826
|
+
# y_est is used for initial conditions only (missing data removed)
|
|
827
|
+
y = x_standardized.T # n x T (with missing data - standard DFM approach)
|
|
828
|
+
opt_nan_est = {'method': 3, 'k': nan_k} # Remove all-NaN rows only for initial conditions
|
|
827
829
|
x_est, _ = rem_nans_spline(x_standardized, method=opt_nan_est['method'], k=opt_nan_est['k'])
|
|
828
830
|
y_est = x_est.T # n x T (missing data removed)
|
|
829
831
|
|
dfm_python/kalman.py
CHANGED
|
@@ -9,6 +9,16 @@ import logging
|
|
|
9
9
|
_logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def _get_numeric_utils():
|
|
13
|
+
"""Lazy import to avoid circular dependency."""
|
|
14
|
+
from dfm_python.core.numeric import (
|
|
15
|
+
_ensure_real_and_symmetric,
|
|
16
|
+
_ensure_covariance_stable,
|
|
17
|
+
_ensure_real
|
|
18
|
+
)
|
|
19
|
+
return _ensure_real_and_symmetric, _ensure_covariance_stable, _ensure_real
|
|
20
|
+
|
|
21
|
+
|
|
12
22
|
@dataclass
|
|
13
23
|
class KalmanFilterState:
|
|
14
24
|
"""Kalman filter state structure.
|
|
@@ -197,27 +207,15 @@ def skf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
|
|
|
197
207
|
# Prior covariance matrix of Z (i.e. V = V_t|t-1)
|
|
198
208
|
# Var(Z) = Var(A*Z + u_t) = Var(A*Z) + Var(u) = A*Vu*A' + Q
|
|
199
209
|
V = A @ Vu @ A.T + Q
|
|
200
|
-
V = 0.5 * (V + V.T) # Trick to make symmetric
|
|
201
|
-
|
|
202
|
-
# Numerical stability: ensure V is positive semi-definite
|
|
203
|
-
# Add small regularization if needed
|
|
204
|
-
try:
|
|
205
|
-
eigenvals = np.linalg.eigvals(V)
|
|
206
|
-
min_eigenval = np.min(eigenvals)
|
|
207
|
-
if min_eigenval < 1e-8:
|
|
208
|
-
# Add regularization to ensure positive semi-definiteness
|
|
209
|
-
V = V + np.eye(V.shape[0]) * (1e-8 - min_eigenval)
|
|
210
|
-
V = 0.5 * (V + V.T) # Re-symmetrize
|
|
211
|
-
except (np.linalg.LinAlgError, ValueError):
|
|
212
|
-
# Eigendecomposition failed - add small regularization
|
|
213
|
-
V = V + np.eye(V.shape[0]) * 1e-8
|
|
214
|
-
V = 0.5 * (V + V.T)
|
|
215
210
|
|
|
216
|
-
# Check for NaN/Inf
|
|
211
|
+
# Check for NaN/Inf before stabilization
|
|
217
212
|
if np.any(np.isnan(V)) or np.any(np.isinf(V)):
|
|
218
213
|
# Fallback: use previous covariance with regularization
|
|
219
214
|
V = Vu + np.eye(V.shape[0]) * 1e-6
|
|
220
|
-
|
|
215
|
+
|
|
216
|
+
# Ensure V is real, symmetric, and positive semi-definite
|
|
217
|
+
_, _ensure_covariance_stable, _ = _get_numeric_utils()
|
|
218
|
+
V = _ensure_covariance_stable(V, min_eigenval=1e-8, ensure_real=True)
|
|
221
219
|
|
|
222
220
|
# Calculate posterior distribution
|
|
223
221
|
# Remove missing series: These are removed from Y, C, and R
|
|
@@ -234,18 +232,10 @@ def skf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
|
|
|
234
232
|
|
|
235
233
|
# Compute innovation covariance F = C_t @ V @ C_t.T + R_t
|
|
236
234
|
F = C_t @ VC + R_t
|
|
237
|
-
F = 0.5 * (F + F.T) # Ensure symmetry
|
|
238
235
|
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
min_F_eigenval = np.min(F_eigenvals)
|
|
243
|
-
if min_F_eigenval < 1e-8:
|
|
244
|
-
F = F + np.eye(F.shape[0]) * (1e-8 - min_F_eigenval)
|
|
245
|
-
F = 0.5 * (F + F.T)
|
|
246
|
-
except (np.linalg.LinAlgError, ValueError):
|
|
247
|
-
F = F + np.eye(F.shape[0]) * 1e-8
|
|
248
|
-
F = 0.5 * (F + F.T)
|
|
236
|
+
# Ensure F is real, symmetric, and positive semi-definite
|
|
237
|
+
_, _ensure_covariance_stable, _ = _get_numeric_utils()
|
|
238
|
+
F = _ensure_covariance_stable(F, min_eigenval=1e-8, ensure_real=True)
|
|
249
239
|
|
|
250
240
|
# Check for NaN/Inf before inversion
|
|
251
241
|
if np.any(np.isnan(F)) or np.any(np.isinf(F)):
|
|
@@ -282,28 +272,20 @@ def skf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
|
|
|
282
272
|
|
|
283
273
|
# Update covariance matrix (posterior) for time t
|
|
284
274
|
Vu = V - VCF @ VC.T
|
|
285
|
-
Vu = 0.5 * (Vu + Vu.T) # Approximation trick to make symmetric
|
|
286
275
|
|
|
287
|
-
# Clean NaN/Inf
|
|
276
|
+
# Clean NaN/Inf before stabilization
|
|
288
277
|
if np.any(~np.isfinite(Vu)):
|
|
289
278
|
Vu = np.nan_to_num(Vu, nan=1e-8, posinf=1e6, neginf=1e-8)
|
|
290
279
|
|
|
291
|
-
#
|
|
292
|
-
try:
|
|
293
|
-
Vu_eigenvals = np.linalg.eigvals(Vu)
|
|
294
|
-
min_Vu_eigenval = np.min(Vu_eigenvals)
|
|
295
|
-
if min_Vu_eigenval < 1e-8:
|
|
296
|
-
Vu = Vu + np.eye(Vu.shape[0]) * (1e-8 - min_Vu_eigenval)
|
|
297
|
-
Vu = 0.5 * (Vu + Vu.T)
|
|
298
|
-
except (np.linalg.LinAlgError, ValueError):
|
|
299
|
-
Vu = Vu + np.eye(Vu.shape[0]) * 1e-8
|
|
300
|
-
Vu = 0.5 * (Vu + Vu.T)
|
|
301
|
-
|
|
302
|
-
# Check for NaN/Inf in Vu
|
|
280
|
+
# Check for NaN/Inf after cleaning
|
|
303
281
|
if np.any(np.isnan(Vu)) or np.any(np.isinf(Vu)):
|
|
304
282
|
_logger.warning(f"skf: Vu contains NaN/Inf at t={t}, using V as fallback")
|
|
305
283
|
Vu = V.copy()
|
|
306
284
|
|
|
285
|
+
# Ensure Vu is real, symmetric, and positive semi-definite
|
|
286
|
+
_, _ensure_covariance_stable, _ = _get_numeric_utils()
|
|
287
|
+
Vu = _ensure_covariance_stable(Vu, min_eigenval=1e-8, ensure_real=True)
|
|
288
|
+
|
|
307
289
|
# Update log-likelihood (with safeguards)
|
|
308
290
|
try:
|
|
309
291
|
det_iF = np.linalg.det(iF)
|
|
@@ -321,11 +303,17 @@ def skf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
|
|
|
321
303
|
|
|
322
304
|
# Store output
|
|
323
305
|
# Store covariance and observation values for t (priors)
|
|
306
|
+
# Ensure Z and V are real before storing
|
|
307
|
+
_ensure_real_and_symmetric, _, _ensure_real = _get_numeric_utils()
|
|
308
|
+
Z = _ensure_real(Z)
|
|
309
|
+
V = _ensure_real_and_symmetric(V)
|
|
324
310
|
Zm[:, t] = Z
|
|
325
311
|
Vm[:, :, t] = V
|
|
326
312
|
|
|
327
313
|
# Store covariance and state values for t (posteriors)
|
|
328
314
|
# i.e. Zu = Z_t|t & Vu = V_t|t
|
|
315
|
+
Zu = _ensure_real(Zu)
|
|
316
|
+
Vu = _ensure_real_and_symmetric(Vu)
|
|
329
317
|
ZmU[:, t + 1] = Zu
|
|
330
318
|
VmU[:, :, t + 1] = Vu
|
|
331
319
|
|
|
@@ -369,7 +357,9 @@ def fis(A: np.ndarray, S: KalmanFilterState) -> KalmanFilterState:
|
|
|
369
357
|
|
|
370
358
|
# Initialize VmT_1 lag 1 covariance matrix for final period
|
|
371
359
|
VmT_1 = np.zeros((m, m, nobs))
|
|
372
|
-
|
|
360
|
+
VmT_1_temp = (np.eye(m) - S.k_t) @ A @ S.VmU[:, :, nobs - 1]
|
|
361
|
+
_ensure_real_and_symmetric, _, _ = _get_numeric_utils()
|
|
362
|
+
VmT_1[:, :, nobs - 1] = _ensure_real_and_symmetric(VmT_1_temp)
|
|
373
363
|
|
|
374
364
|
# Used for recursion process
|
|
375
365
|
J_2 = S.VmU[:, :, nobs - 1] @ A.T @ pinv(S.Vm[:, :, nobs - 1])
|
|
@@ -395,8 +385,9 @@ def fis(A: np.ndarray, S: KalmanFilterState) -> KalmanFilterState:
|
|
|
395
385
|
ZmT[:, t] = np.nan_to_num(ZmT[:, t], nan=0.0, posinf=0.0, neginf=0.0)
|
|
396
386
|
|
|
397
387
|
# Update smoothed factor covariance matrix
|
|
398
|
-
|
|
399
|
-
|
|
388
|
+
VmT_temp = VmU + J_1 @ (V_T - Vm1) @ J_1.T
|
|
389
|
+
_ensure_real_and_symmetric, _, _ = _get_numeric_utils()
|
|
390
|
+
VmT[:, :, t] = _ensure_real_and_symmetric(VmT_temp)
|
|
400
391
|
|
|
401
392
|
# Clean NaN/Inf and ensure PSD (keep only critical regularization)
|
|
402
393
|
if np.any(~np.isfinite(VmT[:, :, t])):
|
|
@@ -406,8 +397,10 @@ def fis(A: np.ndarray, S: KalmanFilterState) -> KalmanFilterState:
|
|
|
406
397
|
# Update weight
|
|
407
398
|
J_2 = S.VmU[:, :, t - 1] @ A.T @ pinv(S.Vm[:, :, t - 1])
|
|
408
399
|
|
|
409
|
-
# Update lag 1 factor covariance matrix
|
|
410
|
-
|
|
400
|
+
# Update lag 1 factor covariance matrix
|
|
401
|
+
VmT_1_temp = VmU @ J_2.T + J_1 @ (V_T1 - A @ VmU) @ J_2.T
|
|
402
|
+
_ensure_real_and_symmetric, _, _ = _get_numeric_utils()
|
|
403
|
+
VmT_1[:, :, t - 1] = _ensure_real_and_symmetric(VmT_1_temp)
|
|
411
404
|
|
|
412
405
|
# Add smoothed estimates as attributes
|
|
413
406
|
S.ZmT = ZmT
|
|
@@ -461,5 +454,11 @@ def run_kf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
|
|
|
461
454
|
VVsmooth = S.VmT_1
|
|
462
455
|
loglik = S.loglik
|
|
463
456
|
|
|
457
|
+
# Ensure loglik is real and finite
|
|
458
|
+
_, _, _ensure_real = _get_numeric_utils()
|
|
459
|
+
loglik = _ensure_real(np.array([loglik]))[0] if np.iscomplexobj(loglik) else loglik
|
|
460
|
+
if not np.isfinite(loglik):
|
|
461
|
+
loglik = -np.inf
|
|
462
|
+
|
|
464
463
|
return zsmooth, Vsmooth, VVsmooth, loglik
|
|
465
464
|
|
dfm_python/utils/data_utils.py
CHANGED
|
@@ -21,7 +21,40 @@ _logger = logging.getLogger(__name__)
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def rem_nans_spline(X: np.ndarray, method: int = 2, k: int = 3) -> Tuple[np.ndarray, np.ndarray]:
|
|
24
|
-
"""Treat NaNs in dataset for DFM estimation.
|
|
24
|
+
"""Treat NaNs in dataset for DFM estimation using standard interpolation methods.
|
|
25
|
+
|
|
26
|
+
This function implements standard econometric practice for handling missing data
|
|
27
|
+
in time series, following the approach used in FRBNY Nowcasting Model and similar
|
|
28
|
+
DFM implementations. The Kalman Filter in the DFM will handle remaining missing
|
|
29
|
+
values during estimation (see miss_data function in kalman.py).
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
X : np.ndarray
|
|
34
|
+
Input data matrix (T x N)
|
|
35
|
+
method : int
|
|
36
|
+
Missing data handling method:
|
|
37
|
+
- 1: Replace all missing values with spline interpolation
|
|
38
|
+
- 2: Remove >80% NaN rows, then fill (default, recommended)
|
|
39
|
+
- 3: Only remove all-NaN rows
|
|
40
|
+
- 4: Remove all-NaN rows, then fill
|
|
41
|
+
- 5: Fill missing values
|
|
42
|
+
k : int
|
|
43
|
+
Spline interpolation order (default: 3 for cubic spline)
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
X : np.ndarray
|
|
48
|
+
Data with NaNs treated
|
|
49
|
+
indNaN : np.ndarray
|
|
50
|
+
Boolean mask indicating original NaN positions
|
|
51
|
+
|
|
52
|
+
Notes
|
|
53
|
+
-----
|
|
54
|
+
This preprocessing step is followed by Kalman Filter-based missing data handling
|
|
55
|
+
during DFM estimation, which is the standard approach in state-space models.
|
|
56
|
+
See Mariano & Murasawa (2003) and Harvey (1989) for theoretical background.
|
|
57
|
+
"""
|
|
25
58
|
T, N = X.shape
|
|
26
59
|
indNaN = np.isnan(X)
|
|
27
60
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dfm-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -59,6 +59,8 @@ A Python implementation of **Dynamic Factor Models (DFM)** for nowcasting and fo
|
|
|
59
59
|
- **Robust missing data**: Spline interpolation and Kalman filter handling
|
|
60
60
|
- **News decomposition**: Attribute forecast changes to data releases
|
|
61
61
|
- **Generic design**: YAML configs or direct object creation, extensible via adapters
|
|
62
|
+
- **Numerical stability**: Comprehensive error handling and robust matrix operations
|
|
63
|
+
- **Well-documented**: Extensive docstrings and tutorials
|
|
62
64
|
|
|
63
65
|
## Installation
|
|
64
66
|
|
|
@@ -250,12 +252,19 @@ Contains all estimation outputs:
|
|
|
250
252
|
- `news.py`: News decomposition
|
|
251
253
|
|
|
252
254
|
**Core Submodules**:
|
|
253
|
-
- `core/em.py`: EM algorithm core
|
|
254
|
-
- `core/numeric.py`: Numerical utilities
|
|
255
|
+
- `core/em.py`: EM algorithm core (init_conditions, em_step, em_converged)
|
|
256
|
+
- `core/numeric.py`: Numerical utilities (matrix operations, regularization, stability)
|
|
255
257
|
- `core/diagnostics.py`: Diagnostic functions
|
|
256
258
|
- `core/results.py`: Result metrics
|
|
257
259
|
- `core/grouping.py`: Frequency grouping utilities
|
|
258
260
|
|
|
261
|
+
**Key Improvements** (v0.2.3+):
|
|
262
|
+
- Consolidated covariance/variance computation functions for robustness
|
|
263
|
+
- Named constants replacing magic numbers for better maintainability
|
|
264
|
+
- Enhanced docstrings with comprehensive examples
|
|
265
|
+
- Improved error handling and numerical stability
|
|
266
|
+
- Unified logging message formats
|
|
267
|
+
|
|
259
268
|
## Testing
|
|
260
269
|
|
|
261
270
|
```bash
|
|
@@ -263,11 +272,13 @@ pytest src/test/ -v
|
|
|
263
272
|
```
|
|
264
273
|
|
|
265
274
|
Tests are organized into 5 files:
|
|
266
|
-
- `test_dfm.py` - Core DFM estimation
|
|
267
|
-
- `test_kalman.py` - Kalman filter/smoother
|
|
275
|
+
- `test_dfm.py` - Core DFM estimation (12 tests)
|
|
276
|
+
- `test_kalman.py` - Kalman filter/smoother (4 tests)
|
|
268
277
|
- `test_news.py` - News decomposition
|
|
269
|
-
- `test_config.py` - Configuration and API
|
|
270
|
-
- `test_api.py` - Edge cases and tutorials
|
|
278
|
+
- `test_config.py` - Configuration and API (11 tests)
|
|
279
|
+
- `test_api.py` - Edge cases and tutorials (11 tests)
|
|
280
|
+
|
|
281
|
+
**Test Coverage**: 38+ tests covering core functionality, edge cases, and numerical stability.
|
|
271
282
|
|
|
272
283
|
## Tutorials
|
|
273
284
|
|
|
@@ -275,19 +286,46 @@ Tests are organized into 5 files:
|
|
|
275
286
|
```bash
|
|
276
287
|
python tutorial/basic_tutorial.py --spec data/sample_spec.csv --data data/sample_data.csv
|
|
277
288
|
```
|
|
289
|
+
Comprehensive tutorial with detailed explanations of DFM components and practical usage examples.
|
|
278
290
|
|
|
279
291
|
2. **`tutorial/hydra_tutorial.py`** - Hydra decorator approach
|
|
280
292
|
```bash
|
|
281
293
|
python tutorial/hydra_tutorial.py max_iter=10 threshold=1e-4
|
|
282
294
|
```
|
|
295
|
+
Demonstrates Hydra-based configuration with CLI overrides.
|
|
283
296
|
|
|
284
297
|
## Troubleshooting
|
|
285
298
|
|
|
286
|
-
**Convergence Issues**:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
**Convergence Issues**:
|
|
300
|
+
- Increase `max_iter` (default: 5000)
|
|
301
|
+
- Relax `threshold` (default: 1e-5)
|
|
302
|
+
- Check data quality and missing data patterns
|
|
303
|
+
- Adjust `regularization_scale` if numerical issues occur
|
|
304
|
+
|
|
305
|
+
**Dimension Mismatch**:
|
|
306
|
+
- Ensure `series_id` matches data column names
|
|
307
|
+
- Verify block structure in configuration
|
|
308
|
+
- Check that all series have valid frequency settings
|
|
309
|
+
|
|
310
|
+
**Numerical Instability**:
|
|
311
|
+
- Monitor warnings in logs
|
|
312
|
+
- Adjust `min_eigenvalue` and `max_eigenvalue` thresholds
|
|
313
|
+
- Investigate data quality (extreme values, excessive missing data)
|
|
314
|
+
- Use `clip_data_values=True` for extreme outliers
|
|
315
|
+
|
|
316
|
+
**Factor Evolution Issues**:
|
|
317
|
+
- Check that innovation variances (Q diagonal) are not zero
|
|
318
|
+
- Verify sufficient data variation in each block
|
|
319
|
+
- Consider increasing `regularization_scale` for sparse data
|
|
320
|
+
|
|
321
|
+
## Code Quality
|
|
322
|
+
|
|
323
|
+
The package has been extensively refactored for:
|
|
324
|
+
- **Code deduplication**: Common patterns consolidated into reusable functions
|
|
325
|
+
- **Maintainability**: Named constants replace magic numbers
|
|
326
|
+
- **Documentation**: Comprehensive docstrings with examples
|
|
327
|
+
- **Robustness**: Enhanced error handling and numerical stability
|
|
328
|
+
- **Consistency**: Unified coding patterns and logging formats
|
|
291
329
|
|
|
292
330
|
## License
|
|
293
331
|
|
|
@@ -306,7 +344,12 @@ MIT License
|
|
|
306
344
|
|
|
307
345
|
---
|
|
308
346
|
|
|
309
|
-
**Package Status**: Stable (v0.2.
|
|
347
|
+
**Package Status**: Stable (v0.2.3+)
|
|
310
348
|
**PyPI**: https://pypi.org/project/dfm-python/
|
|
311
|
-
**Python**: 3.
|
|
312
|
-
**Latest Changes**:
|
|
349
|
+
**Python**: 3.10+
|
|
350
|
+
**Latest Changes**:
|
|
351
|
+
- Extensive code refactoring for maintainability and robustness
|
|
352
|
+
- Consolidated covariance/variance computation functions
|
|
353
|
+
- Named constants replacing magic numbers
|
|
354
|
+
- Enhanced documentation and tutorials
|
|
355
|
+
- See [CHANGELOG.md](CHANGELOG.md) for details
|