dfm-python 0.2.0__py3-none-any.whl → 0.2.2__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/dfm.py CHANGED
@@ -13,9 +13,6 @@ mapped to higher-frequency latent states via deterministic tent kernels.
13
13
  """
14
14
 
15
15
  import numpy as np
16
- from scipy.linalg import inv, pinv, block_diag
17
- from scipy.sparse.linalg import eigs
18
- from scipy.sparse import csc_matrix
19
16
  from dataclasses import dataclass
20
17
  from typing import Tuple, Optional, Any, Dict, Union, List
21
18
  import warnings
@@ -24,606 +21,41 @@ import pandas as pd
24
21
 
25
22
  from .kalman import run_kf
26
23
  from .config import DFMConfig
24
+ from .core.results import calculate_rmse
25
+ from .core.grouping import group_series_by_frequency
26
+ from .core.numeric import (
27
+ _ensure_symmetric,
28
+ _compute_principal_components,
29
+ _clean_matrix,
30
+ _ensure_positive_definite,
31
+ _compute_regularization_param,
32
+ _apply_ar_clipping,
33
+ _cap_max_eigenvalue,
34
+ _estimate_ar_coefficient,
35
+ _safe_divide,
36
+ _check_finite,
37
+ )
38
+ from .core.diagnostics import (
39
+ _display_dfm_tables,
40
+ diagnose_series,
41
+ print_series_diagnosis,
42
+ )
43
+ from .core.em import (
44
+ init_conditions,
45
+ em_step,
46
+ em_converged,
47
+ NaNHandlingOptions,
48
+ )
49
+ from .core.helpers import safe_get_method, safe_get_attr
27
50
 
28
51
  from .utils.data_utils import rem_nans_spline
29
52
  from .utils.aggregation import (
30
53
  get_aggregation_structure,
31
54
  FREQUENCY_HIERARCHY,
32
- generate_R_mat,
33
- get_tent_weights_for_pair,
34
- generate_tent_weights,
35
55
  )
36
56
 
37
- # Use rem_nans_spline directly from utils.data_utils - no wrapper needed
38
-
39
57
  _logger = logging.getLogger(__name__)
40
58
 
41
- def _ensure_symmetric(M: np.ndarray) -> np.ndarray:
42
- """Ensure matrix is symmetric by averaging with its transpose."""
43
- return 0.5 * (M + M.T)
44
-
45
-
46
- def _compute_principal_components(cov_matrix: np.ndarray, n_components: int,
47
- block_idx: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]:
48
- """Compute principal components via eigendecomposition.
49
-
50
- This function extracts the top n_components eigenvectors and eigenvalues
51
- from a covariance matrix using various fallback strategies for numerical stability.
52
-
53
- Parameters
54
- ----------
55
- cov_matrix : np.ndarray
56
- Covariance matrix for eigendecomposition
57
- n_components : int
58
- Number of principal components to extract
59
- block_idx : int, optional
60
- Block index for logging purposes
61
-
62
- Returns
63
- -------
64
- eigenvalues : np.ndarray
65
- Top n_components eigenvalues (sorted by magnitude, descending)
66
- eigenvectors : np.ndarray
67
- Corresponding eigenvectors (columns)
68
- """
69
- if cov_matrix.size == 1:
70
- # Single series case
71
- eigenvector = np.array([[1.0]])
72
- eigenvalue = cov_matrix[0, 0] if np.isfinite(cov_matrix[0, 0]) else 1.0
73
- return np.array([eigenvalue]), eigenvector
74
-
75
- n_series = cov_matrix.shape[0]
76
-
77
- # Strategy 1: Use sparse eigs if k < n_series - 1
78
- if n_components < n_series - 1:
79
- try:
80
- cov_sparse = csc_matrix(cov_matrix)
81
- eigenvalues, eigenvectors = eigs(cov_sparse, k=n_components, which='LM')
82
- eigenvectors = eigenvectors.real
83
- # Validate results
84
- if np.any(~np.isfinite(eigenvalues)) or np.any(~np.isfinite(eigenvectors)):
85
- raise ValueError("Invalid eigenvalue results")
86
- return eigenvalues.real, eigenvectors
87
- except (ValueError, np.linalg.LinAlgError, RuntimeError) as e:
88
- # Fallback to regular eig
89
- if block_idx is not None:
90
- _logger.warning(
91
- f"init_conditions: Sparse eigendecomposition failed for block {block_idx+1}, "
92
- f"falling back to np.linalg.eig. Error: {type(e).__name__}"
93
- )
94
- eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
95
- # Sort by magnitude and take top n_components
96
- sort_idx = np.argsort(np.abs(eigenvalues))[::-1][:n_components]
97
- return eigenvalues[sort_idx].real, eigenvectors[:, sort_idx].real
98
-
99
- # Strategy 2: Use regular eig for full eigendecomposition
100
- try:
101
- eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
102
- # Filter valid eigenvalues
103
- valid_mask = np.isfinite(eigenvalues)
104
- if np.sum(valid_mask) < n_components:
105
- raise ValueError("Not enough valid eigenvalues")
106
- # Sort by magnitude and take top n_components
107
- valid_eigenvalues = eigenvalues[valid_mask]
108
- valid_eigenvectors = eigenvectors[:, valid_mask]
109
- sort_idx = np.argsort(np.abs(valid_eigenvalues))[::-1][:n_components]
110
- return valid_eigenvalues[sort_idx].real, valid_eigenvectors[:, sort_idx].real
111
- except (IndexError, ValueError, np.linalg.LinAlgError) as e:
112
- # Ultimate fallback: use identity matrix
113
- if block_idx is not None:
114
- _logger.warning(
115
- f"init_conditions: Eigendecomposition failed for block {block_idx+1}, "
116
- f"using identity matrix as fallback. Error: {type(e).__name__}"
117
- )
118
- eigenvectors = np.eye(n_series)[:, :n_components]
119
- eigenvalues = np.ones(n_components)
120
- return eigenvalues, eigenvectors
121
-
122
-
123
- def _clean_matrix(M: np.ndarray, matrix_type: str = 'general',
124
- default_nan: float = 0.0, default_inf: Optional[float] = None) -> np.ndarray:
125
- """Clean matrix by removing NaN/Inf values and ensuring numerical stability.
126
-
127
- Parameters
128
- ----------
129
- M : np.ndarray
130
- Matrix to clean
131
- matrix_type : str
132
- Type of matrix: 'general', 'covariance', 'diagonal', 'loading'
133
- default_nan : float
134
- Default value for NaN (default: 0.0)
135
- default_inf : float, optional
136
- Default value for Inf. If None, uses matrix-specific defaults.
137
-
138
- Returns
139
- -------
140
- np.ndarray
141
- Cleaned matrix
142
- """
143
- if matrix_type == 'covariance':
144
- # Covariance matrices: ensure symmetry and positive semi-definite
145
- M = np.nan_to_num(M, nan=default_nan, posinf=1e6, neginf=-1e6)
146
- M = _ensure_symmetric(M)
147
- # Ensure positive semi-definite
148
- try:
149
- eigenvals = np.linalg.eigvals(M)
150
- min_eigenval = np.min(eigenvals)
151
- if min_eigenval < 1e-8:
152
- M = M + np.eye(M.shape[0]) * (1e-8 - min_eigenval)
153
- M = _ensure_symmetric(M)
154
- except (np.linalg.LinAlgError, ValueError):
155
- M = M + np.eye(M.shape[0]) * 1e-8
156
- M = _ensure_symmetric(M)
157
- elif matrix_type == 'diagonal':
158
- # Diagonal matrices: clean diagonal only
159
- diag = np.diag(M)
160
- diag = np.nan_to_num(diag, nan=default_nan,
161
- posinf=default_inf if default_inf is not None else 1e4,
162
- neginf=default_nan)
163
- diag = np.maximum(diag, 1e-6) # Ensure positive
164
- M = np.diag(diag)
165
- elif matrix_type == 'loading':
166
- # Loading matrices: clip extreme values
167
- M = np.nan_to_num(M, nan=default_nan, posinf=1.0, neginf=-1.0)
168
- else:
169
- # General matrices: just clean NaN/Inf
170
- default_inf_val = default_inf if default_inf is not None else 1e6
171
- M = np.nan_to_num(M, nan=default_nan, posinf=default_inf_val, neginf=-default_inf_val)
172
-
173
- return M
174
-
175
-
176
- def _ensure_positive_definite(M: np.ndarray, min_eigenval: float = 1e-8,
177
- warn: bool = True) -> Tuple[np.ndarray, Dict[str, Any]]:
178
- """Ensure matrix is positive semi-definite by adding regularization if needed.
179
-
180
- WARNING: Adding regularization biases the covariance matrix. This should only
181
- be necessary for numerical stability. If frequently required, investigate
182
- the source of non-positive-definite matrices.
183
-
184
- Parameters
185
- ----------
186
- M : np.ndarray
187
- Matrix to regularize (should be symmetric)
188
- min_eigenval : float
189
- Minimum eigenvalue threshold (default: 1e-8)
190
- warn : bool
191
- Whether to log warnings when regularization is applied
192
-
193
- Returns
194
- -------
195
- M_reg : np.ndarray
196
- Positive semi-definite matrix
197
- stats : dict
198
- Statistics: {'regularized': bool, 'min_eigenval_before': float,
199
- 'reg_amount': float, 'min_eigenval_after': float}
200
- """
201
- M = _ensure_symmetric(M)
202
- stats = {
203
- 'regularized': False,
204
- 'min_eigenval_before': None,
205
- 'reg_amount': 0.0,
206
- 'min_eigenval_after': None
207
- }
208
-
209
- # Early exit for empty matrices
210
- if M.size == 0 or M.shape[0] == 0:
211
- return M, stats
212
-
213
- try:
214
- # Use eigvalsh for symmetric matrices to avoid spurious complex parts
215
- eigenvals = np.linalg.eigvalsh(M)
216
- min_eig = float(np.min(eigenvals))
217
- stats['min_eigenval_before'] = float(min_eig)
218
-
219
- if min_eig < min_eigenval:
220
- reg_amount = min_eigenval - min_eig
221
- M = M + np.eye(M.shape[0]) * reg_amount
222
- M = _ensure_symmetric(M)
223
- stats['regularized'] = True
224
- stats['reg_amount'] = float(reg_amount)
225
-
226
- # Verify after regularization
227
- eigenvals_after = np.linalg.eigvalsh(M)
228
- stats['min_eigenval_after'] = float(np.min(eigenvals_after))
229
-
230
- if warn:
231
- _logger.warning(
232
- f"Matrix regularization applied: min eigenvalue {min_eig:.2e} < {min_eigenval:.2e}, "
233
- f"added {reg_amount:.2e} to diagonal. This biases the covariance matrix. "
234
- f"Consider investigating the source of non-positive-definite matrices."
235
- )
236
- else:
237
- stats['min_eigenval_after'] = float(min_eig)
238
- except (np.linalg.LinAlgError, ValueError) as e:
239
- # Eigendecomposition failed - add small regularization
240
- M = M + np.eye(M.shape[0]) * min_eigenval
241
- M = _ensure_symmetric(M)
242
- stats['regularized'] = True
243
- stats['reg_amount'] = float(min_eigenval)
244
-
245
- if warn:
246
- _logger.warning(
247
- f"Matrix regularization applied (eigendecomposition failed: {e}). "
248
- f"Added {min_eigenval:.2e} to diagonal. This biases the covariance matrix."
249
- )
250
-
251
- return M, stats
252
-
253
-
254
- def _compute_regularization_param(matrix: np.ndarray, scale_factor: float = 1e-5,
255
- warn: bool = True) -> Tuple[float, Dict[str, Any]]:
256
- """Compute regularization parameter based on matrix scale.
257
-
258
- WARNING: Regularization biases estimates toward zero. Use only when necessary
259
- for numerical stability. Consider investigating ill-conditioned matrices if
260
- regularization is frequently required.
261
-
262
- Parameters
263
- ----------
264
- matrix : np.ndarray
265
- Matrix to compute regularization for
266
- scale_factor : float
267
- Scaling factor relative to trace (default: 1e-5)
268
- warn : bool
269
- Whether to log warnings when regularization is computed
270
-
271
- Returns
272
- -------
273
- reg_param : float
274
- Regularization parameter
275
- stats : dict
276
- Statistics: {'trace': float, 'scale_factor': float, 'reg_param': float}
277
- """
278
- trace = np.trace(matrix)
279
- reg_param = max(trace * scale_factor, 1e-8)
280
-
281
- stats = {
282
- 'trace': float(trace),
283
- 'scale_factor': float(scale_factor),
284
- 'reg_param': float(reg_param)
285
- }
286
-
287
- if warn and reg_param > 1e-8:
288
- _logger.info(
289
- f"Regularization parameter computed: {reg_param:.2e} "
290
- f"(trace={trace:.2e}, scale={scale_factor:.2e}). "
291
- f"This adds bias to estimates - consider investigating matrix conditioning."
292
- )
293
-
294
- return reg_param, stats
295
-
296
-
297
- def _clip_ar_coefficients(A: np.ndarray, min_val: float = -0.99, max_val: float = 0.99,
298
- warn: bool = True) -> Tuple[np.ndarray, Dict[str, Any]]:
299
- """Clip AR coefficients to stability bounds.
300
-
301
- AR(1) processes require |A| < 1 for stationarity. This function clips
302
- coefficients to a safe range (default: [-0.99, 0.99]) to ensure numerical
303
- stability while maintaining theoretical validity.
304
-
305
- WARNING: Clipping can bias estimates if true coefficients are near unit root.
306
- Consider investigating near-unit root behavior if clipping occurs frequently.
307
-
308
- Parameters
309
- ----------
310
- A : np.ndarray
311
- AR coefficients (can be scalar, 1D array, or 2D matrix)
312
- min_val : float
313
- Minimum allowed value (default: -0.99)
314
- max_val : float
315
- Maximum allowed value (default: 0.99)
316
- warn : bool
317
- Whether to log warnings when clipping occurs
318
-
319
- Returns
320
- -------
321
- A_clipped : np.ndarray
322
- Clipped AR coefficients with same shape as input
323
- stats : dict
324
- Statistics about clipping: {'n_clipped': int, 'n_total': int, 'clipped_indices': list}
325
- """
326
- A_flat = A.flatten()
327
- n_total = len(A_flat)
328
-
329
- # Find values that need clipping
330
- below_min = A_flat < min_val
331
- above_max = A_flat > max_val
332
- needs_clip = below_min | above_max
333
- n_clipped = np.sum(needs_clip)
334
-
335
- # Clip the values
336
- A_clipped = np.clip(A, min_val, max_val)
337
-
338
- # Prepare statistics
339
- stats = {
340
- 'n_clipped': int(n_clipped),
341
- 'n_total': int(n_total),
342
- 'clipped_indices': np.where(needs_clip)[0].tolist() if n_clipped > 0 else [],
343
- 'min_violations': int(np.sum(below_min)),
344
- 'max_violations': int(np.sum(above_max))
345
- }
346
-
347
- # Warn if clipping occurred
348
- if warn and n_clipped > 0:
349
- pct_clipped = 100.0 * n_clipped / n_total if n_total > 0 else 0.0
350
- _logger.warning(
351
- f"AR coefficient clipping applied: {n_clipped}/{n_total} ({pct_clipped:.1f}%) "
352
- f"coefficients clipped to [{min_val}, {max_val}]. "
353
- f"This may indicate near-unit root behavior. "
354
- f"Consider investigating stationarity if clipping occurs frequently."
355
- )
356
-
357
- return A_clipped, stats
358
-
359
-
360
- def _apply_ar_clipping(A: np.ndarray, config: Optional[DFMConfig] = None) -> Tuple[np.ndarray, Dict[str, Any]]:
361
- """Apply AR coefficient clipping based on configuration.
362
-
363
- Parameters
364
- ----------
365
- A : np.ndarray
366
- AR coefficients to clip
367
- config : DFMConfig, optional
368
- Configuration object. If None, uses defaults (clipping enabled)
369
-
370
- Returns
371
- -------
372
- A_clipped : np.ndarray
373
- Clipped AR coefficients
374
- stats : dict
375
- Statistics about clipping
376
- """
377
- if config is None or config.clip_ar_coefficients:
378
- min_val = config.ar_clip_min if config else -0.99
379
- max_val = config.ar_clip_max if config else 0.99
380
- warn = config.warn_on_ar_clip if config else True
381
- return _clip_ar_coefficients(A, min_val, max_val, warn)
382
- else:
383
- # Clipping disabled - return as-is with empty stats
384
- return A, {'n_clipped': 0, 'n_total': A.size, 'clipped_indices': []}
385
-
386
-
387
- def _apply_regularization(M: np.ndarray, matrix_type: str = 'covariance',
388
- config: Optional[DFMConfig] = None) -> Tuple[np.ndarray, Dict[str, Any]]:
389
- """Apply regularization based on configuration.
390
-
391
- Parameters
392
- ----------
393
- M : np.ndarray
394
- Matrix to regularize
395
- matrix_type : str
396
- Type of matrix ('covariance', 'general')
397
- config : DFMConfig, optional
398
- Configuration object. If None, uses defaults (regularization enabled)
399
-
400
- Returns
401
- -------
402
- M_reg : np.ndarray
403
- Regularized matrix
404
- stats : dict
405
- Statistics about regularization
406
- """
407
- if config is None or config.use_regularization:
408
- min_eigenval = config.min_eigenvalue if config else 1e-8
409
- warn = config.warn_on_regularization if config else True
410
- return _ensure_positive_definite(M, min_eigenval, warn)
411
- else:
412
- # Regularization disabled - just ensure symmetric
413
- return _ensure_symmetric(M), {'regularized': False}
414
-
415
-
416
- def _cap_max_eigenvalue(M: np.ndarray, max_eigenval: float = 1e6) -> np.ndarray:
417
- """Cap maximum eigenvalue of a matrix to prevent numerical explosion.
418
-
419
- Parameters
420
- ----------
421
- M : np.ndarray
422
- Matrix to cap (should be symmetric)
423
- max_eigenval : float
424
- Maximum allowed eigenvalue (default: 1e6)
425
-
426
- Returns
427
- -------
428
- np.ndarray
429
- Matrix with capped eigenvalues
430
- """
431
- try:
432
- eigenvals = np.linalg.eigvals(M)
433
- max_eig = np.max(eigenvals)
434
- if max_eig > max_eigenval:
435
- scale = max_eigenval / max_eig
436
- return M * scale
437
- except (np.linalg.LinAlgError, ValueError):
438
- # Eigendecomposition failed - cap diagonal values as fallback
439
- M_diag = np.diag(M)
440
- M_diag = np.maximum(M_diag, 1e-8)
441
- M_diag = np.minimum(M_diag, max_eigenval)
442
- M_capped = np.diag(M_diag)
443
- return _ensure_symmetric(M_capped)
444
- return M
445
-
446
-
447
- def _estimate_ar_coefficient(EZZ_FB: np.ndarray, EZZ_BB: np.ndarray,
448
- vsmooth_sum: Optional[np.ndarray] = None,
449
- T: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]:
450
- """Estimate AR coefficients and innovation variances from expectations.
451
-
452
- This is the core EM update formula for AR(1) processes:
453
- A = E[z_t * z_{t-1}'] / E[z_{t-1} * z_{t-1}']
454
- Q = (E[z_t * z_t'] - A * E[z_t * z_{t-1}']) / T
455
-
456
- Parameters
457
- ----------
458
- EZZ_FB : np.ndarray
459
- E[z_t * z_{t-1}'] - can be diagonal (1D) or full matrix
460
- EZZ_BB : np.ndarray
461
- E[z_{t-1} * z_{t-1}'] - can be diagonal (1D) or full matrix
462
- vsmooth_sum : np.ndarray, optional
463
- Sum of smoothed covariances to add to expectations
464
- T : int, optional
465
- Number of time periods for variance computation
466
-
467
- Returns
468
- -------
469
- A_diag : np.ndarray
470
- AR coefficients (diagonal elements)
471
- Q_diag : np.ndarray
472
- Innovation variances (diagonal elements)
473
- """
474
- # Handle both scalar and array inputs
475
- if np.isscalar(EZZ_FB):
476
- EZZ_FB = np.array([EZZ_FB])
477
- EZZ_BB = np.array([EZZ_BB])
478
-
479
- # Extract diagonal if full matrices
480
- if EZZ_FB.ndim > 1:
481
- EZZ_FB_diag = np.diag(EZZ_FB).copy()
482
- EZZ_BB_diag = np.diag(EZZ_BB).copy()
483
- else:
484
- EZZ_FB_diag = EZZ_FB.copy()
485
- EZZ_BB_diag = EZZ_BB.copy()
486
-
487
- # Add smoothed covariance if provided
488
- if vsmooth_sum is not None:
489
- if vsmooth_sum.ndim > 1:
490
- vsmooth_diag = np.diag(vsmooth_sum)
491
- else:
492
- vsmooth_diag = vsmooth_sum
493
- EZZ_BB_diag = EZZ_BB_diag + vsmooth_diag
494
-
495
- # Regularize denominators to prevent division by zero
496
- min_denom = np.maximum(np.abs(EZZ_BB_diag) * 1e-6, 1e-10)
497
- EZZ_BB_diag = np.where(
498
- (np.isnan(EZZ_BB_diag) | np.isinf(EZZ_BB_diag) | (np.abs(EZZ_BB_diag) < min_denom)),
499
- min_denom, EZZ_BB_diag
500
- )
501
-
502
- # Clean numerators
503
- EZZ_FB_diag = _clean_matrix(EZZ_FB_diag, 'general', default_nan=0.0) if EZZ_FB_diag.ndim > 0 else np.nan_to_num(EZZ_FB_diag, nan=0.0, posinf=1e6, neginf=-1e6)
504
-
505
- # Compute AR coefficients
506
- A_diag = EZZ_FB_diag / EZZ_BB_diag
507
-
508
- # Compute innovation variances if T is provided
509
- if T is not None:
510
- # Need E[z_t^2] - extract from EZZ_BB if available, or compute separately
511
- # For now, assume EZZ_idio is provided separately if needed
512
- # This will be handled by the caller
513
- Q_diag = None
514
- else:
515
- Q_diag = None
516
-
517
- return A_diag, Q_diag
518
-
519
-
520
- def calculate_rmse(actual: np.ndarray, predicted: np.ndarray,
521
- mask: Optional[np.ndarray] = None) -> Tuple[float, np.ndarray]:
522
- """Calculate Root Mean Squared Error (RMSE) between actual and predicted values.
523
-
524
- RMSE is calculated as: sqrt(mean((actual - predicted)^2))
525
-
526
- Parameters
527
- ----------
528
- actual : np.ndarray
529
- Actual values (T × N) or (T,) array
530
- predicted : np.ndarray
531
- Predicted/forecasted values (T × N) or (T,) array, same shape as actual
532
- mask : np.ndarray, optional
533
- Boolean mask (T × N) or (T,) indicating which values to include in calculation.
534
- If None, only non-NaN values are used.
535
-
536
- Returns
537
- -------
538
- rmse_overall : float
539
- Overall RMSE averaged across all series and time periods
540
- rmse_per_series : np.ndarray
541
- RMSE for each series (N,) or scalar if 1D input
542
- """
543
- # Ensure arrays are the same shape
544
- if actual.shape != predicted.shape:
545
- raise ValueError(f"actual and predicted must have same shape, got {actual.shape} and {predicted.shape}")
546
-
547
- # Create mask for valid values
548
- if mask is None:
549
- # Use non-NaN values in both actual and predicted
550
- mask = np.isfinite(actual) & np.isfinite(predicted)
551
- else:
552
- # Combine user mask with finite check
553
- mask = mask & np.isfinite(actual) & np.isfinite(predicted)
554
-
555
- # Calculate squared errors
556
- errors_sq = (actual - predicted) ** 2
557
-
558
- # Handle 1D case (single series)
559
- if actual.ndim == 1:
560
- if np.sum(mask) == 0:
561
- return np.nan, np.array([np.nan])
562
- rmse_series = np.sqrt(np.mean(errors_sq[mask]))
563
- return rmse_series, np.array([rmse_series])
564
-
565
- # Handle 2D case (multiple series)
566
- T, N = actual.shape
567
-
568
- # Calculate RMSE per series
569
- rmse_per_series = np.zeros(N)
570
- for i in range(N):
571
- series_mask = mask[:, i]
572
- if np.sum(series_mask) > 0:
573
- rmse_per_series[i] = np.sqrt(np.mean(errors_sq[series_mask, i]))
574
- else:
575
- rmse_per_series[i] = np.nan
576
-
577
- # Calculate overall RMSE (average across all valid observations)
578
- if np.any(mask):
579
- rmse_overall = np.sqrt(np.mean(errors_sq[mask]))
580
- else:
581
- rmse_overall = np.nan
582
-
583
- return rmse_overall, rmse_per_series
584
-
585
-
586
- def _safe_divide(numerator: np.ndarray, denominator: float, default: float = 0.0) -> np.ndarray:
587
- """Safely divide numerator by denominator, handling zero and invalid values.
588
-
589
- This utility function prevents division by zero and handles NaN/Inf values
590
- that can arise in numerical computations. It's used throughout the codebase
591
- to ensure robust numerical operations.
592
-
593
- Parameters
594
- ----------
595
- numerator : np.ndarray
596
- Numerator array of any shape
597
- denominator : float
598
- Denominator value (scalar)
599
- default : float, optional
600
- Default value to use if denominator is zero, NaN, or Inf, by default 0.0.
601
- Also used to replace any NaN/Inf values in the result.
602
-
603
- Returns
604
- -------
605
- np.ndarray
606
- Result of division with same shape as numerator.
607
- Elements where denominator is invalid or result is non-finite are set to default.
608
-
609
- Examples
610
- --------
611
- >>> x = np.array([1.0, 2.0, 3.0])
612
- >>> _safe_divide(x, 2.0)
613
- array([0.5, 1.0, 1.5])
614
- >>> _safe_divide(x, 0.0, default=1.0)
615
- array([1.0, 1.0, 1.0])
616
- """
617
- if denominator == 0 or np.isnan(denominator) or np.isinf(denominator):
618
- return np.full_like(numerator, default)
619
- result = numerator / denominator
620
- # Replace any NaN/Inf in result with default
621
- result = np.where(np.isfinite(result), result, default)
622
- return result
623
-
624
-
625
- # Validation functions removed - validation is done inline where needed for performance
626
-
627
59
 
628
60
  @dataclass
629
61
  class DFMResult:
@@ -685,7 +117,9 @@ class DFMResult:
685
117
 
686
118
  Examples
687
119
  --------
688
- >>> Res = dfm(X, config, threshold=1e-4)
120
+ >>> from dfm_python import DFM
121
+ >>> model = DFM()
122
+ >>> Res = model.fit(X, config, threshold=1e-4)
689
123
  >>> # Access smoothed factors
690
124
  >>> common_factor = Res.Z[:, 0]
691
125
  >>> # Access factor loadings for first series
@@ -751,8 +185,7 @@ class DFMResult:
751
185
  idx = time_index if time_index is not None else self.time_index
752
186
  cols = factor_names if factor_names is not None else [f"F{i+1}" for i in range(self.num_state())]
753
187
  return pd.DataFrame(self.Z, index=idx, columns=cols)
754
- except Exception:
755
- # Fallback: return numpy array
188
+ except (ImportError, ValueError, TypeError):
756
189
  return self.Z
757
190
 
758
191
  def to_pandas_smoothed(self, time_index: Optional[object] = None, series_ids: Optional[List[str]] = None):
@@ -762,7 +195,7 @@ class DFMResult:
762
195
  idx = time_index if time_index is not None else self.time_index
763
196
  cols = series_ids if series_ids is not None else (self.series_ids if self.series_ids is not None else [f"S{i+1}" for i in range(self.num_series())])
764
197
  return pd.DataFrame(self.X_sm, index=idx, columns=cols)
765
- except Exception:
198
+ except (ImportError, ValueError, TypeError):
766
199
  return self.X_sm
767
200
 
768
201
  def save(self, path: str) -> None:
@@ -771,2362 +204,445 @@ class DFMResult:
771
204
  import pickle
772
205
  with open(path, 'wb') as f:
773
206
  pickle.dump(self, f)
774
- except Exception as e:
207
+ except (IOError, OSError, pickle.PickleError) as e:
775
208
  raise RuntimeError(f"Failed to save DFMResult to {path}: {e}")
776
209
 
777
210
 
778
- def em_converged(loglik: float, previous_loglik: float, threshold: float = 1e-4,
779
- check_decreased: bool = True) -> Tuple[bool, bool]:
780
- """Check whether EM algorithm has converged.
781
-
782
- Convergence is determined by the relative change in log-likelihood being
783
- below the threshold: |loglik - previous_loglik| / avg(|loglik|, |previous_loglik|) < threshold.
784
-
785
- Parameters
786
- ----------
787
- loglik : float
788
- Log-likelihood from current EM iteration. Should be finite and typically
789
- negative (log-likelihoods are usually negative).
790
- previous_loglik : float
791
- Log-likelihood from previous EM iteration. Used to compute change.
792
- threshold : float, optional
793
- Convergence threshold (default 1e-4). Smaller values require more iterations
794
- but provide more precise convergence. Typical range: 1e-5 to 1e-3.
795
- check_decreased : bool, optional
796
- Whether to check if log-likelihood decreased (default True). If True and
797
- loglik < previous_loglik - 1e-3, decrease flag is set and a warning printed.
798
-
799
- Returns
800
- -------
801
- converged : bool
802
- True if convergence criteria satisfied, False otherwise.
803
- decrease : bool
804
- True if log-likelihood decreased (only meaningful if check_decreased=True).
805
- A decrease may indicate numerical issues but is allowed to happen.
806
-
807
- Examples
808
- --------
809
- >>> loglik_current = -1234.56
810
- >>> loglik_previous = -1235.12
811
- >>> converged, decreased = em_converged(loglik_current, loglik_previous, threshold=1e-4)
812
- >>> if converged:
813
- ... print("EM algorithm converged")
814
- """
815
- converged = False
816
- decrease = False
817
-
818
- if check_decreased:
819
- if loglik - previous_loglik < -1e-3: # Allow for a little imprecision
820
- _logger.warning(f"Likelihood decreased from {previous_loglik:.4f} to {loglik:.4f}")
821
- decrease = True
822
-
823
- # Check convergence criteria
824
- delta_loglik = abs(loglik - previous_loglik)
825
- avg_loglik = (abs(loglik) + abs(previous_loglik) + np.finfo(float).eps) / 2
826
-
827
- if (delta_loglik / avg_loglik) < threshold:
828
- converged = True
829
-
830
- return converged, decrease
211
+ # Core functions are imported directly from core modules - no proxy functions needed
831
212
 
832
213
 
833
- def group_series_by_frequency(
834
- idx_i: np.ndarray,
835
- frequencies: np.ndarray,
836
- clock: str
837
- ) -> Dict[str, np.ndarray]:
838
- """Group series indices by their actual frequency.
839
-
840
- This function groups series by their actual frequency values, allowing
841
- each frequency to be processed independently. Faster frequencies than clock
842
- are validated and rejected.
214
+ class DFM:
215
+ """Core Dynamic Factor Model class.
843
216
 
844
- Parameters
845
- ----------
846
- idx_i : np.ndarray
847
- Array of series indices to group (e.g., indices of series in a block)
848
- frequencies : np.ndarray
849
- Array of frequency strings for all series (length N)
850
- clock : str
851
- Clock frequency ('d', 'w', 'm', 'q', 'sa', 'a')
852
-
853
- Returns
854
- -------
855
- Dict[str, np.ndarray]
856
- Dictionary with frequency strings as keys and arrays of indices as values.
857
- Also includes 'clock' key for series matching the clock frequency.
858
-
859
- Examples
860
- --------
861
- >>> frequencies = np.array(['m', 'm', 'q', 'm', 'q'])
862
- >>> idx_i = np.array([0, 1, 2, 3, 4])
863
- >>> groups = group_series_by_frequency(idx_i, frequencies, 'm')
864
- >>> groups['m'] # Monthly series
865
- array([0, 1, 3])
866
- >>> groups['q'] # Quarterly series
867
- array([2, 4])
217
+ This class provides the main DFM estimation functionality. The core algorithm
218
+ is implemented in the `fit()` method, which performs EM estimation.
868
219
  """
869
- if frequencies is None or len(frequencies) == 0:
870
- # Fallback: assume all are same as clock if frequencies not provided
871
- return {clock: idx_i.copy()}
872
-
873
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3) # Default to monthly
874
220
 
875
- freq_groups: Dict[str, List[int]] = {}
876
- faster_indices = []
221
+ def __init__(self):
222
+ """Initialize DFM instance."""
223
+ self._config: Optional[DFMConfig] = None
224
+ self._data: Optional[np.ndarray] = None
225
+ self._time: Optional[np.ndarray] = None
226
+ self._original_data: Optional[np.ndarray] = None
227
+ self._result: Optional[DFMResult] = None
877
228
 
878
- for idx in idx_i:
879
- if idx >= len(frequencies):
880
- # Index out of bounds - skip
881
- continue
229
+ def fit(self,
230
+ X: np.ndarray,
231
+ config: DFMConfig,
232
+ threshold: Optional[float] = None,
233
+ max_iter: Optional[int] = None,
234
+ ar_lag: Optional[int] = None,
235
+ nan_method: Optional[int] = None,
236
+ nan_k: Optional[int] = None,
237
+ clock: Optional[str] = None,
238
+ clip_ar_coefficients: Optional[bool] = None,
239
+ ar_clip_min: Optional[float] = None,
240
+ ar_clip_max: Optional[float] = None,
241
+ clip_data_values: Optional[bool] = None,
242
+ data_clip_threshold: Optional[float] = None,
243
+ use_regularization: Optional[bool] = None,
244
+ regularization_scale: Optional[float] = None,
245
+ min_eigenvalue: Optional[float] = None,
246
+ max_eigenvalue: Optional[float] = None,
247
+ use_damped_updates: Optional[bool] = None,
248
+ damping_factor: Optional[float] = None,
249
+ **kwargs) -> DFMResult:
250
+ """Fit the DFM model using EM algorithm.
882
251
 
883
- freq = frequencies[idx]
884
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3) # Default to monthly
252
+ This is the core estimation method. It performs the complete EM workflow:
253
+ 1. Initialization via PCA and OLS
254
+ 2. EM iterations until convergence
255
+ 3. Final Kalman smoothing
885
256
 
886
- if freq_hierarchy < clock_hierarchy:
887
- # Faster frequency (lower hierarchy number) - NOT SUPPORTED
888
- faster_indices.append(idx)
889
- else:
890
- # Group by actual frequency
891
- if freq not in freq_groups:
892
- freq_groups[freq] = []
893
- freq_groups[freq].append(idx)
894
-
895
- # Validate: faster frequencies are not supported
896
- if len(faster_indices) > 0:
897
- faster_series_names = [f"series_{idx}" for idx in faster_indices]
898
- raise ValueError(
899
- f"Higher frequencies (daily, weekly) are not supported. "
900
- f"Found {len(faster_indices)} series with frequency faster than clock '{clock}'. "
901
- f"Please use monthly, quarterly, semi-annual, or annual frequencies only."
902
- )
903
-
904
- # Convert lists to numpy arrays
905
- return {freq: np.array(indices, dtype=int) for freq, indices in freq_groups.items()}
906
-
907
-
908
- def init_conditions(x: np.ndarray, r: np.ndarray, p: int, blocks: np.ndarray,
909
- opt_nan: dict, Rcon: Optional[np.ndarray], q: Optional[np.ndarray],
910
- nQ: Optional[int], i_idio: np.ndarray,
911
- clock: str = 'm', tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
912
- frequencies: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray,
913
- np.ndarray, np.ndarray, np.ndarray]:
914
- """Calculate initial conditions for parameter estimation.
915
-
916
- This function computes initial parameter estimates using principal component
917
- analysis (PCA) for factors and ordinary least squares (OLS) for AR coefficients.
918
- These initial values are then refined by the EM algorithm in `dfm()`.
919
-
920
- The function handles:
921
- - Factor extraction via eigendecomposition of residual covariance
922
- - AR coefficient estimation via OLS regression
923
- - Quarterly aggregation constraints for quarterly series
924
- - Idiosyncratic component initialization
925
-
926
- Parameters
927
- ----------
928
- x : np.ndarray
929
- Standardized data matrix (T x N), where T is time periods and N is series.
930
- Data should already be standardized (zero mean, unit variance).
931
- Missing values (NaN) are handled via spline interpolation.
932
- r : np.ndarray
933
- Number of common factors for each block (n_blocks,). Typically all ones
934
- indicating one factor per block. Example: np.array([1, 1, 1, 1]) for 4 blocks.
935
- p : int
936
- Number of lags in the transition equation. Typically p=1 (AR(1) process).
937
- Higher values create more complex dynamics but require more parameters.
938
- blocks : np.ndarray
939
- Block loading structure (N x n_blocks). Each row corresponds to a series,
940
- each column to a block. Value 1 indicates the series loads on that block.
941
- All series must load on the first block (global factor).
942
- opt_nan : dict
943
- Options for missing value handling. Should contain:
944
- - 'method': int, method for NaN handling (typically 2 or 3)
945
- - 'k': int, spline parameter (typically 3)
946
- See `rem_nans_spline` for details.
947
- Rcon : np.ndarray
948
- Aggregation constraints matrix for slower-frequency series (e.g., 4 x 5 for quarterly when clock is monthly). Implements the "tent structure"
949
- that aggregates monthly values to quarterly. Standard structure is:
950
- [[2, -1, 0, 0, 0],
951
- [3, 0, -1, 0, 0],
952
- [2, 0, 0, -1, 0],
953
- [1, 0, 0, 0, -1]]
954
- q : np.ndarray
955
- Constraints vector (4,). Typically all zeros: np.zeros(4).
956
- Used with Rcon to enforce slower-frequency aggregation.
957
- nQ : int, optional
958
- Number of slower-frequency variables (e.g., quarterly when clock is monthly).
959
- If None, computed from frequencies array. Must satisfy 0 <= nQ < N.
960
- i_idio : np.ndarray
961
- Logical array (N,) indicating idiosyncratic components: 1 for same-frequency series,
962
- 0 for slower-frequency series. Typically: np.concatenate([np.ones(N-nQ), np.zeros(nQ)]).
963
-
964
- Returns
965
- -------
966
- A : np.ndarray
967
- Initial transition matrix (m x m), where m is the state dimension.
968
- Block diagonal structure: factors, monthly idiosyncratic, quarterly idiosyncratic.
969
- C : np.ndarray
970
- Initial observation/loading matrix (N x m). Loadings are estimated via PCA
971
- (eigendecomposition of residual covariance) for monthly series and constrained
972
- least squares for quarterly series.
973
- Q : np.ndarray
974
- Initial covariance matrix for transition residuals (m x m). Block diagonal.
975
- Diagonal elements represent innovation variances.
976
- R : np.ndarray
977
- Initial covariance matrix for observation residuals (N x N). Diagonal matrix.
978
- Diagonal elements are residual variances (idiosyncratic variances).
979
- Z_0 : np.ndarray
980
- Initial state vector (m,). Typically zeros: np.zeros(m).
981
- V_0 : np.ndarray
982
- Initial covariance matrix for state (m x m). Computed from steady-state
983
- of the AR process: V_0 = inv(I - kron(A, A)) @ vec(Q).
257
+ Parameters
258
+ ----------
259
+ X : np.ndarray
260
+ Data matrix (T x N), where T is time periods and N is number of series.
261
+ config : DFMConfig
262
+ Unified DFM configuration object.
263
+ threshold : float, optional
264
+ EM convergence threshold. If None, uses config.threshold.
265
+ max_iter : int, optional
266
+ Maximum EM iterations. If None, uses config.max_iter.
267
+ **kwargs
268
+ Additional parameters (ar_lag, nan_method, etc.) that override config values.
269
+
270
+ Returns
271
+ -------
272
+ DFMResult
273
+ Estimation results including parameters, factors, and diagnostics.
274
+ """
275
+ # Store config and data for later use
276
+ self._config = config
277
+ self._data = X
984
278
 
985
- Raises
986
- ------
987
- ValueError
988
- If inputs are invalid or dimensions don't match. Also raised if numerical
989
- issues prevent computation (though fallback strategies will be attempted).
279
+ # Call the core _dfm_core() function logic
280
+ result = _dfm_core(
281
+ X, config,
282
+ threshold=threshold,
283
+ max_iter=max_iter,
284
+ ar_lag=ar_lag,
285
+ nan_method=nan_method,
286
+ nan_k=nan_k,
287
+ clock=clock,
288
+ clip_ar_coefficients=clip_ar_coefficients,
289
+ ar_clip_min=ar_clip_min,
290
+ ar_clip_max=ar_clip_max,
291
+ clip_data_values=clip_data_values,
292
+ data_clip_threshold=data_clip_threshold,
293
+ use_regularization=use_regularization,
294
+ regularization_scale=regularization_scale,
295
+ min_eigenvalue=min_eigenvalue,
296
+ max_eigenvalue=max_eigenvalue,
297
+ use_damped_updates=use_damped_updates,
298
+ damping_factor=damping_factor,
299
+ **kwargs
300
+ )
990
301
 
991
- Notes
992
- -----
993
- - The function uses fallback strategies when numerical issues occur (e.g., singular
994
- matrices, eigendecomposition failures). These are logged at WARNING level.
995
- - For quarterly series, loadings must satisfy aggregation constraints that ensure
996
- quarterly aggregates match monthly weighted sums.
997
- - The state dimension m is computed as: sum(r) * max(p, 5) + monthly_idio + quarterly_idio * 5
998
-
999
- Examples
1000
- --------
1001
- >>> from dfm_python import load_config, load_data, dfm
1002
- >>> # Load configuration from YAML or create DFMConfig directly
1003
- >>> config = load_config('config.yaml')
1004
- >>> # Load data from file
1005
- >>> X, Time, Z = load_data('data.csv', config)
1006
- >>> # Standardize data
1007
- >>> x = (X - np.nanmean(X, axis=0)) / np.nanstd(X, axis=0)
1008
- >>> # Set up parameters
1009
- >>> r = np.array(config.factors_per_block)
1010
- >>> p = config.ar_lag
1011
- >>> blocks = config.get_blocks_array()
1012
- >>> frequencies = np.array(config.get_frequencies())
1013
- >>> clock = config.clock
1014
- >>> nQ = np.sum(frequencies != clock) # Count slower-frequency series
1015
- >>> i_idio = np.array([1 if f == clock else 0 for f in frequencies])
1016
- >>> opt_nan = {'method': config.nan_method, 'k': config.nan_k}
1017
- >>> # Get aggregation structure for tent kernels
1018
- >>> from dfm_python.utils.aggregation import get_aggregation_structure
1019
- >>> agg_info = get_aggregation_structure(config, clock=clock)
1020
- >>> tent_weights_dict = agg_info.get('tent_weights', {})
1021
- >>> # Compute initial conditions
1022
- >>> A, C, Q, R, Z_0, V_0 = init_conditions(
1023
- ... x, r, p, blocks, opt_nan, None, None, nQ, i_idio,
1024
- ... clock=clock, tent_weights_dict=tent_weights_dict, frequencies=frequencies
1025
- ... )
1026
- """
1027
- # Input validation is done inline where needed
1028
-
1029
- # Handle missing_data method (no aggregation constraints)
1030
- if Rcon is None or q is None:
1031
- pC = 1 # No tent structure
1032
- else:
1033
- pC = Rcon.shape[1] # Tent structure size (quarterly to monthly)
1034
- ppC = int(max(p, pC)) # Ensure integer
1035
- n_blocks = blocks.shape[1] # Number of blocks
1036
-
1037
- # Spline without NaNs
1038
- xBal, _ = rem_nans_spline(x, method=opt_nan['method'], k=opt_nan['k'])
1039
-
1040
- T, N = xBal.shape
1041
-
1042
- # Determine pC from tent_weights_dict if available, otherwise use Rcon
1043
- # pC is the maximum tent kernel size needed for slower frequencies
1044
- pC = 1
1045
- if tent_weights_dict is not None and len(tent_weights_dict) > 0:
1046
- # Find maximum tent kernel size
1047
- for tent_weights in tent_weights_dict.values():
1048
- if tent_weights is not None and len(tent_weights) > pC:
1049
- pC = len(tent_weights)
1050
- elif Rcon is not None:
1051
- pC = Rcon.shape[1]
1052
-
1053
- # Fallback: if nQ is provided but frequencies not, compute from nQ
1054
- if nQ is None and frequencies is not None:
1055
- # Count series slower than clock
1056
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1057
- nQ = sum(1 for f in frequencies if FREQUENCY_HIERARCHY.get(f, 3) > clock_hierarchy)
1058
- elif nQ is None:
1059
- nQ = 0 # Default to 0 if neither provided
1060
-
1061
- # Create 2D boolean array for NaN indicators
1062
- indNaN = np.isnan(xBal)
1063
-
1064
- # Create views/copies as needed (xNaN needs to be modifiable)
1065
- xNaN = xBal.copy() # Need copy for NaN assignment
1066
- xNaN[indNaN] = np.nan
1067
- data_residuals = xBal # Residual data after removing factors (can use view, not modified)
1068
- resNaN = xNaN.copy() # Need copy for modifications
302
+ self._result = result
303
+ return result
1069
304
 
1070
- # Initialize model coefficient output
1071
- C = None
1072
- A = None
1073
- Q = None
1074
- V_0 = None
305
+ @property
306
+ def result(self) -> Optional[DFMResult]:
307
+ """Get the last fit result."""
308
+ return self._result
1075
309
 
1076
- # Set first observations as NaNs for quarterly-monthly aggregation scheme
1077
- # Only if using aggregation (pC > 1)
1078
- if pC > 1:
1079
- indNaN[:pC-1, :] = True
310
+ @property
311
+ def config(self) -> Optional[DFMConfig]:
312
+ """Get the current configuration."""
313
+ return self._config
314
+
315
+
316
+ def _resolve_param(override: Any, default: Any) -> Any:
317
+ """Resolve parameter: use override if provided, else use default."""
318
+ return override if override is not None else default
319
+
320
+
321
+ def _prepare_data_and_params(
322
+ X: np.ndarray,
323
+ config: DFMConfig,
324
+ threshold: Optional[float] = None,
325
+ max_iter: Optional[int] = None,
326
+ ar_lag: Optional[int] = None,
327
+ nan_method: Optional[int] = None,
328
+ nan_k: Optional[int] = None,
329
+ clock: Optional[str] = None,
330
+ clip_ar_coefficients: Optional[bool] = None,
331
+ ar_clip_min: Optional[float] = None,
332
+ ar_clip_max: Optional[float] = None,
333
+ clip_data_values: Optional[bool] = None,
334
+ data_clip_threshold: Optional[float] = None,
335
+ use_regularization: Optional[bool] = None,
336
+ regularization_scale: Optional[float] = None,
337
+ min_eigenvalue: Optional[float] = None,
338
+ max_eigenvalue: Optional[float] = None,
339
+ use_damped_updates: Optional[bool] = None,
340
+ damping_factor: Optional[float] = None,
341
+ ) -> Tuple[np.ndarray, np.ndarray, Dict[str, Any]]:
342
+ """Prepare data and resolve all parameters from config and overrides.
1080
343
 
1081
- # Loop for each block
1082
- for i in range(n_blocks):
1083
- r_i = int(r[i]) # r_i = 1 when block is loaded (ensure integer)
1084
-
1085
- # Reset block-specific variables at start of each iteration
1086
- # Prevents dimension mismatch from previous block values
1087
- factor_projection_lagged = None
1088
- ar_coeffs = None
1089
-
1090
- # Observation equation: estimate factor loadings for this block
1091
- block_loadings = np.zeros((N, int(r_i * ppC)))
1092
- idx_i = np.where(blocks[:, i] == 1)[0] # Series loading block i
1093
-
1094
- # Group series by their actual frequency
1095
- freq_groups = group_series_by_frequency(idx_i, frequencies, clock)
1096
- idx_clock_freq = freq_groups.get(clock, np.array([], dtype=int)) # Same frequency as clock
1097
-
1098
- if len(idx_clock_freq) > 0:
1099
- # Returns eigenvector v with largest eigenvalue d
1100
- # Calculate covariance, handling edge cases
1101
- try:
1102
- # Extract data for this block, handling NaN values
1103
- res_block = data_residuals[:, idx_clock_freq].copy()
1104
- # Remove rows with any NaN (np.cov doesn't handle NaN)
1105
- finite_rows = np.all(np.isfinite(res_block), axis=1)
1106
- n_finite = np.sum(finite_rows)
1107
- n_total = len(finite_rows)
1108
- completeness_pct = (n_finite / n_total * 100) if n_total > 0 else 0.0
1109
-
1110
- # Check minimum data requirements
1111
- min_obs_required = max(2, len(idx_clock_freq) + 1) # Need at least n+1 observations for n series
1112
- if n_finite < min_obs_required:
1113
- # Not enough finite data - use identity
1114
- _logger.warning(
1115
- f"init_conditions: Block {i+1} has insufficient data for covariance calculation. "
1116
- f"Series in block: {len(idx_clock_freq)}, Finite observations: {n_finite}/{n_total} "
1117
- f"({completeness_pct:.1f}%), Required: {min_obs_required}. "
1118
- f"Using identity matrix as fallback."
1119
- )
1120
- raise ValueError(
1121
- f"Insufficient finite data for block {i+1}: "
1122
- f"found {n_finite} finite observations, but need at least {min_obs_required} "
1123
- f"(number of series in block: {len(idx_clock_freq)}). "
1124
- f"This may be due to excessive missing data or transformation issues. "
1125
- f"Consider checking data quality or adjusting nan_method."
1126
- )
1127
-
1128
- res_block_clean = res_block[finite_rows, :]
1129
-
1130
- # Ensure res_block_clean is properly shaped before covariance calculation
1131
- # Handle edge cases where indexing might produce unexpected shapes
1132
- if res_block_clean.size == 0:
1133
- # No data - use identity
1134
- cov_res = np.eye(len(idx_clock_freq))
1135
- elif res_block_clean.ndim == 0:
1136
- # 0D array - shouldn't happen, but handle it
1137
- cov_res = np.eye(len(idx_clock_freq))
1138
- elif res_block_clean.ndim == 1:
1139
- # 1D array - reshape based on number of series
1140
- if len(idx_clock_freq) == 1:
1141
- # Single series: variance is just the variance
1142
- var_val = np.var(res_block_clean, ddof=0)
1143
- if np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10:
1144
- var_val = 1.0
1145
- cov_res = np.array([[var_val]])
1146
- else:
1147
- # Multiple series but 1D - reshape to (1, n_series)
1148
- res_block_clean = res_block_clean.reshape(1, -1)
1149
- if res_block_clean.shape[0] < 2:
1150
- cov_res = np.eye(len(idx_clock_freq))
1151
- else:
1152
- cov_res = np.cov(res_block_clean.T)
1153
- elif res_block_clean.ndim == 2:
1154
- # 2D array - normal case
1155
- if len(idx_clock_freq) == 1:
1156
- # Single series: variance is just the variance
1157
- series_data = res_block_clean.flatten()
1158
- var_val = np.var(series_data, ddof=0)
1159
- if np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10:
1160
- var_val = 1.0
1161
- cov_res = np.array([[var_val]])
1162
- elif res_block_clean.shape[1] == 1:
1163
- # Single series but multiple observations
1164
- series_data = res_block_clean.flatten()
1165
- var_val = np.var(series_data, ddof=0)
1166
- if np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10:
1167
- var_val = 1.0
1168
- cov_res = np.array([[var_val]])
1169
- else:
1170
- # Multiple series: compute covariance matrix
1171
- if res_block_clean.shape[0] < 2:
1172
- # Not enough observations for covariance - use identity
1173
- cov_res = np.eye(len(idx_clock_freq))
1174
- else:
1175
- cov_res = np.cov(res_block_clean.T)
1176
- else:
1177
- # 3D+ array - shouldn't happen, but use identity
1178
- cov_res = np.eye(len(idx_clock_freq))
1179
-
1180
- # Check for NaN/Inf in covariance
1181
- if np.any(np.isnan(cov_res)) or np.any(np.isinf(cov_res)):
1182
- # Replace with identity if covariance invalid
1183
- _logger.warning(
1184
- f"init_conditions: Block {i+1} covariance contains NaN/Inf values. "
1185
- f"Series in block: {len(idx_clock_freq)}, Finite observations: {n_finite}/{n_total}. "
1186
- f"Using identity matrix as fallback."
1187
- )
1188
- cov_res = np.eye(len(idx_clock_freq))
1189
- # Check for constant series (zero variance) and regularize
1190
- var_diag = np.diag(cov_res)
1191
- if np.any(var_diag < 1e-10):
1192
- cov_res = cov_res + np.eye(len(idx_clock_freq)) * 1e-8
1193
- except (ValueError, np.linalg.LinAlgError) as e:
1194
- # Covariance calculation failed - use identity as fallback
1195
- # This can happen with insufficient data or numerical issues
1196
- _logger.warning(
1197
- f"init_conditions: Covariance calculation failed for block {i+1}. "
1198
- f"Series in block: {len(idx_clock_freq)}, Series indices: {idx_clock_freq.tolist()}. "
1199
- f"Error: {type(e).__name__}: {str(e)}. Using identity matrix as fallback."
1200
- )
1201
- cov_res = np.eye(len(idx_clock_freq))
1202
-
1203
- # Compute principal components via eigendecomposition
1204
- d, v = _compute_principal_components(cov_res, int(r_i), block_idx=i)
1205
-
1206
- # Flip sign for cleaner output
1207
- if np.sum(v) < 0:
1208
- v = -v
1209
-
1210
- # Scale eigenvectors by square root of eigenvalues for proper PCA loadings
1211
- # This ensures loadings are on a reasonable scale relative to the data
1212
- # Eigenvalues represent variance explained, so sqrt(eigenvalue) gives proper scale
1213
- d_positive = np.maximum(d, 1e-8) # Ensure positive
1214
- # Handle scalar/0D case for np.diag
1215
- sqrt_d = np.sqrt(d_positive)
1216
- if np.isscalar(sqrt_d) or (isinstance(sqrt_d, np.ndarray) and sqrt_d.ndim == 0):
1217
- # Scalar case: just multiply
1218
- v_scaled = v * float(sqrt_d)
1219
- else:
1220
- # Array case: use diag
1221
- v_scaled = v @ np.diag(sqrt_d) # Scale by sqrt(eigenvalues)
1222
-
1223
- # Normalize to prevent extreme values while preserving relative magnitudes
1224
- # Use L2 normalization per column to keep loadings bounded
1225
- if v_scaled.ndim == 2 and v_scaled.shape[1] > 0:
1226
- for col_idx in range(v_scaled.shape[1]):
1227
- col_norm = np.linalg.norm(v_scaled[:, col_idx])
1228
- if col_norm > 0:
1229
- # Handle scalar d_positive case
1230
- if np.isscalar(d_positive) or (isinstance(d_positive, np.ndarray) and d_positive.ndim == 0):
1231
- d_val = float(d_positive)
1232
- else:
1233
- d_val = d_positive[col_idx] if col_idx < len(d_positive) else 1.0
1234
- v_scaled[:, col_idx] = v_scaled[:, col_idx] / col_norm * np.sqrt(d_val)
1235
-
1236
- # For monthly series with loaded blocks, use scaled eigenvectors
1237
- block_loadings[idx_clock_freq, :int(r_i)] = v_scaled
1238
- f = data_residuals[:, idx_clock_freq] @ v_scaled # Data projection for scaled eigenvector direction
1239
-
1240
- # Lag matrix using loading
1241
- F = None
1242
- max_lag = max(p + 1, pC)
1243
- for kk in range(max_lag):
1244
- lag_data = f[pC - kk:T - kk, :]
1245
- if F is None:
1246
- F = lag_data
1247
- else:
1248
- F = np.hstack([F, lag_data])
1249
-
1250
- # Projected data with lag structure (for slower frequency series)
1251
- factor_projection_lagged = F[:, :int(r_i * pC)]
1252
-
1253
- # Process series with frequencies different from clock (need tent kernel)
1254
- # Process each frequency group separately
1255
- for freq, idx_iFreq in freq_groups.items():
1256
- if freq == clock:
1257
- continue # Already processed above
1258
-
1259
- # Get tent weights for this frequency
1260
- if tent_weights_dict is not None and freq in tent_weights_dict:
1261
- tent_weights = tent_weights_dict[freq]
1262
- else:
1263
- # Try to get from lookup
1264
- tent_weights = get_tent_weights_for_pair(freq, clock)
1265
- if tent_weights is None:
1266
- # Fallback: generate symmetric tent weights based on frequency gap
1267
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1268
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
1269
- n_periods_est = freq_hierarchy - clock_hierarchy + 1
1270
- if n_periods_est > 0 and n_periods_est <= 12:
1271
- tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
1272
- _logger.warning(
1273
- f"init_conditions: No tent weights found for frequency '{freq}' "
1274
- f"with clock '{clock}', generating symmetric tent weights with {n_periods_est} periods."
1275
- )
1276
- else:
1277
- raise ValueError(
1278
- f"init_conditions: Cannot determine tent weights for frequency '{freq}' "
1279
- f"with clock '{clock}'. Please provide tent_weights_dict or ensure "
1280
- f"the frequency pair is supported."
1281
- )
1282
-
1283
- # Generate R_mat for this frequency
1284
- R_mat_freq, q_freq = generate_R_mat(tent_weights)
1285
- pC_freq = len(tent_weights)
1286
-
1287
- # Ensure factor projection has enough columns for this frequency
1288
- if factor_projection_lagged.shape[1] < r_i * pC_freq:
1289
- # Pad if needed
1290
- factor_projection_freq = np.hstack([
1291
- factor_projection_lagged,
1292
- np.zeros((factor_projection_lagged.shape[0], r_i * pC_freq - factor_projection_lagged.shape[1]))
1293
- ])
1294
- else:
1295
- factor_projection_freq = factor_projection_lagged[:, :int(r_i * pC_freq)]
1296
-
1297
- # Create constraint matrix for this frequency
1298
- Rcon_i = np.kron(R_mat_freq, np.eye(int(r_i)))
1299
- q_i = np.kron(q_freq, np.zeros(int(r_i)))
1300
-
1301
- # Loop for series of this frequency
1302
- for j in idx_iFreq:
1303
- # For series j, values are dropped to accommodate lag structure
1304
- series_data = resNaN[pC_freq:, j]
1305
-
1306
- if len(series_data) < factor_projection_freq.shape[0]:
1307
- # Align dimensions: pad or trim as needed
1308
- if len(series_data) > 0:
1309
- series_data_padded = np.full(factor_projection_freq.shape[0], np.nan)
1310
- series_data_padded[:len(series_data)] = series_data
1311
- series_data = series_data_padded
1312
-
1313
- if np.sum(~np.isnan(series_data)) < factor_projection_freq.shape[1] + 2:
1314
- # Replace with spline if too many NaNs
1315
- series_data = data_residuals[pC_freq:, j]
1316
-
1317
- # Extract finite values for regression
1318
- finite_mask = ~np.isnan(series_data)
1319
- factor_projection_clean = factor_projection_freq[finite_mask, :]
1320
- series_data_clean = series_data[finite_mask]
1321
-
1322
- if len(series_data_clean) > 0 and factor_projection_clean.shape[0] > 0:
1323
- try:
1324
- # Least squares: C = inv(X'X) * X'y
1325
- gram_matrix = factor_projection_clean.T @ factor_projection_clean
1326
- gram_inv = inv(gram_matrix)
1327
- loadings = gram_inv @ factor_projection_clean.T @ series_data_clean
1328
-
1329
- # Apply constraints (tent kernel aggregation)
1330
- if Rcon_i is not None and q_i is not None and Rcon_i.size > 0:
1331
- constraint_term = gram_inv @ Rcon_i.T @ inv(Rcon_i @ gram_inv @ Rcon_i.T) @ (Rcon_i @ loadings - q_i)
1332
- loadings = loadings - constraint_term
1333
-
1334
- # Place in block_loadings matrix (use appropriate columns)
1335
- block_loadings[j, :int(pC_freq * r_i)] = loadings[:int(pC_freq * r_i)]
1336
- except (np.linalg.LinAlgError, ValueError, IndexError) as e:
1337
- # Matrix inversion or constraint application failed - set to zero
1338
- _logger.debug(
1339
- f"init_conditions: C matrix update failed for series {j} in block {i+1}, "
1340
- f"setting to zero. Error: {type(e).__name__}"
1341
- )
1342
- block_loadings[j, :int(pC_freq * r_i)] = 0.0
1343
-
1344
- # Pad factor projection with zeros to match data dimensions (T x N)
1345
- # This must be inside the block loop to handle different r_i per block
1346
- if factor_projection_lagged is not None:
1347
- # Validate dimensions match before stacking (safety check)
1348
- expected_width = int(pC * r_i)
1349
- if factor_projection_lagged.shape[1] != expected_width:
1350
- _logger.warning(
1351
- f"init_conditions: Block {i+1} factor projection dimension mismatch. "
1352
- f"Expected width {expected_width}, got {factor_projection_lagged.shape[1]}. Resetting."
1353
- )
1354
- factor_projection_lagged = None
1355
-
1356
- if factor_projection_lagged is not None:
1357
- # factor_projection_lagged has shape (T - pC + 1, r_i * pC) from lag_data
1358
- # Pad to match data_residuals shape (T, r_i * pC)
1359
- factor_projection_padded = np.vstack([
1360
- np.zeros((pC - 1, int(pC * r_i))),
1361
- factor_projection_lagged
1362
- ])
1363
- # Ensure it matches T exactly
1364
- if factor_projection_padded.shape[0] < T:
1365
- factor_projection_padded = np.vstack([
1366
- factor_projection_padded,
1367
- np.zeros((T - factor_projection_padded.shape[0], int(pC * r_i)))
1368
- ])
1369
- else:
1370
- # If no clock-frequency series, create dummy projection
1371
- factor_projection_padded = np.zeros((T, int(pC * r_i)))
1372
-
1373
- # Update residuals by removing factor projections (only for this block's series)
1374
- # block_loadings has shape (N, r_i * ppC), but we only use the first r_i * pC columns for residuals
1375
- # factor_projection_padded has shape (T, r_i * pC)
1376
- block_loadings_residual = block_loadings[:, :int(pC * r_i)] # Extract relevant columns
1377
- if factor_projection_padded.shape[0] == data_residuals.shape[0]:
1378
- # Dimensions match - subtract factor projection
1379
- data_residuals[:, idx_i] = data_residuals[:, idx_i] - factor_projection_padded @ block_loadings_residual[idx_i, :].T
1380
- else:
1381
- # Trim or pad to match dimensions
1382
- if factor_projection_padded.shape[0] > data_residuals.shape[0]:
1383
- factor_projection_padded = factor_projection_padded[:data_residuals.shape[0], :]
1384
- else:
1385
- factor_projection_padded = np.vstack([
1386
- factor_projection_padded,
1387
- np.zeros((data_residuals.shape[0] - factor_projection_padded.shape[0], factor_projection_padded.shape[1]))
1388
- ])
1389
- data_residuals[:, idx_i] = data_residuals[:, idx_i] - factor_projection_padded @ block_loadings_residual[idx_i, :].T
1390
-
1391
- resNaN = data_residuals.copy()
1392
- resNaN[indNaN] = np.nan
1393
-
1394
- # Combine loadings from all blocks
1395
- if C is None:
1396
- C = block_loadings
1397
- else:
1398
- C = np.hstack([C, block_loadings])
1399
-
1400
- # Transition equation
1401
- if len(idx_clock_freq) > 0:
1402
- z = F[:, :int(r_i)] # Projected data (no lag)
1403
- Z_lag = F[:, int(r_i):int(r_i * (p + 1))] # Data with lag 1
1404
-
1405
- # Transition equation: estimate AR coefficients for this block
1406
- block_transition = np.zeros((int(r_i * ppC), int(r_i * ppC)))
1407
-
1408
- if Z_lag.shape[0] > 0 and Z_lag.shape[1] > 0:
1409
- try:
1410
- # OLS regression: estimate AR coefficients from lagged factors
1411
- ar_coeffs = inv(Z_lag.T @ Z_lag) @ Z_lag.T @ z
1412
- block_transition[:int(r_i), :int(r_i * p)] = ar_coeffs.T
1413
- except (np.linalg.LinAlgError, ValueError) as e:
1414
- # OLS regression failed - set AR coefficients to zero
1415
- # This can happen if Z_lag is singular or has insufficient rank
1416
- _logger.debug(
1417
- f"init_conditions: OLS regression failed for block {i+1}, "
1418
- f"setting AR coefficients to zero. Error: {type(e).__name__}"
1419
- )
1420
- block_transition[:int(r_i), :int(r_i * p)] = 0.0
1421
-
1422
- # Identity matrix for lag structure (shift operator)
1423
- if r_i * (ppC - 1) > 0:
1424
- block_transition[int(r_i):, :int(r_i * (ppC - 1))] = np.eye(int(r_i * (ppC - 1)))
1425
-
1426
- # Innovation covariance for this block
1427
- block_innovation_cov = np.zeros((int(ppC * r_i), int(ppC * r_i)))
1428
- if len(z) > 0:
1429
- innovation_residuals = z - Z_lag @ ar_coeffs if ar_coeffs is not None else z
1430
- # Clean innovation residuals before covariance calculation
1431
- innovation_residuals = np.nan_to_num(innovation_residuals, nan=0.0, posinf=0.0, neginf=0.0)
1432
-
1433
- if innovation_residuals.shape[1] > 1:
1434
- # Use np.cov for multiple series
1435
- try:
1436
- Q_block = np.cov(innovation_residuals.T)
1437
- # Validate result
1438
- if np.any(~np.isfinite(Q_block)):
1439
- Q_block = np.eye(int(r_i)) * 0.1
1440
- except (ValueError, np.linalg.LinAlgError):
1441
- Q_block = np.eye(int(r_i)) * 0.1
1442
- else:
1443
- # Single series: use variance
1444
- variance = np.var(innovation_residuals)
1445
- if not np.isfinite(variance) or variance <= 0:
1446
- variance = 0.1
1447
- Q_block = np.array([[variance]]) if innovation_residuals.ndim > 1 else np.eye(int(r_i)) * variance
1448
-
1449
- block_innovation_cov[:int(r_i), :int(r_i)] = Q_block
1450
-
1451
- # Clean block matrices before kron operation
1452
- block_transition_clean = _clean_matrix(block_transition, 'loading')
1453
- block_innovation_cov_clean = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
1454
-
1455
- # Initial covariance
1456
- try:
1457
- # Check block_transition_clean for invalid values before kron
1458
- if np.any(~np.isfinite(block_transition_clean)):
1459
- raise ValueError("Invalid values in block_transition")
1460
-
1461
- kron_transition = np.kron(block_transition_clean, block_transition_clean)
1462
- # Check kron result
1463
- if np.any(~np.isfinite(kron_transition)):
1464
- raise ValueError("Invalid values in kron(block_transition, block_transition)")
1465
-
1466
- identity_kron = np.eye(int((r_i * ppC)**2)) - kron_transition
1467
- # Check before inversion
1468
- if np.any(~np.isfinite(identity_kron)):
1469
- raise ValueError("Invalid values in (I - kron(A, A))")
1470
-
1471
- innovation_cov_flat = block_innovation_cov_clean.flatten()
1472
- # Check innovation_cov_flat
1473
- if np.any(~np.isfinite(innovation_cov_flat)):
1474
- raise ValueError("Invalid values in block_innovation_cov")
1475
-
1476
- init_cov_block = np.reshape(
1477
- inv(identity_kron) @ innovation_cov_flat,
1478
- (int(r_i * ppC), int(r_i * ppC))
1479
- )
1480
- # Verify result is valid
1481
- if np.any(~np.isfinite(init_cov_block)):
1482
- raise ValueError("NaN/Inf in init_cov_block")
1483
- except (np.linalg.LinAlgError, ValueError, IndexError) as e:
1484
- # Matrix inversion failed or result invalid - use diagonal fallback
1485
- _logger.warning(
1486
- f"init_conditions: Initial covariance calculation failed for block {i+1}, "
1487
- f"using diagonal fallback (0.1 * I). Error: {type(e).__name__}"
1488
- )
1489
- init_cov_block = np.eye(int(r_i * ppC)) * 0.1
1490
-
1491
- # Combine transition and innovation matrices for all blocks
1492
- if A is None:
1493
- A = block_transition
1494
- Q = block_innovation_cov
1495
- V_0 = init_cov_block
1496
- else:
1497
- # Ensure all matrices are square before block_diag
1498
- if (block_transition.shape[0] == block_transition.shape[1] and
1499
- block_innovation_cov.shape[0] == block_innovation_cov.shape[1] and
1500
- init_cov_block.shape[0] == init_cov_block.shape[1]):
1501
- A = block_diag(A, block_transition)
1502
- Q = block_diag(Q, block_innovation_cov)
1503
- V_0 = block_diag(V_0, init_cov_block)
1504
- else:
1505
- # Fallback: concatenate if dimensions don't match
1506
- _logger.debug(
1507
- f"init_conditions: block_diag dimensions mismatch for block {i+1}, "
1508
- f"using np.block concatenation fallback"
1509
- )
1510
- A = np.block([[A, np.zeros((A.shape[0], block_transition.shape[1]))],
1511
- [np.zeros((block_transition.shape[0], A.shape[1])), block_transition]])
1512
- Q = np.block([[Q, np.zeros((Q.shape[0], block_innovation_cov.shape[1]))],
1513
- [np.zeros((block_innovation_cov.shape[0], Q.shape[1])), block_innovation_cov]])
1514
- V_0 = np.block([[V_0, np.zeros((V_0.shape[0], init_cov_block.shape[1]))],
1515
- [np.zeros((init_cov_block.shape[0], V_0.shape[1])), init_cov_block]])
1516
- else:
1517
- # Dummy if no clock-frequency series
1518
- block_transition = np.eye(int(r_i * ppC)) * 0.9
1519
- block_innovation_cov = np.eye(int(r_i * ppC)) * 0.1
1520
- init_cov_block = np.eye(int(r_i * ppC)) * 0.1
1521
- if A is None:
1522
- A = block_transition
1523
- Q = block_innovation_cov
1524
- V_0 = init_cov_block
1525
- else:
1526
- # Ensure all matrices are square
1527
- if (block_transition.shape[0] == block_transition.shape[1] and
1528
- block_innovation_cov.shape[0] == block_innovation_cov.shape[1] and
1529
- init_cov_block.shape[0] == init_cov_block.shape[1]):
1530
- A = block_diag(A, block_transition)
1531
- Q = block_diag(Q, block_innovation_cov)
1532
- V_0 = block_diag(V_0, init_cov_block)
1533
- else:
1534
- # Fallback: use np.block
1535
- _logger.debug(
1536
- f"init_conditions: block_diag dimensions mismatch for block {i+1} (V_0), "
1537
- f"using np.block concatenation fallback"
1538
- )
1539
- A = np.block([[A, np.zeros((A.shape[0], block_transition.shape[1]))],
1540
- [np.zeros((block_transition.shape[0], A.shape[1])), block_transition]])
1541
- Q = np.block([[Q, np.zeros((Q.shape[0], block_innovation_cov.shape[1]))],
1542
- [np.zeros((block_innovation_cov.shape[0], Q.shape[1])), block_innovation_cov]])
1543
- V_0 = np.block([[V_0, np.zeros((V_0.shape[0], init_cov_block.shape[1]))],
1544
- [np.zeros((init_cov_block.shape[0], V_0.shape[1])), init_cov_block]])
344
+ Returns
345
+ -------
346
+ X_clean : np.ndarray
347
+ Cleaned input data (Inf replaced with NaN)
348
+ blocks : np.ndarray
349
+ Block structure array (N x n_blocks)
350
+ params : dict
351
+ Dictionary of resolved parameters
352
+ """
353
+ # Clean input data
354
+ inf_mask = np.isinf(X)
355
+ if np.any(inf_mask):
356
+ X = np.where(inf_mask, np.nan, X)
357
+ warnings.warn("Data contains Inf values, replaced with NaN", UserWarning)
1545
358
 
1546
- # Add idiosyncratic components
1547
- # i_idio indicates which series have idiosyncratic components
1548
- # For clock frequency: 1 (have idiosyncratic)
1549
- # For slower frequencies: 0 (no direct idiosyncratic, use tent kernel structure)
1550
- eyeN = np.eye(N)
1551
- eyeN = eyeN[:, i_idio.astype(bool)] # Keep only series with idiosyncratic components
359
+ blocks = config.get_blocks_array()
360
+ T, N = X.shape
1552
361
 
1553
- if C is None:
1554
- C = eyeN
1555
- else:
1556
- C = np.hstack([C, eyeN])
362
+ # Resolve all parameters
363
+ params = {
364
+ 'p': _resolve_param(ar_lag, config.ar_lag),
365
+ 'r': (np.array(config.factors_per_block)
366
+ if config.factors_per_block is not None
367
+ else np.ones(blocks.shape[1])),
368
+ 'nan_method': _resolve_param(nan_method, config.nan_method),
369
+ 'nan_k': _resolve_param(nan_k, config.nan_k),
370
+ 'threshold': _resolve_param(threshold, config.threshold),
371
+ 'max_iter': _resolve_param(max_iter, config.max_iter),
372
+ 'clock': _resolve_param(clock, config.clock),
373
+ 'clip_ar_coefficients': _resolve_param(clip_ar_coefficients, config.clip_ar_coefficients),
374
+ 'ar_clip_min': _resolve_param(ar_clip_min, config.ar_clip_min),
375
+ 'ar_clip_max': _resolve_param(ar_clip_max, config.ar_clip_max),
376
+ 'clip_data_values': _resolve_param(clip_data_values, config.clip_data_values),
377
+ 'data_clip_threshold': _resolve_param(data_clip_threshold, config.data_clip_threshold),
378
+ 'use_regularization': _resolve_param(use_regularization, config.use_regularization),
379
+ 'regularization_scale': _resolve_param(regularization_scale, config.regularization_scale),
380
+ 'min_eigenvalue': _resolve_param(min_eigenvalue, config.min_eigenvalue),
381
+ 'max_eigenvalue': _resolve_param(max_eigenvalue, config.max_eigenvalue),
382
+ 'use_damped_updates': _resolve_param(use_damped_updates, config.use_damped_updates),
383
+ 'damping_factor': _resolve_param(damping_factor, config.damping_factor),
384
+ 'T': T,
385
+ 'N': N,
386
+ }
1557
387
 
1558
- # Add idiosyncratic components for slower frequency series
1559
- # Each slower frequency series needs tent kernel structure
1560
- if nQ > 0 and frequencies is not None:
1561
- # Count series by slower frequency
1562
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1563
- slower_series_count = {} # freq -> count
1564
- slower_series_indices = {} # freq -> list of indices
1565
-
1566
- for j in range(N):
1567
- if j < len(frequencies):
1568
- freq = frequencies[j]
1569
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
1570
- if freq_hierarchy > clock_hierarchy:
1571
- if freq not in slower_series_count:
1572
- slower_series_count[freq] = 0
1573
- slower_series_indices[freq] = []
1574
- slower_series_count[freq] += 1
1575
- slower_series_indices[freq].append(j)
1576
-
1577
- # Build idiosyncratic structure for each slower frequency
1578
- n_same_freq = N - nQ # Series with same frequency as clock
1579
- slower_idio_blocks = []
1580
-
1581
- for freq, count in slower_series_count.items():
1582
- # Get tent weights for this frequency
1583
- if tent_weights_dict is not None and freq in tent_weights_dict:
1584
- tent_weights = tent_weights_dict[freq]
1585
- else:
1586
- tent_weights = get_tent_weights_for_pair(freq, clock)
1587
- if tent_weights is None:
1588
- # Fallback: generate symmetric tent weights based on frequency gap
1589
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1590
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
1591
- n_periods_est = freq_hierarchy - clock_hierarchy + 1
1592
- if n_periods_est > 0 and n_periods_est <= 12:
1593
- tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
1594
- else:
1595
- raise ValueError(
1596
- f"init_conditions: Cannot determine tent weights for frequency '{freq}' "
1597
- f"with clock '{clock}'. Please provide tent_weights_dict or ensure "
1598
- f"the frequency pair is supported."
1599
- )
1600
-
1601
- n_periods = len(tent_weights)
1602
-
1603
- # Create block: zeros for same freq series, tent structure for slower freq series
1604
- idio_block = np.zeros((N, n_periods * count))
1605
-
1606
- # Place tent structure for this frequency's series
1607
- for idx, j in enumerate(slower_series_indices[freq]):
1608
- col_start = idx * n_periods
1609
- col_end = (idx + 1) * n_periods
1610
- idio_block[j, col_start:col_end] = tent_weights
1611
-
1612
- slower_idio_blocks.append(idio_block)
1613
-
1614
- # Combine all slower frequency idiosyncratic blocks
1615
- if slower_idio_blocks:
1616
- slower_idio_full = np.hstack(slower_idio_blocks)
1617
- C = np.hstack([C, slower_idio_full])
388
+ # Display blocks structure if debug logging enabled
389
+ if _logger.isEnabledFor(logging.DEBUG):
390
+ try:
391
+ series_names = config.get_series_names()
392
+ block_names = (config.block_names if len(config.block_names) == blocks.shape[1]
393
+ else [f'Block_{i+1}' for i in range(blocks.shape[1])])
394
+ df = pd.DataFrame(blocks,
395
+ index=[name.replace(' ', '_') for name in series_names],
396
+ columns=block_names)
397
+ _logger.debug('Block Loading Structure')
398
+ _logger.debug(f'\n{df.to_string()}')
399
+ _logger.debug(f'Blocks shape: {blocks.shape}')
400
+ except Exception as e:
401
+ _logger.debug(f'Error displaying block structure: {e}')
1618
402
 
1619
- # Initialize covariance matrix
1620
- # Calculate variance, handling NaN/Inf cases
1621
- var_values = np.nanvar(resNaN, axis=0)
1622
- # Replace NaN/Inf with small default value
1623
- var_values = np.where((np.isnan(var_values) | np.isinf(var_values)), 1e-4, var_values)
1624
- # Ensure minimum variance to avoid numerical issues
1625
- var_values = np.maximum(var_values, 1e-4)
1626
- R = np.diag(var_values)
403
+ return X, blocks, params
404
+
405
+
406
+ def _prepare_aggregation_structure(
407
+ config: DFMConfig,
408
+ clock: str
409
+ ) -> Tuple[Dict[str, np.ndarray], Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray], np.ndarray, int]:
410
+ """Prepare aggregation structure for mixed-frequency handling.
1627
411
 
1628
- # Monthly idiosyncratic AR parameters
1629
- ii_idio = np.where(i_idio)[0]
1630
- n_idio = len(ii_idio)
1631
- BM = np.zeros((n_idio, n_idio))
1632
- SM = np.zeros((n_idio, n_idio))
412
+ Returns
413
+ -------
414
+ tent_weights_dict : dict
415
+ Dictionary mapping frequency pairs to tent weights
416
+ R_mat : np.ndarray or None
417
+ Constraint matrix for tent kernel aggregation
418
+ q : np.ndarray or None
419
+ Constraint vector for tent kernel aggregation
420
+ frequencies : np.ndarray or None
421
+ Array of frequencies for each series
422
+ i_idio : np.ndarray
423
+ Indicator array (1 for clock frequency, 0 for slower frequencies)
424
+ nQ : int
425
+ Number of slower-frequency series
426
+ """
427
+ agg_info = get_aggregation_structure(config, clock=clock)
428
+ tent_weights_dict = agg_info.get('tent_weights', {})
429
+ frequencies = np.array(config.get_frequencies()) if config.series else None
1633
430
 
1634
- for idx, i in enumerate(ii_idio):
1635
- R[i, i] = 1e-4
1636
-
1637
- res_i = resNaN[:, i]
1638
- res_i_full = data_residuals[:, i]
1639
-
1640
- # Count leading/ending NaNs
1641
- leadZero = 0
1642
- for t in range(T):
1643
- if np.isnan(res_i[t]):
1644
- leadZero += 1
1645
- else:
1646
- break
1647
-
1648
- endZero = 0
1649
- for t in range(T - 1, -1, -1):
1650
- if np.isnan(res_i[t]):
1651
- endZero += 1
1652
- else:
1653
- break
1654
-
1655
- # Truncate
1656
- if endZero > 0:
1657
- res_i_trunc = res_i_full[leadZero:T - endZero]
1658
- else:
1659
- res_i_trunc = res_i_full[leadZero:]
1660
-
1661
- if len(res_i_trunc) > 1:
1662
- # AR(1) process: res_i[1:] = BM * res_i[:-1] + error
1663
- # MATLAB: inv(res_i(1:end-1)'*res_i(1:end-1))*res_i(1:end-1)'*res_i(2:end)
1664
- # Copy to avoid read-only array issues
1665
- res_i_copy = res_i_trunc.copy()
1666
- X_ar = res_i_copy[:-1].reshape(-1, 1) # Column vector (T-1 x 1)
1667
- y_ar = res_i_copy[1:].reshape(-1, 1) # Column vector (T-1 x 1)
1668
- # X_ar.T @ X_ar is (1 x T-1) @ (T-1 x 1) = (1 x 1) scalar
1669
- XTX = X_ar.T @ X_ar
1670
- if XTX.size == 1 and XTX[0, 0] != 0 and not (np.isnan(XTX[0, 0]) or np.isinf(XTX[0, 0])):
1671
- try:
1672
- BM_val = (1.0 / XTX[0, 0]) * (X_ar.T @ y_ar)
1673
- BM_val_clean = BM_val[0, 0] if BM_val.size > 0 else 0.1
1674
- # Ensure finite value
1675
- if np.isnan(BM_val_clean) or np.isinf(BM_val_clean):
1676
- BM_val_clean = 0.1
1677
- BM[idx, idx] = BM_val_clean
1678
- # Calculate variance with bounds checking
1679
- residuals = res_i_trunc[1:] - res_i_trunc[:-1] * BM[idx, idx]
1680
- var_val = np.var(residuals)
1681
- SM[idx, idx] = var_val if not (np.isnan(var_val) or np.isinf(var_val)) else 0.1
1682
- except (ValueError, ZeroDivisionError, IndexError) as e:
1683
- # AR coefficient calculation or variance computation failed - use defaults
1684
- # This can happen with insufficient data or division by zero
1685
- BM[idx, idx] = 0.1
1686
- SM[idx, idx] = 0.1
1687
- else:
1688
- BM[idx, idx] = 0.1
1689
- SM[idx, idx] = 0.1
1690
- else:
1691
- BM[idx, idx] = 0.1
1692
- SM[idx, idx] = 0.1
431
+ # Find R_mat and q for tent kernel constraints
432
+ R_mat = None
433
+ q = None
434
+ if agg_info['structures']:
435
+ max_periods = 0
436
+ for (slower_freq, clock_freq), (R, q_vec) in agg_info['structures'].items():
437
+ if R is not None:
438
+ n_periods = R.shape[1]
439
+ if n_periods > max_periods:
440
+ max_periods = n_periods
441
+ R_mat = R
442
+ q = q_vec
1693
443
 
1694
- # Slower frequency idiosyncratic
1695
- Rdiag = np.diag(R).copy() # Copy to avoid read-only array
1696
- # Handle division by zero and NaN/Inf
1697
- # Validate Rdiag values before division
1698
- # Get indices of slower frequency series
444
+ # Compute i_idio and nQ
1699
445
  if frequencies is not None:
1700
446
  clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1701
- slower_indices = [j for j in range(N) if j < len(frequencies) and
1702
- FREQUENCY_HIERARCHY.get(frequencies[j], 3) > clock_hierarchy]
1703
- else:
1704
- # Fallback: assume last nQ series are slower
1705
- slower_indices = list(range(N - nQ, N)) if nQ > 0 else []
1706
-
1707
- Rdiag_slower = Rdiag[slower_indices] if slower_indices else np.array([])
1708
- # Process slower frequency idiosyncratic components
1709
- # Group by frequency and create AR structure for each
1710
- BQ_blocks = []
1711
- SQ_blocks = []
1712
- initViQ_blocks = []
1713
-
1714
- if len(slower_indices) > 0 and frequencies is not None:
1715
- # Group slower series by frequency
1716
- slower_freq_groups = {}
1717
- for j in slower_indices:
1718
- if j < len(frequencies):
1719
- freq = frequencies[j]
1720
- if freq not in slower_freq_groups:
1721
- slower_freq_groups[freq] = []
1722
- slower_freq_groups[freq].append(j)
1723
-
1724
- for freq, idx_list in slower_freq_groups.items():
1725
- n_freq = len(idx_list)
1726
-
1727
- # Get tent weights for this frequency
1728
- if tent_weights_dict is not None and freq in tent_weights_dict:
1729
- tent_weights = tent_weights_dict[freq]
1730
- else:
1731
- tent_weights = get_tent_weights_for_pair(freq, clock)
1732
- if tent_weights is None:
1733
- # Fallback: generate symmetric tent weights based on frequency gap
1734
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
1735
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
1736
- n_periods_est = freq_hierarchy - clock_hierarchy + 1
1737
- if n_periods_est > 0 and n_periods_est <= 12:
1738
- tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
1739
- else:
1740
- raise ValueError(
1741
- f"init_conditions: Cannot determine tent weights for frequency '{freq}' "
1742
- f"with clock '{clock}'. Please provide tent_weights_dict or ensure "
1743
- f"the frequency pair is supported."
1744
- )
1745
-
1746
- n_periods = len(tent_weights)
1747
-
1748
- # Get variance for this frequency's series
1749
- Rdiag_freq = Rdiag[idx_list]
1750
- if len(Rdiag_freq) > 0:
1751
- Rdiag_freq = np.where(
1752
- (np.isnan(Rdiag_freq) | np.isinf(Rdiag_freq) | (Rdiag_freq < 0)),
1753
- 1e-4, Rdiag_freq
1754
- )
1755
- # Use appropriate variance divisor based on tent kernel size
1756
- # For quarterly (5 periods), divisor is 19.0
1757
- # For other frequencies, use a similar scaling
1758
- variance_divisor = np.sum(tent_weights**2) # Sum of squares of tent weights
1759
- sig_e = _safe_divide(Rdiag_freq, variance_divisor, default=1e-4)
1760
- sig_e = np.where((np.isnan(sig_e) | np.isinf(sig_e) | (sig_e < 1e-6)), 1e-4, sig_e)
1761
- else:
1762
- sig_e = np.ones(n_freq) * 1e-4
1763
-
1764
- # Set Rdiag for these series
1765
- Rdiag[idx_list] = 1e-4
1766
-
1767
- # AR structure for this frequency
1768
- rho0 = 0.1
1769
- temp = np.zeros((n_periods, n_periods))
1770
- temp[0, 0] = 1
1771
-
1772
- SQ_freq = np.kron(np.diag((1 - rho0**2) * sig_e), temp)
1773
-
1774
- # BQ template for this frequency
1775
- BQ_template = np.vstack([
1776
- np.hstack([rho0, np.zeros(n_periods - 1)]),
1777
- np.hstack([np.eye(n_periods - 1), np.zeros((n_periods - 1, 1))])
1778
- ])
1779
- BQ_freq = np.kron(np.eye(int(n_freq)), BQ_template)
1780
-
1781
- BQ_blocks.append(BQ_freq)
1782
- SQ_blocks.append(SQ_freq)
1783
-
1784
- # Initial covariance for this frequency
1785
- try:
1786
- I_kron = np.eye((n_periods * n_freq)**2)
1787
- kron_mat = np.kron(BQ_freq, BQ_freq)
1788
- inv_mat = I_kron - kron_mat
1789
- if np.any(np.isnan(inv_mat)) or np.any(np.isinf(inv_mat)):
1790
- raise ValueError("NaN/Inf in inversion matrix")
1791
- initViQ_freq = np.reshape(
1792
- inv(inv_mat) @ SQ_freq.flatten(),
1793
- (n_periods * n_freq, n_periods * n_freq)
1794
- )
1795
- if np.any(np.isnan(initViQ_freq)) or np.any(np.isinf(initViQ_freq)):
1796
- raise ValueError("NaN/Inf in initViQ result")
1797
- initViQ_blocks.append(initViQ_freq)
1798
- except (np.linalg.LinAlgError, ValueError, IndexError) as e:
1799
- _logger.warning(
1800
- f"init_conditions: {freq} idiosyncratic initial covariance calculation failed, "
1801
- f"using diagonal fallback. Error: {type(e).__name__}"
1802
- )
1803
- initViQ_blocks.append(np.eye(n_periods * n_freq) * 0.1)
1804
-
1805
- R = np.diag(Rdiag)
1806
-
1807
- # Combine all slower frequency blocks
1808
- if BQ_blocks:
1809
- BQ = block_diag(*BQ_blocks) if len(BQ_blocks) > 1 else BQ_blocks[0]
1810
- SQ = block_diag(*SQ_blocks) if len(SQ_blocks) > 1 else SQ_blocks[0]
1811
- initViQ = block_diag(*initViQ_blocks) if len(initViQ_blocks) > 1 else initViQ_blocks[0]
447
+ N = len(frequencies)
448
+ i_idio = np.array([
449
+ 1 if j >= len(frequencies) or FREQUENCY_HIERARCHY.get(frequencies[j], 3) <= clock_hierarchy
450
+ else 0
451
+ for j in range(N)
452
+ ])
453
+ nQ = N - np.sum(i_idio)
1812
454
  else:
1813
- # No slower frequency series
1814
- BQ = np.array([]).reshape(0, 0)
1815
- SQ = np.array([]).reshape(0, 0)
1816
- initViQ = np.array([]).reshape(0, 0)
1817
-
1818
-
1819
- try:
1820
- # Calculate denominator, handling division by zero
1821
- eye_diag = np.diag(np.eye(BM.shape[0]))
1822
- BM_diag_sq = np.diag(BM)**2
1823
- denom_val = eye_diag - BM_diag_sq
1824
- # Avoid division by zero
1825
- denom_val = np.where(np.abs(denom_val) < 1e-10, 1.0, denom_val)
1826
- denom_viM = np.diag(1.0 / denom_val)
1827
- # Check for NaN/Inf
1828
- if np.any(np.isnan(denom_viM)) or np.any(np.isinf(denom_viM)):
1829
- raise ValueError("NaN/Inf in denom_viM")
1830
- initViM = denom_viM @ SM
1831
- # Check result
1832
- if np.any(np.isnan(initViM)) or np.any(np.isinf(initViM)):
1833
- raise ValueError("NaN/Inf in initViM result")
1834
- except (np.linalg.LinAlgError, ValueError, ZeroDivisionError) as e:
1835
- # Matrix inversion failed, division by zero, or result invalid - use diagonal from SM
1836
- # This can happen if denom_viM calculation fails or SM has issues
1837
- _logger.warning(
1838
- f"init_conditions: Monthly idiosyncratic initial covariance calculation failed, "
1839
- f"using diagonal from SM as fallback. Error: {type(e).__name__}"
1840
- )
1841
- initViM = np.diag(np.diag(SM)).copy()
1842
- # Ensure finite
1843
- initViM = np.where(np.isnan(initViM) | np.isinf(initViM), 0.1, initViM)
1844
-
1845
- # Final block diagonal - ensure all matrices are square
1846
- # Verify dimensions before block_diag
1847
- if BM.shape[0] != BM.shape[1]:
1848
- BM = np.diag(np.diag(BM)) # Force square
1849
- if SM.shape[0] != SM.shape[1]:
1850
- SM = np.diag(np.diag(SM)) # Force square
1851
- if BQ.shape[0] != BQ.shape[1]:
1852
- # BQ should already be square, but if not, force it
1853
- min_dim = min(BQ.shape)
1854
- BQ = np.diag(np.diag(BQ[:min_dim, :min_dim]))
1855
- if SQ.shape[0] != SQ.shape[1]:
1856
- SQ = np.diag(np.diag(SQ))
1857
-
1858
- A = block_diag(A, BM, BQ)
1859
- Q = block_diag(Q, SM, SQ)
1860
- Z_0 = np.zeros(A.shape[0])
1861
-
1862
- # Ensure V_0 matrices are square before block_diag
1863
- if V_0.shape[0] != V_0.shape[1]:
1864
- V_0 = np.diag(np.diag(V_0))
1865
- if initViM.shape[0] != initViM.shape[1]:
1866
- initViM = np.diag(np.diag(initViM))
1867
- if initViQ.shape[0] != initViQ.shape[1]:
1868
- initViQ = np.diag(np.diag(initViQ))
1869
-
1870
- V_0 = block_diag(V_0, initViM, initViQ)
1871
-
1872
- # Diagnostic check: Verify all outputs are finite before returning
1873
- output_params = {
1874
- 'A': A, 'C': C, 'Q': Q, 'R': R, 'Z_0': Z_0, 'V_0': V_0
1875
- }
1876
-
1877
- for param_name, param_value in output_params.items():
1878
- nan_count = np.sum(np.isnan(param_value)) if param_value.size > 0 else 0
1879
- inf_count = np.sum(np.isinf(param_value)) if param_value.size > 0 else 0
1880
-
1881
- if nan_count > 0:
1882
- _logger.warning(
1883
- f"init_conditions: Output {param_name} contains {nan_count} NaN values "
1884
- f"(shape: {param_value.shape})"
1885
- )
1886
- if inf_count > 0:
1887
- _logger.warning(
1888
- f"init_conditions: Output {param_name} contains {inf_count} Inf values "
1889
- f"(shape: {param_value.shape})"
1890
- )
1891
-
1892
- # Attempt to clean NaN/Inf values if detected
1893
- if nan_count > 0 or inf_count > 0:
1894
- if param_name in ['A', 'Q', 'V_0']:
1895
- # Covariance matrices: regularize
1896
- output_params[param_name] = _clean_matrix(param_value, 'covariance', default_nan=0.0)
1897
- elif param_name == 'R':
1898
- # Diagonal matrix: clean diagonal
1899
- output_params[param_name] = _clean_matrix(param_value, 'diagonal', default_nan=1e-4)
1900
- elif param_name == 'C':
1901
- # Loading matrix: replace with zeros
1902
- output_params[param_name] = _clean_matrix(param_value, 'loading')
1903
- elif param_name == 'Z_0':
1904
- # Initial state: reset to zeros
1905
- output_params[param_name] = np.zeros_like(param_value)
1906
-
1907
- # Update variables with cleaned values
1908
- A, C, Q, R, Z_0, V_0 = output_params['A'], output_params['C'], output_params['Q'], \
1909
- output_params['R'], output_params['Z_0'], output_params['V_0']
455
+ i_idio = np.ones(config.get_blocks_array().shape[0])
456
+ nQ = 0
1910
457
 
1911
- return A, C, Q, R, Z_0, V_0
458
+ return tent_weights_dict, R_mat, q, frequencies, i_idio, nQ
1912
459
 
1913
460
 
1914
- def em_step(y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
1915
- R: np.ndarray, Z_0: np.ndarray, V_0: np.ndarray,
1916
- r: np.ndarray, p: int, R_mat: Optional[np.ndarray], q: Optional[np.ndarray],
1917
- nQ: Optional[int], i_idio: np.ndarray, blocks: np.ndarray,
1918
- tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
1919
- clock: str = 'm',
1920
- frequencies: Optional[np.ndarray] = None,
1921
- config: Optional[DFMConfig] = None) -> Tuple[np.ndarray, np.ndarray,
1922
- np.ndarray, np.ndarray,
1923
- np.ndarray, np.ndarray, float]:
1924
- """Apply EM algorithm for parameter reestimation.
1925
-
1926
- This function implements one iteration of the Expectation-Maximization (EM) algorithm
1927
- for dynamic factor models. It consists of:
1928
-
1929
- 1. **E-step**: Run Kalman filter and smoother to obtain smoothed factor estimates
1930
- and covariances: E[Z_t | data], E[Z_t Z_t' | data], etc.
1931
-
1932
- 2. **M-step**: Update model parameters (C, R, A, Q) by maximizing the expected
1933
- complete-data log-likelihood given the smoothed estimates.
1934
-
1935
- The function handles:
1936
- - Monthly and quarterly series separately
1937
- - Block structure for factors
1938
- - Quarterly aggregation constraints
1939
- - Idiosyncratic components (monthly and quarterly)
1940
- - Missing data (via selection matrices Wt)
461
+ def _safe_mean_std(matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
462
+ """Compute mean and standard deviation for each column, handling missing values.
1941
463
 
1942
464
  Parameters
1943
465
  ----------
1944
- y : np.ndarray
1945
- Data matrix (n x T), where n is number of series and T is time periods.
1946
- Note: y is TRANSPOSED relative to input X (which is T x N).
1947
- Missing values should be NaN. Data should be standardized.
1948
- A : np.ndarray
1949
- Current transition matrix (m x m). Describes factor dynamics.
1950
- C : np.ndarray
1951
- Current observation/loading matrix (n x m). Maps factors to observed series.
1952
- Q : np.ndarray
1953
- Current covariance matrix for transition residuals (m x m).
1954
- R : np.ndarray
1955
- Current covariance matrix for observation residuals (n x n). Typically diagonal.
1956
- Z_0 : np.ndarray
1957
- Current initial state vector (m,).
1958
- V_0 : np.ndarray
1959
- Current initial covariance matrix (m x m).
1960
- r : np.ndarray
1961
- Number of factors per block (n_blocks,). Typically all ones.
1962
- p : int
1963
- Number of lags in transition equation. Typically 1.
1964
- R_mat : np.ndarray
1965
- Quarterly aggregation constraints matrix (4 x 5). Same as Rcon in init_conditions.
1966
- q : np.ndarray
1967
- Constraints vector (4,). Typically zeros.
1968
- nQ : int
1969
- Number of quarterly variables.
1970
- i_idio : np.ndarray
1971
- Logical array (n,) indicating idiosyncratic components: 1 for monthly, 0 for quarterly.
1972
- blocks : np.ndarray
1973
- Block loading structure (n x n_blocks). See init_conditions() for details.
466
+ matrix : np.ndarray
467
+ Input matrix (T x N)
1974
468
 
1975
469
  Returns
1976
470
  -------
1977
- C_new : np.ndarray
1978
- Updated observation matrix (n x m). Loadings are re-estimated via least squares
1979
- using smoothed factor estimates.
1980
- R_new : np.ndarray
1981
- Updated observation residual covariance (n x n). Diagonal matrix containing
1982
- updated idiosyncratic variances.
1983
- A_new : np.ndarray
1984
- Updated transition matrix (m x m). AR coefficients are re-estimated via OLS
1985
- using smoothed factor estimates.
1986
- Q_new : np.ndarray
1987
- Updated transition residual covariance (m x m). Contains innovation variances.
1988
- Z_0 : np.ndarray
1989
- Updated initial state (m,). Typically unchanged from input.
1990
- V_0_new : np.ndarray
1991
- Updated initial covariance (m x m). Computed from smoothed state estimates.
1992
- loglik : float
1993
- Log-likelihood of the data under the current parameters. This is the
1994
- incomplete-data log-likelihood computed by the Kalman filter.
1995
-
1996
- Raises
1997
- ------
1998
- ValueError
1999
- If inputs are invalid or matrix dimensions don't match. Also raised if
2000
- numerical issues occur (e.g., singular matrices, NaN/Inf propagation).
2001
-
2002
- Notes
2003
- -----
2004
- - This is a computationally intensive function that involves multiple matrix
2005
- inversions and large matrix operations. Performance scales with O(T * m^3).
2006
- - The function handles missing data via diagonal selection matrices Wt that
2007
- zero out missing observations.
2008
- - For quarterly series, loadings must satisfy aggregation constraints enforced
2009
- via constrained least squares.
2010
- - Some parameter updates may fail silently (e.g., singular matrices) and use
2011
- previous values. These cases are handled gracefully to allow EM to continue.
2012
-
2013
- Examples
2014
- --------
2015
- >>> # Get initial parameters from init_conditions()
2016
- >>> A, C, Q, R, Z_0, V_0 = init_conditions(...)
2017
- >>> # Prepare data (transpose to n x T)
2018
- >>> y = x.T # n x T
2019
- >>> # Run one EM step
2020
- >>> C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
2021
- ... y, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks,
2022
- ... tent_weights_dict=tent_weights_dict, clock=clock, frequencies=frequencies, config=config
2023
- ... )
2024
- >>> # Update parameters for next iteration
2025
- >>> A, C, Q, R, Z_0, V_0 = A_new, C_new, Q_new, R_new, Z_0_new, V_0_new
471
+ means : np.ndarray
472
+ Column means (N,)
473
+ stds : np.ndarray
474
+ Column standard deviations (N,)
2026
475
  """
2027
- # Uncomment if debugging: _validate_em_step_inputs(y, A, C, Q, R, Z_0, V_0, r, p, blocks, nQ)
2028
-
2029
- # Numerical stability: check and clean parameter matrices before Kalman filter
2030
- # This prevents NaN/Inf from propagating into the filter
2031
- if np.any(np.isnan(A)) or np.any(np.isinf(A)):
2032
- _logger.warning("em_step: A contains NaN/Inf, using regularized identity")
2033
- A = np.eye(A.shape[0]) * 0.9 + _clean_matrix(A, 'loading') * 0.1
2034
-
2035
- if np.any(np.isnan(Q)) or np.any(np.isinf(Q)):
2036
- _logger.warning("em_step: Q contains NaN/Inf, regularizing")
2037
- Q = _clean_matrix(Q, 'covariance', default_nan=1e-6)
2038
-
2039
- if np.any(np.isnan(R)) or np.any(np.isinf(R)):
2040
- _logger.warning("em_step: R contains NaN/Inf, regularizing")
2041
- R = _clean_matrix(R, 'diagonal', default_nan=1e-4, default_inf=1e4)
2042
-
2043
- if np.any(np.isnan(C)) or np.any(np.isinf(C)):
2044
- _logger.warning("em_step: C contains NaN/Inf, regularizing")
2045
- C = _clean_matrix(C, 'loading')
2046
-
2047
- if np.any(np.isnan(Z_0)) or np.any(np.isinf(Z_0)):
2048
- _logger.warning("em_step: Z_0 contains NaN/Inf, resetting to zeros")
2049
- Z_0 = np.zeros_like(Z_0)
2050
-
2051
- if np.any(np.isnan(V_0)) or np.any(np.isinf(V_0)):
2052
- _logger.warning("em_step: V_0 contains NaN/Inf, using regularized identity")
2053
- V_0 = np.eye(V_0.shape[0]) * 0.1
2054
-
2055
- n, T = y.shape # n series, T time periods
2056
-
2057
- # Determine nQ if not provided
2058
- if nQ is None and frequencies is not None:
2059
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
2060
- nQ = sum(1 for f in frequencies if FREQUENCY_HIERARCHY.get(f, 3) > clock_hierarchy)
2061
- elif nQ is None:
2062
- nQ = 0
476
+ n_series = matrix.shape[1]
477
+ means = np.zeros(n_series)
478
+ stds = np.ones(n_series)
479
+ for j in range(n_series):
480
+ col = matrix[:, j]
481
+ mask = np.isfinite(col)
482
+ if np.any(mask):
483
+ means[j] = float(np.nanmean(col[mask]))
484
+ std_val = float(np.nanstd(col[mask]))
485
+ stds[j] = std_val if std_val > 0 else 1.0
486
+ else:
487
+ means[j] = 0.0
488
+ stds[j] = 1.0
489
+ return means, stds
490
+
491
+
492
+ def _standardize_data(
493
+ X: np.ndarray,
494
+ clip_data_values: bool,
495
+ data_clip_threshold: float
496
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
497
+ """Standardize data and handle missing values.
2063
498
 
2064
- # Determine pC from tent_weights_dict if available, otherwise use R_mat
2065
- pC = 1
2066
- if tent_weights_dict is not None and len(tent_weights_dict) > 0:
2067
- # Find maximum tent kernel size
2068
- for tent_weights in tent_weights_dict.values():
2069
- if tent_weights is not None and len(tent_weights) > pC:
2070
- pC = len(tent_weights)
2071
- elif R_mat is not None:
2072
- pC = R_mat.shape[1]
499
+ Returns
500
+ -------
501
+ x_standardized : np.ndarray
502
+ Standardized data (T x N)
503
+ Mx : np.ndarray
504
+ Series means (N,)
505
+ Wx : np.ndarray
506
+ Series standard deviations (N,)
507
+ """
508
+ Mx, Wx = _safe_mean_std(X)
2073
509
 
2074
- ppC = int(max(p, pC)) # Ensure integer
2075
- num_blocks = blocks.shape[1]
510
+ # Handle zero/near-zero standard deviations
511
+ min_std = 1e-6
512
+ Wx = np.maximum(Wx, min_std)
2076
513
 
2077
- # Get config defaults if not provided
2078
- if config is None:
2079
- from types import SimpleNamespace
2080
- config = SimpleNamespace(
2081
- clip_ar_coefficients=True,
2082
- ar_clip_min=-0.99,
2083
- ar_clip_max=0.99,
2084
- warn_on_ar_clip=True,
2085
- clip_data_values=True,
2086
- data_clip_threshold=100.0,
2087
- warn_on_data_clip=True,
2088
- use_regularization=True,
2089
- regularization_scale=1e-6,
2090
- min_eigenvalue=1e-8,
2091
- max_eigenvalue=1e6,
2092
- warn_on_regularization=True,
2093
- use_damped_updates=True,
2094
- damping_factor=0.8,
2095
- warn_on_damped_update=True
514
+ # Handle NaN standard deviations
515
+ nan_std_mask = np.isnan(Wx) | np.isnan(Mx)
516
+ if np.any(nan_std_mask):
517
+ _logger.warning(
518
+ f"Series with NaN mean/std detected: {np.sum(nan_std_mask)}. "
519
+ f"Setting Wx=1.0, Mx=0.0 for these series."
2096
520
  )
521
+ Wx[nan_std_mask] = 1.0
522
+ Mx[nan_std_mask] = 0.0
2097
523
 
2098
- # E-step: Run Kalman filter and smoother
2099
- # Kalman filter expects k x nobs, so we pass y as-is
2100
- zsmooth, vsmooth, vvsmooth, loglik = run_kf(y, A, C, Q, R, Z_0, V_0)
2101
-
2102
- # zsmooth is m x (T+1) from run_kf (factors x time)
2103
- # MATLAB: Zsmooth = runKF(y, A, C, Q, R, Z_0, V_0)' - transpose makes it (T+1) x m
2104
- # So Zsmooth(t+1, bl_idx_same_freq(i,:)) selects time t+1, factors indicated by bl_idx_same_freq(i,:)
2105
- # In Python: transpose to match MATLAB: (T+1) x m (time x factors)
2106
- # Keep initial state (column 0) since MATLAB uses t+1 indexing (skips initial)
2107
- Zsmooth = zsmooth.T # m x (T+1) -> (T+1) x m
2108
-
2109
- # M-step: Update parameters
2110
- # Initialize output
2111
- A_new = A.copy()
2112
- Q_new = Q.copy()
2113
- V_0_new = V_0.copy()
524
+ # Standardize
525
+ x_standardized = (X - Mx) / Wx
2114
526
 
2115
- # Update factor parameters for each block
2116
- for i in range(num_blocks):
2117
- r_i = int(r[i])
2118
- factor_lag_size = r_i * p # Number of lagged factors for this block
2119
- # Calculate starting index for this block's factors in the state vector
2120
- # Sum of factors from all previous blocks, scaled by max_lag_size
2121
- factor_start_idx = int(np.sum(r[:i]) * ppC) # Python 0-based: sum of r[0] to r[i-1]
2122
- # Subset of state vector for this block's factors (current and lagged)
2123
- b_subset = slice(factor_start_idx, factor_start_idx + factor_lag_size) # Python 0-based
2124
- t_start = factor_start_idx # Starting index for this block
2125
- t_end = int(factor_start_idx + r_i * ppC) # Ending index for this block
2126
-
2127
- # E[f_t * f_t' | Omega_T] - Expected value of factor outer product at time t
2128
- # Zsmooth[b_subset, 1:] selects factors for this block, skipping initial state (column 0)
2129
- expected_factor_outer = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, 1:].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, 1:], axis=2)
2130
-
2131
- # E[f_{t-1} * f_{t-1}' | Omega_T] - Expected value of lagged factor outer product
2132
- # Zsmooth[b_subset, :-1] selects factors for this block, excluding last time period
2133
- expected_factor_lag_outer = Zsmooth[b_subset, :-1] @ Zsmooth[b_subset, :-1].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, :-1], axis=2)
2134
-
2135
- # E[f_t * f_{t-1}' | Omega_T] - Expected value of factor-lag cross product
2136
- expected_factor_lag_cross = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, :-1].T + np.sum(vvsmooth[b_subset, :, :][:, b_subset, :], axis=2)
2137
-
2138
- # Clean expected values before matrix operations
2139
- expected_factor_lag_outer = _clean_matrix(expected_factor_lag_outer, 'covariance', default_nan=0.0)
2140
- expected_factor_lag_cross = _clean_matrix(expected_factor_lag_cross, 'general', default_nan=0.0)
2141
-
2142
- # Update transition and innovation matrices for block i
2143
- block_transition = A[t_start:t_end, t_start:t_end].copy()
2144
- block_innovation_cov = Q[t_start:t_end, t_start:t_end].copy()
2145
-
2146
- try:
2147
- # Check expected_factor_lag_outer before inversion
2148
- expected_lag_sub = expected_factor_lag_outer[:factor_lag_size, :factor_lag_size]
2149
- if np.any(np.isnan(expected_lag_sub)) or np.any(np.isinf(expected_lag_sub)):
2150
- raise ValueError("Invalid values in expected_factor_lag_outer")
2151
-
2152
- # Regularize if needed (ensure positive definite)
2153
- min_eigenval = config.min_eigenvalue if config else 1e-8
2154
- warn_reg = config.warn_on_regularization if config else True
2155
- expected_lag_sub, _ = _ensure_positive_definite(expected_lag_sub, min_eigenval, warn_reg)
2156
-
2157
- # Check if matrix is too ill-conditioned
2158
- try:
2159
- eigenvals = np.linalg.eigvals(expected_lag_sub)
2160
- max_eigenval = np.max(eigenvals)
2161
- min_eigenval = np.min(eigenvals)
2162
-
2163
- if max_eigenval > 0:
2164
- cond_num = max_eigenval / max(min_eigenval, 1e-12)
2165
- if cond_num > 1e12:
2166
- # Use pseudo-inverse for ill-conditioned matrices
2167
- try:
2168
- expected_lag_inv = pinv(expected_lag_sub, cond=1e-8)
2169
- except TypeError:
2170
- # Fallback for older scipy versions
2171
- expected_lag_inv = pinv(expected_lag_sub)
2172
- else:
2173
- expected_lag_inv = inv(expected_lag_sub)
2174
- else:
2175
- # All eigenvalues are zero or negative - use identity
2176
- expected_lag_inv = np.eye(factor_lag_size) * (1.0 / max(1e-8, np.trace(expected_lag_sub) / factor_lag_size))
2177
- except (np.linalg.LinAlgError, ValueError):
2178
- # Eigendecomposition failed - use pseudo-inverse
2179
- expected_lag_inv = pinv(expected_lag_sub)
2180
-
2181
- # Compute AR coefficients: block_transition = E[f_t * f_{t-1}'] * inv(E[f_{t-1} * f_{t-1}'])
2182
- transition_update = expected_factor_lag_cross[:r_i, :factor_lag_size] @ expected_lag_inv
2183
-
2184
- # Cap AR coefficients to reasonable bounds (configurable)
2185
- transition_update, _ = _apply_ar_clipping(transition_update, config)
2186
- block_transition[:r_i, :factor_lag_size] = transition_update
2187
-
2188
- # Compute innovation covariance: block_innovation_cov = (E[f_t * f_t'] - block_transition * E[f_t * f_{t-1}']) / T
2189
- block_innovation_cov[:r_i, :r_i] = (
2190
- expected_factor_outer[:r_i, :r_i] -
2191
- block_transition[:r_i, :factor_lag_size] @ expected_factor_lag_cross[:r_i, :factor_lag_size].T
2192
- ) / T
2193
-
2194
- # Clean innovation covariance and ensure positive semi-definite
2195
- block_innovation_cov = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
2196
-
2197
- # Ensure positive semi-definite for the factor block
2198
- min_eigenval = config.min_eigenvalue if config else 1e-8
2199
- innovation_cov_reg, reg_stats = _apply_regularization(
2200
- block_innovation_cov[:r_i, :r_i], 'covariance', config
2201
- )
2202
- block_innovation_cov[:r_i, :r_i] = innovation_cov_reg
2203
-
2204
- # Cap maximum eigenvalue to prevent explosion
2205
- max_eigenval = config.max_eigenvalue if config else 1e6
2206
- block_innovation_cov[:r_i, :r_i] = _cap_max_eigenvalue(
2207
- block_innovation_cov[:r_i, :r_i], max_eigenval=max_eigenval
527
+ # Clip extreme values if enabled
528
+ if clip_data_values:
529
+ n_clipped_before = np.sum(np.abs(x_standardized) > data_clip_threshold)
530
+ x_standardized = np.clip(x_standardized, -data_clip_threshold, data_clip_threshold)
531
+ if n_clipped_before > 0:
532
+ pct_clipped = 100.0 * n_clipped_before / x_standardized.size
533
+ _logger.warning(
534
+ f"Data value clipping applied: {n_clipped_before} values ({pct_clipped:.2f}%) "
535
+ f"clipped beyond ±{data_clip_threshold} standard deviations."
2208
536
  )
2209
- except (np.linalg.LinAlgError, ValueError) as e:
2210
- # If update fails, use damped version of previous block_transition to prevent collapse
2211
- if np.allclose(block_transition[:r_i, :factor_lag_size], 0):
2212
- # If all zeros, initialize with small random values
2213
- block_transition[:r_i, :factor_lag_size] = np.random.randn(r_i, factor_lag_size) * 0.1
2214
- else:
2215
- # Damp previous values slightly
2216
- block_transition[:r_i, :factor_lag_size] = block_transition[:r_i, :factor_lag_size] * 0.95
2217
- _logger.debug(f"em_step: Transition update failed for block {i+1}, using fallback: {type(e).__name__}")
2218
-
2219
- # Clean NaN/Inf from block_transition with reasonable bounds
2220
- if np.any(~np.isfinite(block_transition)):
2221
- block_transition = _clean_matrix(block_transition, 'loading', default_nan=0.0, default_inf=0.99)
2222
- # Ensure AR coefficients are within stability bounds
2223
- block_transition, _ = _apply_ar_clipping(block_transition, config)
2224
-
2225
- # Place updated block matrices in output
2226
- A_new[t_start:t_end, t_start:t_end] = block_transition
2227
- Q_new[t_start:t_end, t_start:t_end] = block_innovation_cov
2228
- V_0_block = vsmooth[t_start:t_end, t_start:t_end, 0]
2229
- # Ensure V_0_block is positive semi-definite
2230
- V_0_block = _clean_matrix(V_0_block, 'covariance', default_nan=0.0)
2231
- min_eigenval = config.min_eigenvalue if config else 1e-8
2232
- warn_reg = config.warn_on_regularization if config else True
2233
- V_0_block, _ = _ensure_positive_definite(V_0_block, min_eigenval, warn_reg)
2234
- V_0_new[t_start:t_end, t_start:t_end] = V_0_block
2235
-
2236
- # Update idiosyncratic component
2237
- # i_idio indicates which series have idiosyncratic components
2238
- # For clock frequency: 1 (have idiosyncratic)
2239
- # For slower frequencies: 0 (no direct idiosyncratic, use tent kernel structure)
2240
- # Calculate starting index for idiosyncratic components in state vector
2241
- # All factors come first, so idiosyncratic starts after all block factors
2242
- idio_start_idx = int(np.sum(r) * ppC)
2243
- n_idio = int(np.sum(i_idio)) # All series with idiosyncratic components
2244
- idio_block_start = idio_start_idx
2245
- i_subset = slice(idio_block_start, idio_block_start + n_idio)
2246
-
2247
- # E[f_t*f_t' | Omega_T] for idiosyncratic (diagonal only)
2248
- # MATLAB: Zsmooth(t_start:end, 2:end) * Zsmooth(t_start:end, 2:end)'
2249
- # Zsmooth is (T+1) x m (time x factors), so Zsmooth(t_start:end, 2:end) selects:
2250
- # - Rows: t_start to end (factor indices for idiosyncratic)
2251
- # - Columns: 2 to end (time periods 1 to T, skipping initial state)
2252
- # Result is (m-t_start+1) x T
2253
- # Then Zsmooth(...) * Zsmooth(...)' gives (m-t_start+1) x (m-t_start+1)
2254
- # We want diagonal only: diag(diag(...))
2255
- #
2256
- # t_start is the start of idiosyncratic component index
2257
- # i_subset = idio_block_start:idio_block_start+n_idio selects the clock-frequency idiosyncratic factors only
2258
- # So we should use i_subset to select rows, not t_start:end
2259
- i_subset_slice = slice(i_subset.start, i_subset.stop)
2260
- Z_idio = Zsmooth[i_subset_slice, 1:] # n_idio x T, clock-frequency idiosyncratic factors only
2261
- n_idio_actual = Z_idio.shape[0] # Number of idiosyncratic factors (should be n_idio)
2262
-
2263
- # Compute expected values for idiosyncratic components (diagonal only, since they're independent)
2264
- # E[z_t^2 | Omega_T] = sum(Z_idio^2) + diagonal of smoothed covariance
2265
- expected_idio_current_sq = np.sum(Z_idio**2, axis=1) # Sum over time
2266
- # vsmooth is (m, m, T+1), select idiosyncratic rows and columns, then time 1:
2267
- vsmooth_idio_block = vsmooth[i_subset_slice, :, :][:, i_subset_slice, 1:] # (n_idio, n_idio, T)
2268
- vsmooth_idio_sum = np.sum(vsmooth_idio_block, axis=2) # (n_idio, n_idio)
2269
- # Ensure vsmooth_idio_sum is square and matches n_idio_actual
2270
- if vsmooth_idio_sum.shape[0] != n_idio_actual or vsmooth_idio_sum.shape[1] != n_idio_actual:
2271
- # If shape mismatch, extract only the diagonal elements we need
2272
- min_dim = min(vsmooth_idio_sum.shape[0], vsmooth_idio_sum.shape[1], n_idio_actual)
2273
- vsmooth_idio_diag = np.diag(vsmooth_idio_sum[:min_dim, :min_dim])
2274
- # Pad or truncate to match expected_idio_current_sq
2275
- if len(vsmooth_idio_diag) < n_idio_actual:
2276
- vsmooth_idio_diag = np.pad(vsmooth_idio_diag, (0, n_idio_actual - len(vsmooth_idio_diag)), mode='constant')
2277
- else:
2278
- vsmooth_idio_diag = vsmooth_idio_diag[:n_idio_actual]
2279
- else:
2280
- vsmooth_idio_diag = np.diag(vsmooth_idio_sum)
2281
- expected_idio_current_sq = expected_idio_current_sq + vsmooth_idio_diag
2282
-
2283
- # E[z_{t-1}^2 | Omega_T] for idiosyncratic (periods 0 to T-1)
2284
- Z_idio_lag = Zsmooth[i_subset_slice, :-1] # Time periods 0 to T-1
2285
- expected_idio_lag_sq = np.sum(Z_idio_lag**2, axis=1) # Sum over time
2286
- vsmooth_lag_block = vsmooth[i_subset_slice, :, :][:, i_subset_slice, :-1] # (n_idio, n_idio, T)
2287
- vsmooth_lag_sum = np.sum(vsmooth_lag_block, axis=2) # (n_idio, n_idio)
2288
- # Ensure shape matches
2289
- if vsmooth_lag_sum.shape[0] != n_idio_actual or vsmooth_lag_sum.shape[1] != n_idio_actual:
2290
- min_dim = min(vsmooth_lag_sum.shape[0], vsmooth_lag_sum.shape[1], n_idio_actual)
2291
- vsmooth_lag_diag = np.diag(vsmooth_lag_sum[:min_dim, :min_dim])
2292
- if len(vsmooth_lag_diag) < n_idio_actual:
2293
- vsmooth_lag_diag = np.pad(vsmooth_lag_diag, (0, n_idio_actual - len(vsmooth_lag_diag)), mode='constant')
2294
- else:
2295
- vsmooth_lag_diag = vsmooth_lag_diag[:n_idio_actual]
2296
- else:
2297
- vsmooth_lag_diag = np.diag(vsmooth_lag_sum)
2298
- expected_idio_lag_sq = expected_idio_lag_sq + vsmooth_lag_diag
2299
-
2300
- # E[z_t * z_{t-1} | Omega_T] for idiosyncratic (element-wise product, sum over time)
2301
- min_cols = min(Z_idio.shape[1], Z_idio_lag.shape[1])
2302
- expected_idio_cross = np.sum(Z_idio[:, :min_cols] * Z_idio_lag[:, :min_cols], axis=1)
2303
- vvsmooth_block = vvsmooth[i_subset_slice, :, :][:, i_subset_slice, :] # (n_idio, n_idio, T+1)
2304
- vvsmooth_sum = np.sum(vvsmooth_block, axis=2) # (n_idio, n_idio)
2305
- # Ensure shape matches
2306
- if vvsmooth_sum.shape[0] != n_idio_actual or vvsmooth_sum.shape[1] != n_idio_actual:
2307
- min_dim = min(vvsmooth_sum.shape[0], vvsmooth_sum.shape[1], n_idio_actual)
2308
- vvsmooth_diag = np.diag(vvsmooth_sum[:min_dim, :min_dim])
2309
- if len(vvsmooth_diag) < n_idio_actual:
2310
- vvsmooth_diag = np.pad(vvsmooth_diag, (0, n_idio_actual - len(vvsmooth_diag)), mode='constant')
2311
- else:
2312
- vvsmooth_diag = vvsmooth_diag[:n_idio_actual]
2313
- else:
2314
- vvsmooth_diag = np.diag(vvsmooth_sum)
2315
- expected_idio_cross = expected_idio_cross + vvsmooth_diag
2316
537
 
2317
- # Update A and Q for idiosyncratic components
2318
- # Idiosyncratic components are independent AR(1) processes, so A_i and Q_i are diagonal
2319
- # Theoretical formula: A_i[i,i] = E[z_t * z_{t-1}] / E[z_{t-1}^2] for each series i
2320
-
2321
- # Estimate AR coefficients using helper function
2322
- ar_coeffs_diag, _ = _estimate_ar_coefficient(
2323
- expected_idio_cross, expected_idio_lag_sq, vsmooth_sum=vsmooth_lag_diag
538
+ # Replace any remaining NaN/Inf
539
+ x_standardized = np.nan_to_num(
540
+ x_standardized,
541
+ nan=0.0,
542
+ posinf=data_clip_threshold if clip_data_values else 100,
543
+ neginf=-data_clip_threshold if clip_data_values else -100
2324
544
  )
2325
545
 
2326
- # Create diagonal transition matrix
2327
- block_transition_idio = np.diag(ar_coeffs_diag)
2328
-
2329
- # Compute innovation covariance: Q_i[i,i] = (E[z_t^2] - A_i[i,i] * E[z_t * z_{t-1}]) / T
2330
- # Clean expected values before computation
2331
- expected_idio_current_sq = np.nan_to_num(expected_idio_current_sq, nan=0.0, posinf=1e6, neginf=-1e6)
2332
- innovation_cov_diag = (expected_idio_current_sq - ar_coeffs_diag * expected_idio_cross) / T
2333
-
2334
- # Ensure innovation covariance is positive (variance must be non-negative)
2335
- innovation_cov_diag = np.maximum(innovation_cov_diag, 1e-8)
2336
-
2337
- # Create diagonal innovation covariance matrix
2338
- block_innovation_cov_idio = np.diag(innovation_cov_diag)
2339
-
2340
- # Place in output matrices (only clock-frequency idiosyncratic)
2341
- # block_transition_idio and block_innovation_cov_idio are n_idio_actual x n_idio_actual
2342
- # i_subset might select more elements than n_idio_actual if state vector includes extra elements
2343
- i_subset_size = i_subset.stop - i_subset.start
2344
- if n_idio_actual == i_subset_size:
2345
- # Perfect match: use full matrices
2346
- A_new[i_subset, i_subset] = block_transition_idio
2347
- Q_new[i_subset, i_subset] = block_innovation_cov_idio
2348
- elif n_idio_actual < i_subset_size:
2349
- # State vector has more elements: place n_idio_actual x n_idio_actual block at start
2350
- A_new[i_subset.start:i_subset.start+n_idio_actual, i_subset.start:i_subset.start+n_idio_actual] = block_transition_idio
2351
- Q_new[i_subset.start:i_subset.start+n_idio_actual, i_subset.start:i_subset.start+n_idio_actual] = block_innovation_cov_idio
2352
- else:
2353
- # n_idio_actual > i_subset_size: truncate matrices to fit
2354
- A_new[i_subset, i_subset] = block_transition_idio[:i_subset_size, :i_subset_size]
2355
- Q_new[i_subset, i_subset] = block_innovation_cov_idio[:i_subset_size, :i_subset_size]
2356
-
2357
- # V_0_new: MATLAB uses diag(diag(Vsmooth(i_subset, i_subset, 1)))
2358
- # Extract diagonal only and place on diagonal of submatrix
2359
- vsmooth_sub = vsmooth[i_subset_slice, :, :][:, i_subset_slice, 0] # n_idio_actual x n_idio_actual
2360
- # Ensure shape matches
2361
- if vsmooth_sub.shape[0] != n_idio_actual or vsmooth_sub.shape[1] != n_idio_actual:
2362
- min_dim = min(vsmooth_sub.shape[0], vsmooth_sub.shape[1], n_idio_actual)
2363
- vsmooth_diag = np.diag(vsmooth_sub[:min_dim, :min_dim])
2364
- if len(vsmooth_diag) < n_idio_actual:
2365
- vsmooth_diag = np.pad(vsmooth_diag, (0, n_idio_actual - len(vsmooth_diag)), mode='constant')
2366
- else:
2367
- vsmooth_diag = vsmooth_diag[:n_idio_actual]
2368
- else:
2369
- vsmooth_diag = np.diag(vsmooth_sub)
2370
- # Place diagonal values on the diagonal of V_0_new submatrix (only for actual idiosyncratic components)
2371
- for idx in range(min(n_idio_actual, i_subset_size)):
2372
- V_0_new[i_subset.start + idx, i_subset.start + idx] = vsmooth_diag[idx] if idx < len(vsmooth_diag) else 0.0
2373
-
2374
- # Update observation matrix C and R
2375
- # MATLAB: Z_0 = Zsmooth(:,1) - selects column 1 (initial state, 1-based)
2376
- # In Python: Zsmooth is (T+1) x m (time x factors), so Zsmooth[0, :] is initial state
2377
- Z_0 = Zsmooth[0, :].copy() # Initial state (first row)
2378
-
2379
- # Set missing data to 0
2380
- nanY = np.isnan(y)
2381
- y_clean = y.copy()
2382
- y_clean[nanY] = 0
2383
-
2384
- # Blocks - get unique block loading patterns
2385
- bl = np.unique(blocks, axis=0) # Gives unique loadings
2386
- n_bl = bl.shape[0] # Number of unique loadings
2387
-
2388
- # Initialize indices: These help with subsetting
2389
- # Initialize as None - will be set to numpy arrays on first iteration
2390
- bl_idx_same_freq = None # Indicator for same-frequency factor loadings
2391
- bl_idx_slower_freq = None # Indicator for slower-frequency factor loadings
2392
- R_con_list = [] # List to build block diagonal
2393
-
2394
- # Loop through each block to build indices
2395
- # MATLAB: for i = 1:num_blocks
2396
- # bl_idx_slower_freq = [bl_idx_slower_freq repmat(bl(:,i),1,r(i)*ppC)];
2397
- # bl_idx_same_freq = [bl_idx_same_freq repmat(bl(:,i),1,r(i)) zeros(n_bl,r(i)*(ppC-1))];
2398
- # end
2399
- # This builds column by column - each column represents a factor position
2400
- # bl(:,i) is n_bl x 1, repmat makes it n_bl x r(i)*ppC
2401
- for i in range(num_blocks):
2402
- r_i = int(r[i])
2403
- # bl[:, i] repeated r(i) times horizontally for clock frequency (lag 0 only)
2404
- # Then zeros for remaining ppC-1 lags
2405
- bl_col_clock_freq = np.repeat(bl[:, i:i+1], r_i, axis=1) # n_bl x r_i
2406
- bl_col_clock_freq = np.hstack([bl_col_clock_freq, np.zeros((n_bl, r_i * (ppC - 1)))]) # n_bl x (r_i * ppC)
2407
-
2408
- # bl[:, i] repeated r(i)*ppC times for slower frequencies (all lags)
2409
- bl_col_slower_freq = np.repeat(bl[:, i:i+1], r_i * ppC, axis=1) # n_bl x (r_i * ppC)
2410
-
2411
- # Append columns (MATLAB uses horizontal concatenation)
2412
- if bl_idx_same_freq is None:
2413
- bl_idx_same_freq = bl_col_clock_freq
2414
- bl_idx_slower_freq = bl_col_slower_freq
2415
- else:
2416
- bl_idx_same_freq = np.hstack([bl_idx_same_freq, bl_col_clock_freq])
2417
- bl_idx_slower_freq = np.hstack([bl_idx_slower_freq, bl_col_slower_freq])
2418
-
2419
- # Build constraint matrix: kron(R_mat, eye(r(i))) - only if R_mat is provided
2420
- if R_mat is not None:
2421
- R_con_list.append(np.kron(R_mat, np.eye(r_i)))
2422
-
2423
- # Convert to boolean (MATLAB: logical)
2424
- # Handle case where no blocks were processed (shouldn't happen, but be safe)
2425
- if bl_idx_same_freq is not None:
2426
- bl_idx_same_freq = bl_idx_same_freq.astype(bool)
2427
- bl_idx_slower_freq = bl_idx_slower_freq.astype(bool)
2428
- else:
2429
- # No blocks processed - create empty boolean arrays
2430
- bl_idx_same_freq = np.array([]).reshape(n_bl, 0).astype(bool)
2431
- bl_idx_slower_freq = np.array([]).reshape(n_bl, 0).astype(bool)
2432
-
2433
- # Build block diagonal constraint matrix
2434
- if len(R_con_list) > 0:
2435
- R_con = block_diag(*R_con_list)
2436
- else:
2437
- R_con = np.array([])
2438
-
2439
- # Set up constraint vector
2440
- if R_mat is not None and q is not None:
2441
- q_con = np.zeros((np.sum(r.astype(int)) * R_mat.shape[0], 1))
2442
- else:
2443
- q_con = np.array([])
2444
-
2445
- # Idiosyncratic indicators
2446
- # i_idio indicates which series have idiosyncratic components
2447
- # For clock frequency: 1 (have idiosyncratic)
2448
- # For slower frequencies: 0 (no direct idiosyncratic, use tent kernel structure)
2449
- i_idio_same = i_idio # All series with idiosyncratic components
2450
- n_idio_same = int(np.sum(i_idio_same))
2451
- c_i_idio = np.cumsum(i_idio.astype(int))
2452
-
2453
- # Initialize C_new
2454
- C_new = C.copy()
2455
-
2456
- # Loop through unique block patterns
2457
- for i in range(n_bl):
2458
- bl_i = bl[i, :]
2459
- rs = int(np.sum(r[bl_i.astype(bool)])) # Total factors for this block pattern
2460
- idx_i = np.where((blocks == bl_i).all(axis=1))[0] # Series with this pattern
2461
-
2462
- # Group series by their actual frequency
2463
- freq_groups = group_series_by_frequency(idx_i, frequencies, clock)
2464
- idx_clock_freq = freq_groups.get(clock, np.array([], dtype=int)) # Same frequency as clock
2465
-
2466
- n_i = len(idx_clock_freq)
2467
-
2468
- if n_i == 0:
2469
- continue
2470
-
2471
- # Get bl_idx_same_freq indices for this block pattern (same frequency: lag 0 only)
2472
- bl_idx_same_freq_i = np.where(bl_idx_same_freq[i, :])[0]
2473
- if len(bl_idx_same_freq_i) == 0:
2474
- continue
2475
-
2476
- # Verify rs matches bl_idx_same_freq_i length
2477
- rs_actual = len(bl_idx_same_freq_i)
2478
- if rs_actual != rs:
2479
- # If mismatch, use actual length
2480
- rs = rs_actual
2481
-
2482
- # Initialize sums for equation 13 (BGR 2010)
2483
- # denom should be (n_i * rs) x (n_i * rs)
2484
- denom_size = n_i * rs
2485
- denom = np.zeros((denom_size, denom_size))
2486
- nom = np.zeros((n_i, rs))
2487
-
2488
- # Clock-frequency indices for idiosyncratic
2489
- i_idio_i = i_idio_same[idx_clock_freq]
2490
- i_idio_ii = c_i_idio[idx_clock_freq]
2491
- i_idio_ii = i_idio_ii[i_idio_i.astype(bool)]
2492
-
2493
- # Loop through each period for clock-frequency variables
2494
- for t in range(T):
2495
- # Wt is diagonal selection matrix: n_i x n_i
2496
- nan_mask = ~nanY[idx_clock_freq, t]
2497
- Wt = np.diag(nan_mask.astype(float))
2498
-
2499
- # Ensure Wt is correct size
2500
- if Wt.shape[0] != n_i:
2501
- Wt = np.diag(np.ones(n_i))
2502
-
2503
- # E[f_t*f_t' | Omega_T]
2504
- # Zsmooth(bl_idx_same_freq(i, :), t+1) selects row t+1, columns where bl_idx_same_freq(i,:) is True
2505
- # This gives 1 x rs (row vector)
2506
- # Then Zsmooth(...)' * Zsmooth(...) = (rs x 1) * (1 x rs) = rs x rs
2507
-
2508
- # Zsmooth is (T+1) x m, so Zsmooth[t+1, bl_idx_same_freq_i] gives 1 x rs
2509
- if t + 1 < Zsmooth.shape[0]:
2510
- Z_block_same_freq_row = Zsmooth[t + 1, bl_idx_same_freq_i] # 1 x rs (row vector)
2511
- ZZZ = Z_block_same_freq_row.reshape(-1, 1) @ Z_block_same_freq_row.reshape(1, -1) # rs x 1 * 1 x rs = rs x rs
2512
- else:
2513
- ZZZ = np.zeros((rs, rs))
2514
-
2515
- # V_block_same_freq: vsmooth is m x m x (T+1), so vsmooth[bl_idx_same_freq_i, bl_idx_same_freq_i, t+1] is rs x rs
2516
- if t + 1 < vsmooth.shape[2]:
2517
- V_block_same_freq = vsmooth[np.ix_(bl_idx_same_freq_i, bl_idx_same_freq_i, [t + 1])]
2518
- if V_block_same_freq.ndim == 3:
2519
- V_block_same_freq = V_block_same_freq[:, :, 0] # Extract 2D slice
2520
- if V_block_same_freq.shape != (rs, rs):
2521
- V_block_same_freq = np.zeros((rs, rs))
2522
- else:
2523
- V_block_same_freq = np.zeros((rs, rs))
2524
-
2525
- # kron should produce (n_i * rs) x (n_i * rs)
2526
- # Wt is n_i x n_i, ZZZ + V_block_same_freq is rs x rs
2527
- # kron((rs x rs), (n_i x n_i)) = (rs * n_i) x (rs * n_i) = (n_i * rs) x (n_i * rs)
2528
- # Ensure dimensions match before kron
2529
- expected_shape = (denom_size, denom_size)
2530
- if ZZZ.shape == (rs, rs) and V_block_same_freq.shape == (rs, rs) and Wt.shape == (n_i, n_i):
2531
- try:
2532
- kron_result = np.kron(ZZZ + V_block_same_freq, Wt)
2533
- if kron_result.shape == expected_shape:
2534
- denom += kron_result
2535
- else:
2536
- # Dimension mismatch - skip this term
2537
- pass
2538
- except ValueError:
2539
- # If kron fails, skip this term
2540
- pass
2541
-
2542
- # E[y_t*f_t' | Omega_T]
2543
- # y is N x T, so y(idx_clock_freq, t) is n_i x 1 (column vector)
2544
- # Zsmooth(bl_idx_same_freq(i, :), t+1) is 1 x rs, so transpose gives rs x 1
2545
- # y(idx_clock_freq, t) * Zsmooth(...)' gives n_i x 1 * rs x 1 = n_i x rs
2546
- # Actually: (n_i x 1) * (1 x rs) = n_i x rs
2547
- if t + 1 < Zsmooth.shape[0]:
2548
- y_vec = y_clean[idx_clock_freq, t].reshape(-1, 1) # n_i x 1
2549
- Z_vec_row = Zsmooth[t + 1, bl_idx_same_freq_i].reshape(1, -1) # 1 x rs
2550
- y_term = y_vec @ Z_vec_row # n_i x 1 * 1 x rs = n_i x rs
2551
- else:
2552
- y_term = np.zeros((len(idx_clock_freq), rs_actual))
2553
-
2554
- # Idiosyncratic component
2555
- # Wt(:, i_idio_i) * (Zsmooth(idio_start_idx + i_idio_ii, t+1) * Zsmooth(bl_idx_same_freq(i, :), t+1)' + ...)
2556
- # Zsmooth(idio_start_idx + i_idio_ii, t+1) is len(i_idio_ii) x 1, Zsmooth(bl_idx_same_freq(i, :), t+1)' is rs x 1
2557
- # So product is len(i_idio_ii) x rs
2558
- if len(i_idio_ii) > 0 and t + 1 < Zsmooth.shape[0]:
2559
- idio_idx = (idio_start_idx + i_idio_ii).astype(int)
2560
- if idio_idx.max() < Zsmooth.shape[1]: # Zsmooth is (T+1) x m
2561
- idio_Z_col = Zsmooth[t + 1, idio_idx].reshape(-1, 1) # len(i_idio_ii) x 1
2562
- idio_Z_outer = idio_Z_col @ Z_vec_row # len(i_idio_ii) x 1 * 1 x rs = len(i_idio_ii) x rs
2563
-
2564
- if t + 1 < vsmooth.shape[2]:
2565
- idio_V = vsmooth[np.ix_(idio_idx, bl_idx_same_freq_i, [t + 1])]
2566
- if idio_V.ndim == 3:
2567
- idio_V = idio_V[:, :, 0] # len(i_idio_ii) x rs
2568
- else:
2569
- idio_V = np.zeros((len(i_idio_ii), rs_actual))
2570
-
2571
- idio_term = Wt[:, i_idio_i.astype(bool)] @ (idio_Z_outer + idio_V) # n_i x len(i_idio_i) * len(i_idio_i) x rs = n_i x rs
2572
- else:
2573
- idio_term = np.zeros((len(idx_clock_freq), rs_actual))
2574
- else:
2575
- idio_term = np.zeros((len(idx_clock_freq), rs_actual))
2576
-
2577
- nom += y_term - idio_term
2578
-
2579
- # Solve for C using regularized least squares
2580
- # Add ridge regularization to prevent extreme loadings while maintaining theoretical soundness
2581
- # Regularization parameter based on data scale
2582
- try:
2583
- # Compute regularization parameter based on trace of denom (data scale)
2584
- scale_factor = config.regularization_scale if config else 1e-6
2585
- warn_reg = config.warn_on_regularization if config else True
2586
- reg_param, reg_stats = _compute_regularization_param(denom, scale_factor, warn_reg)
2587
-
2588
- # Regularized solution: (denom + lambda*I)^(-1) @ nom
2589
- denom_reg = denom + np.eye(denom.shape[0]) * reg_param
2590
- vec_C = inv(denom_reg) @ nom.flatten()
2591
-
2592
- # Assign using explicit indexing to avoid read-only issues
2593
- C_update = vec_C.reshape(n_i, rs)
2594
-
2595
- # Clean invalid values (preserve scale, don't hard clip)
2596
- C_update = _clean_matrix(C_update, 'loading', default_nan=0.0, default_inf=0.0)
2597
-
2598
- # Only clip extreme outliers (beyond reasonable range for standardized data)
2599
- # Use adaptive bounds based on data scale, not fixed values
2600
- C_scale = np.std(C_update[C_update != 0]) if np.any(C_update != 0) else 1.0
2601
- C_max = max(10.0, C_scale * 5) # Adaptive bound: 5 standard deviations or 10, whichever is larger
2602
- C_update = np.clip(C_update, -C_max, C_max)
2603
-
2604
- for i, row_idx in enumerate(idx_clock_freq):
2605
- for j, col_idx in enumerate(bl_idx_same_freq_i):
2606
- C_new[row_idx, col_idx] = C_update[i, j]
2607
- except (np.linalg.LinAlgError, ValueError) as e:
2608
- # Matrix inversion failed or dimension mismatch - skip this block
2609
- # This can happen if denom is singular or shapes don't match
2610
- # Log would be helpful but keeping silent for now to match MATLAB behavior
2611
- pass
2612
-
2613
- # Process series with frequencies different from clock (need tent kernel)
2614
- # Create slower_freqs: only frequencies slower than clock
2615
- slower_freqs = {}
2616
- if frequencies is not None:
2617
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
2618
- for freq, idx_list in freq_groups.items():
2619
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
2620
- if freq_hierarchy > clock_hierarchy: # Slower than clock
2621
- slower_freqs[freq] = idx_list
2622
-
2623
- # Process each frequency group separately
2624
- for freq, idx_iFreq in freq_groups.items():
2625
- if freq == clock:
2626
- continue # Already processed above
2627
- # Get tent weights for this frequency
2628
- if tent_weights_dict is not None and freq in tent_weights_dict:
2629
- tent_weights = tent_weights_dict[freq]
2630
- else:
2631
- tent_weights = get_tent_weights_for_pair(freq, clock)
2632
- if tent_weights is None:
2633
- # Fallback: generate symmetric tent weights based on frequency gap
2634
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
2635
- freq_hierarchy = FREQUENCY_HIERARCHY.get(freq, 3)
2636
- n_periods_est = freq_hierarchy - clock_hierarchy + 1
2637
- if n_periods_est > 0 and n_periods_est <= 12:
2638
- tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
2639
- _logger.warning(
2640
- f"em_step: No tent weights found for frequency '{freq}' "
2641
- f"with clock '{clock}', generating symmetric tent weights with {n_periods_est} periods."
2642
- )
2643
- else:
2644
- raise ValueError(
2645
- f"em_step: Cannot determine tent weights for frequency '{freq}' "
2646
- f"with clock '{clock}'. Please provide tent_weights_dict or ensure "
2647
- f"the frequency pair is supported."
2648
- )
2649
-
2650
- pC_freq = len(tent_weights)
2651
- rps = rs * pC_freq
2652
-
2653
- # Generate R_mat for this frequency
2654
- R_mat_freq, q_freq = generate_R_mat(tent_weights)
2655
-
2656
- # Build constraint matrix for this frequency and block pattern
2657
- R_con_i = np.kron(R_mat_freq, np.eye(int(rs)))
2658
- q_con_i = np.kron(q_freq, np.zeros(int(rs)))
2659
-
2660
- # Get bl_idx_slower_freq indices for this frequency
2661
- # bl_idx_slower_freq contains all slower frequency factors (all lags)
2662
- # We need to select the appropriate columns for this frequency
2663
- # For now, we'll use the full bl_idx_slower_freq and let the constraint matrix handle it
2664
- if i < bl_idx_slower_freq.shape[0]:
2665
- # Select columns corresponding to this block pattern and frequency
2666
- # bl_idx_slower_freq[i, :] gives all slower frequency factors for block pattern i
2667
- # We need to select the first rps columns (for this frequency)
2668
- bl_idx_slower_freq_i = np.where(bl_idx_slower_freq[i, :])[0]
2669
- if len(bl_idx_slower_freq_i) >= rps:
2670
- bl_idx_slower_freq_i = bl_idx_slower_freq_i[:rps] # Take first rps columns
2671
- elif len(bl_idx_slower_freq_i) > 0:
2672
- # Pad if needed
2673
- bl_idx_slower_freq_i = np.pad(bl_idx_slower_freq_i, (0, rps - len(bl_idx_slower_freq_i)), mode='edge')
2674
- else:
2675
- # No slower frequency factors - skip
2676
- continue
2677
- else:
2678
- continue
2679
-
2680
- # Remove empty constraints
2681
- if R_con_i.size > 0:
2682
- no_c = ~np.any(R_con_i, axis=1)
2683
- R_con_i = R_con_i[~no_c, :]
2684
- q_con_i = q_con_i[~no_c]
2685
-
2686
- # Loop through series of this frequency in this block pattern
2687
- for j in idx_iFreq:
2688
- # Determine actual size based on bl_idx_slower_freq_i
2689
- if len(bl_idx_slower_freq_i) > 0:
2690
- rps_actual = len(bl_idx_slower_freq_i)
2691
- else:
2692
- rps_actual = rps
2693
-
2694
- denom = np.zeros((rps_actual, rps_actual))
2695
- nom = np.zeros((1, rps_actual))
2696
-
2697
- # Find ordinal position of this slower frequency series
2698
- # Count how many slower frequency series come before this one
2699
- idx_j_slower = sum(1 for k in idx_iFreq if k < j and
2700
- (frequencies is None or k >= len(frequencies) or frequencies[k] == freq))
2701
-
2702
- # Location of factor structure for slower frequency var residuals
2703
- # Each slower frequency series has n_periods idiosyncratic components
2704
- # Count total idiosyncratic components for all previous slower frequency series
2705
- n_idio_slower_before = 0
2706
- for prev_freq, prev_idx_list in slower_freqs.items():
2707
- if prev_freq == freq:
2708
- # Count only series before j in this frequency group
2709
- n_idio_slower_before += sum(1 for k in prev_idx_list if k < j) * pC_freq
2710
- break
2711
- else:
2712
- # Count all series in previous frequency groups
2713
- if tent_weights_dict is not None and prev_freq in tent_weights_dict:
2714
- prev_tent = tent_weights_dict[prev_freq]
2715
- else:
2716
- prev_tent = get_tent_weights_for_pair(prev_freq, clock)
2717
- if prev_tent is None:
2718
- # Fallback: generate symmetric tent weights
2719
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
2720
- prev_freq_hierarchy = FREQUENCY_HIERARCHY.get(prev_freq, 3)
2721
- n_periods_est = prev_freq_hierarchy - clock_hierarchy + 1
2722
- if n_periods_est > 0 and n_periods_est <= 12:
2723
- prev_tent = generate_tent_weights(n_periods_est, 'symmetric')
2724
- else:
2725
- # If cannot generate, use empty array (will cause error downstream)
2726
- prev_tent = np.array([])
2727
- n_idio_slower_before += len(prev_idx_list) * len(prev_tent)
2728
-
2729
- i_idio_jQ = np.arange(idio_start_idx + n_idio_same + n_idio_slower_before,
2730
- idio_start_idx + n_idio_same + n_idio_slower_before + pC_freq)
2731
-
2732
- # Update V_0, A, Q for slower frequency idiosyncratic
2733
- # Each slower frequency series has n_periods idiosyncratic components (tent structure)
2734
- # These components represent the same underlying slower frequency idiosyncratic process,
2735
- # so they should share the same AR coefficient, estimated from their aggregate behavior
2736
-
2737
- if len(i_idio_jQ) > 0 and i_idio_jQ[-1] < V_0_new.shape[0]:
2738
- # Update V_0 for slower frequency idiosyncratic
2739
- vsmooth_Q = vsmooth[i_idio_jQ, :, :][:, i_idio_jQ, 0] # Extract submatrix
2740
- # Place values explicitly to avoid broadcasting issues
2741
- for idx1, i1 in enumerate(i_idio_jQ):
2742
- for idx2, i2 in enumerate(i_idio_jQ):
2743
- if i1 < V_0_new.shape[0] and i2 < V_0_new.shape[1]:
2744
- V_0_new[i1, i2] = vsmooth_Q[idx1, idx2]
2745
-
2746
- # Estimate AR coefficient for this slower frequency series from its smoothed idiosyncratic factors
2747
- # Extract smoothed idiosyncratic factors for this slower frequency series (n_periods components)
2748
- # Zsmooth is (T+1) x m, so Zsmooth[:, i_idio_jQ] gives (T+1) x n_periods
2749
- if i_idio_jQ[0] < Zsmooth.shape[1] and i_idio_jQ[-1] < Zsmooth.shape[1]:
2750
- Z_idio_Q = Zsmooth[1:, i_idio_jQ] # T x n_periods, skip initial state
2751
- Z_idio_Q_BB = Zsmooth[:-1, i_idio_jQ] # T x n_periods, periods 0 to T-1
2752
-
2753
- # Aggregate the n_periods components using tent weights to get a single slower frequency idiosyncratic series
2754
- # This represents the underlying slower frequency process
2755
- tent_weights_norm = tent_weights / np.sum(tent_weights) # Normalize tent weights
2756
- z_Q_agg = (Z_idio_Q @ tent_weights_norm) # T x 1, aggregated slower frequency idiosyncratic
2757
- z_Q_agg_BB = (Z_idio_Q_BB @ tent_weights_norm) # T x 1, lagged
2758
-
2759
- # Estimate AR coefficient: A_Q = E[z_t * z_{t-1}] / E[z_{t-1}^2]
2760
- # This is the same formula as for same frequency idiosyncratic, but applied to aggregated slower frequency
2761
- EZZ_BB_Q = np.sum(z_Q_agg_BB**2) # Scalar
2762
- EZZ_FB_Q = np.sum(z_Q_agg * z_Q_agg_BB) # Scalar
2763
-
2764
- # Add variance from smoothed covariance
2765
- vsmooth_Q_sum = np.sum(vsmooth[i_idio_jQ, :, :][:, i_idio_jQ, :-1], axis=(0, 1, 2))
2766
- EZZ_BB_Q += vsmooth_Q_sum * np.sum(tent_weights_norm**2) # Scale by normalized tent weights
2767
-
2768
- # Estimate AR coefficient for slower frequency series
2769
- # Regularize denominator to prevent division by zero
2770
- min_denom_Q = max(abs(EZZ_BB_Q) * 1e-6, 1e-10)
2771
- EZZ_BB_Q = max(EZZ_BB_Q, min_denom_Q)
2772
-
2773
- # Compute AR coefficient: A_Q = E[z_t * z_{t-1}] / E[z_{t-1}^2]
2774
- A_Q = EZZ_FB_Q / EZZ_BB_Q
2775
- A_Q, _ = _apply_ar_clipping(A_Q, config) # Stability bounds
2776
-
2777
- # Compute innovation variance: Q_Q = (E[z_t^2] - A_Q * E[z_t * z_{t-1}]) / T
2778
- EZZ_Q = np.sum(z_Q_agg**2)
2779
- vsmooth_Q_sum_current = np.sum(vsmooth[i_idio_jQ, :, :][:, i_idio_jQ, 1:], axis=(0, 1, 2))
2780
- EZZ_Q += vsmooth_Q_sum_current * np.sum(tent_weights_norm**2)
2781
- Q_Q = (EZZ_Q - A_Q * EZZ_FB_Q) / T
2782
- Q_Q = max(Q_Q, 1e-8) # Ensure positive
2783
-
2784
- # Apply the same AR coefficient and variance to all n_periods components of this slower frequency series
2785
- # This is theoretically correct: they represent the same underlying process
2786
- for idx_Q in i_idio_jQ:
2787
- if idx_Q < A_new.shape[0] and idx_Q < A_new.shape[1]:
2788
- A_new[idx_Q, idx_Q] = A_Q
2789
- Q_new[idx_Q, idx_Q] = Q_Q
2790
-
2791
- # Loop through each period for slower frequency variables
2792
- for t in range(T):
2793
- # MATLAB: Wt = diag(~nanY(j, t)) where j is scalar, nanY(j, t) is scalar
2794
- # In MATLAB, diag(scalar) gives 1x1 matrix
2795
- # In Python, np.diag() requires 1D array, so wrap scalar in array
2796
- nan_val = ~nanY[j, t]
2797
- if np.isscalar(nan_val):
2798
- # Scalar case: create 1x1 matrix
2799
- Wt = np.array([[float(nan_val)]])
2800
- else:
2801
- # Array case: use np.diag() normally
2802
- Wt = np.diag(nan_val.astype(float))
2803
-
2804
- # Zsmooth(bl_idx_slower_freq(i,:), t+1) - Zsmooth is (T+1) x m
2805
- # Ensure bl_idx_slower_freq_i indices are valid
2806
- if len(bl_idx_slower_freq_i) == 0:
2807
- # No slower frequency factors for this block - skip
2808
- continue
2809
-
2810
- # Ensure indices are within bounds
2811
- valid_bl_idx_slower_freq_i = bl_idx_slower_freq_i[bl_idx_slower_freq_i < Zsmooth.shape[1]]
2812
- if len(valid_bl_idx_slower_freq_i) == 0:
2813
- # No valid indices - skip
2814
- continue
2815
-
2816
- if t + 1 < Zsmooth.shape[0]:
2817
- Z_block_slower_freq_row = Zsmooth[t + 1, valid_bl_idx_slower_freq_i] # 1 x rps_actual
2818
- Z_block_slower_freq_col = Z_block_slower_freq_row.reshape(-1, 1) # rps_actual x 1
2819
- ZZZ_slower_freq = Z_block_slower_freq_col @ Z_block_slower_freq_row.reshape(1, -1) # rps_actual x 1 * 1 x rps_actual = rps_actual x rps_actual
2820
-
2821
- # Ensure vsmooth indices are valid
2822
- valid_vsmooth_idx = valid_bl_idx_slower_freq_i[valid_bl_idx_slower_freq_i < vsmooth.shape[0]]
2823
- if len(valid_vsmooth_idx) > 0:
2824
- V_block_slower_freq = vsmooth[np.ix_(valid_vsmooth_idx, valid_vsmooth_idx, [t + 1])]
2825
- if V_block_slower_freq.ndim == 3:
2826
- V_block_slower_freq = V_block_slower_freq[:, :, 0]
2827
- # Ensure V_block_slower_freq matches ZZZ_slower_freq size
2828
- if V_block_slower_freq.shape != ZZZ_slower_freq.shape:
2829
- # Resize to match
2830
- min_size = min(V_block_slower_freq.shape[0], ZZZ_slower_freq.shape[0])
2831
- V_block_slower_freq = V_block_slower_freq[:min_size, :min_size]
2832
- ZZZ_slower_freq = ZZZ_slower_freq[:min_size, :min_size]
2833
- else:
2834
- V_block_slower_freq = np.zeros_like(ZZZ_slower_freq)
2835
- else:
2836
- # t+1 is out of bounds - use zeros
2837
- Z_block_slower_freq_row = np.zeros(rps_actual)
2838
- Z_block_slower_freq_col = np.zeros((rps_actual, 1))
2839
- ZZZ_slower_freq = np.zeros((rps_actual, rps_actual))
2840
- V_block_slower_freq = np.zeros((rps_actual, rps_actual))
2841
-
2842
- # E[f_t*f_t' | Omega_T]
2843
- # kron(Zsmooth(...)*Zsmooth(...)' + Vsmooth(...), Wt)
2844
- # Wt is 1x1 matrix for single slower frequency series, so kron((rps x rps), (1 x 1)) = rps x rps
2845
- # Since Wt is 1x1, kron(A, Wt) = A * Wt[0,0]
2846
- if Wt.shape == (1, 1):
2847
- denom += (ZZZ_slower_freq + V_block_slower_freq) * Wt[0, 0]
2848
- else:
2849
- denom += np.kron(ZZZ_slower_freq + V_block_slower_freq, Wt)
2850
-
2851
- # E[y_t*f_t' | Omega_T]
2852
- # y(j, t) * Zsmooth(bl_idx_slower_freq(i,:), t+1)' - y is scalar, Zsmooth(...)' is rps x 1
2853
- # Result is 1 x rps
2854
- nom += y_clean[j, t] * Z_block_slower_freq_row.reshape(1, -1) # 1 x rps
2855
-
2856
- # Subtract idiosyncratic component
2857
- # Use the tent weights for this frequency
2858
- if len(i_idio_jQ) > 0 and t + 1 < Zsmooth.shape[0] and i_idio_jQ[-1] < Zsmooth.shape[1]:
2859
- # Use tent weights for this frequency (already determined above)
2860
- tent = tent_weights.reshape(1, -1)
2861
-
2862
- # Ensure tent matches i_idio_jQ length
2863
- n_periods_actual = len(i_idio_jQ)
2864
- if tent.shape[1] != n_periods_actual:
2865
- # Adjust tent to match actual periods (truncate or pad)
2866
- if tent.shape[1] > n_periods_actual:
2867
- tent = tent[:, :n_periods_actual]
2868
- else:
2869
- # Pad with last value
2870
- pad_width = n_periods_actual - tent.shape[1]
2871
- tent = np.hstack([tent, np.tile(tent[:, -1:], (1, pad_width))])
2872
-
2873
- idio_Z_col = Zsmooth[t + 1, i_idio_jQ].reshape(-1, 1) # n_periods x 1
2874
- idio_term_scalar = (tent @ idio_Z_col) * Z_block_slower_freq_row # scalar * 1 x rps = 1 x rps
2875
-
2876
- if t + 1 < vsmooth.shape[2]:
2877
- idio_V = vsmooth[np.ix_(i_idio_jQ, bl_idx_slower_freq_i, [t + 1])]
2878
- if idio_V.ndim == 3:
2879
- idio_V = idio_V[:, :, 0] # n_periods x rps
2880
- idio_term_V = tent @ idio_V # 1 x n_periods * n_periods x rps = 1 x rps
2881
- else:
2882
- idio_term_V = np.zeros((1, len(bl_idx_slower_freq_i)))
2883
-
2884
- # Wt is 1x1 matrix for slower frequency, extract scalar value
2885
- wt_scalar = Wt[0, 0] if Wt.shape == (1, 1) else Wt
2886
- nom -= wt_scalar * (idio_term_scalar + idio_term_V) # Scalar * 1 x rps
2887
-
2888
- # Solve for C (with constraints if applicable)
2889
- # Use regularized least squares for better numerical stability
2890
- try:
2891
- # Compute regularization parameter based on data scale
2892
- scale_factor = config.regularization_scale if config else 1e-6
2893
- warn_reg = config.warn_on_regularization if config else True
2894
- reg_param, reg_stats = _compute_regularization_param(denom, scale_factor, warn_reg)
2895
-
2896
- # Regularized solution before constraints
2897
- denom_reg = denom + np.eye(denom.shape[0]) * reg_param
2898
- C_i = inv(denom_reg) @ nom.T
2899
-
2900
- # Clean invalid values
2901
- C_i = _clean_matrix(C_i, 'loading', default_nan=0.0, default_inf=0.0)
2902
-
2903
- # Apply constraints (tent kernel aggregation)
2904
- if R_con_i.size > 0 and len(q_con_i) == R_con_i.shape[0]:
2905
- denom_inv = inv(denom_reg) # Use regularized inverse for constraints too
2906
- constraint_term = denom_inv @ R_con_i.T @ inv(R_con_i @ denom_inv @ R_con_i.T) @ (R_con_i @ C_i - q_con_i)
2907
- C_i_constr = C_i - constraint_term
2908
- else:
2909
- C_i_constr = C_i
2910
-
2911
- # Clean and apply adaptive bounds after constraints
2912
- C_i_constr = _clean_matrix(C_i_constr, 'loading', default_nan=0.0, default_inf=0.0)
2913
- C_scale = np.std(C_i_constr[C_i_constr != 0]) if np.any(C_i_constr != 0) else 1.0
2914
- C_max = max(10.0, C_scale * 5)
2915
- C_i_constr = np.clip(C_i_constr, -C_max, C_max)
2916
-
2917
- # Place in output matrix
2918
- # Assign using explicit indexing to avoid read-only issues
2919
- if len(bl_idx_slower_freq_i) > 0:
2920
- C_update = C_i_constr.flatten()[:len(bl_idx_slower_freq_i)]
2921
- for k, col_idx in enumerate(bl_idx_slower_freq_i):
2922
- C_new[j, col_idx] = C_update[k]
2923
- except (np.linalg.LinAlgError, ValueError, IndexError) as e:
2924
- # Matrix inversion failed, dimension mismatch, or index error - skip this series
2925
- # This can happen if denom is singular, constraints are inconsistent, or indices invalid
2926
- pass
2927
-
2928
- # Update R (covariance of observation residuals)
2929
- # R is diagonal, so we compute diagonal elements directly (more efficient and theoretically cleaner)
2930
- # Theoretical formula: R[i,i] = (1/T_i) * sum_{t: obs available} [ (y_i(t) - C_i @ Z_t)^2 + C_i @ V_t @ C_i' ]
2931
- # where T_i is number of non-missing observations for series i
2932
- # This is the EM update for observation residual variance
2933
-
2934
- R_diag = np.zeros(n)
2935
- n_obs_per_series = np.zeros(n, dtype=int) # Count non-missing observations per series
2936
-
2937
- for t in range(T):
2938
- # Get smoothed factor and covariance at time t
2939
- Z_t = Zsmooth[t + 1, :].reshape(-1, 1) # m x 1
2940
- vsmooth_t = vsmooth[:, :, t + 1] # m x m
2941
-
2942
- # Compute prediction for all series: y_pred = C_new @ Z_t gives n x 1
2943
- # Note: Using C_new is correct here - we're computing R based on updated loadings
2944
- y_pred = C_new @ Z_t
2945
- y_pred = y_pred.flatten() # n x 1 -> (n,)
2946
-
2947
- # For each series i, compute R[i,i] contribution
2948
- for i in range(n):
2949
- # Skip if observation is missing
2950
- if nanY[i, t]:
2951
- continue
2952
-
2953
- # Increment observation count
2954
- n_obs_per_series[i] += 1
2955
-
2956
- # Prediction error squared: (y_i(t) - C_i @ Z_t)^2
2957
- resid_i = y_clean[i, t] - y_pred[i]
2958
- resid_sq = resid_i**2
2959
-
2960
- # Uncertainty from factor estimation: C_i @ V_t @ C_i'
2961
- # This accounts for uncertainty in the smoothed factor estimates
2962
- C_i = C_new[i, :].reshape(1, -1) # 1 x m
2963
- var_factor = (C_i @ vsmooth_t @ C_i.T)[0, 0] # Scalar
2964
-
2965
- # Add to R[i,i] accumulator
2966
- R_diag[i] += resid_sq + var_factor
2967
-
2968
- # Average over time periods (only count non-missing observations)
2969
- # Avoid division by zero - if no observations, keep previous R value
2970
- n_obs_per_series = np.maximum(n_obs_per_series, 1)
2971
- R_diag = R_diag / n_obs_per_series
2972
-
2973
- # For series with no observations, use previous R value
2974
- no_obs_mask = n_obs_per_series == 1 # Only 1 because we set minimum to 1 above
2975
- if np.any(no_obs_mask):
2976
- R_prev_diag = np.diag(R)
2977
- R_diag[no_obs_mask] = R_prev_diag[no_obs_mask]
2978
-
2979
- # Ensure R_diag is positive (variance must be non-negative)
2980
- # Use adaptive minimum based on data scale, not arbitrary fixed value
2981
- # Minimum variance should be proportional to the typical scale of prediction errors
2982
- # Use a small fraction of the mean variance as minimum
2983
- mean_var = np.mean(R_diag[R_diag > 0]) if np.any(R_diag > 0) else 1e-4
2984
- min_var = np.maximum(mean_var * 1e-6, 1e-8)
2985
- R_diag = np.maximum(R_diag, min_var)
2986
-
2987
- # Clean any invalid values (preserve scale)
2988
- valid_mask = np.isfinite(R_diag) & (R_diag > 0)
2989
- if np.any(valid_mask):
2990
- # Use median of valid values as fallback for invalid ones
2991
- median_var = np.median(R_diag[valid_mask])
2992
- R_diag = np.where(valid_mask, R_diag, median_var)
2993
- else:
2994
- # All invalid - use default
2995
- R_diag.fill(1e-4)
2996
-
2997
- # Create diagonal R matrix
2998
- R_new = np.diag(R_diag)
2999
-
3000
- # Final validation: Ensure Q_new is positive semi-definite
3001
- Q_new = _clean_matrix(Q_new, 'covariance', default_nan=0.0)
3002
- min_eigenval = config.min_eigenvalue if config else 1e-8
3003
- warn_reg = config.warn_on_regularization if config else True
3004
- Q_new, _ = _apply_regularization(Q_new, 'covariance', config)
3005
-
3006
- # Cap maximum eigenvalue to prevent explosion
3007
- max_eigenval = config.max_eigenvalue if config else 1e6
3008
- Q_new = _cap_max_eigenvalue(Q_new, max_eigenval=max_eigenval)
3009
-
3010
- # Final validation: Ensure V_0_new is positive semi-definite
3011
- V_0_new = _clean_matrix(V_0_new, 'covariance', default_nan=0.0)
3012
- V_0_new, _ = _apply_regularization(V_0_new, 'covariance', config)
3013
-
3014
- return C_new, R_new, A_new, Q_new, Z_0, V_0_new, loglik
3015
-
3016
-
3017
- class DFM:
3018
- """Core Dynamic Factor Model class.
3019
-
3020
- This class provides the main DFM estimation functionality. The core algorithm
3021
- is implemented in the `fit()` method, which performs EM estimation.
3022
-
3023
- For a higher-level API with convenience methods (load_config, load_data, etc.),
3024
- see `dfm_api.py` which provides a stateful wrapper around this class.
546
+ return x_standardized, Mx, Wx
547
+
548
+
549
+ def _run_em_algorithm(
550
+ y: np.ndarray,
551
+ y_est: np.ndarray,
552
+ A: np.ndarray,
553
+ C: np.ndarray,
554
+ Q: np.ndarray,
555
+ R: np.ndarray,
556
+ Z_0: np.ndarray,
557
+ V_0: np.ndarray,
558
+ r: np.ndarray,
559
+ p: int,
560
+ R_mat: Optional[np.ndarray],
561
+ q: Optional[np.ndarray],
562
+ nQ: int,
563
+ i_idio: np.ndarray,
564
+ blocks: np.ndarray,
565
+ tent_weights_dict: Dict[str, np.ndarray],
566
+ clock: str,
567
+ frequencies: Optional[np.ndarray],
568
+ config: DFMConfig,
569
+ threshold: float,
570
+ max_iter: int,
571
+ use_damped_updates: bool,
572
+ damping_factor: float,
573
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float, int, bool]:
574
+ """Run EM algorithm until convergence.
3025
575
 
3026
- Example:
3027
- >>> from dfm_python.dfm import DFM
3028
- >>> from dfm_python.config import DFMConfig
3029
- >>> model = DFM()
3030
- >>> result = model.fit(X, config, max_iter=100)
576
+ Returns
577
+ -------
578
+ A, C, Q, R, Z_0, V_0 : np.ndarray
579
+ Final parameter estimates
580
+ loglik : float
581
+ Final log-likelihood
582
+ num_iter : int
583
+ Number of iterations completed
584
+ converged : bool
585
+ Whether convergence was achieved
3031
586
  """
587
+ previous_loglik = -np.inf
588
+ num_iter = 0
589
+ converged = False
3032
590
 
3033
- def __init__(self):
3034
- """Initialize DFM instance."""
3035
- self._config: Optional[DFMConfig] = None
3036
- self._data: Optional[np.ndarray] = None
3037
- self._time: Optional[np.ndarray] = None
3038
- self._original_data: Optional[np.ndarray] = None
3039
- self._result: Optional[DFMResult] = None
3040
-
3041
- def fit(self,
3042
- X: np.ndarray,
3043
- config: DFMConfig,
3044
- threshold: Optional[float] = None,
3045
- max_iter: Optional[int] = None,
3046
- ar_lag: Optional[int] = None,
3047
- nan_method: Optional[int] = None,
3048
- nan_k: Optional[int] = None,
3049
- clock: Optional[str] = None,
3050
- clip_ar_coefficients: Optional[bool] = None,
3051
- ar_clip_min: Optional[float] = None,
3052
- ar_clip_max: Optional[float] = None,
3053
- clip_data_values: Optional[bool] = None,
3054
- data_clip_threshold: Optional[float] = None,
3055
- use_regularization: Optional[bool] = None,
3056
- regularization_scale: Optional[float] = None,
3057
- min_eigenvalue: Optional[float] = None,
3058
- max_eigenvalue: Optional[float] = None,
3059
- use_damped_updates: Optional[bool] = None,
3060
- damping_factor: Optional[float] = None,
3061
- **kwargs) -> DFMResult:
3062
- """Fit the DFM model using EM algorithm.
3063
-
3064
- This is the core estimation method. It performs the complete EM workflow:
3065
- 1. Initialization via PCA and OLS
3066
- 2. EM iterations until convergence
3067
- 3. Final Kalman smoothing
3068
-
3069
- Parameters
3070
- ----------
3071
- X : np.ndarray
3072
- Data matrix (T x N), where T is time periods and N is number of series.
3073
- config : DFMConfig
3074
- Unified DFM configuration object.
3075
- threshold : float, optional
3076
- EM convergence threshold. If None, uses config.threshold.
3077
- max_iter : int, optional
3078
- Maximum EM iterations. If None, uses config.max_iter.
3079
- **kwargs
3080
- Additional parameters (ar_lag, nan_method, etc.) that override config values.
3081
-
3082
- Returns
3083
- -------
3084
- DFMResult
3085
- Estimation results including parameters, factors, and diagnostics.
3086
- """
3087
- # Store config and data for later use
3088
- self._config = config
3089
- self._data = X
3090
-
3091
- # Call the core dfm() function logic
3092
- result = _dfm_core(
3093
- X, config,
3094
- threshold=threshold,
3095
- max_iter=max_iter,
3096
- ar_lag=ar_lag,
3097
- nan_method=nan_method,
3098
- nan_k=nan_k,
591
+ while num_iter < max_iter and not converged:
592
+ C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
593
+ y_est, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks,
594
+ tent_weights_dict=tent_weights_dict,
3099
595
  clock=clock,
3100
- clip_ar_coefficients=clip_ar_coefficients,
3101
- ar_clip_min=ar_clip_min,
3102
- ar_clip_max=ar_clip_max,
3103
- clip_data_values=clip_data_values,
3104
- data_clip_threshold=data_clip_threshold,
3105
- use_regularization=use_regularization,
3106
- regularization_scale=regularization_scale,
3107
- min_eigenvalue=min_eigenvalue,
3108
- max_eigenvalue=max_eigenvalue,
3109
- use_damped_updates=use_damped_updates,
3110
- damping_factor=damping_factor,
3111
- **kwargs
596
+ frequencies=frequencies,
597
+ config=config
3112
598
  )
3113
599
 
3114
- self._result = result
3115
- return result
600
+ # Handle likelihood decreases with damped updates
601
+ if num_iter > 0 and loglik < previous_loglik - 1e-3:
602
+ if use_damped_updates:
603
+ damping = damping_factor
604
+ C = damping * C_new + (1 - damping) * C
605
+ R = damping * R_new + (1 - damping) * R
606
+ A = damping * A_new + (1 - damping) * A
607
+ Q = damping * Q_new + (1 - damping) * Q
608
+ Z_0 = damping * Z_0_new + (1 - damping) * Z_0
609
+ V_0 = damping * V_0_new + (1 - damping) * V_0
610
+
611
+ if loglik < previous_loglik - 0.1:
612
+ try:
613
+ _, _, _, loglik_damped = run_kf(y_est, A, C, Q, R, Z_0, V_0)
614
+ if loglik_damped > previous_loglik:
615
+ loglik = loglik_damped
616
+ else:
617
+ loglik = previous_loglik
618
+ except (np.linalg.LinAlgError, ValueError, RuntimeError) as e:
619
+ _logger.debug(f"Likelihood recomputation failed: {type(e).__name__}, using damped update")
620
+ else:
621
+ loglik = previous_loglik
622
+ else:
623
+ C, R, A, Q = C_new, R_new, A_new, Q_new
624
+ Z_0, V_0 = Z_0_new, V_0_new
625
+
626
+ if num_iter > 2:
627
+ converged, _ = em_converged(loglik, previous_loglik, threshold, True)
628
+
629
+ if (num_iter % 10 == 0) and (num_iter > 0):
630
+ pct_change = 100 * ((loglik - previous_loglik) / abs(previous_loglik)) if previous_loglik != 0 else 0
631
+ _logger.info(f'Iteration {num_iter}/{max_iter}: loglik={loglik:.6f} ({pct_change:6.2f}% change)')
632
+
633
+ previous_loglik = loglik
634
+ num_iter += 1
3116
635
 
3117
- @property
3118
- def result(self) -> Optional[DFMResult]:
3119
- """Get the last fit result."""
3120
- return self._result
636
+ if num_iter < max_iter:
637
+ _logger.info(f'Convergence achieved at iteration {num_iter}')
638
+ else:
639
+ _logger.warning(f'Stopped at maximum iterations ({max_iter}) without convergence')
3121
640
 
3122
- @property
3123
- def config(self) -> Optional[DFMConfig]:
3124
- """Get the current configuration."""
3125
- return self._config
641
+ return A, C, Q, R, Z_0, V_0, loglik, num_iter, converged
3126
642
 
3127
643
 
3128
- def _dfm_core(X: np.ndarray, config: DFMConfig,
3129
- threshold: Optional[float] = None,
644
+ def _dfm_core(X: np.ndarray, config: DFMConfig,
645
+ threshold: Optional[float] = None,
3130
646
  max_iter: Optional[int] = None,
3131
647
  ar_lag: Optional[int] = None,
3132
648
  nan_method: Optional[int] = None,
@@ -3180,15 +696,31 @@ def _dfm_core(X: np.ndarray, config: DFMConfig,
3180
696
  - Estimation parameters: ar_lag, threshold, max_iter, nan_method, nan_k
3181
697
  Typically obtained from `load_config()`.
3182
698
  threshold : float, optional
3183
- EM convergence threshold. If None, uses config.threshold (default: 1e-5).
3184
- Convergence is declared when:
3185
- |loglik_current - loglik_previous| / avg(|loglik_current|, |loglik_previous|) < threshold.
3186
- Smaller values require more iterations but provide more precise convergence.
3187
- Typical range: 1e-5 to 1e-3.
699
+ EM convergence threshold. Default uses config.threshold (typically 1e-4).
700
+ EM iterations stop when log-likelihood improvement < threshold.
3188
701
  max_iter : int, optional
3189
- Maximum EM iterations. If None, uses config.max_iter (default: 5000).
3190
- For testing, use smaller values like 10-20.
3191
-
702
+ Maximum number of EM iterations. Default uses config.max_iter (typically 5000).
703
+ If convergence not reached, returns results from last iteration.
704
+ ar_lag : int, optional
705
+ AR lag order for factors. Default uses config.ar_lag (typically 1).
706
+ Higher values allow more complex dynamics but increase parameters.
707
+ nan_method : int, optional
708
+ Method for handling missing data during initialization:
709
+ - 1: Spline interpolation (default, recommended)
710
+ - 2: Forward fill then backward fill
711
+ - 3: Mean imputation
712
+ Default uses config.nan_method.
713
+ nan_k : int, optional
714
+ Spline interpolation order (only if nan_method=1). Default uses config.nan_k (typically 3).
715
+ clock : str, optional
716
+ Clock frequency ('m', 'q', 'sa', 'a'). Default uses config.clock.
717
+ Series with frequencies slower than clock use tent kernel aggregation.
718
+ **kwargs
719
+ Additional parameters that override config values:
720
+ - clip_ar_coefficients: Clip AR coefficients to stability bounds
721
+ - use_regularization: Apply regularization to covariance matrices
722
+ - use_damped_updates: Use damped parameter updates for stability
723
+
3192
724
  Returns
3193
725
  -------
3194
726
  DFMResult
@@ -3240,291 +772,79 @@ def _dfm_core(X: np.ndarray, config: DFMConfig,
3240
772
  """
3241
773
  _logger.info('Estimating the dynamic factor model (DFM)')
3242
774
 
3243
- # Additional check: ensure no NaN/Inf in input data that could cause issues
3244
- nan_mask = np.isnan(X)
3245
- inf_mask = np.isinf(X)
3246
- if np.any(inf_mask):
3247
- # Replace Inf with NaN (will be handled by missing data mechanisms)
3248
- X = np.where(inf_mask, np.nan, X)
3249
- warnings.warn("Data contains Inf values, replaced with NaN", UserWarning)
3250
-
3251
- # Store model parameters
3252
- blocks = config.get_blocks_array()
3253
- # nQ will be computed from frequencies if needed, but we use frequencies directly
3254
-
3255
- # Helper function to resolve parameter overrides
3256
- def _resolve_param(override, default):
3257
- """Resolve parameter: use override if provided, else use default."""
3258
- return override if override is not None else default
3259
-
3260
- # Get configurable parameters from unified DFMConfig
3261
- # Allow override via function arguments (main settings from config/default.yaml)
3262
- p = _resolve_param(ar_lag, config.ar_lag)
3263
- r = (np.array(config.factors_per_block)
3264
- if config.factors_per_block is not None
3265
- else np.ones(blocks.shape[1]))
3266
- nan_method = _resolve_param(nan_method, config.nan_method)
3267
- nan_k = _resolve_param(nan_k, config.nan_k)
3268
- threshold = _resolve_param(threshold, config.threshold)
3269
- max_iter = _resolve_param(max_iter, config.max_iter)
3270
- clock = _resolve_param(clock, config.clock)
3271
-
3272
- # Numerical stability parameters (can be overridden)
3273
- clip_ar_coefficients = _resolve_param(clip_ar_coefficients, config.clip_ar_coefficients)
3274
- ar_clip_min = _resolve_param(ar_clip_min, config.ar_clip_min)
3275
- ar_clip_max = _resolve_param(ar_clip_max, config.ar_clip_max)
3276
- clip_data_values = _resolve_param(clip_data_values, config.clip_data_values)
3277
- data_clip_threshold = _resolve_param(data_clip_threshold, config.data_clip_threshold)
3278
- use_regularization = _resolve_param(use_regularization, config.use_regularization)
3279
- regularization_scale = _resolve_param(regularization_scale, config.regularization_scale)
3280
- min_eigenvalue = _resolve_param(min_eigenvalue, config.min_eigenvalue)
3281
- max_eigenvalue = _resolve_param(max_eigenvalue, config.max_eigenvalue)
3282
- use_damped_updates = _resolve_param(use_damped_updates, config.use_damped_updates)
3283
- damping_factor = _resolve_param(damping_factor, config.damping_factor)
3284
-
3285
- # Display blocks (Block Loading Structure) - use logger for generic output
3286
- if _logger.isEnabledFor(logging.DEBUG):
3287
- try:
3288
- series_names = config.get_series_names()
3289
- block_names = (config.block_names if len(config.block_names) == blocks.shape[1]
3290
- else [f'Block_{i+1}' for i in range(blocks.shape[1])])
3291
- df = pd.DataFrame(blocks,
3292
- index=[name.replace(' ', '_') for name in series_names],
3293
- columns=block_names)
3294
- _logger.debug('Block Loading Structure')
3295
- _logger.debug(f'\n{df.to_string()}')
3296
- _logger.debug(f'Blocks shape: {blocks.shape}')
3297
- except Exception as e:
3298
- _logger.debug(f'Error displaying block structure: {e}')
3299
-
3300
- T, N = X.shape
3301
-
3302
- # Clock is already resolved from config via _resolve_param above
3303
- # No need to get it again
3304
-
3305
- # Get aggregation structure based on clock
3306
- agg_info = get_aggregation_structure(config, clock=clock)
3307
-
3308
- # Extract tent weights for use in em_step
3309
- tent_weights_dict = agg_info.get('tent_weights', {})
3310
-
3311
- # Determine which series need tent kernels (slower than clock)
3312
- # Get frequencies array for use in init_conditions and em_step
3313
- frequencies = np.array(config.get_frequencies()) if config.series else None
3314
-
3315
- # Find the slowest frequency that has a tent kernel
3316
- # This determines the R_mat and pC to use
3317
- R_mat = None
3318
- q = None
3319
- pC = 1
3320
-
3321
- # Check structures for tent kernels (they're keyed as (slower_freq, clock))
3322
- if agg_info['structures']:
3323
- # Use the first available structure (typically quarterly->monthly)
3324
- # In practice, we'll use the one with the most periods (most complex)
3325
- max_periods = 0
3326
- for (slower_freq, clock_freq), (R, q_vec) in agg_info['structures'].items():
3327
- if R is not None:
3328
- n_periods = R.shape[1]
3329
- if n_periods > max_periods:
3330
- max_periods = n_periods
3331
- R_mat = R
3332
- q = q_vec
3333
- pC = n_periods
3334
-
3335
- # Prepare data with robust standardization (avoid RuntimeWarnings on all-NaN columns)
3336
- def _safe_mean_std(matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
3337
- n_series = matrix.shape[1]
3338
- means = np.zeros(n_series)
3339
- stds = np.ones(n_series)
3340
- for j in range(n_series):
3341
- col = matrix[:, j]
3342
- mask = np.isfinite(col)
3343
- if np.any(mask):
3344
- means[j] = float(np.nanmean(col[mask]))
3345
- std_val = float(np.nanstd(col[mask]))
3346
- stds[j] = std_val if std_val > 0 else 1.0
3347
- else:
3348
- means[j] = 0.0
3349
- stds[j] = 1.0
3350
- return means, stds
3351
-
3352
- Mx, Wx = _safe_mean_std(X)
3353
-
3354
- # Handle zero/near-zero standard deviations (constant or near-constant series)
3355
- # Set minimum standard deviation to prevent division by zero
3356
- min_std = 1e-6
3357
- Wx = np.maximum(Wx, min_std)
3358
-
3359
- # Handle NaN standard deviations (all NaN series)
3360
- nan_std_mask = np.isnan(Wx) | np.isnan(Mx)
3361
- if np.any(nan_std_mask):
3362
- _logger.warning(
3363
- f"Series with NaN mean/std detected: {np.sum(nan_std_mask)}. "
3364
- f"Setting Wx=1.0, Mx=0.0 for these series."
3365
- )
3366
- Wx[nan_std_mask] = 1.0
3367
- Mx[nan_std_mask] = 0.0
3368
-
3369
- # Standardize series
3370
- xNaN = (X - Mx) / Wx
775
+ # Step 1: Prepare data and resolve parameters
776
+ X, blocks, params = _prepare_data_and_params(
777
+ X, config,
778
+ threshold=threshold, max_iter=max_iter, ar_lag=ar_lag,
779
+ nan_method=nan_method, nan_k=nan_k, clock=clock,
780
+ clip_ar_coefficients=clip_ar_coefficients,
781
+ ar_clip_min=ar_clip_min, ar_clip_max=ar_clip_max,
782
+ clip_data_values=clip_data_values,
783
+ data_clip_threshold=data_clip_threshold,
784
+ use_regularization=use_regularization,
785
+ regularization_scale=regularization_scale,
786
+ min_eigenvalue=min_eigenvalue, max_eigenvalue=max_eigenvalue,
787
+ use_damped_updates=use_damped_updates,
788
+ damping_factor=damping_factor
789
+ )
3371
790
 
3372
- # Clip extreme values if enabled in config
3373
- if clip_data_values:
3374
- n_clipped_before = np.sum(np.abs(xNaN) > data_clip_threshold)
3375
- xNaN = np.clip(xNaN, -data_clip_threshold, data_clip_threshold)
3376
- if n_clipped_before > 0:
3377
- pct_clipped = 100.0 * n_clipped_before / xNaN.size
3378
- _logger.warning(
3379
- f"Data value clipping applied: {n_clipped_before} values ({pct_clipped:.2f}%) "
3380
- f"clipped beyond ±{data_clip_threshold} standard deviations. "
3381
- f"This may remove important outliers. Consider investigating extreme values "
3382
- f"or disabling clipping (clip_data_values: false) if this is frequent."
3383
- )
791
+ # Extract parameters from dict for clarity
792
+ p = params['p']
793
+ r = params['r']
794
+ nan_method = params['nan_method']
795
+ nan_k = params['nan_k']
796
+ threshold = params['threshold']
797
+ max_iter = params['max_iter']
798
+ clock = params['clock']
799
+ clip_data_values = params['clip_data_values']
800
+ data_clip_threshold = params['data_clip_threshold']
801
+ use_damped_updates = params['use_damped_updates']
802
+ damping_factor = params['damping_factor']
803
+ T, N = params['T'], params['N']
804
+
805
+ # Step 2: Prepare aggregation structure
806
+ tent_weights_dict, R_mat, q, frequencies, i_idio, nQ = _prepare_aggregation_structure(
807
+ config, clock
808
+ )
3384
809
 
3385
- # Replace any remaining NaN/Inf with 0 (after clipping)
3386
- xNaN = np.nan_to_num(xNaN, nan=0.0, posinf=data_clip_threshold if clip_data_values else 100,
3387
- neginf=-data_clip_threshold if clip_data_values else -100)
810
+ # Step 3: Standardize data
811
+ x_standardized, Mx, Wx = _standardize_data(X, clip_data_values, data_clip_threshold)
3388
812
 
3389
- # Initial conditions - use configurable nan_method and nan_k
813
+ # Step 4: Initial conditions
3390
814
  opt_nan = {'method': nan_method, 'k': nan_k}
3391
-
3392
- # Compute i_idio: 1 for clock frequency, 0 for slower frequencies
3393
- # Slower frequencies use tent kernel structure instead of direct idiosyncratic components
3394
- if frequencies is not None:
3395
- clock_hierarchy = FREQUENCY_HIERARCHY.get(clock, 3)
3396
- i_idio = np.array([
3397
- 1 if j >= len(frequencies) or FREQUENCY_HIERARCHY.get(frequencies[j], 3) <= clock_hierarchy
3398
- else 0
3399
- for j in range(N)
3400
- ])
3401
- else:
3402
- # Fallback: assume all series have idiosyncratic components
3403
- i_idio = np.ones(N)
3404
-
3405
- nQ = N - np.sum(i_idio) if frequencies is not None else 0
3406
-
3407
815
  A, C, Q, R, Z_0, V_0 = init_conditions(
3408
- xNaN, r, p, blocks, opt_nan, R_mat, q, nQ, i_idio,
816
+ x_standardized, r, p, blocks, opt_nan, R_mat, q, nQ, i_idio,
3409
817
  clock=clock, tent_weights_dict=tent_weights_dict, frequencies=frequencies
3410
818
  )
3411
819
 
3412
- # Verify initial conditions are valid (should already be cleaned by init_conditions)
3413
- if np.any(~np.isfinite(A)) or np.any(~np.isfinite(C)) or np.any(~np.isfinite(Q)) or np.any(~np.isfinite(R)):
820
+ # Verify initial conditions
821
+ if not _check_finite(A, "A") or not _check_finite(C, "C") or not _check_finite(Q, "Q") or not _check_finite(R, "R"):
3414
822
  _logger.warning("Initial conditions contain NaN/Inf - this should not happen after init_conditions()")
3415
823
 
3416
- # Initialize EM loop
3417
- previous_loglik = -np.inf
3418
- num_iter = 0
3419
- LL = [-np.inf]
3420
- converged = False
3421
-
3422
- # y for estimation is WITH missing data (n x T)
3423
- y = xNaN.T # Transpose to get n x T
3424
-
3425
- # Remove leading and ending NaNs for estimation
3426
- # Use separate dict to avoid modifying opt_nan
824
+ # Step 5: Prepare data for EM (with and without missing values)
825
+ y = x_standardized.T # n x T (with missing data)
3427
826
  opt_nan_est = {'method': 3, 'k': nan_k}
3428
- xNaN_est, _ = rem_nans_spline(xNaN, method=opt_nan_est['method'], k=opt_nan_est['k'])
3429
- y_est = xNaN_est.T # n x T
3430
-
3431
- # EM loop with likelihood monitoring
3432
- # In proper EM, likelihood should be non-decreasing. Handle decreases gracefully.
3433
- while num_iter < max_iter and not converged:
3434
- C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
3435
- y_est, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks,
3436
- tent_weights_dict=tent_weights_dict,
3437
- clock=clock,
3438
- frequencies=frequencies,
3439
- config=config
3440
- )
3441
-
3442
- # Check if likelihood decreased significantly (more than numerical precision)
3443
- # Small decreases (< 1e-3) are acceptable due to numerical precision
3444
- # Larger decreases indicate numerical issues - use damped update
3445
- if num_iter > 0 and loglik < previous_loglik - 1e-3:
3446
- # Likelihood decreased - use damped update to preserve stability
3447
- if use_damped_updates:
3448
- damping = damping_factor
3449
- # Apply damped update (blend new and current parameters)
3450
- C = damping * C_new + (1 - damping) * C
3451
- R = damping * R_new + (1 - damping) * R
3452
- A = damping * A_new + (1 - damping) * A
3453
- Q = damping * Q_new + (1 - damping) * Q
3454
- Z_0 = damping * Z_0_new + (1 - damping) * Z_0
3455
- V_0 = damping * V_0_new + (1 - damping) * V_0
3456
-
3457
- # Recompute likelihood with damped parameters if decrease was significant
3458
- if loglik < previous_loglik - 0.1: # Significant decrease
3459
- try:
3460
- _, _, _, loglik_damped = run_kf(y_est, A, C, Q, R, Z_0, V_0)
3461
- if loglik_damped > previous_loglik:
3462
- loglik = loglik_damped
3463
- else:
3464
- # Damped update still worse - keep previous loglik
3465
- loglik = previous_loglik
3466
- except Exception:
3467
- # Likelihood recomputation failed - keep damped update
3468
- _logger.debug("Likelihood recomputation failed, using damped update")
3469
- else:
3470
- # Damped updates disabled - keep previous parameters
3471
- loglik = previous_loglik
3472
- else:
3473
- # Normal update - likelihood increased or small decrease
3474
- C, R, A, Q = C_new, R_new, A_new, Q_new
3475
- Z_0, V_0 = Z_0_new, V_0_new
3476
-
3477
- if num_iter > 2:
3478
- converged, decreased = em_converged(loglik, previous_loglik, threshold, True)
3479
- # If likelihood decreased significantly and we're not near convergence,
3480
- # it may indicate numerical issues - but we've already handled it above
3481
-
3482
- if (num_iter % 10 == 0) and (num_iter > 0):
3483
- pct_change = 100 * ((loglik - previous_loglik) / abs(previous_loglik)) if previous_loglik != 0 else 0
3484
- _logger.info(f'Iteration {num_iter}/{max_iter}: loglik={loglik:.6f} ({pct_change:6.2f}% change)')
3485
-
3486
- LL.append(loglik)
3487
- previous_loglik = loglik
3488
- num_iter += 1
3489
-
3490
- if num_iter < max_iter:
3491
- _logger.info(f'Convergence achieved at iteration {num_iter}')
3492
- else:
3493
- _logger.warning(f'Stopped at maximum iterations ({max_iter}) without convergence')
827
+ x_est, _ = rem_nans_spline(x_standardized, method=opt_nan_est['method'], k=opt_nan_est['k'])
828
+ y_est = x_est.T # n x T (missing data removed)
829
+
830
+ # Step 6: Run EM algorithm
831
+ A, C, Q, R, Z_0, V_0, loglik, num_iter, converged = _run_em_algorithm(
832
+ y, y_est, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks,
833
+ tent_weights_dict, clock, frequencies, config,
834
+ threshold, max_iter, use_damped_updates, damping_factor
835
+ )
3494
836
 
3495
- # Final run of Kalman filter and smoother
3496
- # MATLAB: Zsmooth = runKF(y, A, C, Q, R, Z_0, V_0)';
3497
- # run_kf returns m x (T+1), transpose to (T+1) x m
837
+ # Step 7: Final Kalman smoothing
3498
838
  zsmooth, _, _, _ = run_kf(y, A, C, Q, R, Z_0, V_0)
3499
839
  Zsmooth = zsmooth.T # m x (T+1) -> (T+1) x m
3500
840
 
3501
- # Get smoothed X (standardized)
3502
- # MATLAB: x_sm = Zsmooth(2:end,:) * C';
3503
- # Zsmooth is (T+1) x m, C is N x m, C' is m x N
3504
- # Zsmooth(2:end,:) selects rows 2 to end (skip initial state), gives T x m
3505
- # Then Zsmooth(2:end,:) * C' gives T x m * m x N = T x N
841
+ # Step 8: Compute smoothed data
3506
842
  x_sm = Zsmooth[1:, :] @ C.T # T x N (standardized smoothed data)
3507
-
3508
- # Unstandardized smoothed data
3509
- # MATLAB: X_sm = repmat(Wx,T,1) .* x_sm + repmat(Mx,T,1)
3510
- # Handle NaN in Wx and Mx (can occur if series had all NaN values)
3511
843
  Wx_clean = np.where(np.isnan(Wx), 1.0, Wx)
3512
844
  Mx_clean = np.where(np.isnan(Mx), 0.0, Mx)
3513
- # Element-wise multiplication: (T x N) .* (T x N) + (T x N)
3514
- # Use broadcasting: x_sm is T x N, Wx_clean is (N,), so x_sm * Wx_clean broadcasts correctly
3515
- X_sm = x_sm * Wx_clean + Mx_clean # Broadcasting: T x N * (N,) + (N,) = T x N
3516
-
3517
- # Calculate RMSE: compare smoothed data to original data (where available)
3518
- # Only use non-missing observations for comparison
3519
- rmse_overall, rmse_per_series = calculate_rmse(X, X_sm, mask=None)
845
+ X_sm = x_sm * Wx_clean + Mx_clean # T x N (unstandardized smoothed data)
3520
846
 
3521
- # Calculate standardized RMSE for better comparison across series
3522
- # Standardize both actual and predicted using the same Wx and Mx
3523
- X_std = (X - Mx_clean) / np.where(Wx_clean != 0, Wx_clean, 1.0)
3524
- x_sm_std = x_sm # Already standardized
3525
- rmse_std_overall, rmse_std_per_series = calculate_rmse(X_std, x_sm_std, mask=None)
3526
-
3527
- # Create result structure
847
+ # Create DFMResult object
3528
848
  Res = DFMResult(
3529
849
  x_sm=x_sm,
3530
850
  X_sm=X_sm,
@@ -3542,377 +862,16 @@ def _dfm_core(X: np.ndarray, config: DFMConfig,
3542
862
  converged=converged,
3543
863
  num_iter=num_iter,
3544
864
  loglik=loglik,
3545
- rmse=rmse_overall,
3546
- rmse_per_series=rmse_per_series,
3547
- rmse_std=rmse_std_overall,
3548
- rmse_std_per_series=rmse_std_per_series,
3549
- series_ids=config.get_series_ids() if hasattr(config, 'get_series_ids') else [],
3550
- block_names=config.block_names if hasattr(config, 'block_names') else None
865
+ series_ids=safe_get_method(config, 'get_series_ids', []),
866
+ block_names=safe_get_attr(config, 'block_names', None)
3551
867
  )
3552
868
 
3553
- # Display output tables (optional - can be disabled for cleaner output)
3554
- try:
869
+ # Display diagnostic tables if debug logging is enabled
870
+ if _logger.isEnabledFor(logging.DEBUG):
3555
871
  _display_dfm_tables(Res, config, nQ)
3556
- except Exception as e:
3557
- _logger.debug(f"Failed to display output tables: {e}")
3558
872
 
3559
873
  return Res
3560
874
 
3561
875
 
3562
- def _display_dfm_tables(Res: DFMResult, config, nQ: int) -> None:
3563
- """Display DFM estimation output tables."""
3564
- # nQ: number of slower-frequency series (e.g., quarterly when clock is monthly)
3565
- # n_same_freq: number of same-frequency series (e.g., monthly when clock is monthly)
3566
- series_ids = config.get_series_ids()
3567
- series_names = config.get_series_names()
3568
- block_names = config.block_names
3569
- n_same_freq = len(series_ids) - nQ
3570
- nLags = max(Res.p, 5) # Minimum 5 lags for mixed-frequency aggregation
3571
- nFactors = int(np.sum(Res.r))
3572
-
3573
- try:
3574
- print('\n\n\n')
3575
-
3576
- # Factor Loadings for Same-Frequency Series
3577
- print('Factor Loadings for Same-Frequency Series')
3578
- # Only select lag(0) terms - every 5th column starting from 0
3579
- C_same_freq = Res.C[:n_same_freq, ::5][:, :nFactors]
3580
- try:
3581
- import pandas as pd
3582
- df = pd.DataFrame(C_same_freq,
3583
- index=[name.replace(' ', '_') for name in series_names[:n_same_freq]],
3584
- columns=block_names[:nFactors] if len(block_names) >= nFactors else [f'Block{i+1}' for i in range(nFactors)])
3585
- print(df.to_string())
3586
- except (ValueError, IndexError, AttributeError) as e:
3587
- # DataFrame creation or display failed - show shape instead
3588
- # This can happen if indices/columns don't match or pandas unavailable
3589
- print(f'Same-frequency loadings shape: {C_same_freq.shape}')
3590
- print('\n\n\n')
3591
-
3592
- # Slower-Frequency Loadings Sample (First Factor)
3593
- print('Slower-Frequency Loadings Sample (First Factor)')
3594
- # Select only slower-frequency series and first 5 columns (lag 0-4)
3595
- C_slower_freq = Res.C[-nQ:, :5]
3596
- try:
3597
- n_lags = min(5, C_slower_freq.shape[1])
3598
- lag_cols = [f'factor1_lag{i}' for i in range(n_lags)]
3599
- df = pd.DataFrame(C_slower_freq,
3600
- index=[name.replace(' ', '_') for name in series_names[-nQ:]],
3601
- columns=lag_cols)
3602
- print(df.to_string())
3603
- except (ValueError, IndexError, AttributeError) as e:
3604
- # DataFrame creation or display failed - show shape instead
3605
- print(f'Slower-frequency loadings shape: {C_slower_freq.shape}')
3606
- print('\n\n\n')
3607
-
3608
- # Autoregressive Coefficients on Factors
3609
- print('Autoregressive Coefficients on Factors')
3610
- A_terms = np.diag(Res.A)
3611
- Q_terms = np.diag(Res.Q)
3612
- # Only select lag(0) terms - every 5th element
3613
- A_terms_factors = A_terms[::5][:nFactors]
3614
- Q_terms_factors = Q_terms[::5][:nFactors]
3615
- try:
3616
- df = pd.DataFrame({
3617
- 'AR_Coefficient': A_terms_factors,
3618
- 'Variance_Residual': Q_terms_factors
3619
- }, index=[name.replace(' ', '_') for name in block_names[:nFactors]])
3620
- print(df.to_string())
3621
- except (ValueError, IndexError, AttributeError) as e:
3622
- # DataFrame creation or display failed - show values instead
3623
- print(f'Factor AR coefficients: {A_terms_factors}')
3624
- print('\n\n\n')
3625
-
3626
- # Autoregressive Coefficients on Idiosyncratic Component
3627
- print('Autoregressive Coefficients on Idiosyncratic Component')
3628
- rp1 = nFactors * 5 # Start of idiosyncratic component (0-indexed)
3629
- # Same-frequency idiosyncratic: indices rp1 to rp1+n_same_freq-1 (inclusive)
3630
- same_freq_idx = np.arange(rp1, rp1 + n_same_freq)
3631
- # Slower-frequency idiosyncratic: every 5th index starting from rp1+n_same_freq
3632
- slower_freq_idx = np.arange(rp1 + n_same_freq, len(A_terms), 5)
3633
- combined_idx = np.concatenate([same_freq_idx, slower_freq_idx])
3634
-
3635
- # Ensure indices are within bounds
3636
- combined_idx = combined_idx[combined_idx < len(A_terms)]
3637
-
3638
- A_idio = A_terms[combined_idx]
3639
- Q_idio = Q_terms[combined_idx]
3640
- try:
3641
- # Map indices back to series names
3642
- # Same-frequency series: direct mapping (0 to n_same_freq-1)
3643
- # Slower-frequency series: need to map to slower-frequency series index
3644
- series_indices = []
3645
- for idx in combined_idx:
3646
- if idx < rp1 + n_same_freq:
3647
- # Same-frequency idiosyncratic - map to same-frequency series
3648
- series_idx = idx - rp1
3649
- if series_idx < n_same_freq:
3650
- series_indices.append(series_idx)
3651
- else:
3652
- series_indices.append(None)
3653
- else:
3654
- # Slower-frequency idiosyncratic - map to slower-frequency series
3655
- # (idx - (rp1 + n_same_freq)) / 5 gives which slower-frequency series
3656
- slower_idx = (idx - (rp1 + n_same_freq)) // 5
3657
- if slower_idx < nQ:
3658
- series_indices.append(n_same_freq + slower_idx)
3659
- else:
3660
- series_indices.append(None)
3661
-
3662
- # Filter out None and get valid series names
3663
- valid_indices = [i for i in series_indices if i is not None and i < len(series_names)]
3664
- series_names_list = [series_names[i].replace(' ', '_') for i in valid_indices[:len(A_idio)]]
3665
-
3666
- df = pd.DataFrame({
3667
- 'AR_Coefficient': A_idio[:len(series_names_list)],
3668
- 'Variance_Residual': Q_idio[:len(series_names_list)]
3669
- }, index=series_names_list)
3670
- print(df.to_string())
3671
- except (ValueError, IndexError, AttributeError) as e:
3672
- # DataFrame creation or display failed - show values instead
3673
- print(f'Idiosyncratic AR coefficients (first 10): {A_idio[:min(10, len(A_idio))]}')
3674
- if len(A_idio) > 10:
3675
- print(f'... (total {len(A_idio)} coefficients)')
3676
- print('\n\n\n')
3677
-
3678
- # Model Fit Statistics (RMSE)
3679
- if Res.rmse is not None and not np.isnan(Res.rmse):
3680
- print('Model Fit Statistics')
3681
- print(f' Overall RMSE (original scale): {Res.rmse:.6f}')
3682
- if Res.rmse_std is not None and not np.isnan(Res.rmse_std):
3683
- print(f' Overall RMSE (standardized scale): {Res.rmse_std:.6f}')
3684
- if Res.rmse_per_series is not None and len(Res.rmse_per_series) > 0:
3685
- print('\n RMSE per Series (Original Scale):')
3686
- try:
3687
- series_names = config.get_series_names() if config.series else [f"Series_{i}" for i in range(len(Res.rmse_per_series))]
3688
- for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
3689
- if not np.isnan(rmse_val):
3690
- # Calculate RMSE as percentage of mean if available
3691
- mean_val = Res.Mx[i] if i < len(Res.Mx) else np.nan
3692
- if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3693
- pct = 100.0 * rmse_val / abs(mean_val)
3694
- print(f' {name:40s}: {rmse_val:.6f} ({pct:.2f}% of mean)')
3695
- else:
3696
- print(f' {name:40s}: {rmse_val:.6f}')
3697
- except Exception:
3698
- # Fallback if series names not available
3699
- for i, rmse_val in enumerate(Res.rmse_per_series):
3700
- if not np.isnan(rmse_val):
3701
- print(f' Series {i:3d}: {rmse_val:.6f}')
3702
-
3703
- # Show standardized RMSE per series
3704
- if Res.rmse_std_per_series is not None and len(Res.rmse_std_per_series) > 0:
3705
- print('\n RMSE per Series (Standardized Scale):')
3706
- try:
3707
- series_names = config.get_series_names() if config.series else [f"Series_{i}" for i in range(len(Res.rmse_std_per_series))]
3708
- for i, (name, rmse_std_val) in enumerate(zip(series_names, Res.rmse_std_per_series)):
3709
- if not np.isnan(rmse_std_val):
3710
- print(f' {name:40s}: {rmse_std_val:.6f} std dev')
3711
- except Exception:
3712
- # Fallback if series names not available
3713
- for i, rmse_std_val in enumerate(Res.rmse_std_per_series):
3714
- if not np.isnan(rmse_std_val):
3715
- print(f' Series {i:3d}: {rmse_std_val:.6f} std dev')
3716
-
3717
- # Diagnostic warnings for high RMSE
3718
- if Res.rmse_per_series is not None and Res.Mx is not None:
3719
- print('\n Diagnostic Warnings:')
3720
- try:
3721
- series_names = config.get_series_names() if config.series else [f"Series_{i}" for i in range(len(Res.rmse_per_series))]
3722
- warnings_count = 0
3723
- for i, (name, rmse_val) in enumerate(zip(series_names, Res.rmse_per_series)):
3724
- if not np.isnan(rmse_val) and i < len(Res.Mx):
3725
- mean_val = Res.Mx[i]
3726
- std_val = Res.Wx[i] if i < len(Res.Wx) else np.nan
3727
- if not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3728
- pct_of_mean = 100.0 * rmse_val / abs(mean_val)
3729
- # Warn if RMSE > 50% of mean or > 10 standard deviations
3730
- if pct_of_mean > 50.0 or (not np.isnan(std_val) and rmse_val > 10.0 * std_val):
3731
- warnings_count += 1
3732
- if warnings_count <= 5: # Limit to first 5 warnings
3733
- print(f' ⚠ {name:40s}: RMSE is {pct_of_mean:.1f}% of mean')
3734
- if not np.isnan(std_val):
3735
- print(f' (RMSE={rmse_val:.2e}, Mean={mean_val:.2e}, Std={std_val:.2e})')
3736
- if warnings_count > 5:
3737
- print(f' ... and {warnings_count - 5} more series with high RMSE')
3738
- except Exception:
3739
- pass
3740
- print('\n\n\n')
3741
-
3742
- except Exception as e:
3743
- _logger.debug(f'Error displaying tables: {e}')
3744
-
3745
-
3746
- def diagnose_series(Res: DFMResult, config: DFMConfig, series_name: Optional[str] = None,
3747
- series_idx: Optional[int] = None) -> Dict[str, Any]:
3748
- """Diagnose model fit issues for a specific series.
3749
-
3750
- This function provides detailed diagnostics to help identify why a series
3751
- may have high RMSE, including factor loadings, standardization values,
3752
- and reconstruction errors.
3753
-
3754
- Parameters
3755
- ----------
3756
- Res : DFMResult
3757
- DFM estimation results
3758
- config : DFMConfig
3759
- DFM configuration
3760
- series_name : str, optional
3761
- Name of the series to diagnose. If provided, series_idx is ignored.
3762
- series_idx : int, optional
3763
- Index of the series to diagnose (0-based). Used if series_name is not provided.
3764
-
3765
- Returns
3766
- -------
3767
- dict
3768
- Dictionary containing diagnostic information:
3769
- - 'series_name': Name of the series
3770
- - 'series_idx': Index of the series
3771
- - 'rmse_original': RMSE on original scale
3772
- - 'rmse_standardized': RMSE on standardized scale
3773
- - 'mean': Series mean (Mx)
3774
- - 'std': Series standard deviation (Wx)
3775
- - 'rmse_pct_of_mean': RMSE as percentage of mean
3776
- - 'rmse_in_std_devs': RMSE in standard deviations
3777
- - 'factor_loadings': Factor loadings for this series
3778
- - 'max_loading': Maximum absolute loading
3779
- - 'loading_sum_sq': Sum of squared loadings
3780
- - 'reconstruction_error_mean': Mean reconstruction error
3781
- - 'reconstruction_error_std': Std of reconstruction error
3782
-
3783
- Examples
3784
- --------
3785
- >>> Res = dfm(X, config)
3786
- >>> diag = diagnose_series(Res, config, series_name="Series_A")
3787
- >>> print(f"Standardized RMSE: {diag['rmse_standardized']:.4f}")
3788
- """
3789
- # Find series index
3790
- if series_name is not None:
3791
- try:
3792
- series_names = config.get_series_names() if config.series else []
3793
- if series_name in series_names:
3794
- series_idx = series_names.index(series_name)
3795
- else:
3796
- # Try case-insensitive match
3797
- series_idx = next((i for i, name in enumerate(series_names)
3798
- if name.lower() == series_name.lower()), None)
3799
- if series_idx is None:
3800
- raise ValueError(f"Series '{series_name}' not found in configuration")
3801
- except (AttributeError, ValueError) as e:
3802
- raise ValueError(f"Cannot find series '{series_name}': {e}")
3803
-
3804
- if series_idx is None:
3805
- raise ValueError("Must provide either series_name or series_idx")
3806
-
3807
- if series_idx < 0 or series_idx >= Res.C.shape[0]:
3808
- raise ValueError(f"Series index {series_idx} out of range [0, {Res.C.shape[0]})")
3809
-
3810
- # Get series name
3811
- try:
3812
- series_names = config.get_series_names() if config.series else []
3813
- name = series_names[series_idx] if series_idx < len(series_names) else f"Series_{series_idx}"
3814
- except (AttributeError, IndexError):
3815
- name = f"Series_{series_idx}"
3816
-
3817
- # Get RMSE values
3818
- rmse_original = None
3819
- rmse_standardized = None
3820
- if Res.rmse_per_series is not None and series_idx < len(Res.rmse_per_series):
3821
- rmse_original = Res.rmse_per_series[series_idx]
3822
- if Res.rmse_std_per_series is not None and series_idx < len(Res.rmse_std_per_series):
3823
- rmse_standardized = Res.rmse_std_per_series[series_idx]
3824
-
3825
- # Get standardization values
3826
- mean_val = Res.Mx[series_idx] if series_idx < len(Res.Mx) else np.nan
3827
- std_val = Res.Wx[series_idx] if series_idx < len(Res.Wx) else np.nan
3828
-
3829
- # Calculate RMSE metrics
3830
- rmse_pct_of_mean = None
3831
- rmse_in_std_devs = None
3832
- if not np.isnan(rmse_original) and not np.isnan(mean_val) and abs(mean_val) > 1e-6:
3833
- rmse_pct_of_mean = 100.0 * rmse_original / abs(mean_val)
3834
- if not np.isnan(rmse_original) and not np.isnan(std_val) and std_val > 1e-6:
3835
- rmse_in_std_devs = rmse_original / std_val
3836
-
3837
- # Get factor loadings
3838
- factor_loadings = Res.C[series_idx, :] if series_idx < Res.C.shape[0] else np.array([])
3839
- max_loading = np.max(np.abs(factor_loadings)) if len(factor_loadings) > 0 else np.nan
3840
- loading_sum_sq = np.sum(factor_loadings ** 2) if len(factor_loadings) > 0 else np.nan
3841
-
3842
- # Calculate reconstruction errors (if we have original data)
3843
- reconstruction_error_mean = None
3844
- reconstruction_error_std = None
3845
- # This would require passing X to the function or storing it in Res
3846
-
3847
- return {
3848
- 'series_name': name,
3849
- 'series_idx': series_idx,
3850
- 'rmse_original': rmse_original,
3851
- 'rmse_standardized': rmse_standardized,
3852
- 'mean': mean_val,
3853
- 'std': std_val,
3854
- 'rmse_pct_of_mean': rmse_pct_of_mean,
3855
- 'rmse_in_std_devs': rmse_in_std_devs,
3856
- 'factor_loadings': factor_loadings,
3857
- 'max_loading': max_loading,
3858
- 'loading_sum_sq': loading_sum_sq,
3859
- 'reconstruction_error_mean': reconstruction_error_mean,
3860
- 'reconstruction_error_std': reconstruction_error_std
3861
- }
3862
-
3863
-
3864
- def print_series_diagnosis(Res: DFMResult, config: DFMConfig,
3865
- series_name: Optional[str] = None,
3866
- series_idx: Optional[int] = None) -> None:
3867
- """Print a formatted diagnosis report for a specific series.
3868
-
3869
- Parameters
3870
- ----------
3871
- Res : DFMResult
3872
- DFM estimation results
3873
- config : DFMConfig
3874
- DFM configuration
3875
- series_name : str, optional
3876
- Name of the series to diagnose
3877
- series_idx : int, optional
3878
- Index of the series to diagnose
3879
-
3880
- Examples
3881
- --------
3882
- >>> Res = dfm(X, config)
3883
- >>> print_series_diagnosis(Res, config, series_name="Series_A")
3884
- """
3885
- diag = diagnose_series(Res, config, series_name=series_name, series_idx=series_idx)
3886
-
3887
- print(f"\n{'='*70}")
3888
- print(f"DIAGNOSTIC REPORT: {diag['series_name']}")
3889
- print(f"{'='*70}\n")
3890
-
3891
- print("RMSE Statistics:")
3892
- print(f" Original scale: {diag['rmse_original']:.6e}" if diag['rmse_original'] is not None else " Original scale: N/A")
3893
- print(f" Standardized scale: {diag['rmse_standardized']:.6f} std dev" if diag['rmse_standardized'] is not None else " Standardized scale: N/A")
3894
- if diag['rmse_pct_of_mean'] is not None:
3895
- print(f" As % of mean: {diag['rmse_pct_of_mean']:.2f}%")
3896
- if diag['rmse_in_std_devs'] is not None:
3897
- print(f" In std deviations: {diag['rmse_in_std_devs']:.2f}x")
3898
-
3899
- print("\nStandardization Values:")
3900
- print(f" Mean: {diag['mean']:.6e}" if not np.isnan(diag['mean']) else " Mean: N/A")
3901
- print(f" Std: {diag['std']:.6e}" if not np.isnan(diag['std']) else " Std: N/A")
3902
-
3903
- print("\nFactor Loadings:")
3904
- if len(diag['factor_loadings']) > 0:
3905
- print(f" Number of loadings: {len(diag['factor_loadings'])}")
3906
- print(f" Max absolute: {diag['max_loading']:.6f}" if not np.isnan(diag['max_loading']) else " Max absolute: N/A")
3907
- print(f" Sum of squares: {diag['loading_sum_sq']:.6f}" if not np.isnan(diag['loading_sum_sq']) else " Sum of squares: N/A")
3908
- # Show top 5 loadings
3909
- abs_loadings = np.abs(diag['factor_loadings'])
3910
- top_indices = np.argsort(abs_loadings)[-5:][::-1]
3911
- print(f" Top 5 loadings:")
3912
- for idx in top_indices:
3913
- print(f" Factor {idx:3d}: {diag['factor_loadings'][idx]:8.4f}")
3914
- else:
3915
- print(" No loadings available")
3916
-
3917
- print(f"\n{'='*70}\n")
876
+ # Diagnostic functions are imported directly from core.diagnostics - no proxy functions needed
3918
877