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