dfm-python 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dfm_python/dfm.py ADDED
@@ -0,0 +1,2366 @@
1
+ """Dynamic Factor Model (DFM) estimation using Expectation-Maximization algorithm.
2
+
3
+ This module implements the core DFM estimation framework, including:
4
+ - Initial parameter estimation via PCA and OLS
5
+ - EM algorithm for iterative parameter refinement
6
+ - Kalman filtering and smoothing for factor extraction
7
+ - Clock-based mixed-frequency handling with tent kernels
8
+ - Robust numerical stability and error handling
9
+
10
+ The implementation follows the FRBNY approach, where all latent factors
11
+ evolve at a common clock frequency, with lower-frequency observations
12
+ mapped to higher-frequency latent states via deterministic tent kernels.
13
+ """
14
+
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
+ from dataclasses import dataclass
20
+ from typing import Tuple, Optional, Any, Dict
21
+ import warnings
22
+ import logging
23
+
24
+ from .kalman import run_kf
25
+ from .config import DFMConfig
26
+
27
+ # Import rem_nans_spline from utils (no longer duplicated)
28
+ from .utils.data_utils import rem_nans_spline
29
+ from .utils.aggregation import (
30
+ get_aggregation_structure,
31
+ FREQUENCY_HIERARCHY,
32
+ )
33
+
34
+ def _rem_nans_spline(X: np.ndarray, method: int = 2, k: int = 3):
35
+ """Treat NaNs in dataset for DFM estimation (wrapper for rem_nans_spline).
36
+
37
+ This is a wrapper around rem_nans_spline from utils.data_utils to maintain
38
+ backward compatibility with the internal _rem_nans_spline interface.
39
+ Returns (X_clean, dummy_keep) where dummy_keep is always True (not used).
40
+ """
41
+ X_clean, _ = rem_nans_spline(X, method=method, k=k)
42
+ # Return dummy keep mask (not actually used, but kept for interface compatibility)
43
+ T_orig, _ = X.shape
44
+ return X_clean, np.ones(T_orig, dtype=bool)
45
+
46
+ # Set up logger for fallback tracking (optional - only if logging is enabled)
47
+ _logger = logging.getLogger(__name__)
48
+ # Only configure if not already configured and if logging is enabled
49
+ if not _logger.handlers:
50
+ # Check if logging is enabled at module level (avoid overhead if disabled)
51
+ root_logger = logging.getLogger()
52
+ if root_logger.level <= logging.WARNING:
53
+ _logger.setLevel(logging.WARNING)
54
+ handler = logging.StreamHandler()
55
+ handler.setFormatter(logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
57
+ datefmt='%Y-%m-%d %H:%M:%S'
58
+ ))
59
+ _logger.addHandler(handler)
60
+
61
+
62
+ # Validation is done inline where needed for performance
63
+
64
+ # ============================================================================
65
+ # Constants
66
+ # ============================================================================
67
+
68
+ # Divisor for quarterly idiosyncratic variance calculation
69
+ # This scaling factor accounts for the temporal aggregation when computing
70
+ # quarterly series variances from monthly latent processes
71
+ QUARTERLY_VARIANCE_DIVISOR: float = 19.0
72
+
73
+ # Maximum scaling factor for eigenvalue capping
74
+ # Used to prevent numerical overflow when eigenvalues become very large
75
+ # during eigendecomposition in initial conditions calculation
76
+ SCALING_FACTOR: float = 1e6
77
+
78
+
79
+ def _ensure_symmetric(M: np.ndarray) -> np.ndarray:
80
+ """Ensure matrix is symmetric by averaging with its transpose.
81
+
82
+ This is a common technique to enforce symmetry in covariance matrices that
83
+ may have accumulated small numerical errors during computation. The operation
84
+ (M + M.T) / 2 guarantees symmetry while preserving the matrix structure.
85
+
86
+ Parameters
87
+ ----------
88
+ M : np.ndarray
89
+ Matrix to symmetrize, typically a covariance matrix (m x m)
90
+
91
+ Returns
92
+ -------
93
+ np.ndarray
94
+ Symmetric matrix of same shape as M, computed as 0.5 * (M + M.T)
95
+
96
+ Notes
97
+ -----
98
+ This function is used extensively throughout the EM algorithm to ensure
99
+ covariance matrices (Q, R, V_0) remain symmetric after numerical operations
100
+ that might introduce small asymmetries due to floating-point arithmetic.
101
+ """
102
+ return 0.5 * (M + M.T)
103
+
104
+
105
+ def _safe_divide(numerator: np.ndarray, denominator: float, default: float = 0.0) -> np.ndarray:
106
+ """Safely divide numerator by denominator, handling zero and invalid values.
107
+
108
+ This utility function prevents division by zero and handles NaN/Inf values
109
+ that can arise in numerical computations. It's used throughout the codebase
110
+ to ensure robust numerical operations.
111
+
112
+ Parameters
113
+ ----------
114
+ numerator : np.ndarray
115
+ Numerator array of any shape
116
+ denominator : float
117
+ Denominator value (scalar)
118
+ default : float, optional
119
+ Default value to use if denominator is zero, NaN, or Inf, by default 0.0.
120
+ Also used to replace any NaN/Inf values in the result.
121
+
122
+ Returns
123
+ -------
124
+ np.ndarray
125
+ Result of division with same shape as numerator.
126
+ Elements where denominator is invalid or result is non-finite are set to default.
127
+
128
+ Examples
129
+ --------
130
+ >>> x = np.array([1.0, 2.0, 3.0])
131
+ >>> _safe_divide(x, 2.0)
132
+ array([0.5, 1.0, 1.5])
133
+ >>> _safe_divide(x, 0.0, default=1.0)
134
+ array([1.0, 1.0, 1.0])
135
+ """
136
+ if denominator == 0 or np.isnan(denominator) or np.isinf(denominator):
137
+ return np.full_like(numerator, default)
138
+ result = numerator / denominator
139
+ # Replace any NaN/Inf in result with default
140
+ result = np.where(np.isfinite(result), result, default)
141
+ return result
142
+
143
+
144
+ # Validation functions removed - validation is done inline where needed for performance
145
+
146
+
147
+ @dataclass
148
+ class DFMResult:
149
+ """DFM estimation results structure.
150
+
151
+ This dataclass contains all outputs from the DFM estimation procedure,
152
+ including estimated parameters, smoothed data, and factors.
153
+
154
+ Attributes
155
+ ----------
156
+ x_sm : np.ndarray
157
+ Standardized smoothed data matrix (T x N), where T is time periods
158
+ and N is number of series. Data is standardized (zero mean, unit variance).
159
+ X_sm : np.ndarray
160
+ Unstandardized smoothed data matrix (T x N). This is the original-scale
161
+ version of x_sm, computed as X_sm = x_sm * Wx + Mx.
162
+ Z : np.ndarray
163
+ Smoothed factor estimates (T x m), where m is the state dimension.
164
+ Columns represent different factors (common factors and idiosyncratic components).
165
+ C : np.ndarray
166
+ Observation/loading matrix (N x m). Each row corresponds to a series,
167
+ each column to a factor. C[i, j] gives the loading of series i on factor j.
168
+ R : np.ndarray
169
+ Covariance matrix for observation equation residuals (N x N).
170
+ Typically diagonal, representing idiosyncratic variances.
171
+ A : np.ndarray
172
+ Transition matrix (m x m) for the state equation. Describes how factors
173
+ evolve over time: Z_t = A @ Z_{t-1} + error.
174
+ Q : np.ndarray
175
+ Covariance matrix for transition equation residuals (m x m).
176
+ Describes the covariance of factor innovations.
177
+ Mx : np.ndarray
178
+ Series means (N,). Used for standardization: x = (X - Mx) / Wx.
179
+ Wx : np.ndarray
180
+ Series standard deviations (N,). Used for standardization.
181
+ Z_0 : np.ndarray
182
+ Initial state vector (m,). Starting values for factors at t=0.
183
+ V_0 : np.ndarray
184
+ Initial covariance matrix (m x m) for factors. Uncertainty about Z_0.
185
+ r : np.ndarray
186
+ Number of factors per block (n_blocks,). Each element specifies
187
+ how many factors are in each block structure.
188
+ p : int
189
+ Number of lags in the autoregressive structure of factors. Typically p=1.
190
+
191
+ Examples
192
+ --------
193
+ >>> Res = dfm(X, config, threshold=1e-4)
194
+ >>> # Access smoothed factors
195
+ >>> common_factor = Res.Z[:, 0]
196
+ >>> # Access factor loadings for first series
197
+ >>> loadings = Res.C[0, :]
198
+ >>> # Reconstruct smoothed series from factors
199
+ >>> reconstructed = Res.Z @ Res.C.T
200
+ """
201
+ x_sm: np.ndarray # Standardized smoothed data (T x N)
202
+ X_sm: np.ndarray # Unstandardized smoothed data (T x N)
203
+ Z: np.ndarray # Smoothed factors (T x m)
204
+ C: np.ndarray # Observation matrix (N x m)
205
+ R: np.ndarray # Covariance for observation residuals (N x N)
206
+ A: np.ndarray # Transition matrix (m x m)
207
+ Q: np.ndarray # Covariance for transition residuals (m x m)
208
+ Mx: np.ndarray # Series means (N,)
209
+ Wx: np.ndarray # Series standard deviations (N,)
210
+ Z_0: np.ndarray # Initial state (m,)
211
+ V_0: np.ndarray # Initial covariance (m x m)
212
+ r: np.ndarray # Number of factors per block
213
+ p: int # Number of lags
214
+
215
+
216
+ def em_converged(loglik: float, previous_loglik: float, threshold: float = 1e-4,
217
+ check_decreased: bool = True) -> Tuple[bool, bool]:
218
+ """Check whether EM algorithm has converged.
219
+
220
+ Convergence is determined by the relative change in log-likelihood being
221
+ below the threshold: |loglik - previous_loglik| / avg(|loglik|, |previous_loglik|) < threshold.
222
+
223
+ Parameters
224
+ ----------
225
+ loglik : float
226
+ Log-likelihood from current EM iteration. Should be finite and typically
227
+ negative (log-likelihoods are usually negative).
228
+ previous_loglik : float
229
+ Log-likelihood from previous EM iteration. Used to compute change.
230
+ threshold : float, optional
231
+ Convergence threshold (default 1e-4). Smaller values require more iterations
232
+ but provide more precise convergence. Typical range: 1e-5 to 1e-3.
233
+ check_decreased : bool, optional
234
+ Whether to check if log-likelihood decreased (default True). If True and
235
+ loglik < previous_loglik - 1e-3, decrease flag is set and a warning printed.
236
+
237
+ Returns
238
+ -------
239
+ converged : bool
240
+ True if convergence criteria satisfied, False otherwise.
241
+ decrease : bool
242
+ True if log-likelihood decreased (only meaningful if check_decreased=True).
243
+ A decrease may indicate numerical issues but is allowed to happen.
244
+
245
+ Examples
246
+ --------
247
+ >>> loglik_current = -1234.56
248
+ >>> loglik_previous = -1235.12
249
+ >>> converged, decreased = em_converged(loglik_current, loglik_previous, threshold=1e-4)
250
+ >>> if converged:
251
+ ... print("EM algorithm converged")
252
+ """
253
+ converged = False
254
+ decrease = False
255
+
256
+ if check_decreased:
257
+ if loglik - previous_loglik < -1e-3: # Allow for a little imprecision
258
+ print(f"******likelihood decreased from {previous_loglik:.4f} to {loglik:.4f}!")
259
+ decrease = True
260
+
261
+ # Check convergence criteria
262
+ delta_loglik = abs(loglik - previous_loglik)
263
+ avg_loglik = (abs(loglik) + abs(previous_loglik) + np.finfo(float).eps) / 2
264
+
265
+ if (delta_loglik / avg_loglik) < threshold:
266
+ converged = True
267
+
268
+ return converged, decrease
269
+
270
+
271
+ def init_conditions(x: np.ndarray, r: np.ndarray, p: int, blocks: np.ndarray,
272
+ opt_nan: dict, Rcon: Optional[np.ndarray], q: Optional[np.ndarray],
273
+ nQ: int, i_idio: np.ndarray,
274
+ clock: str = 'm', tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
275
+ frequencies: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray,
276
+ np.ndarray, np.ndarray, np.ndarray]:
277
+ """Calculate initial conditions for parameter estimation.
278
+
279
+ This function computes initial parameter estimates using principal component
280
+ analysis (PCA) for factors and ordinary least squares (OLS) for AR coefficients.
281
+ These initial values are then refined by the EM algorithm in `dfm()`.
282
+
283
+ The function handles:
284
+ - Factor extraction via eigendecomposition of residual covariance
285
+ - AR coefficient estimation via OLS regression
286
+ - Quarterly aggregation constraints for quarterly series
287
+ - Idiosyncratic component initialization
288
+
289
+ Parameters
290
+ ----------
291
+ x : np.ndarray
292
+ Standardized data matrix (T x N), where T is time periods and N is series.
293
+ Data should already be standardized (zero mean, unit variance).
294
+ Missing values (NaN) are handled via spline interpolation.
295
+ r : np.ndarray
296
+ Number of common factors for each block (n_blocks,). Typically all ones
297
+ indicating one factor per block. Example: np.array([1, 1, 1, 1]) for 4 blocks.
298
+ p : int
299
+ Number of lags in the transition equation. Typically p=1 (AR(1) process).
300
+ Higher values create more complex dynamics but require more parameters.
301
+ blocks : np.ndarray
302
+ Block loading structure (N x n_blocks). Each row corresponds to a series,
303
+ each column to a block. Value 1 indicates the series loads on that block.
304
+ All series must load on the first block (global factor).
305
+ opt_nan : dict
306
+ Options for missing value handling. Should contain:
307
+ - 'method': int, method for NaN handling (typically 2 or 3)
308
+ - 'k': int, spline parameter (typically 3)
309
+ See `rem_nans_spline` for details.
310
+ Rcon : np.ndarray
311
+ Quarterly aggregation constraints matrix (4 x 5). Implements the "tent structure"
312
+ that aggregates monthly values to quarterly. Standard structure is:
313
+ [[2, -1, 0, 0, 0],
314
+ [3, 0, -1, 0, 0],
315
+ [2, 0, 0, -1, 0],
316
+ [1, 0, 0, 0, -1]]
317
+ q : np.ndarray
318
+ Constraints vector (4,). Typically all zeros: np.zeros(4).
319
+ Used with Rcon to enforce quarterly aggregation.
320
+ nQ : int
321
+ Number of quarterly variables. Must satisfy 0 <= nQ < N.
322
+ The first (N - nQ) series are assumed to be monthly.
323
+ i_idio : np.ndarray
324
+ Logical array (N,) indicating idiosyncratic components: 1 for monthly series,
325
+ 0 for quarterly series. Typically: np.concatenate([np.ones(N-nQ), np.zeros(nQ)]).
326
+
327
+ Returns
328
+ -------
329
+ A : np.ndarray
330
+ Initial transition matrix (m x m), where m is the state dimension.
331
+ Block diagonal structure: factors, monthly idiosyncratic, quarterly idiosyncratic.
332
+ C : np.ndarray
333
+ Initial observation/loading matrix (N x m). Loadings are estimated via PCA
334
+ (eigendecomposition of residual covariance) for monthly series and constrained
335
+ least squares for quarterly series.
336
+ Q : np.ndarray
337
+ Initial covariance matrix for transition residuals (m x m). Block diagonal.
338
+ Diagonal elements represent innovation variances.
339
+ R : np.ndarray
340
+ Initial covariance matrix for observation residuals (N x N). Diagonal matrix.
341
+ Diagonal elements are residual variances (idiosyncratic variances).
342
+ Z_0 : np.ndarray
343
+ Initial state vector (m,). Typically zeros: np.zeros(m).
344
+ V_0 : np.ndarray
345
+ Initial covariance matrix for state (m x m). Computed from steady-state
346
+ of the AR process: V_0 = inv(I - kron(A, A)) @ vec(Q).
347
+
348
+ Raises
349
+ ------
350
+ ValueError
351
+ If inputs are invalid or dimensions don't match. Also raised if numerical
352
+ issues prevent computation (though fallback strategies will be attempted).
353
+
354
+ Notes
355
+ -----
356
+ - The function uses fallback strategies when numerical issues occur (e.g., singular
357
+ matrices, eigendecomposition failures). These are logged at WARNING level.
358
+ - For quarterly series, loadings must satisfy aggregation constraints that ensure
359
+ quarterly aggregates match monthly weighted sums.
360
+ - The state dimension m is computed as: sum(r) * max(p, 5) + monthly_idio + quarterly_idio * 5
361
+
362
+ Examples
363
+ --------
364
+ >>> from dfm_python import load_config, load_data, dfm
365
+ >>> # Load configuration from YAML or CSV
366
+ >>> config = load_config('config.yaml')
367
+ >>> # Load data from CSV file
368
+ >>> X, Time, Z = load_data('data.csv', config)
369
+ >>> # Standardize data
370
+ >>> x = (X - np.nanmean(X, axis=0)) / np.nanstd(X, axis=0)
371
+ >>> # Set up parameters
372
+ >>> r = np.ones(config.Blocks.shape[1])
373
+ >>> p = 1
374
+ >>> nQ = np.sum(np.array(config.Frequency) == 'q')
375
+ >>> i_idio = np.concatenate([np.ones(len(config.SeriesID) - nQ), np.zeros(nQ)])
376
+ >>> Rcon = np.array([[2, -1, 0, 0, 0], [3, 0, -1, 0, 0],
377
+ ... [2, 0, 0, -1, 0], [1, 0, 0, 0, -1]])
378
+ >>> q = np.zeros(4)
379
+ >>> opt_nan = {'method': 2, 'k': 3}
380
+ >>> # Compute initial conditions
381
+ >>> A, C, Q, R, Z_0, V_0 = init_conditions(
382
+ ... x, r, p, config.Blocks, opt_nan, Rcon, q, nQ, i_idio
383
+ ... )
384
+ """
385
+ # Input validation is done inline where needed
386
+
387
+ # Handle missing_data method (no aggregation constraints)
388
+ if Rcon is None or q is None:
389
+ pC = 1 # No tent structure
390
+ else:
391
+ pC = Rcon.shape[1] # Tent structure size (quarterly to monthly)
392
+ ppC = int(max(p, pC)) # Ensure integer
393
+ n_b = blocks.shape[1] # Number of blocks
394
+
395
+ # Spline without NaNs
396
+ xBal, _ = _rem_nans_spline(x, method=opt_nan['method'], k=opt_nan['k'])
397
+
398
+ T, N = xBal.shape
399
+ nM = N - nQ # Number of monthly series
400
+
401
+ # Create 2D boolean array for NaN indicators
402
+ indNaN = np.isnan(xBal)
403
+
404
+ # Create views/copies as needed (xNaN needs to be modifiable)
405
+ xNaN = xBal.copy() # Need copy for NaN assignment
406
+ xNaN[indNaN] = np.nan
407
+ res = xBal # Can use view, not modified
408
+ resNaN = xNaN.copy() # Need copy for modifications
409
+
410
+ # Initialize model coefficient output
411
+ C = None
412
+ A = None
413
+ Q = None
414
+ V_0 = None
415
+
416
+ # Set first observations as NaNs for quarterly-monthly aggregation scheme
417
+ # Only if using aggregation (pC > 1)
418
+ if pC > 1:
419
+ indNaN[:pC-1, :] = True
420
+
421
+ # Loop for each block
422
+ for i in range(n_b):
423
+ r_i = int(r[i]) # r_i = 1 when block is loaded (ensure integer)
424
+
425
+ # Reset block-specific variables at start of each iteration to prevent dimension mismatch
426
+ # These variables are block-specific and must be reset to avoid using values from previous blocks
427
+ ff = None
428
+ A_temp = None
429
+
430
+ # Observation equation
431
+ C_i = np.zeros((N, int(r_i * ppC)))
432
+ idx_i = np.where(blocks[:, i] == 1)[0] # Series loading block i
433
+ idx_iM = idx_i[idx_i < nM] # Monthly series indices
434
+ idx_iQ = idx_i[idx_i >= nM] # Quarterly series indices
435
+
436
+ if len(idx_iM) > 0:
437
+ # Returns eigenvector v with largest eigenvalue d
438
+ # Calculate covariance, handling edge cases
439
+ try:
440
+ # Extract data for this block, handling NaN values
441
+ res_block = res[:, idx_iM].copy()
442
+ # Remove rows with any NaN (np.cov doesn't handle NaN)
443
+ finite_rows = np.all(np.isfinite(res_block), axis=1)
444
+ n_finite = np.sum(finite_rows)
445
+ n_total = len(finite_rows)
446
+ completeness_pct = (n_finite / n_total * 100) if n_total > 0 else 0.0
447
+
448
+ # Check minimum data requirements
449
+ min_obs_required = max(2, len(idx_iM) + 1) # Need at least n+1 observations for n series
450
+ if n_finite < min_obs_required:
451
+ # Not enough finite data - use identity
452
+ _logger.warning(
453
+ f"init_conditions: Block {i+1} has insufficient data for covariance calculation. "
454
+ f"Series in block: {len(idx_iM)}, Finite observations: {n_finite}/{n_total} "
455
+ f"({completeness_pct:.1f}%), Required: {min_obs_required}. "
456
+ f"Using identity matrix as fallback."
457
+ )
458
+ raise ValueError(
459
+ f"Insufficient finite data for block {i+1} ('{block_names[i]}'): "
460
+ f"found {n_finite} finite observations, but need at least {min_obs_required} "
461
+ f"(number of series in block: {len(idx_iM)}). "
462
+ f"This may be due to excessive missing data or transformation issues. "
463
+ f"Consider checking data quality or adjusting nan_method."
464
+ )
465
+
466
+ res_block_clean = res_block[finite_rows, :]
467
+ cov_res = np.cov(res_block_clean.T)
468
+
469
+ # Check for NaN/Inf in covariance
470
+ if np.any(np.isnan(cov_res)) or np.any(np.isinf(cov_res)):
471
+ # Replace with identity if covariance invalid
472
+ _logger.warning(
473
+ f"init_conditions: Block {i+1} covariance contains NaN/Inf values. "
474
+ f"Series in block: {len(idx_iM)}, Finite observations: {n_finite}/{n_total}. "
475
+ f"Using identity matrix as fallback."
476
+ )
477
+ cov_res = np.eye(len(idx_iM))
478
+ # Check for constant series (zero variance) and regularize
479
+ var_diag = np.diag(cov_res)
480
+ if np.any(var_diag < 1e-10):
481
+ cov_res = cov_res + np.eye(len(idx_iM)) * 1e-8
482
+ except (ValueError, np.linalg.LinAlgError) as e:
483
+ # Covariance calculation failed - use identity as fallback
484
+ # This can happen with insufficient data or numerical issues
485
+ _logger.warning(
486
+ f"init_conditions: Covariance calculation failed for block {i+1}. "
487
+ f"Series in block: {len(idx_iM)}, Series indices: {idx_iM.tolist()}. "
488
+ f"Error: {type(e).__name__}: {str(e)}. Using identity matrix as fallback."
489
+ )
490
+ cov_res = np.eye(len(idx_iM))
491
+
492
+ if cov_res.size == 1:
493
+ # Single series case
494
+ v = np.array([[1.0]])
495
+ d = cov_res[0, 0] if not (np.isnan(cov_res[0, 0]) or np.isinf(cov_res[0, 0])) else 1.0
496
+ else:
497
+ n_series = len(idx_iM)
498
+ # Use eigs only if k < n_series - 1, otherwise use regular eig
499
+ if int(r_i) < n_series - 1:
500
+ try:
501
+ # Use sparse matrix for eigs
502
+ cov_sparse = csc_matrix(cov_res)
503
+ d, v = eigs(cov_sparse, k=int(r_i), which='LM')
504
+ v = v.real
505
+ # Check for NaN/Inf in results
506
+ if np.any(np.isnan(d)) or np.any(np.isinf(d)) or np.any(np.isnan(v)) or np.any(np.isinf(v)):
507
+ raise ValueError("Invalid eigenvalue results")
508
+ except (ValueError, np.linalg.LinAlgError, RuntimeError) as e:
509
+ # Sparse eigendecomposition failed - fallback to regular eig
510
+ # This can happen if matrix is too small for sparse methods or has numerical issues
511
+ _logger.warning(
512
+ f"init_conditions: Sparse eigendecomposition failed for block {i+1}, "
513
+ f"falling back to np.linalg.eig. Error: {type(e).__name__}"
514
+ )
515
+ d, v = np.linalg.eig(cov_res)
516
+ # Sort by eigenvalue magnitude and take top r_i
517
+ idx = np.argsort(np.abs(d))[::-1][:int(r_i)]
518
+ d = d[idx]
519
+ v = v[:, idx]
520
+ v = v.real
521
+ else:
522
+ # Use regular eig for full eigendecomposition
523
+ try:
524
+ d, v = np.linalg.eig(cov_res)
525
+ # Check for NaN/Inf
526
+ valid = ~(np.isnan(d) | np.isinf(d))
527
+ if np.sum(valid) < int(r_i):
528
+ raise ValueError("Not enough valid eigenvalues")
529
+ # Sort by eigenvalue magnitude and take top r_i
530
+ idx = np.argsort(np.abs(d[valid]))[::-1][:int(r_i)]
531
+ d = d[valid][idx]
532
+ v = v[:, valid][:, idx]
533
+ v = v.real
534
+ except (IndexError, ValueError, np.linalg.LinAlgError) as e:
535
+ # Eigendecomposition or indexing failed - use identity as ultimate fallback
536
+ # This indicates severe numerical issues, but allows initialization to continue
537
+ _logger.warning(
538
+ f"init_conditions: Eigendecomposition failed for block {i+1}, "
539
+ f"using identity matrix as ultimate fallback. Error: {type(e).__name__}"
540
+ )
541
+ v = np.eye(len(idx_iM))[:, :int(r_i)]
542
+ d = np.ones(int(r_i))
543
+
544
+ # Flip sign for cleaner output
545
+ if np.sum(v) < 0:
546
+ v = -v
547
+
548
+ # For monthly series with loaded blocks, replace with eigenvector
549
+ C_i[idx_iM, :int(r_i)] = v
550
+ f = res[:, idx_iM] @ v # Data projection for eigenvector direction
551
+
552
+ # Lag matrix using loading
553
+ F = None
554
+ max_lag = max(p + 1, pC)
555
+ for kk in range(max_lag):
556
+ lag_data = f[pC - kk:T - kk, :]
557
+ if F is None:
558
+ F = lag_data
559
+ else:
560
+ F = np.hstack([F, lag_data])
561
+
562
+ # Handle aggregation constraints (only if Rcon is provided)
563
+ if Rcon is not None and q is not None:
564
+ Rcon_i = np.kron(Rcon, np.eye(int(r_i))) # Quarterly-monthly aggregation
565
+ q_i = np.kron(q, np.zeros(int(r_i)))
566
+ else:
567
+ Rcon_i = None
568
+ q_i = None
569
+
570
+ # Projected data with lag structure
571
+ ff = F[:, :int(r_i * pC)]
572
+
573
+ # Loop for quarterly variables
574
+ for j in idx_iQ:
575
+ # For series j, values are dropped to accommodate lag structure
576
+ xx_j = resNaN[pC:, j]
577
+
578
+ if len(xx_j) < ff.shape[0]:
579
+ # Align dimensions: pad or trim as needed
580
+ if len(xx_j) > 0:
581
+ xx_j_padded = np.full(ff.shape[0], np.nan)
582
+ xx_j_padded[:len(xx_j)] = xx_j
583
+ xx_j = xx_j_padded
584
+
585
+ if np.sum(~np.isnan(xx_j)) < ff.shape[1] + 2:
586
+ xx_j = res[pC:, j] # Replace with spline if too many NaNs
587
+
588
+ ff_j = ff[~np.isnan(xx_j), :]
589
+ xx_j_clean = xx_j[~np.isnan(xx_j)]
590
+
591
+ if len(xx_j_clean) > 0 and ff_j.shape[0] > 0:
592
+ try:
593
+ iff_j = inv(ff_j.T @ ff_j)
594
+ Cc = iff_j @ ff_j.T @ xx_j_clean # Least squares
595
+
596
+ # Monthly to quarterly conversion with constraints (if using aggregation)
597
+ if Rcon_i is not None and q_i is not None:
598
+ constraint_term = iff_j @ Rcon_i.T @ inv(Rcon_i @ iff_j @ Rcon_i.T) @ (Rcon_i @ Cc - q_i)
599
+ Cc = Cc - constraint_term
600
+
601
+ C_i[j, :int(pC * r_i)] = Cc
602
+ except (np.linalg.LinAlgError, ValueError, IndexError) as e:
603
+ # Matrix inversion or constraint application failed - set to zero
604
+ # This can happen if constraint matrix is singular or dimensions mismatch
605
+ _logger.debug(
606
+ f"init_conditions: C matrix update failed for series {j} in block {i+1}, "
607
+ f"setting to zero. Error: {type(e).__name__}"
608
+ )
609
+ C_i[j, :int(pC * r_i)] = 0.0
610
+
611
+ # Pad ff with zeros to match res dimensions (T x N) - BLOCK-SPECIFIC
612
+ # This must be inside the block loop to handle different r_i per block
613
+ if ff is not None:
614
+ # Validate dimensions match before stacking (safety check)
615
+ expected_width = int(pC * r_i)
616
+ if ff.shape[1] != expected_width:
617
+ # This should not happen if ff is properly reset, but add safety check
618
+ _logger.warning(
619
+ f"init_conditions: Block {i+1} ff dimension mismatch detected. "
620
+ f"Expected width {expected_width}, got {ff.shape[1]}. Resetting ff."
621
+ )
622
+ ff = None
623
+
624
+ if ff is not None:
625
+ # ff has shape (T - pC + 1, r_i * pC) from lag_data
626
+ # Need to pad to match res shape (T, r_i * pC)
627
+ ff_padded_i = np.vstack([np.zeros((pC - 1, int(pC * r_i))), ff])
628
+ # Ensure it matches T exactly
629
+ if ff_padded_i.shape[0] < T:
630
+ ff_padded_i = np.vstack([ff_padded_i, np.zeros((T - ff_padded_i.shape[0], int(pC * r_i)))])
631
+ else:
632
+ # If no monthly series, create dummy ff
633
+ ff_padded_i = np.zeros((T, int(pC * r_i)))
634
+ # Note: If we're in this else branch, len(idx_iM) == 0, so the condition below is unreachable
635
+ # but kept for safety in case of edge cases
636
+
637
+ # Residual calculations - ensure shapes match (only for this block's series)
638
+ # C_i has shape (N, r_i * ppC), but we only use the first r_i * pC columns for residuals
639
+ # ff_padded_i has shape (T, r_i * pC)
640
+ # We need to extract the relevant columns from C_i
641
+ C_i_residual = C_i[:, :int(pC * r_i)] # Only use first pC * r_i columns
642
+ if ff_padded_i.shape[0] == res.shape[0]:
643
+ # Update residuals only for series in this block
644
+ res[:, idx_i] = res[:, idx_i] - ff_padded_i @ C_i_residual[idx_i, :].T
645
+ else:
646
+ # Trim or pad to match
647
+ if ff_padded_i.shape[0] > res.shape[0]:
648
+ ff_padded_i = ff_padded_i[:res.shape[0], :]
649
+ else:
650
+ ff_padded_i = np.vstack([ff_padded_i, np.zeros((res.shape[0] - ff_padded_i.shape[0], ff_padded_i.shape[1]))])
651
+ res[:, idx_i] = res[:, idx_i] - ff_padded_i @ C_i_residual[idx_i, :].T
652
+
653
+ resNaN = res.copy()
654
+ resNaN[indNaN] = np.nan
655
+
656
+ # Combine loadings
657
+ if C is None:
658
+ C = C_i
659
+ else:
660
+ C = np.hstack([C, C_i])
661
+
662
+ # Transition equation
663
+ if len(idx_iM) > 0:
664
+ z = F[:, :int(r_i)] # Projected data (no lag)
665
+ Z_lag = F[:, int(r_i):int(r_i * (p + 1))] # Data with lag 1
666
+
667
+ A_i = np.zeros((int(r_i * ppC), int(r_i * ppC)))
668
+
669
+ if Z_lag.shape[0] > 0 and Z_lag.shape[1] > 0:
670
+ try:
671
+ A_temp = inv(Z_lag.T @ Z_lag) @ Z_lag.T @ z # OLS: AR(p) process
672
+ A_i[:int(r_i), :int(r_i * p)] = A_temp.T
673
+ except (np.linalg.LinAlgError, ValueError) as e:
674
+ # OLS regression failed - set AR coefficients to zero
675
+ # This can happen if Z_lag is singular or has insufficient rank
676
+ _logger.debug(
677
+ f"init_conditions: OLS regression failed for block {i+1}, "
678
+ f"setting AR coefficients to zero. Error: {type(e).__name__}"
679
+ )
680
+ A_i[:int(r_i), :int(r_i * p)] = 0.0
681
+
682
+ # Identity matrix for lag structure
683
+ if r_i * (ppC - 1) > 0:
684
+ A_i[int(r_i):, :int(r_i * (ppC - 1))] = np.eye(int(r_i * (ppC - 1)))
685
+
686
+ Q_i = np.zeros((int(ppC * r_i), int(ppC * r_i)))
687
+ if len(z) > 0:
688
+ e = z - Z_lag @ A_temp if A_temp is not None else z
689
+ # Clean e before covariance calculation
690
+ e_clean = e.copy()
691
+ e_clean[np.isnan(e_clean)] = 0.0
692
+ e_clean[np.isinf(e_clean)] = 0.0
693
+
694
+ if e_clean.shape[1] > 1:
695
+ # Use np.cov but check for NaN/Inf
696
+ try:
697
+ Q_block = np.cov(e_clean.T)
698
+ # Check for NaN/Inf in result
699
+ if np.any(np.isnan(Q_block)) or np.any(np.isinf(Q_block)):
700
+ Q_block = np.eye(int(r_i)) * 0.1
701
+ except (ValueError, np.linalg.LinAlgError):
702
+ Q_block = np.eye(int(r_i)) * 0.1
703
+ else:
704
+ var_val = np.var(e_clean)
705
+ Q_block = np.array([[var_val]]) if not (np.isnan(var_val) or np.isinf(var_val)) else np.eye(int(r_i)) * 0.1
706
+
707
+ Q_i[:int(r_i), :int(r_i)] = Q_block
708
+
709
+ # Clean A_i and Q_i before kron operation
710
+ A_i_clean = np.nan_to_num(A_i, nan=0.0, posinf=1.0, neginf=-1.0)
711
+ Q_i_clean = np.nan_to_num(Q_i, nan=0.0, posinf=1.0, neginf=-1.0)
712
+
713
+ # Initial covariance
714
+ try:
715
+ # Check A_i_clean for invalid values before kron
716
+ if np.any(np.isnan(A_i_clean)) or np.any(np.isinf(A_i_clean)):
717
+ raise ValueError("Invalid values in A_i")
718
+
719
+ kron_matrix = np.kron(A_i_clean, A_i_clean)
720
+ # Check kron result
721
+ if np.any(np.isnan(kron_matrix)) or np.any(np.isinf(kron_matrix)):
722
+ raise ValueError("Invalid values in kron(A_i, A_i)")
723
+
724
+ inv_matrix = np.eye(int((r_i * ppC)**2)) - kron_matrix
725
+ # Check before inversion
726
+ if np.any(np.isnan(inv_matrix)) or np.any(np.isinf(inv_matrix)):
727
+ raise ValueError("Invalid values in (I - kron(A, A))")
728
+
729
+ Q_flat = Q_i_clean.flatten()
730
+ # Check Q_flat
731
+ if np.any(np.isnan(Q_flat)) or np.any(np.isinf(Q_flat)):
732
+ raise ValueError("Invalid values in Q_i")
733
+
734
+ initV_i = np.reshape(
735
+ inv(inv_matrix) @ Q_flat,
736
+ (int(r_i * ppC), int(r_i * ppC))
737
+ )
738
+ # Verify result is valid
739
+ if np.any(np.isnan(initV_i)) or np.any(np.isinf(initV_i)):
740
+ raise ValueError("NaN/Inf in initV_i")
741
+ except (np.linalg.LinAlgError, ValueError, IndexError) as e:
742
+ # Matrix inversion failed or result invalid - use diagonal fallback
743
+ # This can happen if (I - kron(A, A)) is singular or numerical issues occur
744
+ _logger.warning(
745
+ f"init_conditions: Initial covariance calculation failed for block {i+1}, "
746
+ f"using diagonal fallback (0.1 * I). Error: {type(e).__name__}"
747
+ )
748
+ initV_i = np.eye(int(r_i * ppC)) * 0.1
749
+
750
+ # Block diagonal matrices
751
+ if A is None:
752
+ A = A_i
753
+ Q = Q_i
754
+ V_0 = initV_i
755
+ else:
756
+ # Ensure all matrices are square before block_diag
757
+ if A_i.shape[0] == A_i.shape[1] and Q_i.shape[0] == Q_i.shape[1] and initV_i.shape[0] == initV_i.shape[1]:
758
+ A = block_diag(A, A_i)
759
+ Q = block_diag(Q, Q_i)
760
+ V_0 = block_diag(V_0, initV_i)
761
+ else:
762
+ # Fallback: concatenate if dimensions don't match
763
+ _logger.debug(
764
+ f"init_conditions: block_diag dimensions mismatch for block {i+1}, "
765
+ f"using np.block concatenation fallback"
766
+ )
767
+ A = np.block([[A, np.zeros((A.shape[0], A_i.shape[1]))],
768
+ [np.zeros((A_i.shape[0], A.shape[1])), A_i]])
769
+ Q = np.block([[Q, np.zeros((Q.shape[0], Q_i.shape[1]))],
770
+ [np.zeros((Q_i.shape[0], Q.shape[1])), Q_i]])
771
+ V_0 = np.block([[V_0, np.zeros((V_0.shape[0], initV_i.shape[1]))],
772
+ [np.zeros((initV_i.shape[0], V_0.shape[1])), initV_i]])
773
+ else:
774
+ # Dummy if no monthly series
775
+ A_i = np.eye(int(r_i * ppC)) * 0.9
776
+ Q_i = np.eye(int(r_i * ppC)) * 0.1
777
+ initV_i = np.eye(int(r_i * ppC)) * 0.1
778
+ if A is None:
779
+ A = A_i
780
+ Q = Q_i
781
+ V_0 = initV_i
782
+ else:
783
+ # Ensure all matrices are square
784
+ if A_i.shape[0] == A_i.shape[1] and Q_i.shape[0] == Q_i.shape[1] and initV_i.shape[0] == initV_i.shape[1]:
785
+ A = block_diag(A, A_i)
786
+ Q = block_diag(Q, Q_i)
787
+ V_0 = block_diag(V_0, initV_i)
788
+ else:
789
+ # Fallback: use np.block
790
+ _logger.debug(
791
+ f"init_conditions: block_diag dimensions mismatch for block {i+1} (V_0), "
792
+ f"using np.block concatenation fallback"
793
+ )
794
+ A = np.block([[A, np.zeros((A.shape[0], A_i.shape[1]))],
795
+ [np.zeros((A_i.shape[0], A.shape[1])), A_i]])
796
+ Q = np.block([[Q, np.zeros((Q.shape[0], Q_i.shape[1]))],
797
+ [np.zeros((Q_i.shape[0], Q.shape[1])), Q_i]])
798
+ V_0 = np.block([[V_0, np.zeros((V_0.shape[0], initV_i.shape[1]))],
799
+ [np.zeros((initV_i.shape[0], V_0.shape[1])), initV_i]])
800
+
801
+ # Add idiosyncratic components
802
+ eyeN = np.eye(N)
803
+ eyeN = eyeN[:, i_idio.astype(bool)] # Keep only monthly columns
804
+
805
+ if C is None:
806
+ C = eyeN
807
+ else:
808
+ C = np.hstack([C, eyeN])
809
+
810
+ # Monthly-quarterly aggregation scheme for quarterly idiosyncratic
811
+ quarterly_idio = np.zeros((nM, 5 * nQ))
812
+ quarterly_idio_q = np.kron(np.eye(nQ), np.array([[1], [2], [3], [2], [1]]))
813
+ quarterly_idio_full = np.vstack([quarterly_idio, quarterly_idio_q.T])
814
+ C = np.hstack([C, quarterly_idio_full])
815
+
816
+ # Initialize covariance matrix
817
+ # Calculate variance, handling NaN/Inf cases
818
+ var_values = np.nanvar(resNaN, axis=0)
819
+ # Replace NaN/Inf with small default value
820
+ var_values = np.where((np.isnan(var_values) | np.isinf(var_values)), 1e-4, var_values)
821
+ # Ensure minimum variance to avoid numerical issues
822
+ var_values = np.maximum(var_values, 1e-4)
823
+ R = np.diag(var_values)
824
+
825
+ # Monthly idiosyncratic AR parameters
826
+ ii_idio = np.where(i_idio)[0]
827
+ n_idio = len(ii_idio)
828
+ BM = np.zeros((n_idio, n_idio))
829
+ SM = np.zeros((n_idio, n_idio))
830
+
831
+ for idx, i in enumerate(ii_idio):
832
+ R[i, i] = 1e-4
833
+
834
+ res_i = resNaN[:, i]
835
+ res_i_full = res[:, i]
836
+
837
+ # Count leading/ending NaNs
838
+ leadZero = 0
839
+ for t in range(T):
840
+ if np.isnan(res_i[t]):
841
+ leadZero += 1
842
+ else:
843
+ break
844
+
845
+ endZero = 0
846
+ for t in range(T - 1, -1, -1):
847
+ if np.isnan(res_i[t]):
848
+ endZero += 1
849
+ else:
850
+ break
851
+
852
+ # Truncate
853
+ if endZero > 0:
854
+ res_i_trunc = res_i_full[leadZero:T - endZero]
855
+ else:
856
+ res_i_trunc = res_i_full[leadZero:]
857
+
858
+ if len(res_i_trunc) > 1:
859
+ # AR(1) process: res_i[1:] = BM * res_i[:-1] + error
860
+ # MATLAB: inv(res_i(1:end-1)'*res_i(1:end-1))*res_i(1:end-1)'*res_i(2:end)
861
+ # Copy to avoid read-only array issues
862
+ res_i_copy = res_i_trunc.copy()
863
+ X_ar = res_i_copy[:-1].reshape(-1, 1) # Column vector (T-1 x 1)
864
+ y_ar = res_i_copy[1:].reshape(-1, 1) # Column vector (T-1 x 1)
865
+ # X_ar.T @ X_ar is (1 x T-1) @ (T-1 x 1) = (1 x 1) scalar
866
+ XTX = X_ar.T @ X_ar
867
+ if XTX.size == 1 and XTX[0, 0] != 0 and not (np.isnan(XTX[0, 0]) or np.isinf(XTX[0, 0])):
868
+ try:
869
+ BM_val = (1.0 / XTX[0, 0]) * (X_ar.T @ y_ar)
870
+ BM_val_clean = BM_val[0, 0] if BM_val.size > 0 else 0.1
871
+ # Ensure finite value
872
+ if np.isnan(BM_val_clean) or np.isinf(BM_val_clean):
873
+ BM_val_clean = 0.1
874
+ BM[idx, idx] = BM_val_clean
875
+ # Calculate variance with bounds checking
876
+ residuals = res_i_trunc[1:] - res_i_trunc[:-1] * BM[idx, idx]
877
+ var_val = np.var(residuals)
878
+ SM[idx, idx] = var_val if not (np.isnan(var_val) or np.isinf(var_val)) else 0.1
879
+ except (ValueError, ZeroDivisionError, IndexError) as e:
880
+ # AR coefficient calculation or variance computation failed - use defaults
881
+ # This can happen with insufficient data or division by zero
882
+ BM[idx, idx] = 0.1
883
+ SM[idx, idx] = 0.1
884
+ else:
885
+ BM[idx, idx] = 0.1
886
+ SM[idx, idx] = 0.1
887
+ else:
888
+ BM[idx, idx] = 0.1
889
+ SM[idx, idx] = 0.1
890
+
891
+ # Quarterly idiosyncratic
892
+ Rdiag = np.diag(R).copy() # Copy to avoid read-only array
893
+ # Handle division by zero and NaN/Inf
894
+ # Validate Rdiag values before division
895
+ Rdiag_quarterly = Rdiag[nM:N]
896
+ if len(Rdiag_quarterly) > 0:
897
+ # Ensure no invalid values before division
898
+ Rdiag_quarterly = np.where(
899
+ (np.isnan(Rdiag_quarterly) | np.isinf(Rdiag_quarterly) | (Rdiag_quarterly < 0)),
900
+ 1e-4, Rdiag_quarterly
901
+ )
902
+ sig_e = _safe_divide(Rdiag_quarterly, QUARTERLY_VARIANCE_DIVISOR, default=1e-4)
903
+ sig_e = np.where((np.isnan(sig_e) | np.isinf(sig_e) | (sig_e < 1e-6)), 1e-4, sig_e)
904
+ else:
905
+ sig_e = np.array([])
906
+ Rdiag[nM:N] = 1e-4
907
+ R = np.diag(Rdiag)
908
+
909
+ # Quarterly AR structure
910
+ rho0 = 0.1
911
+ temp = np.zeros((5, 5))
912
+ temp[0, 0] = 1
913
+
914
+ SQ = np.kron(np.diag((1 - rho0**2) * sig_e), temp)
915
+ # BQ should be (5*nQ) x (5*nQ) - build properly
916
+ BQ_template = np.vstack([
917
+ np.hstack([rho0, np.zeros(4)]),
918
+ np.hstack([np.eye(4), np.zeros((4, 1))])
919
+ ])
920
+ BQ = np.kron(np.eye(int(nQ)), BQ_template) # Ensure nQ is int
921
+
922
+ try:
923
+ # Check if matrix is invertible before inversion
924
+ I_kron = np.eye((5 * nQ)**2)
925
+ kron_mat = np.kron(BQ, BQ)
926
+ inv_mat = I_kron - kron_mat
927
+ # Check for NaN/Inf in matrix
928
+ if np.any(np.isnan(inv_mat)) or np.any(np.isinf(inv_mat)):
929
+ raise ValueError("NaN/Inf in inversion matrix")
930
+ initViQ = np.reshape(
931
+ inv(inv_mat) @ SQ.flatten(),
932
+ (5 * nQ, 5 * nQ)
933
+ )
934
+ # Check result for NaN/Inf
935
+ if np.any(np.isnan(initViQ)) or np.any(np.isinf(initViQ)):
936
+ raise ValueError("NaN/Inf in initViQ result")
937
+ except (np.linalg.LinAlgError, ValueError, IndexError) as e:
938
+ # Matrix inversion failed or result invalid - use diagonal fallback for quarterly idiosyncratic
939
+ # This can happen if (I - kron(BQ, BQ)) is singular or nQ is zero
940
+ _logger.warning(
941
+ f"init_conditions: Quarterly idiosyncratic initial covariance calculation failed, "
942
+ f"using diagonal fallback (0.1 * I). Error: {type(e).__name__}"
943
+ )
944
+ initViQ = np.eye(5 * nQ) * 0.1
945
+
946
+ try:
947
+ # Calculate denominator, handling division by zero
948
+ eye_diag = np.diag(np.eye(BM.shape[0]))
949
+ BM_diag_sq = np.diag(BM)**2
950
+ denom_val = eye_diag - BM_diag_sq
951
+ # Avoid division by zero
952
+ denom_val = np.where(np.abs(denom_val) < 1e-10, 1.0, denom_val)
953
+ denom_viM = np.diag(1.0 / denom_val)
954
+ # Check for NaN/Inf
955
+ if np.any(np.isnan(denom_viM)) or np.any(np.isinf(denom_viM)):
956
+ raise ValueError("NaN/Inf in denom_viM")
957
+ initViM = denom_viM @ SM
958
+ # Check result
959
+ if np.any(np.isnan(initViM)) or np.any(np.isinf(initViM)):
960
+ raise ValueError("NaN/Inf in initViM result")
961
+ except (np.linalg.LinAlgError, ValueError, ZeroDivisionError) as e:
962
+ # Matrix inversion failed, division by zero, or result invalid - use diagonal from SM
963
+ # This can happen if denom_viM calculation fails or SM has issues
964
+ _logger.warning(
965
+ f"init_conditions: Monthly idiosyncratic initial covariance calculation failed, "
966
+ f"using diagonal from SM as fallback. Error: {type(e).__name__}"
967
+ )
968
+ initViM = np.diag(np.diag(SM)).copy()
969
+ # Ensure finite
970
+ initViM = np.where(np.isnan(initViM) | np.isinf(initViM), 0.1, initViM)
971
+
972
+ # Final block diagonal - ensure all matrices are square
973
+ # Verify dimensions before block_diag
974
+ if BM.shape[0] != BM.shape[1]:
975
+ BM = np.diag(np.diag(BM)) # Force square
976
+ if SM.shape[0] != SM.shape[1]:
977
+ SM = np.diag(np.diag(SM)) # Force square
978
+ if BQ.shape[0] != BQ.shape[1]:
979
+ # BQ should already be square, but if not, force it
980
+ min_dim = min(BQ.shape)
981
+ BQ = np.diag(np.diag(BQ[:min_dim, :min_dim]))
982
+ if SQ.shape[0] != SQ.shape[1]:
983
+ SQ = np.diag(np.diag(SQ))
984
+
985
+ A = block_diag(A, BM, BQ)
986
+ Q = block_diag(Q, SM, SQ)
987
+ Z_0 = np.zeros(A.shape[0])
988
+
989
+ # Ensure V_0 matrices are square before block_diag
990
+ if V_0.shape[0] != V_0.shape[1]:
991
+ V_0 = np.diag(np.diag(V_0))
992
+ if initViM.shape[0] != initViM.shape[1]:
993
+ initViM = np.diag(np.diag(initViM))
994
+ if initViQ.shape[0] != initViQ.shape[1]:
995
+ initViQ = np.diag(np.diag(initViQ))
996
+
997
+ V_0 = block_diag(V_0, initViM, initViQ)
998
+
999
+ # Diagnostic check: Verify all outputs are finite before returning
1000
+ output_params = {
1001
+ 'A': A, 'C': C, 'Q': Q, 'R': R, 'Z_0': Z_0, 'V_0': V_0
1002
+ }
1003
+
1004
+ for param_name, param_value in output_params.items():
1005
+ nan_count = np.sum(np.isnan(param_value)) if param_value.size > 0 else 0
1006
+ inf_count = np.sum(np.isinf(param_value)) if param_value.size > 0 else 0
1007
+
1008
+ if nan_count > 0:
1009
+ _logger.warning(
1010
+ f"init_conditions: Output {param_name} contains {nan_count} NaN values "
1011
+ f"(shape: {param_value.shape})"
1012
+ )
1013
+ if inf_count > 0:
1014
+ _logger.warning(
1015
+ f"init_conditions: Output {param_name} contains {inf_count} Inf values "
1016
+ f"(shape: {param_value.shape})"
1017
+ )
1018
+
1019
+ # Attempt to clean NaN/Inf values if detected
1020
+ if nan_count > 0 or inf_count > 0:
1021
+ if param_name in ['A', 'Q', 'V_0']:
1022
+ # Covariance matrices: regularize
1023
+ param_value = np.nan_to_num(param_value, nan=0.0, posinf=1e6, neginf=-1e6)
1024
+ param_value = 0.5 * (param_value + param_value.T) # Ensure symmetry
1025
+ param_value = param_value + np.eye(param_value.shape[0]) * 1e-8
1026
+ output_params[param_name] = param_value
1027
+ elif param_name == 'R':
1028
+ # Diagonal matrix: clean diagonal
1029
+ param_value = np.diag(np.diag(param_value)) # Ensure diagonal
1030
+ R_diag = np.diag(param_value)
1031
+ R_diag = np.where(
1032
+ (np.isnan(R_diag)) | (np.isinf(R_diag)) | (R_diag < 1e-6),
1033
+ 1e-4, R_diag
1034
+ )
1035
+ output_params[param_name] = np.diag(R_diag)
1036
+ elif param_name == 'C':
1037
+ # Loading matrix: replace with zeros
1038
+ param_value = np.nan_to_num(param_value, nan=0.0, posinf=1.0, neginf=-1.0)
1039
+ output_params[param_name] = param_value
1040
+ elif param_name == 'Z_0':
1041
+ # Initial state: reset to zeros
1042
+ param_value = np.zeros_like(param_value)
1043
+ output_params[param_name] = param_value
1044
+
1045
+ # Update variables with cleaned values
1046
+ A, C, Q, R, Z_0, V_0 = output_params['A'], output_params['C'], output_params['Q'], \
1047
+ output_params['R'], output_params['Z_0'], output_params['V_0']
1048
+
1049
+ return A, C, Q, R, Z_0, V_0
1050
+
1051
+
1052
+ def em_step(y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
1053
+ R: np.ndarray, Z_0: np.ndarray, V_0: np.ndarray,
1054
+ r: np.ndarray, p: int, R_mat: Optional[np.ndarray], q: Optional[np.ndarray],
1055
+ nQ: int, i_idio: np.ndarray, blocks: np.ndarray,
1056
+ tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
1057
+ clock: str = 'm',
1058
+ frequencies: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray,
1059
+ np.ndarray, np.ndarray,
1060
+ np.ndarray, np.ndarray, float]:
1061
+ """Apply EM algorithm for parameter reestimation.
1062
+
1063
+ This function implements one iteration of the Expectation-Maximization (EM) algorithm
1064
+ for dynamic factor models. It consists of:
1065
+
1066
+ 1. **E-step**: Run Kalman filter and smoother to obtain smoothed factor estimates
1067
+ and covariances: E[Z_t | data], E[Z_t Z_t' | data], etc.
1068
+
1069
+ 2. **M-step**: Update model parameters (C, R, A, Q) by maximizing the expected
1070
+ complete-data log-likelihood given the smoothed estimates.
1071
+
1072
+ The function handles:
1073
+ - Monthly and quarterly series separately
1074
+ - Block structure for factors
1075
+ - Quarterly aggregation constraints
1076
+ - Idiosyncratic components (monthly and quarterly)
1077
+ - Missing data (via selection matrices Wt)
1078
+
1079
+ Parameters
1080
+ ----------
1081
+ y : np.ndarray
1082
+ Data matrix (n x T), where n is number of series and T is time periods.
1083
+ Note: y is TRANSPOSED relative to input X (which is T x N).
1084
+ Missing values should be NaN. Data should be standardized.
1085
+ A : np.ndarray
1086
+ Current transition matrix (m x m). Describes factor dynamics.
1087
+ C : np.ndarray
1088
+ Current observation/loading matrix (n x m). Maps factors to observed series.
1089
+ Q : np.ndarray
1090
+ Current covariance matrix for transition residuals (m x m).
1091
+ R : np.ndarray
1092
+ Current covariance matrix for observation residuals (n x n). Typically diagonal.
1093
+ Z_0 : np.ndarray
1094
+ Current initial state vector (m,).
1095
+ V_0 : np.ndarray
1096
+ Current initial covariance matrix (m x m).
1097
+ r : np.ndarray
1098
+ Number of factors per block (n_blocks,). Typically all ones.
1099
+ p : int
1100
+ Number of lags in transition equation. Typically 1.
1101
+ R_mat : np.ndarray
1102
+ Quarterly aggregation constraints matrix (4 x 5). Same as Rcon in init_conditions.
1103
+ q : np.ndarray
1104
+ Constraints vector (4,). Typically zeros.
1105
+ nQ : int
1106
+ Number of quarterly variables.
1107
+ i_idio : np.ndarray
1108
+ Logical array (n,) indicating idiosyncratic components: 1 for monthly, 0 for quarterly.
1109
+ blocks : np.ndarray
1110
+ Block loading structure (n x n_blocks). See init_conditions() for details.
1111
+
1112
+ Returns
1113
+ -------
1114
+ C_new : np.ndarray
1115
+ Updated observation matrix (n x m). Loadings are re-estimated via least squares
1116
+ using smoothed factor estimates.
1117
+ R_new : np.ndarray
1118
+ Updated observation residual covariance (n x n). Diagonal matrix containing
1119
+ updated idiosyncratic variances.
1120
+ A_new : np.ndarray
1121
+ Updated transition matrix (m x m). AR coefficients are re-estimated via OLS
1122
+ using smoothed factor estimates.
1123
+ Q_new : np.ndarray
1124
+ Updated transition residual covariance (m x m). Contains innovation variances.
1125
+ Z_0 : np.ndarray
1126
+ Updated initial state (m,). Typically unchanged from input.
1127
+ V_0_new : np.ndarray
1128
+ Updated initial covariance (m x m). Computed from smoothed state estimates.
1129
+ loglik : float
1130
+ Log-likelihood of the data under the current parameters. This is the
1131
+ incomplete-data log-likelihood computed by the Kalman filter.
1132
+
1133
+ Raises
1134
+ ------
1135
+ ValueError
1136
+ If inputs are invalid or matrix dimensions don't match. Also raised if
1137
+ numerical issues occur (e.g., singular matrices, NaN/Inf propagation).
1138
+
1139
+ Notes
1140
+ -----
1141
+ - This is a computationally intensive function that involves multiple matrix
1142
+ inversions and large matrix operations. Performance scales with O(T * m^3).
1143
+ - The function handles missing data via diagonal selection matrices Wt that
1144
+ zero out missing observations.
1145
+ - For quarterly series, loadings must satisfy aggregation constraints enforced
1146
+ via constrained least squares.
1147
+ - Some parameter updates may fail silently (e.g., singular matrices) and use
1148
+ previous values. These cases are handled gracefully to allow EM to continue.
1149
+
1150
+ Examples
1151
+ --------
1152
+ >>> # Get initial parameters from init_conditions()
1153
+ >>> A, C, Q, R, Z_0, V_0 = init_conditions(...)
1154
+ >>> # Prepare data (transpose to n x T)
1155
+ >>> y = x.T # n x T
1156
+ >>> # Run one EM step
1157
+ >>> C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
1158
+ ... y, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks
1159
+ ... )
1160
+ >>> # Update parameters for next iteration
1161
+ >>> A, C, Q, R, Z_0, V_0 = A_new, C_new, Q_new, R_new, Z_0_new, V_0_new
1162
+ """
1163
+ # Validate inputs (note: validation disabled during iterations for performance)
1164
+ # Uncomment if debugging: _validate_em_step_inputs(y, A, C, Q, R, Z_0, V_0, r, p, blocks, nQ)
1165
+
1166
+ # Numerical stability: check and clean parameter matrices before Kalman filter
1167
+ # This prevents NaN/Inf from propagating into the filter
1168
+ if np.any(np.isnan(A)) or np.any(np.isinf(A)):
1169
+ _logger.warning("em_step: A contains NaN/Inf, using regularized identity")
1170
+ A = np.eye(A.shape[0]) * 0.9 + np.nan_to_num(A, nan=0.0, posinf=1.0, neginf=-1.0) * 0.1
1171
+
1172
+ if np.any(np.isnan(Q)) or np.any(np.isinf(Q)):
1173
+ _logger.warning("em_step: Q contains NaN/Inf, regularizing")
1174
+ Q = np.nan_to_num(Q, nan=1e-6, posinf=1e6, neginf=1e-6)
1175
+ Q = 0.5 * (Q + Q.T) # Ensure symmetry
1176
+ Q = Q + np.eye(Q.shape[0]) * 1e-8 # Ensure positive definite
1177
+
1178
+ if np.any(np.isnan(R)) or np.any(np.isinf(R)):
1179
+ _logger.warning("em_step: R contains NaN/Inf, regularizing")
1180
+ R = np.nan_to_num(R, nan=1e-4, posinf=1e4, neginf=1e-4)
1181
+ R = 0.5 * (R + R.T) # Ensure symmetry
1182
+ # Ensure R is diagonal with positive values
1183
+ R_diag = np.diag(R)
1184
+ R_diag = np.where((R_diag < 1e-6) | np.isnan(R_diag) | np.isinf(R_diag), 1e-4, R_diag)
1185
+ R = np.diag(R_diag)
1186
+
1187
+ if np.any(np.isnan(C)) or np.any(np.isinf(C)):
1188
+ _logger.warning("em_step: C contains NaN/Inf, regularizing")
1189
+ C = np.nan_to_num(C, nan=0.0, posinf=1.0, neginf=-1.0)
1190
+
1191
+ if np.any(np.isnan(Z_0)) or np.any(np.isinf(Z_0)):
1192
+ _logger.warning("em_step: Z_0 contains NaN/Inf, resetting to zeros")
1193
+ Z_0 = np.zeros_like(Z_0)
1194
+
1195
+ if np.any(np.isnan(V_0)) or np.any(np.isinf(V_0)):
1196
+ _logger.warning("em_step: V_0 contains NaN/Inf, using regularized identity")
1197
+ V_0 = np.eye(V_0.shape[0]) * 0.1
1198
+
1199
+ n, T = y.shape # n series, T time periods
1200
+ nM = n - nQ
1201
+ # Handle missing_data method (no aggregation constraints)
1202
+ if R_mat is None or q is None:
1203
+ pC = 1 # No tent structure
1204
+ else:
1205
+ pC = R_mat.shape[1]
1206
+ ppC = int(max(p, pC)) # Ensure integer
1207
+ num_blocks = blocks.shape[1]
1208
+
1209
+ # E-step: Run Kalman filter and smoother
1210
+ # Note: y is n x T (series x time), need to transpose for Kalman filter
1211
+ # Kalman filter expects k x nobs, so we pass y as-is
1212
+ zsmooth, vsmooth, vvsmooth, loglik = run_kf(y, A, C, Q, R, Z_0, V_0)
1213
+
1214
+ # zsmooth is m x (T+1) from run_kf (factors x time)
1215
+ # MATLAB: Zsmooth = runKF(y, A, C, Q, R, Z_0, V_0)' - transpose makes it (T+1) x m
1216
+ # So Zsmooth(t+1, bl_idxM(i,:)) selects time t+1, factors indicated by bl_idxM(i,:)
1217
+ # In Python: transpose to match MATLAB: (T+1) x m (time x factors)
1218
+ # Keep initial state (column 0) since MATLAB uses t+1 indexing (skips initial)
1219
+ Zsmooth = zsmooth.T # m x (T+1) -> (T+1) x m
1220
+
1221
+ # M-step: Update parameters
1222
+ # Initialize output
1223
+ A_new = A.copy()
1224
+ Q_new = Q.copy()
1225
+ V_0_new = V_0.copy()
1226
+
1227
+ # Update factor parameters for each block
1228
+ for i in range(num_blocks):
1229
+ r_i = int(r[i])
1230
+ rp = r_i * p
1231
+ # MATLAB: rp1 = sum(r(1:i-1))*ppC (1-based, so we use sum of previous blocks)
1232
+ rp1 = int(np.sum(r[:i]) * ppC) # Python 0-based: sum of r[0] to r[i-1]
1233
+ # MATLAB: b_subset = rp1+1:rp1+rp (1-based indexing)
1234
+ b_subset = slice(rp1, rp1 + rp) # Python 0-based: rp1 to rp1+rp-1
1235
+ t_start = rp1 # Python 0-based
1236
+ t_end = int(rp1 + r_i * ppC) # Ensure integer
1237
+
1238
+ # E[f_t * f_t' | Omega_T]
1239
+ # MATLAB: Zsmooth(b_subset, 2:end) means columns 2 to end (skip initial state)
1240
+ # Python: Zsmooth[b_subset, 1:] is equivalent (skip column 0)
1241
+ EZZ = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, 1:].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, 1:], axis=2)
1242
+
1243
+ # E[f_{t-1} * f_{t-1}' | Omega_T]
1244
+ # MATLAB: Zsmooth(b_subset, 1:end-1) means columns 1 to end-1
1245
+ EZZ_BB = Zsmooth[b_subset, :-1] @ Zsmooth[b_subset, :-1].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, :-1], axis=2)
1246
+
1247
+ # E[f_t * f_{t-1}' | Omega_T]
1248
+ # MATLAB: Zsmooth(b_subset, 2:end) * Zsmooth(b_subset, 1:end-1)'
1249
+ EZZ_FB = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, :-1].T + np.sum(vvsmooth[b_subset, :, :][:, b_subset, :], axis=2)
1250
+
1251
+ # Clean EZZ_BB and EZZ_FB before matrix operations
1252
+ EZZ_BB = np.nan_to_num(EZZ_BB, nan=0.0, posinf=1e6, neginf=-1e6)
1253
+ EZZ_FB = np.nan_to_num(EZZ_FB, nan=0.0, posinf=1e6, neginf=-1e6)
1254
+
1255
+ # Update A and Q for block i
1256
+ A_i = A[t_start:t_end, t_start:t_end].copy()
1257
+ Q_i = Q[t_start:t_end, t_start:t_end].copy()
1258
+
1259
+ try:
1260
+ # Check EZZ_BB before inversion
1261
+ EZZ_BB_sub = EZZ_BB[:rp, :rp]
1262
+ if np.any(np.isnan(EZZ_BB_sub)) or np.any(np.isinf(EZZ_BB_sub)):
1263
+ raise ValueError("Invalid values in EZZ_BB")
1264
+
1265
+ # Regularize if needed (ensure positive definite)
1266
+ eigenvals = np.linalg.eigvals(EZZ_BB_sub)
1267
+ min_eigenval = np.min(eigenvals)
1268
+ if min_eigenval < 1e-8:
1269
+ EZZ_BB_sub = EZZ_BB_sub + np.eye(rp) * (1e-8 - min_eigenval)
1270
+ EZZ_BB_sub = 0.5 * (EZZ_BB_sub + EZZ_BB_sub.T)
1271
+
1272
+ # MATLAB: A_i(1:r_i,1:rp) = EZZ_FB(1:r_i,1:rp) * inv(EZZ_BB(1:rp,1:rp))
1273
+ A_i[:r_i, :rp] = EZZ_FB[:r_i, :rp] @ inv(EZZ_BB_sub)
1274
+ # MATLAB: Q_i(1:r_i,1:r_i) = (EZZ(1:r_i,1:r_i) - A_i(1:r_i,1:rp)* EZZ_FB(1:r_i,1:rp)') / T
1275
+ Q_i[:r_i, :r_i] = (EZZ[:r_i, :r_i] - A_i[:r_i, :rp] @ EZZ_FB[:r_i, :rp].T) / T
1276
+
1277
+ # Clean Q_i result and ensure positive semi-definite
1278
+ Q_i = np.nan_to_num(Q_i, nan=0.0, posinf=1e6, neginf=-1e6)
1279
+ Q_i = 0.5 * (Q_i + Q_i.T) # Ensure symmetry
1280
+
1281
+ # Ensure positive semi-definite: ensure diagonal >= 0 and add regularization if needed
1282
+ Q_diag = np.diag(Q_i[:r_i, :r_i])
1283
+ if np.any(Q_diag < 0):
1284
+ # Replace negative diagonals with small positive values
1285
+ Q_diag = np.maximum(Q_diag, 1e-8)
1286
+ np.fill_diagonal(Q_i[:r_i, :r_i], Q_diag)
1287
+ # Re-symmetrize
1288
+ Q_i[:r_i, :r_i] = 0.5 * (Q_i[:r_i, :r_i] + Q_i[:r_i, :r_i].T)
1289
+
1290
+ # Regularize to ensure positive definiteness, but limit regularization and cap values
1291
+ try:
1292
+ eigenvals = np.linalg.eigvals(Q_i[:r_i, :r_i])
1293
+ min_eigenval = np.min(eigenvals)
1294
+ max_eigenval = np.max(eigenvals)
1295
+
1296
+ # Cap maximum eigenvalue to prevent explosion
1297
+ if max_eigenval > 1e6:
1298
+ scale = 1e6 / max_eigenval
1299
+ Q_i[:r_i, :r_i] = Q_i[:r_i, :r_i] * scale
1300
+ eigenvals = eigenvals * scale
1301
+ min_eigenval = np.min(eigenvals)
1302
+
1303
+ if min_eigenval < 1e-8:
1304
+ reg_amount = min(1e-8 - min_eigenval, 1e-6) # Limit regularization
1305
+ Q_i[:r_i, :r_i] = Q_i[:r_i, :r_i] + np.eye(r_i) * reg_amount
1306
+ Q_i[:r_i, :r_i] = 0.5 * (Q_i[:r_i, :r_i] + Q_i[:r_i, :r_i].T)
1307
+ except (np.linalg.LinAlgError, ValueError):
1308
+ # Eigendecomposition failed - use minimum diagonal value and cap
1309
+ Q_diag_temp = np.diag(Q_i[:r_i, :r_i])
1310
+ Q_diag_temp = np.maximum(Q_diag_temp, 1e-8)
1311
+ Q_diag_temp = np.minimum(Q_diag_temp, 1e6) # Cap at 1e6
1312
+ np.fill_diagonal(Q_i[:r_i, :r_i], Q_diag_temp)
1313
+ Q_i[:r_i, :r_i] = 0.5 * (Q_i[:r_i, :r_i] + Q_i[:r_i, :r_i].T)
1314
+
1315
+ # Ensure Q_i is symmetric (keep regularization, remove excessive capping)
1316
+ Q_i[:r_i, :r_i] = 0.5 * (Q_i[:r_i, :r_i] + Q_i[:r_i, :r_i].T)
1317
+ except (np.linalg.LinAlgError, ValueError):
1318
+ # Keep previous values if inversion fails or invalid values detected
1319
+ pass
1320
+
1321
+ # Clean NaN/Inf from A_i (remove excessive clipping during iterations)
1322
+ if np.any(~np.isfinite(A_i)):
1323
+ A_i = np.nan_to_num(A_i, nan=0.0, posinf=0.0, neginf=0.0)
1324
+ A_new[t_start:t_end, t_start:t_end] = A_i
1325
+
1326
+ Q_new[t_start:t_end, t_start:t_end] = Q_i
1327
+ # MATLAB: Vsmooth(t_start:t_end, t_start:t_end, 1) - note MATLAB uses 1-based indexing
1328
+ V_0_block = vsmooth[t_start:t_end, t_start:t_end, 0]
1329
+ # Ensure V_0_block is positive semi-definite
1330
+ V_0_block = np.nan_to_num(V_0_block, nan=0.0, posinf=1e6, neginf=-1e6)
1331
+ V_0_block = 0.5 * (V_0_block + V_0_block.T)
1332
+ V_0_diag = np.diag(V_0_block)
1333
+ if np.any(V_0_diag < 0):
1334
+ V_0_diag = np.maximum(V_0_diag, 1e-8)
1335
+ np.fill_diagonal(V_0_block, V_0_diag)
1336
+ V_0_block = 0.5 * (V_0_block + V_0_block.T)
1337
+ V_0_new[t_start:t_end, t_start:t_end] = V_0_block
1338
+
1339
+ # Update idiosyncratic component
1340
+ rp1 = int(np.sum(r) * ppC)
1341
+ niM = int(np.sum(i_idio[:nM]))
1342
+ t_start = rp1
1343
+ i_subset = slice(t_start, t_start + niM)
1344
+
1345
+ # E[f_t*f_t' | Omega_T] for idiosyncratic (diagonal only)
1346
+ # MATLAB: Zsmooth(t_start:end, 2:end) * Zsmooth(t_start:end, 2:end)'
1347
+ # Zsmooth is (T+1) x m (time x factors), so Zsmooth(t_start:end, 2:end) selects:
1348
+ # - Rows: t_start to end (factor indices for idiosyncratic)
1349
+ # - Columns: 2 to end (time periods 1 to T, skipping initial state)
1350
+ # Result is (m-t_start+1) x T
1351
+ # Then Zsmooth(...) * Zsmooth(...)' gives (m-t_start+1) x (m-t_start+1)
1352
+ # We want diagonal only: diag(diag(...))
1353
+ #
1354
+ # t_start is the start of idiosyncratic component index
1355
+ # i_subset = t_start:rp1+niM selects the monthly idiosyncratic factors only
1356
+ # So we should use i_subset to select rows, not t_start:end
1357
+ i_subset_slice = slice(i_subset.start, i_subset.stop)
1358
+ Z_idio = Zsmooth[i_subset_slice, 1:] # niM x T, monthly idiosyncratic factors only
1359
+ ni_idio = Z_idio.shape[0] # Number of idiosyncratic factors (should be niM)
1360
+
1361
+ # Z_idio @ Z_idio.T gives ni_idio x ni_idio (sum over time)
1362
+ # Extract diagonal only
1363
+ EZZ_idio_diag_vals = np.sum(Z_idio**2, axis=1) # Sum over time, gives ni_idio values
1364
+ # vsmooth is m x m x (T+1), sum over time dimension (axis 2) for idiosyncratic rows/cols
1365
+ vsmooth_idio_sum = np.sum(vsmooth[i_subset_slice, :, :][:, i_subset_slice, 1:], axis=2) # ni_idio x ni_idio
1366
+ EZZ_idio_diag = np.diag(EZZ_idio_diag_vals) + np.diag(np.diag(vsmooth_idio_sum))
1367
+
1368
+ # E[f_{t-1}*f_{t-1}' | Omega_T] for idiosyncratic (diagonal only)
1369
+ # MATLAB: Zsmooth(t_start:end, 1:end-1) means columns 1 to T (periods 0 to T-1)
1370
+ Z_idio_BB = Zsmooth[i_subset_slice, :-1] # ni_idio x T, time periods 0 to T-1
1371
+ EZZ_BB_idio_diag_vals = np.sum(Z_idio_BB**2, axis=1) # Sum over time
1372
+ vsmooth_BB_sum = np.sum(vsmooth[i_subset_slice, :, :][:, i_subset_slice, :-1], axis=2)
1373
+ EZZ_BB_idio_diag = np.diag(EZZ_BB_idio_diag_vals) + np.diag(np.diag(vsmooth_BB_sum))
1374
+
1375
+ # E[f_t*f_{t-1}' | Omega_T] for idiosyncratic (diagonal only)
1376
+ # MATLAB: Zsmooth(t_start:end, 2:end) * Zsmooth(t_start:end, 1:end-1)'
1377
+ # Z_idio is ni_idio x T (periods 1 to T)
1378
+ # Z_idio_BB is ni_idio x T (periods 0 to T-1)
1379
+ # Element-wise product: Z_idio[:, t] * Z_idio_BB[:, t] for t=0 to T-1 (aligned)
1380
+ min_cols = min(Z_idio.shape[1], Z_idio_BB.shape[1])
1381
+ EZZ_FB_idio_diag_vals = np.sum(Z_idio[:, :min_cols] * Z_idio_BB[:, :min_cols], axis=1) # Element-wise, sum over time
1382
+ vvsmooth_sum = np.sum(vvsmooth[i_subset_slice, :, :][:, i_subset_slice, :], axis=2)
1383
+ EZZ_FB_idio_diag = np.diag(EZZ_FB_idio_diag_vals) + np.diag(np.diag(vvsmooth_sum))
1384
+
1385
+ # Update A and Q for idiosyncratic monthly component
1386
+ # Clean diagonal values before division
1387
+ EZZ_BB_diag = np.diag(EZZ_BB_idio_diag).copy()
1388
+ EZZ_BB_diag = np.where((np.isnan(EZZ_BB_diag) | np.isinf(EZZ_BB_diag) | (EZZ_BB_diag < 1e-8)),
1389
+ 1.0, EZZ_BB_diag)
1390
+
1391
+ # Clean EZZ_FB_idio_diag
1392
+ EZZ_FB_idio_diag = np.nan_to_num(EZZ_FB_idio_diag, nan=0.0, posinf=1e6, neginf=-1e6)
1393
+ EZZ_idio_diag = np.nan_to_num(EZZ_idio_diag, nan=0.0, posinf=1e6, neginf=-1e6)
1394
+
1395
+ A_i = EZZ_FB_idio_diag @ np.diag(1.0 / EZZ_BB_diag)
1396
+ Q_i_update = (EZZ_idio_diag - A_i @ EZZ_FB_idio_diag.T) / T
1397
+ # Cap Q_i_update to prevent explosion
1398
+ Q_i_update = np.clip(Q_i_update, -1e6, 1e6)
1399
+ Q_i = Q_i_update
1400
+
1401
+ # Clean results
1402
+ A_i = np.nan_to_num(A_i, nan=0.0, posinf=1.0, neginf=-1.0)
1403
+ Q_i = np.nan_to_num(Q_i, nan=0.0, posinf=1e6, neginf=-1e6)
1404
+ Q_i = 0.5 * (Q_i + Q_i.T) # Ensure symmetry
1405
+
1406
+ # Ensure positive semi-definite: check and fix diagonal
1407
+ Q_diag = np.diag(Q_i)
1408
+ if np.any(Q_diag < 0):
1409
+ Q_diag = np.maximum(Q_diag, 1e-8)
1410
+ np.fill_diagonal(Q_i, Q_diag)
1411
+ Q_i = 0.5 * (Q_i + Q_i.T)
1412
+
1413
+ # Regularize if needed, but limit to prevent unreasonably large values
1414
+ if Q_i.size > 0:
1415
+ try:
1416
+ eigenvals = np.linalg.eigvals(Q_i)
1417
+ min_eigenval = np.min(eigenvals)
1418
+ max_eigenval = np.max(eigenvals)
1419
+
1420
+ # Cap maximum eigenvalue to prevent explosion
1421
+ if max_eigenval > 1e6:
1422
+ scale = 1e6 / max_eigenval
1423
+ Q_i = Q_i * scale
1424
+ eigenvals = eigenvals * scale
1425
+ min_eigenval = np.min(eigenvals)
1426
+
1427
+ if min_eigenval < 1e-8:
1428
+ reg_amount = min(1e-8 - min_eigenval, 1e-6) # Limit regularization
1429
+ Q_i = Q_i + np.eye(Q_i.shape[0]) * reg_amount
1430
+ Q_i = 0.5 * (Q_i + Q_i.T)
1431
+ except (np.linalg.LinAlgError, ValueError):
1432
+ # Eigendecomposition failed - use minimum diagonal and cap
1433
+ Q_diag_temp = np.diag(Q_i)
1434
+ Q_diag_temp = np.maximum(Q_diag_temp, 1e-8)
1435
+ Q_diag_temp = np.minimum(Q_diag_temp, 1e6) # Cap at 1e6
1436
+ np.fill_diagonal(Q_i, Q_diag_temp)
1437
+ Q_i = 0.5 * (Q_i + Q_i.T)
1438
+
1439
+ # Final cap check on Q_i
1440
+ # Ensure Q_i is well-behaved (already regularized above)
1441
+ Q_i = 0.5 * (Q_i + Q_i.T)
1442
+
1443
+ # Place in output matrices (only monthly idiosyncratic)
1444
+ # MATLAB: A_new(i_subset, i_subset) = A_i(1:niM, 1:niM)
1445
+ # A_i should be ni_idio x ni_idio, and we extract niM x niM (they should be equal)
1446
+ if ni_idio == niM:
1447
+ A_new[i_subset, i_subset] = A_i
1448
+ Q_new[i_subset, i_subset] = Q_i
1449
+ else:
1450
+ # If sizes don't match, take the appropriate slice
1451
+ A_new[i_subset, i_subset] = A_i[:niM, :niM]
1452
+ Q_new[i_subset, i_subset] = Q_i[:niM, :niM]
1453
+
1454
+ # V_0_new: MATLAB uses diag(diag(Vsmooth(i_subset, i_subset, 1)))
1455
+ # Extract diagonal only and place on diagonal of submatrix
1456
+ vsmooth_sub = vsmooth[i_subset_slice, :, :][:, i_subset_slice, 0] # ni_idio x ni_idio
1457
+ vsmooth_diag = np.diag(vsmooth_sub) # ni_idio values
1458
+ # Place diagonal values on the diagonal of V_0_new submatrix
1459
+ for idx, val in enumerate(vsmooth_diag):
1460
+ V_0_new[i_subset.start + idx, i_subset.start + idx] = val
1461
+
1462
+ # Update observation matrix C and R
1463
+ # MATLAB: Z_0 = Zsmooth(:,1) - selects column 1 (initial state, 1-based)
1464
+ # In Python: Zsmooth is (T+1) x m (time x factors), so Zsmooth[0, :] is initial state
1465
+ Z_0 = Zsmooth[0, :].copy() # Initial state (first row)
1466
+
1467
+ # Set missing data to 0
1468
+ nanY = np.isnan(y)
1469
+ y_clean = y.copy()
1470
+ y_clean[nanY] = 0
1471
+
1472
+ # Blocks - get unique block loading patterns
1473
+ bl = np.unique(blocks, axis=0) # Gives unique loadings
1474
+ n_bl = bl.shape[0] # Number of unique loadings
1475
+
1476
+ # Initialize indices: These help with subsetting
1477
+ # Initialize as None - will be set to numpy arrays on first iteration
1478
+ bl_idxM = None # Indicator for monthly factor loadings
1479
+ bl_idxQ = None # Indicator for quarterly factor loadings
1480
+ R_con_list = [] # List to build block diagonal
1481
+
1482
+ # Loop through each block to build indices
1483
+ # MATLAB: for i = 1:num_blocks
1484
+ # bl_idxQ = [bl_idxQ repmat(bl(:,i),1,r(i)*ppC)];
1485
+ # bl_idxM = [bl_idxM repmat(bl(:,i),1,r(i)) zeros(n_bl,r(i)*(ppC-1))];
1486
+ # end
1487
+ # This builds column by column - each column represents a factor position
1488
+ # bl(:,i) is n_bl x 1, repmat makes it n_bl x r(i)*ppC
1489
+ for i in range(num_blocks):
1490
+ r_i = int(r[i])
1491
+ # bl[:, i] repeated r(i) times horizontally for monthly (lag 0 only)
1492
+ # Then zeros for remaining ppC-1 lags
1493
+ bl_col_monthly = np.repeat(bl[:, i:i+1], r_i, axis=1) # n_bl x r_i
1494
+ bl_col_monthly = np.hstack([bl_col_monthly, np.zeros((n_bl, r_i * (ppC - 1)))]) # n_bl x (r_i * ppC)
1495
+
1496
+ # bl[:, i] repeated r(i)*ppC times for quarterly (all lags)
1497
+ bl_col_quarterly = np.repeat(bl[:, i:i+1], r_i * ppC, axis=1) # n_bl x (r_i * ppC)
1498
+
1499
+ # Append columns (MATLAB uses horizontal concatenation)
1500
+ if bl_idxM is None:
1501
+ bl_idxM = bl_col_monthly
1502
+ bl_idxQ = bl_col_quarterly
1503
+ else:
1504
+ bl_idxM = np.hstack([bl_idxM, bl_col_monthly])
1505
+ bl_idxQ = np.hstack([bl_idxQ, bl_col_quarterly])
1506
+
1507
+ # Build constraint matrix: kron(R_mat, eye(r(i))) - only if R_mat is provided
1508
+ if R_mat is not None:
1509
+ R_con_list.append(np.kron(R_mat, np.eye(r_i)))
1510
+
1511
+ # Convert to boolean (MATLAB: logical)
1512
+ # Handle case where no blocks were processed (shouldn't happen, but be safe)
1513
+ if bl_idxM is not None:
1514
+ bl_idxM = bl_idxM.astype(bool)
1515
+ bl_idxQ = bl_idxQ.astype(bool)
1516
+ else:
1517
+ # No blocks processed - create empty boolean arrays
1518
+ bl_idxM = np.array([]).reshape(n_bl, 0).astype(bool)
1519
+ bl_idxQ = np.array([]).reshape(n_bl, 0).astype(bool)
1520
+
1521
+ # Build block diagonal constraint matrix
1522
+ if len(R_con_list) > 0:
1523
+ R_con = block_diag(*R_con_list)
1524
+ else:
1525
+ R_con = np.array([])
1526
+
1527
+ # Set up constraint vector
1528
+ if R_mat is not None and q is not None:
1529
+ q_con = np.zeros((np.sum(r.astype(int)) * R_mat.shape[0], 1))
1530
+ else:
1531
+ q_con = np.array([])
1532
+
1533
+ # Monthly/quarterly indicators
1534
+ i_idio_M = i_idio[:nM]
1535
+ n_idio_M = int(np.sum(i_idio_M))
1536
+ c_i_idio = np.cumsum(i_idio.astype(int))
1537
+
1538
+ # Initialize C_new
1539
+ C_new = C.copy()
1540
+
1541
+ # Loop through unique block patterns
1542
+ for i in range(n_bl):
1543
+ bl_i = bl[i, :]
1544
+ rs = int(np.sum(r[bl_i.astype(bool)])) # Total factors for this block pattern
1545
+ idx_i = np.where((blocks == bl_i).all(axis=1))[0] # Series with this pattern
1546
+ idx_iM = idx_i[idx_i < nM] # Only monthly
1547
+ n_i = len(idx_iM)
1548
+
1549
+ if n_i == 0:
1550
+ continue
1551
+
1552
+ # Get bl_idxM indices for this block pattern (monthly: lag 0 only)
1553
+ bl_idxM_i = np.where(bl_idxM[i, :])[0]
1554
+ if len(bl_idxM_i) == 0:
1555
+ continue
1556
+
1557
+ # Verify rs matches bl_idxM_i length
1558
+ rs_actual = len(bl_idxM_i)
1559
+ if rs_actual != rs:
1560
+ # If mismatch, use actual length
1561
+ rs = rs_actual
1562
+
1563
+ # Initialize sums for equation 13 (BGR 2010)
1564
+ # denom should be (n_i * rs) x (n_i * rs)
1565
+ denom_size = n_i * rs
1566
+ denom = np.zeros((denom_size, denom_size))
1567
+ nom = np.zeros((n_i, rs))
1568
+
1569
+ # Monthly indices for idiosyncratic
1570
+ i_idio_i = i_idio_M[idx_iM]
1571
+ i_idio_ii = c_i_idio[idx_iM]
1572
+ i_idio_ii = i_idio_ii[i_idio_i.astype(bool)]
1573
+
1574
+ # Loop through each period for monthly variables
1575
+ for t in range(T):
1576
+ # Wt is diagonal selection matrix: n_i x n_i
1577
+ nan_mask = ~nanY[idx_iM, t]
1578
+ Wt = np.diag(nan_mask.astype(float))
1579
+
1580
+ # Ensure Wt is correct size
1581
+ if Wt.shape[0] != n_i:
1582
+ # This shouldn't happen, but handle it
1583
+ Wt = np.diag(np.ones(n_i))
1584
+
1585
+ # E[f_t*f_t' | Omega_T]
1586
+ # MATLAB: Zsmooth(bl_idxM(i, :), t+1)
1587
+ # Zsmooth is (T+1) x m (time x factors)
1588
+ # bl_idxM(i, :) is logical 1 x m indicating which factor columns
1589
+ # Zsmooth(bl_idxM(i, :), t+1) selects row t+1, columns where bl_idxM(i,:) is True
1590
+ # This gives 1 x rs (row vector)
1591
+ # Then Zsmooth(...)' * Zsmooth(...) = (rs x 1) * (1 x rs) = rs x rs
1592
+
1593
+ # Zsmooth is (T+1) x m, so Zsmooth[t+1, bl_idxM_i] gives 1 x rs
1594
+ if t + 1 < Zsmooth.shape[0]:
1595
+ Z_block_M_row = Zsmooth[t + 1, bl_idxM_i] # 1 x rs (row vector)
1596
+ ZZZ = Z_block_M_row.reshape(-1, 1) @ Z_block_M_row.reshape(1, -1) # rs x 1 * 1 x rs = rs x rs
1597
+ else:
1598
+ ZZZ = np.zeros((rs, rs))
1599
+
1600
+ # V_block_M: vsmooth is m x m x (T+1), so vsmooth[bl_idxM_i, bl_idxM_i, t+1] is rs x rs
1601
+ if t + 1 < vsmooth.shape[2]:
1602
+ V_block_M = vsmooth[np.ix_(bl_idxM_i, bl_idxM_i, [t + 1])]
1603
+ if V_block_M.ndim == 3:
1604
+ V_block_M = V_block_M[:, :, 0] # Extract 2D slice
1605
+ if V_block_M.shape != (rs, rs):
1606
+ V_block_M = np.zeros((rs, rs))
1607
+ else:
1608
+ V_block_M = np.zeros((rs, rs))
1609
+
1610
+ # kron should produce (n_i * rs) x (n_i * rs)
1611
+ # Wt is n_i x n_i, ZZZ + V_block_M is rs x rs
1612
+ # kron((rs x rs), (n_i x n_i)) = (rs * n_i) x (rs * n_i) = (n_i * rs) x (n_i * rs)
1613
+ # Ensure dimensions match before kron
1614
+ expected_shape = (denom_size, denom_size)
1615
+ if ZZZ.shape == (rs, rs) and V_block_M.shape == (rs, rs) and Wt.shape == (n_i, n_i):
1616
+ try:
1617
+ kron_result = np.kron(ZZZ + V_block_M, Wt)
1618
+ if kron_result.shape == expected_shape:
1619
+ denom += kron_result
1620
+ else:
1621
+ # Dimension mismatch - skip this term
1622
+ pass
1623
+ except ValueError:
1624
+ # If kron fails, skip this term
1625
+ pass
1626
+
1627
+ # E[y_t*f_t' | Omega_T]
1628
+ # MATLAB: y(idx_iM, t) * Zsmooth(bl_idxM(i, :), t+1)'
1629
+ # y is N x T, so y(idx_iM, t) is n_i x 1 (column vector)
1630
+ # Zsmooth(bl_idxM(i, :), t+1) is 1 x rs, so transpose gives rs x 1
1631
+ # y(idx_iM, t) * Zsmooth(...)' gives n_i x 1 * rs x 1 = n_i x rs (outer product? No, matrix mult)
1632
+ # Actually: (n_i x 1) * (1 x rs) = n_i x rs
1633
+ if t + 1 < Zsmooth.shape[0]:
1634
+ y_vec = y_clean[idx_iM, t].reshape(-1, 1) # n_i x 1
1635
+ Z_vec_row = Zsmooth[t + 1, bl_idxM_i].reshape(1, -1) # 1 x rs
1636
+ y_term = y_vec @ Z_vec_row # n_i x 1 * 1 x rs = n_i x rs
1637
+ else:
1638
+ y_term = np.zeros((len(idx_iM), rs_actual))
1639
+
1640
+ # Idiosyncratic component
1641
+ # MATLAB: Wt(:, i_idio_i) * (Zsmooth(rp1 + i_idio_ii, t+1) * Zsmooth(bl_idxM(i, :), t+1)' + ...)
1642
+ # Zsmooth(rp1 + i_idio_ii, t+1) is len(i_idio_ii) x 1, Zsmooth(bl_idxM(i, :), t+1)' is rs x 1
1643
+ # So product is len(i_idio_ii) x rs
1644
+ if len(i_idio_ii) > 0 and t + 1 < Zsmooth.shape[0]:
1645
+ idio_idx = (rp1 + i_idio_ii).astype(int)
1646
+ if idio_idx.max() < Zsmooth.shape[1]: # Zsmooth is (T+1) x m
1647
+ idio_Z_col = Zsmooth[t + 1, idio_idx].reshape(-1, 1) # len(i_idio_ii) x 1
1648
+ idio_Z_outer = idio_Z_col @ Z_vec_row # len(i_idio_ii) x 1 * 1 x rs = len(i_idio_ii) x rs
1649
+
1650
+ if t + 1 < vsmooth.shape[2]:
1651
+ idio_V = vsmooth[np.ix_(idio_idx, bl_idxM_i, [t + 1])]
1652
+ if idio_V.ndim == 3:
1653
+ idio_V = idio_V[:, :, 0] # len(i_idio_ii) x rs
1654
+ else:
1655
+ idio_V = np.zeros((len(i_idio_ii), rs_actual))
1656
+
1657
+ 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
1658
+ else:
1659
+ idio_term = np.zeros((len(idx_iM), rs_actual))
1660
+ else:
1661
+ idio_term = np.zeros((len(idx_iM), rs_actual))
1662
+
1663
+ nom += y_term - idio_term
1664
+
1665
+ # Solve for C
1666
+ try:
1667
+ vec_C = inv(denom) @ nom.flatten()
1668
+ # Assign using explicit indexing to avoid read-only issues
1669
+ C_update = vec_C.reshape(n_i, rs)
1670
+ for i, row_idx in enumerate(idx_iM):
1671
+ for j, col_idx in enumerate(bl_idxM_i):
1672
+ C_new[row_idx, col_idx] = C_update[i, j]
1673
+ except (np.linalg.LinAlgError, ValueError) as e:
1674
+ # Matrix inversion failed or dimension mismatch - skip this block
1675
+ # This can happen if denom is singular or shapes don't match
1676
+ # Log would be helpful but keeping silent for now to match MATLAB behavior
1677
+ pass
1678
+
1679
+ # Update quarterly variables
1680
+ idx_iQ = idx_i[idx_i >= nM]
1681
+ if len(idx_iQ) == 0:
1682
+ continue # No quarterly series for this block pattern
1683
+
1684
+ rps = rs * ppC
1685
+
1686
+ # Get constraint matrix for this block pattern
1687
+ # Verify i is within bl_idxQ bounds
1688
+ if i >= bl_idxQ.shape[0]:
1689
+ continue # Block pattern index out of bounds
1690
+
1691
+ if R_con.size > 0:
1692
+ bl_idxQ_i = np.where(bl_idxQ[i, :])[0]
1693
+ if len(bl_idxQ_i) > 0 and bl_idxQ_i.max() < R_con.shape[1]:
1694
+ R_con_i = R_con[:, bl_idxQ_i]
1695
+ q_con_i = q_con
1696
+
1697
+ # Remove empty constraints
1698
+ no_c = ~np.any(R_con_i, axis=1)
1699
+ R_con_i = R_con_i[~no_c, :]
1700
+ q_con_i = q_con_i[~no_c]
1701
+ else:
1702
+ # Handle empty bl_idxQ_i case
1703
+ if len(bl_idxQ_i) > 0:
1704
+ R_con_i = np.array([]).reshape(0, len(bl_idxQ_i))
1705
+ else:
1706
+ R_con_i = np.array([]).reshape(0, 1) # Default to 1 column for empty case
1707
+ q_con_i = np.array([])
1708
+ else:
1709
+ bl_idxQ_i = np.where(bl_idxQ[i, :])[0]
1710
+ # Handle empty bl_idxQ_i case
1711
+ if len(bl_idxQ_i) > 0:
1712
+ R_con_i = np.array([]).reshape(0, len(bl_idxQ_i))
1713
+ else:
1714
+ R_con_i = np.array([]).reshape(0, 1) # Default to 1 column for empty case
1715
+ q_con_i = np.array([])
1716
+
1717
+ # Loop through quarterly series in this block pattern
1718
+ for j in idx_iQ:
1719
+ denom = np.zeros((rps, rps))
1720
+ nom = np.zeros((1, rps))
1721
+
1722
+ idx_jQ = j - nM # Ordinal position of quarterly variable
1723
+ # Location of factor structure for quarterly var residuals
1724
+ i_idio_jQ = np.arange(rp1 + n_idio_M + 5 * idx_jQ, rp1 + n_idio_M + 5 * (idx_jQ + 1))
1725
+
1726
+ # Update V_0, A, Q for quarterly idiosyncratic
1727
+ # MATLAB: V_0_new(i_idio_jQ, i_idio_jQ) = Vsmooth(i_idio_jQ, i_idio_jQ, 1)
1728
+ if len(i_idio_jQ) > 0 and i_idio_jQ[-1] < V_0_new.shape[0]:
1729
+ vsmooth_Q = vsmooth[i_idio_jQ, :, :][:, i_idio_jQ, 0] # Extract submatrix
1730
+ # Place values explicitly to avoid broadcasting issues
1731
+ for idx1, i1 in enumerate(i_idio_jQ):
1732
+ for idx2, i2 in enumerate(i_idio_jQ):
1733
+ V_0_new[i1, i2] = vsmooth_Q[idx1, idx2]
1734
+ if i_idio_jQ[0] < A_new.shape[0] and (i_idio_jQ[0] - rp1) >= 0:
1735
+ # Get from monthly idiosyncratic structure if available
1736
+ try:
1737
+ if rp1 < A_new.shape[0] and rp1 < A_new.shape[1]:
1738
+ A_new[i_idio_jQ[0], i_idio_jQ[0]] = A_new[rp1, rp1]
1739
+ Q_new[i_idio_jQ[0], i_idio_jQ[0]] = Q_new[rp1, rp1]
1740
+ except (IndexError, ValueError) as e:
1741
+ # Index out of bounds or assignment failed - skip this update
1742
+ pass
1743
+
1744
+ # Loop through each period for quarterly variables
1745
+ for t in range(T):
1746
+ # MATLAB: Wt = diag(~nanY(j, t)) where j is scalar, nanY(j, t) is scalar
1747
+ # In MATLAB, diag(scalar) gives 1x1 matrix
1748
+ # In Python, np.diag() requires 1D array, so wrap scalar in array
1749
+ nan_val = ~nanY[j, t]
1750
+ if np.isscalar(nan_val):
1751
+ # Scalar case: create 1x1 matrix
1752
+ Wt = np.array([[float(nan_val)]])
1753
+ else:
1754
+ # Array case: use np.diag() normally
1755
+ Wt = np.diag(nan_val.astype(float))
1756
+
1757
+ # MATLAB: Zsmooth(bl_idxQ(i,:), t+1) - Zsmooth is (T+1) x m
1758
+ if t + 1 < Zsmooth.shape[0]:
1759
+ Z_block_Q_row = Zsmooth[t + 1, bl_idxQ_i] # 1 x rps
1760
+ Z_block_Q_col = Z_block_Q_row.reshape(-1, 1) # rps x 1
1761
+ ZZZ_Q = Z_block_Q_col @ Z_block_Q_row.reshape(1, -1) # rps x 1 * 1 x rps = rps x rps
1762
+
1763
+ V_block_Q = vsmooth[np.ix_(bl_idxQ_i, bl_idxQ_i, [t + 1])]
1764
+ if V_block_Q.ndim == 3:
1765
+ V_block_Q = V_block_Q[:, :, 0]
1766
+ else:
1767
+ rps_actual = len(bl_idxQ_i)
1768
+ Z_block_Q_row = np.zeros(rps_actual)
1769
+ Z_block_Q_col = np.zeros((rps_actual, 1))
1770
+ ZZZ_Q = np.zeros((rps_actual, rps_actual))
1771
+ V_block_Q = np.zeros((rps_actual, rps_actual))
1772
+
1773
+ # E[f_t*f_t' | Omega_T]
1774
+ # MATLAB: kron(Zsmooth(...)*Zsmooth(...)' + Vsmooth(...), Wt)
1775
+ # Wt is 1x1 matrix for single quarterly series, so kron((rps x rps), (1 x 1)) = rps x rps
1776
+ # Since Wt is 1x1, kron(A, Wt) = A * Wt[0,0]
1777
+ if Wt.shape == (1, 1):
1778
+ denom += (ZZZ_Q + V_block_Q) * Wt[0, 0]
1779
+ else:
1780
+ denom += np.kron(ZZZ_Q + V_block_Q, Wt)
1781
+
1782
+ # E[y_t*f_t' | Omega_T]
1783
+ # MATLAB: y(j, t) * Zsmooth(bl_idxQ(i,:), t+1)' - y is scalar, Zsmooth(...)' is rps x 1
1784
+ # Result is 1 x rps
1785
+ nom += y_clean[j, t] * Z_block_Q_row.reshape(1, -1) # 1 x rps
1786
+
1787
+ # Subtract idiosyncratic component
1788
+ # MATLAB: Wt * ([1 2 3 2 1] * Zsmooth(i_idio_jQ, t+1) * Zsmooth(bl_idxQ(i,:), t+1)' + ...)
1789
+ # tent is 1 x n_periods, Zsmooth(i_idio_jQ, t+1) is n_periods x 1, so tent * Zsmooth gives scalar
1790
+ if len(i_idio_jQ) > 0 and t + 1 < Zsmooth.shape[0] and i_idio_jQ[-1] < Zsmooth.shape[1]:
1791
+ # Determine frequency of this series and use appropriate tent weights
1792
+ series_freq = None
1793
+ if frequencies is not None and j < len(frequencies):
1794
+ series_freq = frequencies[j]
1795
+
1796
+ # Use tent weights from lookup if available
1797
+ if tent_weights_dict is not None and series_freq and series_freq in tent_weights_dict:
1798
+ tent = tent_weights_dict[series_freq].reshape(1, -1)
1799
+ elif tent_weights_dict is not None and 'q' in tent_weights_dict:
1800
+ # Fallback to quarterly if frequency-specific not found
1801
+ tent = tent_weights_dict['q'].reshape(1, -1)
1802
+ else:
1803
+ # Default quarterly tent structure (assumes 5 periods) - FRBNY standard
1804
+ tent = np.array([1, 2, 3, 2, 1]).reshape(1, -1) # 1 x 5
1805
+
1806
+ # Ensure tent matches i_idio_jQ length
1807
+ n_periods_actual = len(i_idio_jQ)
1808
+ if tent.shape[1] != n_periods_actual:
1809
+ # Adjust tent to match actual periods (truncate or pad)
1810
+ if tent.shape[1] > n_periods_actual:
1811
+ tent = tent[:, :n_periods_actual]
1812
+ else:
1813
+ # Pad with last value
1814
+ pad_width = n_periods_actual - tent.shape[1]
1815
+ tent = np.hstack([tent, np.tile(tent[:, -1:], (1, pad_width))])
1816
+
1817
+ idio_Z_col = Zsmooth[t + 1, i_idio_jQ].reshape(-1, 1) # n_periods x 1
1818
+ idio_term_scalar = (tent @ idio_Z_col) * Z_block_Q_row # scalar * 1 x rps = 1 x rps
1819
+
1820
+ if t + 1 < vsmooth.shape[2]:
1821
+ idio_V = vsmooth[np.ix_(i_idio_jQ, bl_idxQ_i, [t + 1])]
1822
+ if idio_V.ndim == 3:
1823
+ idio_V = idio_V[:, :, 0] # n_periods x rps
1824
+ idio_term_V = tent @ idio_V # 1 x n_periods * n_periods x rps = 1 x rps
1825
+ else:
1826
+ idio_term_V = np.zeros((1, len(bl_idxQ_i)))
1827
+
1828
+ # Wt is 1x1 matrix for quarterly, extract scalar value
1829
+ wt_scalar = Wt[0, 0] if Wt.shape == (1, 1) else Wt
1830
+ nom -= wt_scalar * (idio_term_scalar + idio_term_V) # Scalar * 1 x rps
1831
+
1832
+ # Solve for C (with constraints if applicable)
1833
+ try:
1834
+ C_i = inv(denom) @ nom.T
1835
+
1836
+ # Apply quarterly constraints
1837
+ if R_con_i.size > 0 and len(q_con_i) == R_con_i.shape[0]:
1838
+ denom_inv = inv(denom)
1839
+ 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)
1840
+ C_i_constr = C_i - constraint_term
1841
+ else:
1842
+ C_i_constr = C_i
1843
+
1844
+ # Place in output matrix
1845
+ # Assign using explicit indexing to avoid read-only issues
1846
+ if len(bl_idxQ_i) > 0:
1847
+ C_update = C_i_constr.flatten()[:len(bl_idxQ_i)]
1848
+ for k, col_idx in enumerate(bl_idxQ_i):
1849
+ C_new[j, col_idx] = C_update[k]
1850
+ except (np.linalg.LinAlgError, ValueError, IndexError) as e:
1851
+ # Matrix inversion failed, dimension mismatch, or index error - skip this series
1852
+ # This can happen if denom is singular, constraints are inconsistent, or indices invalid
1853
+ pass
1854
+
1855
+ # Update R (covariance of observation residuals)
1856
+ R_new = np.zeros((n, n))
1857
+ for t in range(T):
1858
+ # Wt is diagonal selection matrix: n x n
1859
+ nan_mask = ~nanY[:, t] # n x 1 boolean array
1860
+ Wt = np.diag(nan_mask.astype(float)) # n x n diagonal matrix
1861
+ # resid = y - Wt @ C @ Z
1862
+ # y_clean[:, t:t+1] is n x 1
1863
+ # Wt is n x n
1864
+ # C_new is n x m
1865
+ # Zsmooth[:, t + 1:t + 2] is (T+1) x m, but we need m x 1 (column vector)
1866
+ # So Zsmooth[t + 1, :] is 1 x m, transpose to m x 1
1867
+ Z_t = Zsmooth[t + 1, :].reshape(-1, 1) # m x 1
1868
+ resid = y_clean[:, t:t+1] - Wt @ C_new @ Z_t # n x 1
1869
+ # R update: resid @ resid.T gives n x n
1870
+ # vsmooth[:, :, t + 1] is m x m, need to extract properly
1871
+ vsmooth_t = vsmooth[:, :, t + 1] # m x m
1872
+ R_new += (resid @ resid.T +
1873
+ Wt @ C_new @ vsmooth_t @ C_new.T @ Wt +
1874
+ (np.eye(n) - Wt) @ R @ (np.eye(n) - Wt))
1875
+
1876
+ R_new = R_new / T
1877
+ # Extract diagonal and copy to make writable
1878
+ RR = np.diag(R_new).copy()
1879
+ # Set minimum values for idiosyncratic components
1880
+ # i_idio[:nM] gives monthly idiosyncratic flags for first nM series
1881
+ # Create boolean mask only for indices within RR bounds
1882
+ i_idio_M_values = i_idio[:min(nM, len(RR))].astype(bool)
1883
+ # Apply mask only to corresponding indices
1884
+ valid_indices = np.arange(len(i_idio_M_values))[i_idio_M_values]
1885
+ RR[valid_indices] = 1e-4
1886
+ # Set quarterly series minimums
1887
+ if nM < len(RR):
1888
+ RR[nM:] = 1e-4
1889
+ # Place back on diagonal
1890
+ np.fill_diagonal(R_new, RR)
1891
+
1892
+ # Final validation: Ensure Q_new is positive semi-definite
1893
+ Q_diag = np.diag(Q_new)
1894
+ if np.any(Q_diag < -1e-10):
1895
+ # Replace negative diagonals with small positive values
1896
+ Q_diag = np.maximum(Q_diag, 1e-8)
1897
+ np.fill_diagonal(Q_new, Q_diag)
1898
+ Q_new = 0.5 * (Q_new + Q_new.T) # Re-symmetrize
1899
+
1900
+ # Regularize if minimum eigenvalue is too negative, but limit to prevent unreasonably large values
1901
+ try:
1902
+ eigenvals = np.linalg.eigvals(Q_new)
1903
+ min_eigenval = np.min(eigenvals)
1904
+ max_eigenval = np.max(eigenvals)
1905
+
1906
+ # Cap maximum eigenvalue to prevent explosion
1907
+ if max_eigenval > 1e6:
1908
+ scale = 1e6 / max_eigenval
1909
+ Q_new = Q_new * scale
1910
+ eigenvals = eigenvals * scale
1911
+ min_eigenval = np.min(eigenvals)
1912
+
1913
+ if min_eigenval < 1e-8:
1914
+ reg_amount = min(1e-8 - min_eigenval, 1e-6) # Limit regularization to prevent huge values
1915
+ Q_new = Q_new + np.eye(Q_new.shape[0]) * reg_amount
1916
+ Q_new = 0.5 * (Q_new + Q_new.T)
1917
+ except (np.linalg.LinAlgError, ValueError):
1918
+ # Eigendecomposition failed - use diagonal matrix with minimum values and cap
1919
+ Q_diag_temp = np.diag(Q_new)
1920
+ Q_diag_temp = np.maximum(Q_diag_temp, 1e-8)
1921
+ Q_diag_temp = np.minimum(Q_diag_temp, 1e6) # Cap at 1e6
1922
+ Q_new = np.diag(Q_diag_temp)
1923
+
1924
+ # Final cap check on Q_new
1925
+ Q_new = np.clip(Q_new, -1e6, 1e6)
1926
+ Q_new = 0.5 * (Q_new + Q_new.T)
1927
+
1928
+ # Final validation: Ensure V_0_new is positive semi-definite
1929
+ V_0_diag = np.diag(V_0_new)
1930
+ if np.any(V_0_diag < -1e-10):
1931
+ V_0_diag = np.maximum(V_0_diag, 1e-8)
1932
+ np.fill_diagonal(V_0_new, V_0_diag)
1933
+ V_0_new = 0.5 * (V_0_new + V_0_new.T)
1934
+
1935
+ # Regularize if needed
1936
+ try:
1937
+ eigenvals = np.linalg.eigvals(V_0_new)
1938
+ min_eigenval = np.min(eigenvals)
1939
+ if min_eigenval < 1e-8:
1940
+ reg_amount = min(1e-8 - min_eigenval, 1e-6)
1941
+ V_0_new = V_0_new + np.eye(V_0_new.shape[0]) * reg_amount
1942
+ V_0_new = 0.5 * (V_0_new + V_0_new.T)
1943
+ except (np.linalg.LinAlgError, ValueError):
1944
+ # Fallback: use diagonal with minimum values
1945
+ V_0_diag_temp = np.diag(V_0_new)
1946
+ V_0_diag_temp = np.maximum(V_0_diag_temp, 1e-8)
1947
+ np.fill_diagonal(V_0_new, V_0_diag_temp)
1948
+ V_0_new = 0.5 * (V_0_new + V_0_new.T)
1949
+
1950
+ # Final cleanup of V_0_new (keep only critical safeguards)
1951
+ if np.any(~np.isfinite(V_0_new)):
1952
+ V_0_new = np.nan_to_num(V_0_new, nan=1e-8, posinf=1e6, neginf=1e-8)
1953
+ V_0_new = 0.5 * (V_0_new + V_0_new.T) # Ensure symmetry
1954
+
1955
+ return C_new, R_new, A_new, Q_new, Z_0, V_0_new, loglik
1956
+
1957
+
1958
+ def dfm(X: np.ndarray, config: DFMConfig, threshold: Optional[float] = None, max_iter: Optional[int] = None) -> DFMResult:
1959
+ """Estimate dynamic factor model using EM algorithm.
1960
+
1961
+ This is the main function for estimating a Dynamic Factor Model (DFM). It implements
1962
+ the complete EM algorithm workflow:
1963
+
1964
+ 1. **Initialization**: Compute initial parameter estimates via PCA and OLS
1965
+ 2. **EM Iterations**: Iteratively update parameters until convergence
1966
+ 3. **Final Smoothing**: Run Kalman smoother with final parameters to obtain
1967
+ smoothed factors and data
1968
+
1969
+ The DFM models observed time series as:
1970
+
1971
+ .. math::
1972
+ y_t = C Z_t + e_t, e_t \\sim N(0, R)
1973
+ Z_t = A Z_{t-1} + v_t, v_t \\sim N(0, Q)
1974
+
1975
+ where:
1976
+ - y_t is the n x 1 vector of observed series at time t
1977
+ - Z_t is the m x 1 vector of unobserved factors
1978
+ - C is the n x m loading matrix
1979
+ - A is the m x m transition matrix
1980
+ - R and Q are covariance matrices
1981
+
1982
+ Parameters
1983
+ ----------
1984
+ X : np.ndarray
1985
+ Data matrix (T x N), where T is time periods and N is number of series.
1986
+ Data can contain missing values (NaN), which are handled via spline interpolation.
1987
+ Missing values are allowed but excessive missing data (>50%) will trigger warnings.
1988
+ config : DFMConfig
1989
+ Unified DFM configuration object containing:
1990
+ - Model structure: Blocks (N x n_blocks), Frequency (per series),
1991
+ Transformation (per series), factors_per_block
1992
+ - Estimation parameters: ar_lag, threshold, max_iter, nan_method, nan_k
1993
+ Typically obtained from `load_config()`.
1994
+ threshold : float, optional
1995
+ EM convergence threshold. If None, uses config.threshold (default: 1e-5).
1996
+ Convergence is declared when:
1997
+ |loglik_current - loglik_previous| / avg(|loglik_current|, |loglik_previous|) < threshold.
1998
+ Smaller values require more iterations but provide more precise convergence.
1999
+ Typical range: 1e-5 to 1e-3.
2000
+ max_iter : int, optional
2001
+ Maximum EM iterations. If None, uses config.max_iter (default: 5000).
2002
+ For testing, use smaller values like 10-20.
2003
+
2004
+ Returns
2005
+ -------
2006
+ DFMResult
2007
+ Dataclass containing all estimation results:
2008
+ - x_sm, X_sm: Smoothed data (standardized and unstandardized)
2009
+ - Z: Smoothed factor estimates
2010
+ - C, A, Q, R: Estimated parameters
2011
+ - Mx, Wx: Standardization parameters
2012
+ - Z_0, V_0: Initial state and covariance
2013
+ - r, p: Model structure parameters
2014
+
2015
+ Raises
2016
+ ------
2017
+ ValueError
2018
+ If inputs are invalid (dimensions, data quality, parameters).
2019
+ Also raised during EM iterations if numerical issues occur (e.g., NaN/Inf).
2020
+ TypeError
2021
+ If input types are incorrect (e.g., X is not numpy array).
2022
+
2023
+ Notes
2024
+ -----
2025
+ - The function automatically standardizes data: x = (X - mean) / std
2026
+ - Initial conditions are computed via `init_conditions()`
2027
+ - EM iterations continue until convergence or max_iter=5000
2028
+ - Missing data is handled by the Kalman filter during estimation
2029
+ - Convergence messages and progress are printed during execution
2030
+
2031
+ Examples
2032
+ --------
2033
+ >>> from dfm_python import load_config, load_data, dfm
2034
+ >>> import pandas as pd
2035
+ >>> # Load configuration (CSV or YAML both work)
2036
+ >>> config = load_config('config.yaml') # or 'config.csv'
2037
+ >>> # Load data from CSV file
2038
+ >>> X, Time, Z = load_data('data.csv', config, sample_start=pd.Timestamp('2000-01-01'))
2039
+ >>> # Estimate DFM
2040
+ >>> Res = dfm(X, config, threshold=1e-4)
2041
+ >>> # Access results
2042
+ >>> factors = Res.Z # (T x m) factor estimates
2043
+ >>> loadings = Res.C # (N x m) factor loadings
2044
+ >>> smoothed_data = Res.X_sm # (T x N) smoothed data
2045
+ >>> # Compute common factor (first factor)
2046
+ >>> common_factor = Res.Z[:, 0]
2047
+ >>> # Project factor onto a series
2048
+ >>> series_idx = 0
2049
+ >>> series_factor = Res.Z @ Res.C[series_idx, :].T
2050
+ """
2051
+ # Validate inputs
2052
+ # Input validation is done inline or skipped for now
2053
+ # _validate_inputs(X, config, threshold)
2054
+
2055
+ print('Estimating the dynamic factor model (DFM) ... \n')
2056
+
2057
+ # Additional check: ensure no NaN/Inf in input data that could cause issues
2058
+ nan_mask = np.isnan(X)
2059
+ inf_mask = np.isinf(X)
2060
+ if np.any(inf_mask):
2061
+ # Replace Inf with NaN (will be handled by missing data mechanisms)
2062
+ X = np.where(inf_mask, np.nan, X)
2063
+ warnings.warn("Data contains Inf values, replaced with NaN", UserWarning)
2064
+
2065
+ # Store model parameters
2066
+ blocks = config.Blocks
2067
+ nQ = np.sum(np.array(config.Frequency) == 'q') # Number of quarterly series
2068
+
2069
+ # Get configurable parameters from unified DFMConfig
2070
+ p = config.ar_lag
2071
+ r = (np.array(config.factors_per_block)
2072
+ if config.factors_per_block is not None
2073
+ else np.ones(blocks.shape[1]))
2074
+ nan_method = config.nan_method
2075
+ nan_k = config.nan_k
2076
+ threshold = threshold if threshold is not None else config.threshold
2077
+ max_iter = max_iter if max_iter is not None else config.max_iter
2078
+
2079
+ # Display blocks (Table 3: Block Loading Structure)
2080
+ try:
2081
+ print('\n\n\n')
2082
+ print('Table 3: Block Loading Structure')
2083
+ import pandas as pd
2084
+ # Create DataFrame similar to MATLAB's array2table
2085
+ df = pd.DataFrame(blocks,
2086
+ index=[name.replace(' ', '_') for name in config.SeriesName],
2087
+ columns=config.BlockNames if hasattr(config, 'BlockNames') and len(config.BlockNames) == blocks.shape[1]
2088
+ else [f'Block_{i+1}' for i in range(blocks.shape[1])])
2089
+ print(df.to_string())
2090
+ print('\n\n\n')
2091
+ except Exception as e:
2092
+ # Display error - non-critical, continue execution
2093
+ # Fallback: show basic info
2094
+ print(f'Blocks shape: {blocks.shape}')
2095
+ print('\n\n\n')
2096
+
2097
+ T, N = X.shape
2098
+
2099
+ # Get clock from config (defaults to 'm' for monthly)
2100
+ clock = getattr(config, 'clock', 'm')
2101
+
2102
+ # Get aggregation structure based on clock
2103
+ agg_info = get_aggregation_structure(config, clock=clock)
2104
+
2105
+ # Extract tent weights for use in em_step
2106
+ tent_weights_dict = agg_info.get('tent_weights', {})
2107
+
2108
+ # Determine which series need tent kernels (slower than clock)
2109
+ # For now, we'll use the most common case: if we have quarterly series and clock is monthly
2110
+ # We'll determine R_mat and pC based on the slowest frequency that needs tent kernel
2111
+ frequencies = np.array(config.Frequency)
2112
+ nQ = np.sum(frequencies == 'q')
2113
+
2114
+ # Find the slowest frequency that has a tent kernel
2115
+ # This determines the R_mat and pC to use
2116
+ R_mat = None
2117
+ q = None
2118
+ pC = 1
2119
+
2120
+ # Check structures for tent kernels (they're keyed as (slower_freq, clock))
2121
+ if agg_info['structures']:
2122
+ # Use the first available structure (typically quarterly->monthly)
2123
+ # In practice, we'll use the one with the most periods (most complex)
2124
+ max_periods = 0
2125
+ for (slower_freq, clock_freq), (R, q_vec) in agg_info['structures'].items():
2126
+ if R is not None:
2127
+ n_periods = R.shape[1]
2128
+ if n_periods > max_periods:
2129
+ max_periods = n_periods
2130
+ R_mat = R
2131
+ q = q_vec
2132
+ pC = n_periods
2133
+
2134
+ # Get frequencies array for use in init_conditions and em_step (compute once)
2135
+ frequencies_array = np.array(config.Frequency) if hasattr(config, 'Frequency') else None
2136
+
2137
+ # Prepare data
2138
+ Mx = np.nanmean(X, axis=0)
2139
+ Wx = np.nanstd(X, axis=0)
2140
+ xNaN = (X - Mx) / Wx # Standardize series
2141
+
2142
+ # Initial conditions - use configurable nan_method and nan_k
2143
+ optNaN = {'method': nan_method, 'k': nan_k}
2144
+ i_idio = np.concatenate([np.ones(N - nQ), np.zeros(nQ)])
2145
+
2146
+ A, C, Q, R, Z_0, V_0 = init_conditions(
2147
+ xNaN, r, p, blocks, optNaN, R_mat, q, nQ, i_idio,
2148
+ clock=clock, tent_weights_dict=tent_weights_dict, frequencies=frequencies_array
2149
+ )
2150
+
2151
+ # Verify initial conditions are valid (should already be cleaned by init_conditions)
2152
+ if np.any(~np.isfinite(A)) or np.any(~np.isfinite(C)) or np.any(~np.isfinite(Q)) or np.any(~np.isfinite(R)):
2153
+ _logger.warning("Initial conditions contain NaN/Inf - this should not happen after init_conditions()")
2154
+
2155
+ # Initialize EM loop
2156
+ previous_loglik = -np.inf
2157
+ num_iter = 0
2158
+ LL = [-np.inf]
2159
+ converged = False
2160
+
2161
+ # y for estimation is WITH missing data (n x T)
2162
+ y = xNaN.T # Transpose to get n x T
2163
+
2164
+ # Remove leading and ending NaNs for estimation
2165
+ optNaN_est = {'method': 3, 'k': nan_k} # Use separate dict to avoid modifying optNaN
2166
+ xNaN_est, _ = _rem_nans_spline(xNaN, method=optNaN_est['method'], k=optNaN_est['k'])
2167
+ y_est = xNaN_est.T # n x T
2168
+
2169
+ # EM loop
2170
+ while num_iter < max_iter and not converged:
2171
+ C_new, R_new, A_new, Q_new, Z_0_new, V_0_new, loglik = em_step(
2172
+ y_est, A, C, Q, R, Z_0, V_0, r, p, R_mat, q, nQ, i_idio, blocks,
2173
+ tent_weights_dict=tent_weights_dict,
2174
+ clock=clock,
2175
+ frequencies=frequencies_array
2176
+ )
2177
+
2178
+ C = C_new
2179
+ R = R_new
2180
+ A = A_new
2181
+ Q = Q_new
2182
+ Z_0 = Z_0_new
2183
+ V_0 = V_0_new
2184
+
2185
+ if num_iter > 2:
2186
+ converged, _ = em_converged(loglik, previous_loglik, threshold, True)
2187
+
2188
+ if (num_iter % 10 == 0) and (num_iter > 0):
2189
+ print(f'Now running the {num_iter}th iteration of max {max_iter}')
2190
+ print(' Loglik (% Change)')
2191
+ pct_change = 100 * ((loglik - previous_loglik) / previous_loglik) if previous_loglik != 0 else 0
2192
+ print(f'{loglik:.6f} ({pct_change:6.2f}%)')
2193
+
2194
+ LL.append(loglik)
2195
+ previous_loglik = loglik
2196
+ num_iter += 1
2197
+
2198
+ if num_iter < max_iter:
2199
+ print(f'Successful: Convergence at {num_iter} iterations')
2200
+ else:
2201
+ print('Stopped because maximum iterations reached')
2202
+
2203
+ # Final run of Kalman filter and smoother
2204
+ # MATLAB: Zsmooth = runKF(y, A, C, Q, R, Z_0, V_0)';
2205
+ # run_kf returns m x (T+1), transpose to (T+1) x m
2206
+ zsmooth, _, _, _ = run_kf(y, A, C, Q, R, Z_0, V_0)
2207
+ Zsmooth = zsmooth.T # m x (T+1) -> (T+1) x m
2208
+
2209
+ # Get smoothed X (standardized)
2210
+ # MATLAB: x_sm = Zsmooth(2:end,:) * C';
2211
+ # Zsmooth is (T+1) x m, C is N x m, C' is m x N
2212
+ # Zsmooth(2:end,:) selects rows 2 to end (skip initial state), gives T x m
2213
+ # Then Zsmooth(2:end,:) * C' gives T x m * m x N = T x N
2214
+ x_sm = Zsmooth[1:, :] @ C.T # T x N (standardized smoothed data)
2215
+
2216
+ # Unstandardized smoothed data
2217
+ # MATLAB: X_sm = repmat(Wx,T,1) .* x_sm + repmat(Mx,T,1)
2218
+ # Handle NaN in Wx and Mx (can occur if series had all NaN values)
2219
+ Wx_clean = np.where(np.isnan(Wx), 1.0, Wx)
2220
+ Mx_clean = np.where(np.isnan(Mx), 0.0, Mx)
2221
+ # Element-wise multiplication: (T x N) .* (T x N) + (T x N)
2222
+ # Use broadcasting: x_sm is T x N, Wx_clean is (N,), so x_sm * Wx_clean broadcasts correctly
2223
+ X_sm = x_sm * Wx_clean + Mx_clean # Broadcasting: T x N * (N,) + (N,) = T x N
2224
+
2225
+ # Create result structure
2226
+ Res = DFMResult(
2227
+ x_sm=x_sm,
2228
+ X_sm=X_sm,
2229
+ Z=Zsmooth[1:, :], # T x m (skip initial state)
2230
+ C=C,
2231
+ R=R,
2232
+ A=A,
2233
+ Q=Q,
2234
+ Mx=Mx,
2235
+ Wx=Wx,
2236
+ Z_0=Z_0,
2237
+ V_0=V_0,
2238
+ r=r,
2239
+ p=p
2240
+ )
2241
+
2242
+ # Display output tables (optional - can be disabled for cleaner output)
2243
+ try:
2244
+ _display_dfm_tables(Res, config, nQ)
2245
+ except Exception:
2246
+ pass # Skip if display fails
2247
+
2248
+ return Res
2249
+
2250
+
2251
+ def _display_dfm_tables(Res: DFMResult, config, nQ: int) -> None:
2252
+ """Display DFM estimation output tables."""
2253
+ nM = len(config.SeriesID) - nQ
2254
+ nLags = max(Res.p, 5) # 5 comes from monthly-quarterly aggregation
2255
+ nFactors = int(np.sum(Res.r))
2256
+
2257
+ try:
2258
+ print('\n\n\n')
2259
+
2260
+ # Table 4: Factor Loadings for Monthly Series
2261
+ print('Table 4: Factor Loadings for Monthly Series')
2262
+ # Only select lag(0) terms - every 5th column starting from 0
2263
+ C_monthly = Res.C[:nM, ::5][:, :nFactors]
2264
+ try:
2265
+ import pandas as pd
2266
+ df = pd.DataFrame(C_monthly,
2267
+ index=[name.replace(' ', '_') for name in config.SeriesName[:nM]],
2268
+ columns=config.BlockNames[:nFactors] if len(config.BlockNames) >= nFactors else [f'Block{i+1}' for i in range(nFactors)])
2269
+ print(df.to_string())
2270
+ except (ValueError, IndexError, AttributeError) as e:
2271
+ # DataFrame creation or display failed - show shape instead
2272
+ # This can happen if indices/columns don't match or pandas unavailable
2273
+ print(f'Monthly loadings shape: {C_monthly.shape}')
2274
+ print('\n\n\n')
2275
+
2276
+ # Table 5: Quarterly Loadings Sample (Global Factor)
2277
+ print('Table 5: Quarterly Loadings Sample (Global Factor)')
2278
+ # Select only quarterly series and first 5 columns (lag 0-4)
2279
+ C_quarterly = Res.C[-nQ:, :5]
2280
+ try:
2281
+ df = pd.DataFrame(C_quarterly,
2282
+ index=[name.replace(' ', '_') for name in config.SeriesName[-nQ:]],
2283
+ columns=['f1_lag0', 'f1_lag1', 'f1_lag2', 'f1_lag3', 'f1_lag4'])
2284
+ print(df.to_string())
2285
+ except (ValueError, IndexError, AttributeError) as e:
2286
+ # DataFrame creation or display failed - show shape instead
2287
+ print(f'Quarterly loadings shape: {C_quarterly.shape}')
2288
+ print('\n\n\n')
2289
+
2290
+ # Table 6: Autoregressive Coefficients on Factors
2291
+ print('Table 6: Autoregressive Coefficients on Factors')
2292
+ A_terms = np.diag(Res.A)
2293
+ Q_terms = np.diag(Res.Q)
2294
+ # Only select lag(0) terms - every 5th element
2295
+ A_terms_factors = A_terms[::5][:nFactors]
2296
+ Q_terms_factors = Q_terms[::5][:nFactors]
2297
+ try:
2298
+ df = pd.DataFrame({
2299
+ 'AR_Coefficient': A_terms_factors,
2300
+ 'Variance_Residual': Q_terms_factors
2301
+ }, index=[name.replace(' ', '_') for name in config.BlockNames[:nFactors]])
2302
+ print(df.to_string())
2303
+ except (ValueError, IndexError, AttributeError) as e:
2304
+ # DataFrame creation or display failed - show values instead
2305
+ print(f'Factor AR coefficients: {A_terms_factors}')
2306
+ print('\n\n\n')
2307
+
2308
+ # Table 7: Autoregressive Coefficients on Idiosyncratic Component
2309
+ print('Table 7: Autoregressive Coefficients on Idiosyncratic Component')
2310
+ # MATLAB: A_terms([nFactors*5+1:nFactors*5+nM nFactors*5+nM+1:5:end])
2311
+ # Note: MATLAB is 1-indexed, Python is 0-indexed
2312
+ # MATLAB nFactors*5+1 corresponds to Python index nFactors*5
2313
+ rp1 = nFactors * 5 # Start of idiosyncratic component (0-indexed)
2314
+ # Monthly idiosyncratic: indices rp1 to rp1+nM-1 (inclusive)
2315
+ monthly_idx = np.arange(rp1, rp1 + nM)
2316
+ # Quarterly idiosyncratic: every 5th index starting from rp1+nM
2317
+ quarterly_idx = np.arange(rp1 + nM, len(A_terms), 5)
2318
+ combined_idx = np.concatenate([monthly_idx, quarterly_idx])
2319
+
2320
+ # Ensure indices are within bounds
2321
+ combined_idx = combined_idx[combined_idx < len(A_terms)]
2322
+
2323
+ A_idio = A_terms[combined_idx]
2324
+ Q_idio = Q_terms[combined_idx]
2325
+ try:
2326
+ # Map indices back to series names
2327
+ # Monthly series: direct mapping (0 to nM-1)
2328
+ # Quarterly series: need to map to quarterly series index
2329
+ series_indices = []
2330
+ for idx in combined_idx:
2331
+ if idx < rp1 + nM:
2332
+ # Monthly idiosyncratic - map to monthly series
2333
+ series_idx = idx - rp1
2334
+ if series_idx < nM:
2335
+ series_indices.append(series_idx)
2336
+ else:
2337
+ series_indices.append(None)
2338
+ else:
2339
+ # Quarterly idiosyncratic - map to quarterly series
2340
+ # (idx - (rp1 + nM)) / 5 gives which quarterly series
2341
+ q_idx = (idx - (rp1 + nM)) // 5
2342
+ if q_idx < nQ:
2343
+ series_indices.append(nM + q_idx)
2344
+ else:
2345
+ series_indices.append(None)
2346
+
2347
+ # Filter out None and get valid series names
2348
+ valid_indices = [i for i in series_indices if i is not None and i < len(config.SeriesName)]
2349
+ series_names_list = [config.SeriesName[i].replace(' ', '_') for i in valid_indices[:len(A_idio)]]
2350
+
2351
+ df = pd.DataFrame({
2352
+ 'AR_Coefficient': A_idio[:len(series_names_list)],
2353
+ 'Variance_Residual': Q_idio[:len(series_names_list)]
2354
+ }, index=series_names_list)
2355
+ print(df.to_string())
2356
+ except (ValueError, IndexError, AttributeError) as e:
2357
+ # DataFrame creation or display failed - show values instead
2358
+ print(f'Idiosyncratic AR coefficients (first 10): {A_idio[:min(10, len(A_idio))]}')
2359
+ if len(A_idio) > 10:
2360
+ print(f'... (total {len(A_idio)} coefficients)')
2361
+ print('\n\n\n')
2362
+
2363
+ except Exception as e:
2364
+ print(f'Error displaying tables: {e}')
2365
+ pass
2366
+