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/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
- indNaN = np.isnan(xBal)
209
- xNaN = xBal.copy()
210
- xNaN[indNaN] = np.nan
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
- resNaN = xNaN.copy()
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
- indNaN[:pC - 1, :] = True
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
- res_block = data_residuals[:, idx_clock_freq].copy()
237
- finite_rows = np.all(np.isfinite(res_block), axis=1)
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
- res_block_clean = res_block[finite_rows, :]
246
- if res_block_clean.size == 0:
247
- cov_res = np.eye(len(idx_clock_freq))
248
- elif res_block_clean.ndim == 1:
249
- var_val = np.var(res_block_clean, ddof=0)
250
- var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
251
- cov_res = np.array([[var_val]])
252
- elif res_block_clean.ndim == 2:
253
- if len(idx_clock_freq) == 1 or res_block_clean.shape[1] == 1:
254
- series_data = res_block_clean.flatten()
255
- var_val = np.var(series_data, ddof=0)
256
- var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
257
- cov_res = np.array([[var_val]])
258
- else:
259
- cov_res = np.cov(res_block_clean.T) if res_block_clean.shape[0] >= 2 else np.eye(len(idx_clock_freq))
260
- else:
261
- cov_res = np.eye(len(idx_clock_freq))
262
- if np.any(~np.isfinite(cov_res)):
263
- cov_res = np.eye(len(idx_clock_freq))
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 = resNaN[pC_freq:, j]
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(f"init_conditions: Block {i+1} initialization failed, using fallback.")
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
- if factor_projection_lagged.shape[1] != expected_width:
340
- _logger.warning(f"init_conditions: factor projection width mismatch for block {i+1}, resetting.")
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
- resNaN = data_residuals.copy()
356
- resNaN[indNaN] = np.nan
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 = np.var(innovation_residuals)
383
- if not np.isfinite(variance) or variance <= 0:
384
- variance = 0.1
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(f"init_conditions: initial covariance failed for block {i+1}; using diagonal fallback.")
398
- init_cov_block = np.eye(int(r_i * ppC)) * 0.1
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(resNaN, axis=0)
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(resNaN, axis=0)
458
- var_values = np.where((np.isnan(var_values) | np.isinf(var_values)), 1e-4, var_values)
459
- var_values = np.maximum(var_values, 1e-4)
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] = 1e-4
469
- res_i_full = data_residuals[:, i]
470
- res_i = resNaN[:, i]
471
- leadZero = int(np.argmax(~np.isnan(res_i))) if np.any(~np.isnan(res_i)) else 0
472
- endZero = int(np.argmax(~np.isnan(res_i[::-1]))) if np.any(~np.isnan(res_i)) else 0
473
- res_i_trunc = res_i_full[leadZero:T - endZero] if endZero > 0 else res_i_full[leadZero:]
474
- if len(res_i_trunc) > 1:
475
- res_i_copy = res_i_trunc.copy()
476
- X_ar = res_i_copy[:-1].reshape(-1, 1)
477
- y_ar = res_i_copy[1:].reshape(-1, 1)
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
- BM_val = (1.0 / XTX[0, 0]) * (X_ar.T @ y_ar)
482
- BM_val_clean = BM_val[0, 0] if BM_val.size > 0 else 0.1
483
- BM_val_clean = 0.1 if (np.isnan(BM_val_clean) or np.isinf(BM_val_clean)) else BM_val_clean
484
- BM[idx, idx] = BM_val_clean
485
- residuals = res_i_trunc[1:] - res_i_trunc[:-1] * BM[idx, idx]
486
- var_val = np.var(residuals)
487
- SM[idx, idx] = var_val if np.isfinite(var_val) else 0.1
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] = 0.1
490
- SM[idx, idx] = 0.1
563
+ BM[idx, idx] = DEFAULT_AR_COEFFICIENT
564
+ SM[idx, idx] = DEFAULT_INNOVATION_VARIANCE
491
565
  else:
492
- BM[idx, idx] = 0.1
493
- SM[idx, idx] = 0.1
566
+ BM[idx, idx] = DEFAULT_AR_COEFFICIENT
567
+ SM[idx, idx] = DEFAULT_INNOVATION_VARIANCE
494
568
  else:
495
- BM[idx, idx] = 0.1
496
- SM[idx, idx] = 0.1
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) * 0.9 # AR coefficient for slower idio
509
- SQ = np.eye(n_slower_idio) * 0.1 # Innovation variance
510
- initViQ = np.eye(n_slower_idio) * 0.1 # Initial covariance
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/smoother to compute expected sufficient statistics
564
- - E[Z_t | Y] (smoothed factors)
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: Loading matrix (via regression of data on factors)
570
- - R: Observation covariance (via residual variance)
571
- - A: Transition matrix (via AR regression on factors)
572
- - Q: Innovation covariance (via innovation variance)
573
- - V_0: Initial covariance (via stationary covariance)
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) with missing data
579
- A, C, Q, R : np.ndarray
580
- Current parameter estimates
581
- Z_0, V_0 : np.ndarray
582
- Current initial state and covariance
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 for clock frequency, 0 for slower)
688
+ Indicator array (n,) where 1 indicates clock frequency, 0 indicates slower.
595
689
  blocks : np.ndarray
596
- Block structure (N x n_blocks)
690
+ Block structure matrix (n x n_blocks).
597
691
  tent_weights_dict : dict, optional
598
- Dictionary mapping frequency pairs to tent weights
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 frequencies for each series
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 (N x m)
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 (N x N)
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, regularizing")
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, regularizing")
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, regularizing")
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, regularizing")
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, 1e-8)
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