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