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