dfm-python 0.2.2__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.2.dist-info → dfm_python-0.2.4.dist-info}/METADATA +59 -16
- dfm_python-0.2.4.dist-info/RECORD +21 -0
- dfm_python-0.2.2.dist-info/RECORD +0 -21
- {dfm_python-0.2.2.dist-info → dfm_python-0.2.4.dist-info}/WHEEL +0 -0
- {dfm_python-0.2.2.dist-info → dfm_python-0.2.4.dist-info}/licenses/LICENSE +0 -0
dfm_python/core/em.py
CHANGED
|
@@ -26,8 +26,11 @@ from .numeric import (
|
|
|
26
26
|
_ensure_symmetric,
|
|
27
27
|
_ensure_square_matrix,
|
|
28
28
|
_compute_principal_components,
|
|
29
|
+
_compute_covariance_safe,
|
|
30
|
+
_compute_variance_safe,
|
|
29
31
|
_clean_matrix,
|
|
30
32
|
_ensure_positive_definite,
|
|
33
|
+
_ensure_innovation_variance_minimum,
|
|
31
34
|
_compute_regularization_param,
|
|
32
35
|
_apply_ar_clipping,
|
|
33
36
|
_cap_max_eigenvalue,
|
|
@@ -46,6 +49,18 @@ from ..utils.aggregation import (
|
|
|
46
49
|
|
|
47
50
|
_logger = logging.getLogger(__name__)
|
|
48
51
|
|
|
52
|
+
# Numerical stability constants
|
|
53
|
+
DEFAULT_AR_COEFFICIENT = 0.1 # Default AR coefficient when estimation fails
|
|
54
|
+
DEFAULT_INNOVATION_VARIANCE = 0.1 # Default innovation variance
|
|
55
|
+
DEFAULT_IDIO_AR_COEFFICIENT = 0.9 # Default AR coefficient for idiosyncratic components
|
|
56
|
+
DEFAULT_IDIO_INNOVATION_VARIANCE = 0.1 # Default innovation variance for idiosyncratic
|
|
57
|
+
DEFAULT_IDIO_INIT_COVARIANCE = 0.1 # Default initial covariance for idiosyncratic
|
|
58
|
+
DEFAULT_OBSERVATION_VARIANCE = 1e-4 # Default observation variance (R diagonal)
|
|
59
|
+
MIN_INNOVATION_VARIANCE = 1e-8 # Minimum innovation variance for factor evolution
|
|
60
|
+
MIN_OBSERVATION_VARIANCE = 1e-8 # Minimum observation variance
|
|
61
|
+
MIN_EIGENVALUE_THRESHOLD = 1e-8 # Minimum eigenvalue for positive definiteness
|
|
62
|
+
MIN_VARIANCE_THRESHOLD = 1e-10 # Minimum variance threshold for validation
|
|
63
|
+
|
|
49
64
|
|
|
50
65
|
class NaNHandlingOptions(TypedDict, total=False):
|
|
51
66
|
"""Options for handling missing data (NaN values).
|
|
@@ -185,7 +200,9 @@ def init_conditions(
|
|
|
185
200
|
ppC = int(max(p, pC))
|
|
186
201
|
n_blocks = blocks.shape[1]
|
|
187
202
|
|
|
188
|
-
# Balance NaNs
|
|
203
|
+
# Balance NaNs using standard interpolation (spline-based)
|
|
204
|
+
# Remaining missing values will be handled by Kalman Filter during estimation
|
|
205
|
+
# This follows standard practice in DFM (Mariano & Murasawa 2003)
|
|
189
206
|
xBal, _ = rem_nans_spline(x, method=opt_nan['method'], k=opt_nan['k'])
|
|
190
207
|
T, N = xBal.shape
|
|
191
208
|
|
|
@@ -205,11 +222,12 @@ def init_conditions(
|
|
|
205
222
|
elif nQ is None:
|
|
206
223
|
nQ = 0
|
|
207
224
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
225
|
+
# Track missing data locations
|
|
226
|
+
missing_data_mask = np.isnan(xBal)
|
|
227
|
+
data_with_nan = xBal.copy()
|
|
228
|
+
data_with_nan[missing_data_mask] = np.nan # Explicitly mark as NaN
|
|
211
229
|
data_residuals = xBal
|
|
212
|
-
|
|
230
|
+
residuals_with_nan = data_with_nan.copy()
|
|
213
231
|
|
|
214
232
|
C = None
|
|
215
233
|
A = None
|
|
@@ -217,7 +235,7 @@ def init_conditions(
|
|
|
217
235
|
V_0 = None
|
|
218
236
|
|
|
219
237
|
if pC > 1:
|
|
220
|
-
|
|
238
|
+
missing_data_mask[:pC - 1, :] = True
|
|
221
239
|
|
|
222
240
|
for i in range(n_blocks):
|
|
223
241
|
r_i = int(r[i])
|
|
@@ -233,8 +251,24 @@ def init_conditions(
|
|
|
233
251
|
|
|
234
252
|
if len(idx_clock_freq) > 0:
|
|
235
253
|
try:
|
|
236
|
-
|
|
237
|
-
|
|
254
|
+
block_residuals = data_residuals[:, idx_clock_freq].copy()
|
|
255
|
+
# For Block_Global (first block), ensure we have sufficient variation
|
|
256
|
+
# Use pairwise complete observations instead of requiring all series
|
|
257
|
+
if i == 0 and len(idx_clock_freq) > 1:
|
|
258
|
+
# For global block, allow missing data but require sufficient pairwise observations
|
|
259
|
+
# Calculate how many series have data at each time point
|
|
260
|
+
n_obs_per_time = np.sum(np.isfinite(block_residuals), axis=1)
|
|
261
|
+
# Require at least 50% of series to have data (or minimum 2 series)
|
|
262
|
+
min_series_required = max(2, int(len(idx_clock_freq) * 0.5))
|
|
263
|
+
valid_times = n_obs_per_time >= min_series_required
|
|
264
|
+
if np.sum(valid_times) < max(10, len(idx_clock_freq) + 1):
|
|
265
|
+
# Fallback: use all available data with pairwise complete
|
|
266
|
+
finite_rows = np.any(np.isfinite(block_residuals), axis=1)
|
|
267
|
+
else:
|
|
268
|
+
finite_rows = valid_times
|
|
269
|
+
else:
|
|
270
|
+
# For other blocks, require all series to be finite (original behavior)
|
|
271
|
+
finite_rows = np.all(np.isfinite(block_residuals), axis=1)
|
|
238
272
|
n_finite = int(np.sum(finite_rows))
|
|
239
273
|
n_total = len(finite_rows)
|
|
240
274
|
if n_finite < max(2, len(idx_clock_freq) + 1):
|
|
@@ -242,26 +276,34 @@ def init_conditions(
|
|
|
242
276
|
f"init_conditions: Block {i+1} has insufficient data; using identity covariance."
|
|
243
277
|
)
|
|
244
278
|
raise ValueError("insufficient data")
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
279
|
+
block_residuals_clean = block_residuals[finite_rows, :]
|
|
280
|
+
# For Block_Global, fill remaining NaNs with column median for covariance calculation
|
|
281
|
+
if i == 0 and len(idx_clock_freq) > 1:
|
|
282
|
+
for col_idx in range(block_residuals_clean.shape[1]):
|
|
283
|
+
col_data = block_residuals_clean[:, col_idx]
|
|
284
|
+
nan_mask = np.isnan(col_data)
|
|
285
|
+
if np.any(nan_mask) and np.any(~nan_mask):
|
|
286
|
+
col_median = np.nanmedian(col_data)
|
|
287
|
+
if np.isfinite(col_median):
|
|
288
|
+
block_residuals_clean[nan_mask, col_idx] = col_median
|
|
289
|
+
else:
|
|
290
|
+
block_residuals_clean[nan_mask, col_idx] = 0.0
|
|
291
|
+
# Compute covariance safely with appropriate method
|
|
292
|
+
# For Block_Global (i == 0), use pairwise complete for robustness
|
|
293
|
+
use_pairwise = (i == 0 and len(idx_clock_freq) > 1)
|
|
294
|
+
cov_res = _compute_covariance_safe(
|
|
295
|
+
block_residuals_clean,
|
|
296
|
+
rowvar=True, # Each row is a time period, each column is a series
|
|
297
|
+
pairwise_complete=use_pairwise,
|
|
298
|
+
min_eigenval=1e-8,
|
|
299
|
+
fallback_to_identity=True
|
|
300
|
+
)
|
|
301
|
+
# For Block_Global, ensure minimum variance in first principal component
|
|
264
302
|
d, v = _compute_principal_components(cov_res, int(r_i), block_idx=i)
|
|
303
|
+
# Ensure first eigenvalue is not too small for Block_Global
|
|
304
|
+
if i == 0 and len(d) > 0:
|
|
305
|
+
d_min = np.max(d) * 0.01 # At least 1% of max eigenvalue
|
|
306
|
+
d = np.maximum(d, d_min)
|
|
265
307
|
if np.sum(v) < 0:
|
|
266
308
|
v = -v
|
|
267
309
|
d_pos = np.maximum(d, 1e-8)
|
|
@@ -309,7 +351,7 @@ def init_conditions(
|
|
|
309
351
|
Rcon_i = np.kron(R_mat_freq, np.eye(int(r_i)))
|
|
310
352
|
q_i = np.kron(q_freq, np.zeros(int(r_i)))
|
|
311
353
|
for j in idx_iFreq:
|
|
312
|
-
series_data =
|
|
354
|
+
series_data = residuals_with_nan[pC_freq:, j]
|
|
313
355
|
if len(series_data) < factor_projection_freq.shape[0] and len(series_data) > 0:
|
|
314
356
|
series_data_padded = np.full(factor_projection_freq.shape[0], np.nan)
|
|
315
357
|
series_data_padded[:len(series_data)] = series_data
|
|
@@ -331,13 +373,20 @@ def init_conditions(
|
|
|
331
373
|
except _NUMERICAL_EXCEPTIONS:
|
|
332
374
|
block_loadings[j, :int(pC_freq * r_i)] = 0.0
|
|
333
375
|
except _NUMERICAL_EXCEPTIONS:
|
|
334
|
-
_logger.warning(
|
|
376
|
+
_logger.warning(
|
|
377
|
+
f"init_conditions: Block {i+1} initialization failed due to numerical error; "
|
|
378
|
+
f"using fallback (identity loadings)."
|
|
379
|
+
)
|
|
335
380
|
if len(idx_clock_freq) > 0:
|
|
336
381
|
block_loadings[idx_clock_freq, :int(r_i)] = np.eye(len(idx_clock_freq), int(r_i))[:len(idx_clock_freq), :int(r_i)]
|
|
337
382
|
if factor_projection_lagged is not None:
|
|
338
383
|
expected_width = int(pC * r_i)
|
|
339
|
-
|
|
340
|
-
|
|
384
|
+
actual_width = factor_projection_lagged.shape[1]
|
|
385
|
+
if actual_width != expected_width:
|
|
386
|
+
_logger.warning(
|
|
387
|
+
f"init_conditions: Factor projection width mismatch for block {i+1} "
|
|
388
|
+
f"(got {actual_width}, expected {expected_width}); resetting."
|
|
389
|
+
)
|
|
341
390
|
factor_projection_lagged = None
|
|
342
391
|
if factor_projection_lagged is not None:
|
|
343
392
|
factor_projection_padded = np.vstack([np.zeros((pC - 1, int(pC * r_i))), factor_projection_lagged])
|
|
@@ -352,8 +401,8 @@ def init_conditions(
|
|
|
352
401
|
else:
|
|
353
402
|
factor_projection_padded = np.vstack([factor_projection_padded, np.zeros((data_residuals.shape[0] - factor_projection_padded.shape[0], factor_projection_padded.shape[1]))])
|
|
354
403
|
data_residuals[:, idx_i] = data_residuals[:, idx_i] - factor_projection_padded @ block_loadings_residual[idx_i, :].T
|
|
355
|
-
|
|
356
|
-
|
|
404
|
+
residuals_with_nan = data_residuals.copy()
|
|
405
|
+
residuals_with_nan[missing_data_mask] = np.nan
|
|
357
406
|
C = block_loadings if C is None else np.hstack([C, block_loadings])
|
|
358
407
|
if len(idx_clock_freq) > 0 and F is not None:
|
|
359
408
|
z = F[:, :int(r_i)]
|
|
@@ -379,13 +428,25 @@ def init_conditions(
|
|
|
379
428
|
except _NUMERICAL_EXCEPTIONS:
|
|
380
429
|
Q_block = np.eye(int(r_i)) * 0.1
|
|
381
430
|
else:
|
|
382
|
-
variance
|
|
383
|
-
|
|
384
|
-
|
|
431
|
+
# Single series case: compute variance safely
|
|
432
|
+
variance = _compute_variance_safe(
|
|
433
|
+
innovation_residuals,
|
|
434
|
+
ddof=0,
|
|
435
|
+
min_variance=1e-8,
|
|
436
|
+
default_variance=0.1
|
|
437
|
+
)
|
|
385
438
|
Q_block = np.array([[variance]]) if innovation_residuals.ndim > 1 else np.eye(int(r_i)) * variance
|
|
386
439
|
block_innovation_cov[:int(r_i), :int(r_i)] = Q_block
|
|
440
|
+
# Ensure Q diagonal has minimum value - critical for factor evolution
|
|
441
|
+
block_innovation_cov[:int(r_i), :int(r_i)] = _ensure_innovation_variance_minimum(
|
|
442
|
+
block_innovation_cov[:int(r_i), :int(r_i)], min_variance=1e-8
|
|
443
|
+
)
|
|
387
444
|
block_transition_clean = _clean_matrix(block_transition, 'loading')
|
|
388
445
|
block_innovation_cov_clean = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
|
|
446
|
+
# Re-ensure minimum after cleaning (in case cleaning set values to 0)
|
|
447
|
+
block_innovation_cov_clean[:int(r_i), :int(r_i)] = _ensure_innovation_variance_minimum(
|
|
448
|
+
block_innovation_cov_clean[:int(r_i), :int(r_i)], min_variance=1e-8
|
|
449
|
+
)
|
|
389
450
|
try:
|
|
390
451
|
kron_transition = np.kron(block_transition_clean, block_transition_clean)
|
|
391
452
|
identity_kron = np.eye(int((r_i * ppC) ** 2)) - kron_transition
|
|
@@ -394,8 +455,11 @@ def init_conditions(
|
|
|
394
455
|
if np.any(~np.isfinite(init_cov_block)):
|
|
395
456
|
raise ValueError("invalid init_cov_block")
|
|
396
457
|
except _NUMERICAL_EXCEPTIONS:
|
|
397
|
-
_logger.warning(
|
|
398
|
-
|
|
458
|
+
_logger.warning(
|
|
459
|
+
f"init_conditions: Initial covariance computation failed for block {i+1} "
|
|
460
|
+
f"(kron/inv failed); using diagonal fallback (0.1 * I)."
|
|
461
|
+
)
|
|
462
|
+
init_cov_block = np.eye(int(r_i * ppC)) * DEFAULT_IDIO_INIT_COVARIANCE
|
|
399
463
|
if A is None:
|
|
400
464
|
A, Q, V_0 = block_transition, block_innovation_cov, init_cov_block
|
|
401
465
|
else:
|
|
@@ -419,7 +483,7 @@ def init_conditions(
|
|
|
419
483
|
if nQ > 0 and frequencies is not None:
|
|
420
484
|
clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
|
|
421
485
|
slower_indices = [j for j in range(N) if j < len(frequencies) and FREQUENCY_HIERARCHY.get(frequencies[j], 3) > clock_h]
|
|
422
|
-
Rdiag = np.nanvar(
|
|
486
|
+
Rdiag = np.nanvar(residuals_with_nan, axis=0)
|
|
423
487
|
Rdiag = np.where((np.isnan(Rdiag) | np.isinf(Rdiag)), 1e-4, Rdiag)
|
|
424
488
|
slower_idio_blocks = []
|
|
425
489
|
slower_series_count: Dict[str, int] = {}
|
|
@@ -454,9 +518,9 @@ def init_conditions(
|
|
|
454
518
|
Rdiag = np.where((np.isnan(Rdiag) | np.isinf(Rdiag) | (Rdiag < 0)), 1e-4, Rdiag)
|
|
455
519
|
R = np.diag(Rdiag)
|
|
456
520
|
else:
|
|
457
|
-
var_values = np.nanvar(
|
|
458
|
-
var_values = np.where((np.isnan(var_values) | np.isinf(var_values)),
|
|
459
|
-
var_values = np.maximum(var_values,
|
|
521
|
+
var_values = np.nanvar(residuals_with_nan, axis=0)
|
|
522
|
+
var_values = np.where((np.isnan(var_values) | np.isinf(var_values)), DEFAULT_OBSERVATION_VARIANCE, var_values)
|
|
523
|
+
var_values = np.maximum(var_values, DEFAULT_OBSERVATION_VARIANCE)
|
|
460
524
|
R = np.diag(var_values)
|
|
461
525
|
slower_idio_dims = []
|
|
462
526
|
|
|
@@ -465,35 +529,45 @@ def init_conditions(
|
|
|
465
529
|
BM = np.zeros((n_idio, n_idio))
|
|
466
530
|
SM = np.zeros((n_idio, n_idio))
|
|
467
531
|
for idx, i in enumerate(ii_idio):
|
|
468
|
-
R[i, i] =
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
532
|
+
R[i, i] = DEFAULT_OBSERVATION_VARIANCE
|
|
533
|
+
series_residuals_full = data_residuals[:, i]
|
|
534
|
+
series_residuals_with_nan = residuals_with_nan[:, i]
|
|
535
|
+
# Find leading and trailing NaN positions
|
|
536
|
+
leading_nan_count = int(np.argmax(~np.isnan(series_residuals_with_nan))) if np.any(~np.isnan(series_residuals_with_nan)) else 0
|
|
537
|
+
trailing_nan_count = int(np.argmax(~np.isnan(series_residuals_with_nan[::-1]))) if np.any(~np.isnan(series_residuals_with_nan)) else 0
|
|
538
|
+
# Truncate to remove leading/trailing NaNs
|
|
539
|
+
if trailing_nan_count > 0:
|
|
540
|
+
series_residuals_truncated = series_residuals_full[leading_nan_count:T - trailing_nan_count]
|
|
541
|
+
else:
|
|
542
|
+
series_residuals_truncated = series_residuals_full[leading_nan_count:]
|
|
543
|
+
if len(series_residuals_truncated) > 1:
|
|
544
|
+
series_data = series_residuals_truncated.copy()
|
|
545
|
+
X_ar = series_data[:-1].reshape(-1, 1)
|
|
546
|
+
y_ar = series_data[1:].reshape(-1, 1)
|
|
478
547
|
XTX = X_ar.T @ X_ar
|
|
479
548
|
if XTX.size == 1 and XTX[0, 0] != 0 and np.isfinite(XTX[0, 0]):
|
|
480
549
|
try:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
BM[idx, idx] =
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
550
|
+
ar_coeff = (1.0 / XTX[0, 0]) * (X_ar.T @ y_ar)
|
|
551
|
+
ar_coeff_clean = ar_coeff[0, 0] if ar_coeff.size > 0 else DEFAULT_AR_COEFFICIENT
|
|
552
|
+
ar_coeff_clean = DEFAULT_AR_COEFFICIENT if (np.isnan(ar_coeff_clean) or np.isinf(ar_coeff_clean)) else ar_coeff_clean
|
|
553
|
+
BM[idx, idx] = ar_coeff_clean
|
|
554
|
+
innovation_residuals = series_residuals_truncated[1:] - series_residuals_truncated[:-1] * BM[idx, idx]
|
|
555
|
+
innovation_variance = _compute_variance_safe(
|
|
556
|
+
innovation_residuals,
|
|
557
|
+
ddof=0,
|
|
558
|
+
min_variance=MIN_INNOVATION_VARIANCE,
|
|
559
|
+
default_variance=DEFAULT_INNOVATION_VARIANCE
|
|
560
|
+
)
|
|
561
|
+
SM[idx, idx] = innovation_variance
|
|
488
562
|
except _NUMERICAL_EXCEPTIONS:
|
|
489
|
-
BM[idx, idx] =
|
|
490
|
-
SM[idx, idx] =
|
|
563
|
+
BM[idx, idx] = DEFAULT_AR_COEFFICIENT
|
|
564
|
+
SM[idx, idx] = DEFAULT_INNOVATION_VARIANCE
|
|
491
565
|
else:
|
|
492
|
-
BM[idx, idx] =
|
|
493
|
-
SM[idx, idx] =
|
|
566
|
+
BM[idx, idx] = DEFAULT_AR_COEFFICIENT
|
|
567
|
+
SM[idx, idx] = DEFAULT_INNOVATION_VARIANCE
|
|
494
568
|
else:
|
|
495
|
-
BM[idx, idx] =
|
|
496
|
-
SM[idx, idx] =
|
|
569
|
+
BM[idx, idx] = DEFAULT_AR_COEFFICIENT
|
|
570
|
+
SM[idx, idx] = DEFAULT_INNOVATION_VARIANCE
|
|
497
571
|
# Monthly idio init covariance
|
|
498
572
|
eye_diag = np.diag(np.eye(BM.shape[0]))
|
|
499
573
|
BM_diag_sq = np.diag(BM) ** 2
|
|
@@ -505,15 +579,18 @@ def init_conditions(
|
|
|
505
579
|
if n_slower_idio > 0:
|
|
506
580
|
# Create transition and innovation covariance for slower idio components
|
|
507
581
|
# Each slower idio component follows an AR(1) process with tent weights
|
|
508
|
-
BQ = np.eye(n_slower_idio) *
|
|
509
|
-
SQ = np.eye(n_slower_idio) *
|
|
510
|
-
initViQ = np.eye(n_slower_idio) *
|
|
582
|
+
BQ = np.eye(n_slower_idio) * DEFAULT_IDIO_AR_COEFFICIENT # AR coefficient for slower idio
|
|
583
|
+
SQ = np.eye(n_slower_idio) * DEFAULT_IDIO_INNOVATION_VARIANCE # Innovation variance
|
|
584
|
+
initViQ = np.eye(n_slower_idio) * DEFAULT_IDIO_INIT_COVARIANCE # Initial covariance
|
|
511
585
|
else:
|
|
512
586
|
BQ = np.array([]).reshape(0, 0)
|
|
513
587
|
SQ = np.array([]).reshape(0, 0)
|
|
514
588
|
initViQ = np.array([]).reshape(0, 0)
|
|
515
589
|
A = block_diag(A, BM, BQ)
|
|
516
590
|
Q = block_diag(Q, SM, SQ)
|
|
591
|
+
# Ensure Q diagonal has minimum value - critical for factor evolution
|
|
592
|
+
if Q.size > 0:
|
|
593
|
+
Q = _ensure_innovation_variance_minimum(Q, min_variance=1e-8)
|
|
517
594
|
Z_0 = np.zeros(A.shape[0])
|
|
518
595
|
# Ensure all covariance matrices are square
|
|
519
596
|
V_0 = _ensure_square_matrix(V_0, method='diag')
|
|
@@ -533,6 +610,9 @@ def init_conditions(
|
|
|
533
610
|
outputs[param_name] = _clean_matrix(param_value, 'loading')
|
|
534
611
|
elif param_name == 'Z_0':
|
|
535
612
|
outputs[param_name] = np.zeros_like(param_value)
|
|
613
|
+
# Final safeguard: ensure Q diagonal has minimum value after all cleaning
|
|
614
|
+
if outputs['Q'].size > 0:
|
|
615
|
+
outputs['Q'] = _ensure_innovation_variance_minimum(outputs['Q'], min_variance=MIN_INNOVATION_VARIANCE)
|
|
536
616
|
return outputs['A'], outputs['C'], outputs['Q'], outputs['R'], outputs['Z_0'], outputs['V_0']
|
|
537
617
|
|
|
538
618
|
|
|
@@ -558,86 +638,144 @@ def em_step(
|
|
|
558
638
|
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
|
|
559
639
|
"""Perform one EM iteration (E-step + M-step) and return updated parameters.
|
|
560
640
|
|
|
561
|
-
This function performs a single iteration of the Expectation-Maximization algorithm
|
|
641
|
+
This function performs a single iteration of the Expectation-Maximization (EM) algorithm
|
|
642
|
+
for Dynamic Factor Model estimation. The EM algorithm alternates between:
|
|
562
643
|
|
|
563
|
-
1. **E-step**: Run Kalman filter
|
|
564
|
-
|
|
644
|
+
1. **E-step (Expectation)**: Run Kalman filter and smoother to compute expected
|
|
645
|
+
sufficient statistics given current parameters:
|
|
646
|
+
- E[Z_t | Y] (smoothed factor estimates)
|
|
565
647
|
- E[Z_t Z_t' | Y] (smoothed factor covariances)
|
|
566
648
|
- E[Z_t Z_{t-1}' | Y] (smoothed factor cross-covariances)
|
|
567
649
|
|
|
568
|
-
2. **M-step**: Update parameters to maximize expected log-likelihood
|
|
569
|
-
- C
|
|
570
|
-
- R
|
|
571
|
-
- A
|
|
572
|
-
- Q
|
|
573
|
-
- V_0
|
|
650
|
+
2. **M-step (Maximization)**: Update parameters to maximize expected log-likelihood:
|
|
651
|
+
- **C**: Loading matrix via regression of data on factors
|
|
652
|
+
- **R**: Observation covariance via residual variance
|
|
653
|
+
- **A**: Transition matrix via AR regression on factors
|
|
654
|
+
- **Q**: Innovation covariance via innovation variance
|
|
655
|
+
- **V_0**: Initial covariance via stationary covariance
|
|
656
|
+
|
|
657
|
+
The algorithm follows the standard DFM EM approach (Doz et al. 2011) with robust
|
|
658
|
+
numerical stability checks and missing data handling.
|
|
574
659
|
|
|
575
660
|
Parameters
|
|
576
661
|
----------
|
|
577
662
|
y : np.ndarray
|
|
578
|
-
Observation matrix (n x T)
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
663
|
+
Observation matrix (n x T), where n is number of series and T is time periods.
|
|
664
|
+
Missing values should be NaN. These are handled automatically by the Kalman filter.
|
|
665
|
+
A : np.ndarray
|
|
666
|
+
Current transition matrix estimate (m x m), where m is state dimension.
|
|
667
|
+
C : np.ndarray
|
|
668
|
+
Current loading matrix estimate (n x m).
|
|
669
|
+
Q : np.ndarray
|
|
670
|
+
Current innovation covariance estimate (m x m).
|
|
671
|
+
R : np.ndarray
|
|
672
|
+
Current observation covariance estimate (n x n), typically diagonal.
|
|
673
|
+
Z_0 : np.ndarray
|
|
674
|
+
Current initial state estimate (m,).
|
|
675
|
+
V_0 : np.ndarray
|
|
676
|
+
Current initial covariance estimate (m x m).
|
|
583
677
|
r : np.ndarray
|
|
584
|
-
Number of factors per block
|
|
678
|
+
Number of factors per block, shape (n_blocks,).
|
|
585
679
|
p : int
|
|
586
|
-
AR lag order
|
|
680
|
+
AR lag order for factor dynamics. Typically p=1.
|
|
587
681
|
R_mat : np.ndarray, optional
|
|
588
|
-
Constraint matrix for tent kernel aggregation
|
|
682
|
+
Constraint matrix for tent kernel aggregation in mixed-frequency models.
|
|
589
683
|
q : np.ndarray, optional
|
|
590
|
-
Constraint vector for tent kernel aggregation
|
|
591
|
-
nQ : int
|
|
592
|
-
Number of slower-frequency series
|
|
684
|
+
Constraint vector for tent kernel aggregation.
|
|
685
|
+
nQ : int, optional
|
|
686
|
+
Number of slower-frequency series. If None, inferred from frequencies.
|
|
593
687
|
i_idio : np.ndarray
|
|
594
|
-
Indicator array (1
|
|
688
|
+
Indicator array (n,) where 1 indicates clock frequency, 0 indicates slower.
|
|
595
689
|
blocks : np.ndarray
|
|
596
|
-
Block structure (
|
|
690
|
+
Block structure matrix (n x n_blocks).
|
|
597
691
|
tent_weights_dict : dict, optional
|
|
598
|
-
Dictionary mapping frequency
|
|
599
|
-
clock : str
|
|
600
|
-
Clock frequency
|
|
692
|
+
Dictionary mapping frequency strings to tent weight arrays.
|
|
693
|
+
clock : str, default 'm'
|
|
694
|
+
Clock frequency for latent factors ('m', 'q', 'sa', 'a').
|
|
601
695
|
frequencies : np.ndarray, optional
|
|
602
|
-
Array of
|
|
696
|
+
Array of frequency strings (n,) for each series.
|
|
603
697
|
config : DFMConfig, optional
|
|
604
|
-
Configuration object for numerical stability parameters
|
|
698
|
+
Configuration object for numerical stability parameters:
|
|
699
|
+
- min_eigenvalue: Minimum eigenvalue threshold for regularization
|
|
700
|
+
- max_eigenvalue: Maximum eigenvalue threshold for capping
|
|
701
|
+
- warn_on_regularization: Whether to warn when regularization is applied
|
|
605
702
|
|
|
606
703
|
Returns
|
|
607
704
|
-------
|
|
608
705
|
C_new : np.ndarray
|
|
609
|
-
Updated loading matrix (
|
|
706
|
+
Updated loading matrix (n x m). Each row corresponds to a series,
|
|
707
|
+
each column to a factor.
|
|
610
708
|
R_new : np.ndarray
|
|
611
|
-
Updated observation covariance (
|
|
709
|
+
Updated observation covariance (n x n), typically diagonal.
|
|
710
|
+
Diagonal elements represent idiosyncratic variances.
|
|
612
711
|
A_new : np.ndarray
|
|
613
|
-
Updated transition matrix (m x m)
|
|
712
|
+
Updated transition matrix (m x m). Describes factor dynamics.
|
|
614
713
|
Q_new : np.ndarray
|
|
615
|
-
Updated innovation covariance (m x m)
|
|
714
|
+
Updated innovation covariance (m x m). Diagonal elements represent
|
|
715
|
+
innovation variances for each factor.
|
|
616
716
|
Z_0_new : np.ndarray
|
|
617
|
-
Updated initial state (m,)
|
|
717
|
+
Updated initial state (m,). Typically unchanged from input.
|
|
618
718
|
V_0_new : np.ndarray
|
|
619
|
-
Updated initial covariance (m x m)
|
|
719
|
+
Updated initial covariance (m x m). Computed from stationary covariance.
|
|
620
720
|
loglik : float
|
|
621
|
-
Log-likelihood value for this iteration
|
|
721
|
+
Log-likelihood value for this iteration. Used for convergence checking.
|
|
722
|
+
|
|
723
|
+
Raises
|
|
724
|
+
------
|
|
725
|
+
ValueError
|
|
726
|
+
If inputs are invalid or numerical issues occur during computation.
|
|
727
|
+
|
|
728
|
+
Notes
|
|
729
|
+
-----
|
|
730
|
+
- All input parameters are validated and cleaned before use
|
|
731
|
+
- Missing data is handled automatically by the Kalman filter
|
|
732
|
+
- Q diagonal elements are enforced to have minimum value (1e-8) for factor evolution
|
|
733
|
+
- All covariance matrices are regularized to ensure positive semi-definiteness
|
|
734
|
+
- AR coefficients are clipped to stability bounds if configured
|
|
735
|
+
|
|
736
|
+
Examples
|
|
737
|
+
--------
|
|
738
|
+
>>> import numpy as np
|
|
739
|
+
>>> from dfm_python.core.em import em_step
|
|
740
|
+
>>> # Observation matrix with missing data
|
|
741
|
+
>>> y = np.random.randn(10, 100)
|
|
742
|
+
>>> y[0, 10:20] = np.nan # Some missing values
|
|
743
|
+
>>> # Current parameter estimates
|
|
744
|
+
>>> A = np.eye(3) * 0.9
|
|
745
|
+
>>> C = np.random.randn(10, 3) * 0.5
|
|
746
|
+
>>> Q = np.eye(3) * 0.1
|
|
747
|
+
>>> R = np.eye(10) * 0.5
|
|
748
|
+
>>> Z_0 = np.zeros(3)
|
|
749
|
+
>>> V_0 = np.eye(3) * 0.1
|
|
750
|
+
>>> r = np.array([1, 2])
|
|
751
|
+
>>> blocks = np.column_stack([np.ones(10), np.ones(10)])
|
|
752
|
+
>>> # Perform one EM iteration
|
|
753
|
+
>>> C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
|
|
754
|
+
... y, A, C, Q, R, Z_0, V_0, r, p=1,
|
|
755
|
+
... R_mat=None, q=None, nQ=0, i_idio=np.ones(10),
|
|
756
|
+
... blocks=blocks, clock='m'
|
|
757
|
+
... )
|
|
758
|
+
>>> assert C_new.shape == C.shape
|
|
759
|
+
>>> assert np.isfinite(loglik)
|
|
622
760
|
"""
|
|
623
761
|
# Validate and clean input parameters
|
|
624
762
|
if not _check_finite(A, "A"):
|
|
625
|
-
_logger.warning("em_step: A contains NaN/Inf,
|
|
763
|
+
_logger.warning("em_step: A contains NaN/Inf, applying regularization (0.9*I + 0.1*cleaned)")
|
|
626
764
|
A = np.eye(A.shape[0]) * 0.9 + _clean_matrix(A, 'loading') * 0.1
|
|
627
765
|
if not _check_finite(Q, "Q"):
|
|
628
|
-
_logger.warning("em_step: Q contains NaN/Inf,
|
|
766
|
+
_logger.warning("em_step: Q contains NaN/Inf, applying regularization (min=1e-6)")
|
|
629
767
|
Q = _clean_matrix(Q, 'covariance', default_nan=1e-6)
|
|
630
768
|
if not _check_finite(R, "R"):
|
|
631
|
-
_logger.warning("em_step: R contains NaN/Inf,
|
|
769
|
+
_logger.warning("em_step: R contains NaN/Inf, applying regularization (min=1e-4)")
|
|
632
770
|
R = _clean_matrix(R, 'diagonal', default_nan=1e-4, default_inf=1e4)
|
|
633
771
|
if not _check_finite(C, "C"):
|
|
634
|
-
_logger.warning("em_step: C contains NaN/Inf,
|
|
772
|
+
_logger.warning("em_step: C contains NaN/Inf, cleaning matrix")
|
|
635
773
|
C = _clean_matrix(C, 'loading')
|
|
636
774
|
if not _check_finite(Z_0, "Z_0"):
|
|
637
775
|
_logger.warning("em_step: Z_0 contains NaN/Inf, resetting to zeros")
|
|
638
776
|
Z_0 = np.zeros_like(Z_0)
|
|
639
777
|
if not _check_finite(V_0, "V_0"):
|
|
640
|
-
_logger.warning("em_step: V_0 contains NaN/Inf, using regularized identity")
|
|
778
|
+
_logger.warning("em_step: V_0 contains NaN/Inf, using regularized identity (0.1*I)")
|
|
641
779
|
V_0 = np.eye(V_0.shape[0]) * 0.1
|
|
642
780
|
|
|
643
781
|
n, T = y.shape
|
|
@@ -720,10 +858,24 @@ def em_step(
|
|
|
720
858
|
block_transition[:r_i, :factor_lag_size] @ expected_factor_lag_cross[:r_i, :factor_lag_size].T
|
|
721
859
|
) / T
|
|
722
860
|
block_innovation_cov = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
|
|
861
|
+
# Ensure diagonal elements have minimum value to prevent Q from becoming zero
|
|
862
|
+
# This is critical for factor evolution - Q[i,i] = 0 means factor i doesn't evolve
|
|
863
|
+
# Ensure Q diagonal has minimum value - critical for factor evolution
|
|
864
|
+
block_innovation_cov[:r_i, :r_i] = _ensure_innovation_variance_minimum(
|
|
865
|
+
block_innovation_cov[:r_i, :r_i], min_variance=1e-8
|
|
866
|
+
)
|
|
723
867
|
innovation_cov_reg, _ = _ensure_positive_definite(block_innovation_cov[:r_i, :r_i], min_eigenval, warn_reg)
|
|
724
868
|
block_innovation_cov[:r_i, :r_i] = innovation_cov_reg
|
|
869
|
+
# Re-ensure minimum after positive definite check (it may have changed diagonal)
|
|
870
|
+
block_innovation_cov[:r_i, :r_i] = _ensure_innovation_variance_minimum(
|
|
871
|
+
block_innovation_cov[:r_i, :r_i], min_variance=1e-8
|
|
872
|
+
)
|
|
725
873
|
max_eig = _get_config_param(config, "max_eigenvalue", 1e6)
|
|
726
874
|
block_innovation_cov[:r_i, :r_i] = _cap_max_eigenvalue(block_innovation_cov[:r_i, :r_i], max_eigenval=max_eig)
|
|
875
|
+
# Re-ensure minimum after max eigenvalue capping (it may have changed diagonal)
|
|
876
|
+
block_innovation_cov[:r_i, :r_i] = _ensure_innovation_variance_minimum(
|
|
877
|
+
block_innovation_cov[:r_i, :r_i], min_variance=1e-8
|
|
878
|
+
)
|
|
727
879
|
except _NUMERICAL_EXCEPTIONS:
|
|
728
880
|
if np.allclose(block_transition[:r_i, :factor_lag_size], 0):
|
|
729
881
|
block_transition[:r_i, :factor_lag_size] = np.random.randn(r_i, factor_lag_size) * 0.1
|
|
@@ -764,7 +916,7 @@ def em_step(
|
|
|
764
916
|
ar_coeffs_diag, _ = _estimate_ar_coefficient(expected_idio_cross, expected_idio_lag_sq, vsmooth_sum=vsmooth_lag_diag)
|
|
765
917
|
block_transition_idio = np.diag(ar_coeffs_diag)
|
|
766
918
|
innovation_cov_diag = (np.maximum(expected_idio_current_sq, 0.0) - ar_coeffs_diag * expected_idio_cross) / T
|
|
767
|
-
innovation_cov_diag = np.maximum(innovation_cov_diag,
|
|
919
|
+
innovation_cov_diag = np.maximum(innovation_cov_diag, MIN_INNOVATION_VARIANCE)
|
|
768
920
|
block_innovation_cov_idio = np.diag(innovation_cov_diag)
|
|
769
921
|
i_subset_size = i_subset.stop - i_subset.start
|
|
770
922
|
if n_idio_actual == i_subset_size:
|
|
@@ -1013,8 +1165,14 @@ def em_step(
|
|
|
1013
1165
|
min_eigenval = _get_config_param(config, "min_eigenvalue", 1e-8)
|
|
1014
1166
|
warn_reg = _get_config_param(config, "warn_on_regularization", True)
|
|
1015
1167
|
max_eigenval = _get_config_param(config, "max_eigenvalue", 1e6)
|
|
1168
|
+
# Ensure Q diagonal has minimum value - critical for factor evolution
|
|
1169
|
+
Q_new = _ensure_innovation_variance_minimum(Q_new, min_variance=MIN_INNOVATION_VARIANCE)
|
|
1016
1170
|
Q_new, _ = _ensure_positive_definite(Q_new, min_eigenval, warn_reg)
|
|
1171
|
+
# Re-ensure minimum after positive definite check (it may have changed diagonal)
|
|
1172
|
+
Q_new = _ensure_innovation_variance_minimum(Q_new, min_variance=MIN_INNOVATION_VARIANCE)
|
|
1017
1173
|
Q_new = _cap_max_eigenvalue(Q_new, max_eigenval=max_eigenval)
|
|
1174
|
+
# Re-ensure minimum after max eigenvalue capping (it may have changed diagonal)
|
|
1175
|
+
Q_new = _ensure_innovation_variance_minimum(Q_new, min_variance=MIN_INNOVATION_VARIANCE)
|
|
1018
1176
|
V_0_new = _clean_matrix(V_0_new, 'covariance', default_nan=0.0)
|
|
1019
1177
|
V_0_new, _ = _ensure_positive_definite(V_0_new, min_eigenval, warn_reg)
|
|
1020
1178
|
return C_new, R_new, A_new, Q_new, Z_0, V_0_new, loglik
|