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/core/em.py ADDED
@@ -0,0 +1,1022 @@
1
+ """Expectation-Maximization core routines (initialization and one EM iteration).
2
+
3
+ This module contains:
4
+ - init_conditions: compute initial A, C, Q, R, Z_0, V_0
5
+ - em_step: perform one EM iteration (E-step via Kalman, M-step updates)
6
+ - em_converged: check EM convergence
7
+ """
8
+
9
+ from typing import Optional, Tuple, Dict, Any, TypedDict
10
+ import logging
11
+ import numpy as np
12
+ from scipy.linalg import inv, pinv, block_diag
13
+
14
+ # Common exception types for numerical operations
15
+ _NUMERICAL_EXCEPTIONS = (
16
+ np.linalg.LinAlgError,
17
+ ValueError,
18
+ ZeroDivisionError,
19
+ OverflowError,
20
+ FloatingPointError,
21
+ )
22
+
23
+ from ..kalman import run_kf
24
+ from ..config import DFMConfig
25
+ from .numeric import (
26
+ _ensure_symmetric,
27
+ _ensure_square_matrix,
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 .grouping import group_series_by_frequency
39
+ from ..utils.data_utils import rem_nans_spline
40
+ from ..utils.aggregation import (
41
+ FREQUENCY_HIERARCHY,
42
+ generate_R_mat,
43
+ get_tent_weights_for_pair,
44
+ generate_tent_weights,
45
+ )
46
+
47
+ _logger = logging.getLogger(__name__)
48
+
49
+
50
+ class NaNHandlingOptions(TypedDict, total=False):
51
+ """Options for handling missing data (NaN values).
52
+
53
+ Attributes
54
+ ----------
55
+ method : int
56
+ Method for handling NaNs:
57
+ - 1: Spline interpolation (recommended)
58
+ - 2: Forward fill then backward fill
59
+ - 3: Mean imputation
60
+ k : int
61
+ Spline interpolation order (only used if method=1).
62
+ Typically 3 (cubic spline).
63
+ """
64
+ method: int
65
+ k: int
66
+
67
+
68
+ def _get_config_param(config: Optional[DFMConfig], param_name: str, default: Any) -> Any:
69
+ """Get configuration parameter with fallback to default.
70
+
71
+ This helper function provides a clean way to access config parameters
72
+ that may not exist, with type-safe defaults.
73
+
74
+ Parameters
75
+ ----------
76
+ config : DFMConfig, optional
77
+ Configuration object
78
+ param_name : str
79
+ Name of the parameter to retrieve
80
+ default : Any
81
+ Default value if parameter doesn't exist
82
+
83
+ Returns
84
+ -------
85
+ Any
86
+ Parameter value or default
87
+ """
88
+ if config is None:
89
+ return default
90
+ return getattr(config, param_name, default)
91
+
92
+
93
+ def em_converged(loglik: float, previous_loglik: float, threshold: float = 1e-4,
94
+ check_decreased: bool = True) -> Tuple[bool, bool]:
95
+ """Check whether EM algorithm has converged.
96
+
97
+ Returns (converged, decreased).
98
+ """
99
+ converged = False
100
+ decrease = False
101
+
102
+ if check_decreased and (loglik - previous_loglik) < -1e-3:
103
+ _logger.warning(f"Likelihood decreased from {previous_loglik:.4f} to {loglik:.4f}")
104
+ decrease = True
105
+
106
+ delta_loglik = abs(loglik - previous_loglik)
107
+ avg_loglik = (abs(loglik) + abs(previous_loglik) + np.finfo(float).eps) / 2
108
+ if (delta_loglik / avg_loglik) < threshold:
109
+ converged = True
110
+ return converged, decrease
111
+
112
+
113
+ def init_conditions(
114
+ x: np.ndarray,
115
+ r: np.ndarray,
116
+ p: int,
117
+ blocks: np.ndarray,
118
+ opt_nan: NaNHandlingOptions,
119
+ Rcon: Optional[np.ndarray],
120
+ q: Optional[np.ndarray],
121
+ nQ: Optional[int],
122
+ i_idio: np.ndarray,
123
+ clock: str = 'm',
124
+ tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
125
+ frequencies: Optional[np.ndarray] = None
126
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
127
+ """Compute initial parameter estimates for DFM via PCA and OLS.
128
+
129
+ This function computes initial values for the DFM parameters:
130
+ - A: Transition matrix (via AR regression on factors)
131
+ - C: Loading matrix (via PCA on data residuals)
132
+ - Q: Innovation covariance (via residual variance)
133
+ - R: Observation covariance (via idiosyncratic variance)
134
+ - Z_0: Initial state (via unconditional mean)
135
+ - V_0: Initial covariance (via stationary covariance)
136
+
137
+ Parameters
138
+ ----------
139
+ x : np.ndarray
140
+ Standardized data matrix (T x N)
141
+ r : np.ndarray
142
+ Number of factors per block (n_blocks,)
143
+ p : int
144
+ AR lag order (typically 1)
145
+ blocks : np.ndarray
146
+ Block structure (N x n_blocks)
147
+ opt_nan : NaNHandlingOptions
148
+ Options for NaN handling with 'method' (int) and 'k' (int) keys.
149
+ See NaNHandlingOptions for details.
150
+ Rcon : np.ndarray, optional
151
+ Constraint matrix for tent kernel aggregation
152
+ q : np.ndarray, optional
153
+ Constraint vector for tent kernel aggregation
154
+ nQ : int, optional
155
+ Number of slower-frequency series
156
+ i_idio : np.ndarray
157
+ Indicator array (1 for clock frequency, 0 for slower)
158
+ clock : str
159
+ Clock frequency ('m', 'q', 'sa', 'a')
160
+ tent_weights_dict : dict, optional
161
+ Dictionary mapping frequency pairs to tent weights
162
+ frequencies : np.ndarray, optional
163
+ Array of frequencies for each series
164
+
165
+ Returns
166
+ -------
167
+ A : np.ndarray
168
+ Initial transition matrix (m x m)
169
+ C : np.ndarray
170
+ Initial loading matrix (N x m)
171
+ Q : np.ndarray
172
+ Initial innovation covariance (m x m)
173
+ R : np.ndarray
174
+ Initial observation covariance (N x N)
175
+ Z_0 : np.ndarray
176
+ Initial state vector (m,)
177
+ V_0 : np.ndarray
178
+ Initial covariance matrix (m x m)
179
+ """
180
+ # Determine pC (tent length)
181
+ if Rcon is None or q is None:
182
+ pC = 1
183
+ else:
184
+ pC = Rcon.shape[1]
185
+ ppC = int(max(p, pC))
186
+ n_blocks = blocks.shape[1]
187
+
188
+ # Balance NaNs
189
+ xBal, _ = rem_nans_spline(x, method=opt_nan['method'], k=opt_nan['k'])
190
+ T, N = xBal.shape
191
+
192
+ # Determine pC from tent weights if provided
193
+ pC = 1
194
+ if tent_weights_dict:
195
+ for tent_weights in tent_weights_dict.values():
196
+ if tent_weights is not None and len(tent_weights) > pC:
197
+ pC = len(tent_weights)
198
+ elif Rcon is not None:
199
+ pC = Rcon.shape[1]
200
+
201
+ # Infer nQ from frequencies if needed
202
+ if nQ is None and frequencies is not None:
203
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
204
+ nQ = sum(1 for f in frequencies if FREQUENCY_HIERARCHY.get(f, 3) > clock_h)
205
+ elif nQ is None:
206
+ nQ = 0
207
+
208
+ indNaN = np.isnan(xBal)
209
+ xNaN = xBal.copy()
210
+ xNaN[indNaN] = np.nan
211
+ data_residuals = xBal
212
+ resNaN = xNaN.copy()
213
+
214
+ C = None
215
+ A = None
216
+ Q = None
217
+ V_0 = None
218
+
219
+ if pC > 1:
220
+ indNaN[:pC - 1, :] = True
221
+
222
+ for i in range(n_blocks):
223
+ r_i = int(r[i])
224
+ factor_projection_lagged = None
225
+ ar_coeffs = None
226
+ F = None # Initialize F at block level to avoid UnboundLocalError
227
+
228
+ block_loadings = np.zeros((N, int(r_i * ppC)))
229
+ idx_i = np.where(blocks[:, i] == 1)[0]
230
+
231
+ freq_groups = group_series_by_frequency(idx_i, frequencies, clock)
232
+ idx_clock_freq = freq_groups.get(clock, np.array([], dtype=int))
233
+
234
+ if len(idx_clock_freq) > 0:
235
+ try:
236
+ res_block = data_residuals[:, idx_clock_freq].copy()
237
+ finite_rows = np.all(np.isfinite(res_block), axis=1)
238
+ n_finite = int(np.sum(finite_rows))
239
+ n_total = len(finite_rows)
240
+ if n_finite < max(2, len(idx_clock_freq) + 1):
241
+ _logger.warning(
242
+ f"init_conditions: Block {i+1} has insufficient data; using identity covariance."
243
+ )
244
+ raise ValueError("insufficient data")
245
+ res_block_clean = res_block[finite_rows, :]
246
+ if res_block_clean.size == 0:
247
+ cov_res = np.eye(len(idx_clock_freq))
248
+ elif res_block_clean.ndim == 1:
249
+ var_val = np.var(res_block_clean, ddof=0)
250
+ var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
251
+ cov_res = np.array([[var_val]])
252
+ elif res_block_clean.ndim == 2:
253
+ if len(idx_clock_freq) == 1 or res_block_clean.shape[1] == 1:
254
+ series_data = res_block_clean.flatten()
255
+ var_val = np.var(series_data, ddof=0)
256
+ var_val = 1.0 if (np.isnan(var_val) or np.isinf(var_val) or var_val < 1e-10) else var_val
257
+ cov_res = np.array([[var_val]])
258
+ else:
259
+ cov_res = np.cov(res_block_clean.T) if res_block_clean.shape[0] >= 2 else np.eye(len(idx_clock_freq))
260
+ else:
261
+ cov_res = np.eye(len(idx_clock_freq))
262
+ if np.any(~np.isfinite(cov_res)):
263
+ cov_res = np.eye(len(idx_clock_freq))
264
+ d, v = _compute_principal_components(cov_res, int(r_i), block_idx=i)
265
+ if np.sum(v) < 0:
266
+ v = -v
267
+ d_pos = np.maximum(d, 1e-8)
268
+ sqrt_d = np.sqrt(d_pos)
269
+ v_scaled = v * float(sqrt_d) if np.isscalar(sqrt_d) or (isinstance(sqrt_d, np.ndarray) and sqrt_d.ndim == 0) else v @ np.diag(sqrt_d)
270
+ if v_scaled.ndim == 2 and v_scaled.shape[1] > 0:
271
+ for col_idx in range(v_scaled.shape[1]):
272
+ col_norm = np.linalg.norm(v_scaled[:, col_idx])
273
+ if col_norm > 0:
274
+ d_val = float(d_pos) if np.isscalar(d_pos) or (isinstance(d_pos, np.ndarray) and d_pos.ndim == 0) else d_pos[col_idx]
275
+ v_scaled[:, col_idx] = v_scaled[:, col_idx] / col_norm * np.sqrt(d_val)
276
+ block_loadings[idx_clock_freq, :int(r_i)] = v_scaled
277
+ f = data_residuals[:, idx_clock_freq] @ v_scaled
278
+ F = None
279
+ max_lag = max(p + 1, pC)
280
+ for kk in range(max_lag):
281
+ lag_data = f[pC - kk:T - kk, :]
282
+ F = lag_data if F is None else np.hstack([F, lag_data])
283
+ if F is not None:
284
+ factor_projection_lagged = F[:, :int(r_i * pC)]
285
+ else:
286
+ factor_projection_lagged = None
287
+ for freq, idx_iFreq in freq_groups.items():
288
+ if freq == clock:
289
+ continue
290
+ tent_weights = tent_weights_dict.get(freq) if (tent_weights_dict and freq in tent_weights_dict) else get_tent_weights_for_pair(freq, clock)
291
+ if tent_weights is None:
292
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
293
+ freq_h = FREQUENCY_HIERARCHY.get(freq, 3)
294
+ n_periods_est = freq_h - clock_h + 1
295
+ if 0 < n_periods_est <= 12:
296
+ tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
297
+ _logger.warning(f"init_conditions: generated symmetric tent weights for '{freq}'")
298
+ else:
299
+ raise ValueError(f"init_conditions: cannot determine tent weights for '{freq}'")
300
+ R_mat_freq, q_freq = generate_R_mat(tent_weights)
301
+ pC_freq = len(tent_weights)
302
+ if factor_projection_lagged.shape[1] < r_i * pC_freq:
303
+ factor_projection_freq = np.hstack([
304
+ factor_projection_lagged,
305
+ np.zeros((factor_projection_lagged.shape[0], r_i * pC_freq - factor_projection_lagged.shape[1]))
306
+ ])
307
+ else:
308
+ factor_projection_freq = factor_projection_lagged[:, :int(r_i * pC_freq)]
309
+ Rcon_i = np.kron(R_mat_freq, np.eye(int(r_i)))
310
+ q_i = np.kron(q_freq, np.zeros(int(r_i)))
311
+ for j in idx_iFreq:
312
+ series_data = resNaN[pC_freq:, j]
313
+ if len(series_data) < factor_projection_freq.shape[0] and len(series_data) > 0:
314
+ series_data_padded = np.full(factor_projection_freq.shape[0], np.nan)
315
+ series_data_padded[:len(series_data)] = series_data
316
+ series_data = series_data_padded
317
+ if np.sum(~np.isnan(series_data)) < factor_projection_freq.shape[1] + 2:
318
+ series_data = data_residuals[pC_freq:, j]
319
+ finite_mask = ~np.isnan(series_data)
320
+ factor_projection_clean = factor_projection_freq[finite_mask, :]
321
+ series_data_clean = series_data[finite_mask]
322
+ if len(series_data_clean) > 0 and factor_projection_clean.shape[0] > 0:
323
+ try:
324
+ gram = factor_projection_clean.T @ factor_projection_clean
325
+ gram_inv = inv(gram)
326
+ loadings = gram_inv @ factor_projection_clean.T @ series_data_clean
327
+ if Rcon_i is not None and q_i is not None and Rcon_i.size > 0:
328
+ constraint_term = gram_inv @ Rcon_i.T @ inv(Rcon_i @ gram_inv @ Rcon_i.T) @ (Rcon_i @ loadings - q_i)
329
+ loadings = loadings - constraint_term
330
+ block_loadings[j, :int(pC_freq * r_i)] = loadings[:int(pC_freq * r_i)]
331
+ except _NUMERICAL_EXCEPTIONS:
332
+ block_loadings[j, :int(pC_freq * r_i)] = 0.0
333
+ except _NUMERICAL_EXCEPTIONS:
334
+ _logger.warning(f"init_conditions: Block {i+1} initialization failed, using fallback.")
335
+ if len(idx_clock_freq) > 0:
336
+ block_loadings[idx_clock_freq, :int(r_i)] = np.eye(len(idx_clock_freq), int(r_i))[:len(idx_clock_freq), :int(r_i)]
337
+ if factor_projection_lagged is not None:
338
+ expected_width = int(pC * r_i)
339
+ if factor_projection_lagged.shape[1] != expected_width:
340
+ _logger.warning(f"init_conditions: factor projection width mismatch for block {i+1}, resetting.")
341
+ factor_projection_lagged = None
342
+ if factor_projection_lagged is not None:
343
+ factor_projection_padded = np.vstack([np.zeros((pC - 1, int(pC * r_i))), factor_projection_lagged])
344
+ if factor_projection_padded.shape[0] < T:
345
+ factor_projection_padded = np.vstack([factor_projection_padded, np.zeros((T - factor_projection_padded.shape[0], int(pC * r_i)))])
346
+ else:
347
+ factor_projection_padded = np.zeros((T, int(pC * r_i)))
348
+ block_loadings_residual = block_loadings[:, :int(pC * r_i)]
349
+ if factor_projection_padded.shape[0] != data_residuals.shape[0]:
350
+ if factor_projection_padded.shape[0] > data_residuals.shape[0]:
351
+ factor_projection_padded = factor_projection_padded[:data_residuals.shape[0], :]
352
+ else:
353
+ factor_projection_padded = np.vstack([factor_projection_padded, np.zeros((data_residuals.shape[0] - factor_projection_padded.shape[0], factor_projection_padded.shape[1]))])
354
+ data_residuals[:, idx_i] = data_residuals[:, idx_i] - factor_projection_padded @ block_loadings_residual[idx_i, :].T
355
+ resNaN = data_residuals.copy()
356
+ resNaN[indNaN] = np.nan
357
+ C = block_loadings if C is None else np.hstack([C, block_loadings])
358
+ if len(idx_clock_freq) > 0 and F is not None:
359
+ z = F[:, :int(r_i)]
360
+ Z_lag = F[:, int(r_i):int(r_i * (p + 1))] if F.shape[1] > int(r_i) else np.zeros((F.shape[0], int(r_i * p)))
361
+ block_transition = np.zeros((int(r_i * ppC), int(r_i * ppC)))
362
+ if Z_lag.shape[0] > 0 and Z_lag.shape[1] > 0:
363
+ try:
364
+ ar_coeffs = inv(Z_lag.T @ Z_lag) @ Z_lag.T @ z
365
+ block_transition[:int(r_i), :int(r_i * p)] = ar_coeffs.T
366
+ except _NUMERICAL_EXCEPTIONS:
367
+ block_transition[:int(r_i), :int(r_i * p)] = 0.0
368
+ if r_i * (ppC - 1) > 0:
369
+ block_transition[int(r_i):, :int(r_i * (ppC - 1))] = np.eye(int(r_i * (ppC - 1)))
370
+ block_innovation_cov = np.zeros((int(ppC * r_i), int(ppC * r_i)))
371
+ if len(z) > 0:
372
+ innovation_residuals = z - Z_lag @ ar_coeffs if ar_coeffs is not None else z
373
+ innovation_residuals = np.nan_to_num(innovation_residuals, nan=0.0, posinf=0.0, neginf=0.0)
374
+ if innovation_residuals.shape[1] > 1:
375
+ try:
376
+ Q_block = np.cov(innovation_residuals.T)
377
+ if np.any(~np.isfinite(Q_block)):
378
+ Q_block = np.eye(int(r_i)) * 0.1
379
+ except _NUMERICAL_EXCEPTIONS:
380
+ Q_block = np.eye(int(r_i)) * 0.1
381
+ else:
382
+ variance = np.var(innovation_residuals)
383
+ if not np.isfinite(variance) or variance <= 0:
384
+ variance = 0.1
385
+ Q_block = np.array([[variance]]) if innovation_residuals.ndim > 1 else np.eye(int(r_i)) * variance
386
+ block_innovation_cov[:int(r_i), :int(r_i)] = Q_block
387
+ block_transition_clean = _clean_matrix(block_transition, 'loading')
388
+ block_innovation_cov_clean = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
389
+ try:
390
+ kron_transition = np.kron(block_transition_clean, block_transition_clean)
391
+ identity_kron = np.eye(int((r_i * ppC) ** 2)) - kron_transition
392
+ innovation_cov_flat = block_innovation_cov_clean.flatten()
393
+ init_cov_block = np.reshape(inv(identity_kron) @ innovation_cov_flat, (int(r_i * ppC), int(r_i * ppC)))
394
+ if np.any(~np.isfinite(init_cov_block)):
395
+ raise ValueError("invalid init_cov_block")
396
+ except _NUMERICAL_EXCEPTIONS:
397
+ _logger.warning(f"init_conditions: initial covariance failed for block {i+1}; using diagonal fallback.")
398
+ init_cov_block = np.eye(int(r_i * ppC)) * 0.1
399
+ if A is None:
400
+ A, Q, V_0 = block_transition, block_innovation_cov, init_cov_block
401
+ else:
402
+ A = block_diag(A, block_transition)
403
+ Q = block_diag(Q, block_innovation_cov)
404
+ V_0 = block_diag(V_0, init_cov_block)
405
+ else:
406
+ block_transition = np.eye(int(r_i * ppC)) * 0.9
407
+ block_innovation_cov = np.eye(int(r_i * ppC)) * 0.1
408
+ init_cov_block = np.eye(int(r_i * ppC)) * 0.1
409
+ if A is None:
410
+ A, Q, V_0 = block_transition, block_innovation_cov, init_cov_block
411
+ else:
412
+ A = block_diag(A, block_transition)
413
+ Q = block_diag(Q, block_innovation_cov)
414
+ V_0 = block_diag(V_0, init_cov_block)
415
+
416
+ eyeN = np.eye(N)[:, i_idio.astype(bool)]
417
+ C = eyeN if C is None else np.hstack([C, eyeN])
418
+
419
+ if nQ > 0 and frequencies is not None:
420
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
421
+ slower_indices = [j for j in range(N) if j < len(frequencies) and FREQUENCY_HIERARCHY.get(frequencies[j], 3) > clock_h]
422
+ Rdiag = np.nanvar(resNaN, axis=0)
423
+ Rdiag = np.where((np.isnan(Rdiag) | np.isinf(Rdiag)), 1e-4, Rdiag)
424
+ slower_idio_blocks = []
425
+ slower_series_count: Dict[str, int] = {}
426
+ slower_series_indices: Dict[str, list] = {}
427
+ slower_idio_dims = [] # Track dimensions for BQ
428
+ for j in range(N):
429
+ if j < len(frequencies):
430
+ freq = frequencies[j]
431
+ if FREQUENCY_HIERARCHY.get(freq, 3) > clock_h:
432
+ slower_series_count[freq] = slower_series_count.get(freq, 0) + 1
433
+ slower_series_indices.setdefault(freq, []).append(j)
434
+ for freq, idx_list in slower_series_indices.items():
435
+ tent_weights = tent_weights_dict.get(freq) if (tent_weights_dict and freq in tent_weights_dict) else get_tent_weights_for_pair(freq, clock)
436
+ if tent_weights is None:
437
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
438
+ freq_h = FREQUENCY_HIERARCHY.get(freq, 3)
439
+ n_periods_est = freq_h - clock_h + 1
440
+ if 0 < n_periods_est <= 12:
441
+ tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
442
+ else:
443
+ raise ValueError(f"init_conditions: cannot determine tent weights for '{freq}'")
444
+ n_periods = len(tent_weights)
445
+ idio_block = np.zeros((N, n_periods * len(idx_list)))
446
+ for idx, j in enumerate(idx_list):
447
+ col_start = idx * n_periods
448
+ idio_block[j, col_start:col_start + n_periods] = tent_weights
449
+ slower_idio_blocks.append(idio_block)
450
+ slower_idio_dims.append(n_periods * len(idx_list)) # Track dimension for this frequency
451
+ if slower_idio_blocks:
452
+ slower_idio_full = np.hstack(slower_idio_blocks)
453
+ C = np.hstack([C, slower_idio_full])
454
+ Rdiag = np.where((np.isnan(Rdiag) | np.isinf(Rdiag) | (Rdiag < 0)), 1e-4, Rdiag)
455
+ R = np.diag(Rdiag)
456
+ else:
457
+ var_values = np.nanvar(resNaN, axis=0)
458
+ var_values = np.where((np.isnan(var_values) | np.isinf(var_values)), 1e-4, var_values)
459
+ var_values = np.maximum(var_values, 1e-4)
460
+ R = np.diag(var_values)
461
+ slower_idio_dims = []
462
+
463
+ ii_idio = np.where(i_idio)[0]
464
+ n_idio = len(ii_idio)
465
+ BM = np.zeros((n_idio, n_idio))
466
+ SM = np.zeros((n_idio, n_idio))
467
+ for idx, i in enumerate(ii_idio):
468
+ R[i, i] = 1e-4
469
+ res_i_full = data_residuals[:, i]
470
+ res_i = resNaN[:, i]
471
+ leadZero = int(np.argmax(~np.isnan(res_i))) if np.any(~np.isnan(res_i)) else 0
472
+ endZero = int(np.argmax(~np.isnan(res_i[::-1]))) if np.any(~np.isnan(res_i)) else 0
473
+ res_i_trunc = res_i_full[leadZero:T - endZero] if endZero > 0 else res_i_full[leadZero:]
474
+ if len(res_i_trunc) > 1:
475
+ res_i_copy = res_i_trunc.copy()
476
+ X_ar = res_i_copy[:-1].reshape(-1, 1)
477
+ y_ar = res_i_copy[1:].reshape(-1, 1)
478
+ XTX = X_ar.T @ X_ar
479
+ if XTX.size == 1 and XTX[0, 0] != 0 and np.isfinite(XTX[0, 0]):
480
+ try:
481
+ BM_val = (1.0 / XTX[0, 0]) * (X_ar.T @ y_ar)
482
+ BM_val_clean = BM_val[0, 0] if BM_val.size > 0 else 0.1
483
+ BM_val_clean = 0.1 if (np.isnan(BM_val_clean) or np.isinf(BM_val_clean)) else BM_val_clean
484
+ BM[idx, idx] = BM_val_clean
485
+ residuals = res_i_trunc[1:] - res_i_trunc[:-1] * BM[idx, idx]
486
+ var_val = np.var(residuals)
487
+ SM[idx, idx] = var_val if np.isfinite(var_val) else 0.1
488
+ except _NUMERICAL_EXCEPTIONS:
489
+ BM[idx, idx] = 0.1
490
+ SM[idx, idx] = 0.1
491
+ else:
492
+ BM[idx, idx] = 0.1
493
+ SM[idx, idx] = 0.1
494
+ else:
495
+ BM[idx, idx] = 0.1
496
+ SM[idx, idx] = 0.1
497
+ # Monthly idio init covariance
498
+ eye_diag = np.diag(np.eye(BM.shape[0]))
499
+ BM_diag_sq = np.diag(BM) ** 2
500
+ denom_val = np.where(np.abs(eye_diag - BM_diag_sq) < 1e-10, 1.0, eye_diag - BM_diag_sq)
501
+ denom_viM = np.diag(1.0 / denom_val)
502
+ initViM = denom_viM @ SM
503
+ # Slower idio init: create BQ and SQ with proper dimensions
504
+ n_slower_idio = sum(slower_idio_dims) if slower_idio_dims else 0
505
+ if n_slower_idio > 0:
506
+ # Create transition and innovation covariance for slower idio components
507
+ # Each slower idio component follows an AR(1) process with tent weights
508
+ BQ = np.eye(n_slower_idio) * 0.9 # AR coefficient for slower idio
509
+ SQ = np.eye(n_slower_idio) * 0.1 # Innovation variance
510
+ initViQ = np.eye(n_slower_idio) * 0.1 # Initial covariance
511
+ else:
512
+ BQ = np.array([]).reshape(0, 0)
513
+ SQ = np.array([]).reshape(0, 0)
514
+ initViQ = np.array([]).reshape(0, 0)
515
+ A = block_diag(A, BM, BQ)
516
+ Q = block_diag(Q, SM, SQ)
517
+ Z_0 = np.zeros(A.shape[0])
518
+ # Ensure all covariance matrices are square
519
+ V_0 = _ensure_square_matrix(V_0, method='diag')
520
+ initViM = _ensure_square_matrix(initViM, method='diag')
521
+ initViQ = _ensure_square_matrix(initViQ, method='diag')
522
+ V_0 = block_diag(V_0, initViM, initViQ)
523
+ # Final clean/validate using _check_finite for consistency
524
+ outputs = {'A': A, 'C': C, 'Q': Q, 'R': R, 'Z_0': Z_0, 'V_0': V_0}
525
+ for param_name, param_value in outputs.items():
526
+ if param_value.size > 0 and not _check_finite(param_value, param_name):
527
+ # Clean based on parameter type
528
+ if param_name in ['A', 'Q', 'V_0']:
529
+ outputs[param_name] = _clean_matrix(param_value, 'covariance', default_nan=0.0)
530
+ elif param_name == 'R':
531
+ outputs[param_name] = _clean_matrix(param_value, 'diagonal', default_nan=1e-4)
532
+ elif param_name == 'C':
533
+ outputs[param_name] = _clean_matrix(param_value, 'loading')
534
+ elif param_name == 'Z_0':
535
+ outputs[param_name] = np.zeros_like(param_value)
536
+ return outputs['A'], outputs['C'], outputs['Q'], outputs['R'], outputs['Z_0'], outputs['V_0']
537
+
538
+
539
+ def em_step(
540
+ y: np.ndarray,
541
+ A: np.ndarray,
542
+ C: np.ndarray,
543
+ Q: np.ndarray,
544
+ R: np.ndarray,
545
+ Z_0: np.ndarray,
546
+ V_0: np.ndarray,
547
+ r: np.ndarray,
548
+ p: int,
549
+ R_mat: Optional[np.ndarray],
550
+ q: Optional[np.ndarray],
551
+ nQ: Optional[int],
552
+ i_idio: np.ndarray,
553
+ blocks: np.ndarray,
554
+ tent_weights_dict: Optional[Dict[str, np.ndarray]] = None,
555
+ clock: str = 'm',
556
+ frequencies: Optional[np.ndarray] = None,
557
+ config: Optional[DFMConfig] = None
558
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
559
+ """Perform one EM iteration (E-step + M-step) and return updated parameters.
560
+
561
+ This function performs a single iteration of the Expectation-Maximization algorithm:
562
+
563
+ 1. **E-step**: Run Kalman filter/smoother to compute expected sufficient statistics
564
+ - E[Z_t | Y] (smoothed factors)
565
+ - E[Z_t Z_t' | Y] (smoothed factor covariances)
566
+ - E[Z_t Z_{t-1}' | Y] (smoothed factor cross-covariances)
567
+
568
+ 2. **M-step**: Update parameters to maximize expected log-likelihood
569
+ - C: Loading matrix (via regression of data on factors)
570
+ - R: Observation covariance (via residual variance)
571
+ - A: Transition matrix (via AR regression on factors)
572
+ - Q: Innovation covariance (via innovation variance)
573
+ - V_0: Initial covariance (via stationary covariance)
574
+
575
+ Parameters
576
+ ----------
577
+ y : np.ndarray
578
+ Observation matrix (n x T) with missing data
579
+ A, C, Q, R : np.ndarray
580
+ Current parameter estimates
581
+ Z_0, V_0 : np.ndarray
582
+ Current initial state and covariance
583
+ r : np.ndarray
584
+ Number of factors per block
585
+ p : int
586
+ AR lag order
587
+ R_mat : np.ndarray, optional
588
+ Constraint matrix for tent kernel aggregation
589
+ q : np.ndarray, optional
590
+ Constraint vector for tent kernel aggregation
591
+ nQ : int
592
+ Number of slower-frequency series
593
+ i_idio : np.ndarray
594
+ Indicator array (1 for clock frequency, 0 for slower)
595
+ blocks : np.ndarray
596
+ Block structure (N x n_blocks)
597
+ tent_weights_dict : dict, optional
598
+ Dictionary mapping frequency pairs to tent weights
599
+ clock : str
600
+ Clock frequency
601
+ frequencies : np.ndarray, optional
602
+ Array of frequencies for each series
603
+ config : DFMConfig, optional
604
+ Configuration object for numerical stability parameters
605
+
606
+ Returns
607
+ -------
608
+ C_new : np.ndarray
609
+ Updated loading matrix (N x m)
610
+ R_new : np.ndarray
611
+ Updated observation covariance (N x N)
612
+ A_new : np.ndarray
613
+ Updated transition matrix (m x m)
614
+ Q_new : np.ndarray
615
+ Updated innovation covariance (m x m)
616
+ Z_0_new : np.ndarray
617
+ Updated initial state (m,)
618
+ V_0_new : np.ndarray
619
+ Updated initial covariance (m x m)
620
+ loglik : float
621
+ Log-likelihood value for this iteration
622
+ """
623
+ # Validate and clean input parameters
624
+ if not _check_finite(A, "A"):
625
+ _logger.warning("em_step: A contains NaN/Inf, regularizing")
626
+ A = np.eye(A.shape[0]) * 0.9 + _clean_matrix(A, 'loading') * 0.1
627
+ if not _check_finite(Q, "Q"):
628
+ _logger.warning("em_step: Q contains NaN/Inf, regularizing")
629
+ Q = _clean_matrix(Q, 'covariance', default_nan=1e-6)
630
+ if not _check_finite(R, "R"):
631
+ _logger.warning("em_step: R contains NaN/Inf, regularizing")
632
+ R = _clean_matrix(R, 'diagonal', default_nan=1e-4, default_inf=1e4)
633
+ if not _check_finite(C, "C"):
634
+ _logger.warning("em_step: C contains NaN/Inf, regularizing")
635
+ C = _clean_matrix(C, 'loading')
636
+ if not _check_finite(Z_0, "Z_0"):
637
+ _logger.warning("em_step: Z_0 contains NaN/Inf, resetting to zeros")
638
+ Z_0 = np.zeros_like(Z_0)
639
+ if not _check_finite(V_0, "V_0"):
640
+ _logger.warning("em_step: V_0 contains NaN/Inf, using regularized identity")
641
+ V_0 = np.eye(V_0.shape[0]) * 0.1
642
+
643
+ n, T = y.shape
644
+ if nQ is None and frequencies is not None:
645
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
646
+ nQ = sum(1 for f in frequencies if FREQUENCY_HIERARCHY.get(f, 3) > clock_h)
647
+ elif nQ is None:
648
+ nQ = 0
649
+
650
+ pC = 1
651
+ if tent_weights_dict:
652
+ for tent_weights in tent_weights_dict.values():
653
+ if tent_weights is not None and len(tent_weights) > pC:
654
+ pC = len(tent_weights)
655
+ elif R_mat is not None:
656
+ pC = R_mat.shape[1]
657
+ ppC = int(max(p, pC))
658
+ num_blocks = blocks.shape[1]
659
+
660
+ if config is None:
661
+ from types import SimpleNamespace
662
+ config = SimpleNamespace(
663
+ clip_ar_coefficients=True,
664
+ ar_clip_min=-0.99,
665
+ ar_clip_max=0.99,
666
+ warn_on_ar_clip=True,
667
+ clip_data_values=True,
668
+ data_clip_threshold=100.0,
669
+ warn_on_data_clip=True,
670
+ use_regularization=True,
671
+ regularization_scale=1e-5,
672
+ min_eigenvalue=1e-8,
673
+ max_eigenvalue=1e6,
674
+ warn_on_regularization=True,
675
+ use_damped_updates=True,
676
+ damping_factor=0.8,
677
+ warn_on_damped_update=True,
678
+ )
679
+
680
+ zsmooth, vsmooth, vvsmooth, loglik = run_kf(y, A, C, Q, R, Z_0, V_0)
681
+ Zsmooth = zsmooth.T
682
+
683
+ A_new = A.copy()
684
+ Q_new = Q.copy()
685
+ V_0_new = V_0.copy()
686
+
687
+ for i in range(num_blocks):
688
+ r_i = int(r[i])
689
+ factor_lag_size = r_i * p
690
+ factor_start_idx = int(np.sum(r[:i]) * ppC)
691
+ t_start = factor_start_idx
692
+ t_end = int(factor_start_idx + r_i * ppC)
693
+ b_subset = slice(factor_start_idx, factor_start_idx + factor_lag_size)
694
+
695
+ expected_factor_outer = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, 1:].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, 1:], axis=2)
696
+ expected_factor_lag_outer = Zsmooth[b_subset, :-1] @ Zsmooth[b_subset, :-1].T + np.sum(vsmooth[b_subset, :, :][:, b_subset, :-1], axis=2)
697
+ expected_factor_lag_cross = Zsmooth[b_subset, 1:] @ Zsmooth[b_subset, :-1].T + np.sum(vvsmooth[b_subset, :, :][:, b_subset, :], axis=2)
698
+
699
+ expected_factor_lag_outer = _clean_matrix(expected_factor_lag_outer, 'covariance', default_nan=0.0)
700
+ expected_factor_lag_cross = _clean_matrix(expected_factor_lag_cross, 'general', default_nan=0.0)
701
+
702
+ block_transition = A[t_start:t_end, t_start:t_end].copy()
703
+ block_innovation_cov = Q[t_start:t_end, t_start:t_end].copy()
704
+ try:
705
+ expected_lag_sub = expected_factor_lag_outer[:factor_lag_size, :factor_lag_size]
706
+ min_eigenval = _get_config_param(config, "min_eigenvalue", 1e-8)
707
+ warn_reg = _get_config_param(config, "warn_on_regularization", True)
708
+ expected_lag_sub, _ = _ensure_positive_definite(expected_lag_sub, min_eigenval, warn_reg)
709
+ try:
710
+ eigenvals = np.linalg.eigvals(expected_lag_sub)
711
+ cond_num = (np.max(eigenvals) / max(np.min(eigenvals), 1e-12)) if np.max(eigenvals) > 0 else 1.0
712
+ expected_lag_inv = pinv(expected_lag_sub, cond=1e-8) if cond_num > 1e12 else inv(expected_lag_sub)
713
+ except _NUMERICAL_EXCEPTIONS:
714
+ expected_lag_inv = pinv(expected_lag_sub)
715
+ transition_update = expected_factor_lag_cross[:r_i, :factor_lag_size] @ expected_lag_inv
716
+ transition_update, _ = _apply_ar_clipping(transition_update, config)
717
+ block_transition[:r_i, :factor_lag_size] = transition_update
718
+ block_innovation_cov[:r_i, :r_i] = (
719
+ expected_factor_outer[:r_i, :r_i] -
720
+ block_transition[:r_i, :factor_lag_size] @ expected_factor_lag_cross[:r_i, :factor_lag_size].T
721
+ ) / T
722
+ block_innovation_cov = _clean_matrix(block_innovation_cov, 'covariance', default_nan=0.0)
723
+ innovation_cov_reg, _ = _ensure_positive_definite(block_innovation_cov[:r_i, :r_i], min_eigenval, warn_reg)
724
+ block_innovation_cov[:r_i, :r_i] = innovation_cov_reg
725
+ max_eig = _get_config_param(config, "max_eigenvalue", 1e6)
726
+ block_innovation_cov[:r_i, :r_i] = _cap_max_eigenvalue(block_innovation_cov[:r_i, :r_i], max_eigenval=max_eig)
727
+ except _NUMERICAL_EXCEPTIONS:
728
+ if np.allclose(block_transition[:r_i, :factor_lag_size], 0):
729
+ block_transition[:r_i, :factor_lag_size] = np.random.randn(r_i, factor_lag_size) * 0.1
730
+ else:
731
+ block_transition[:r_i, :factor_lag_size] *= 0.95
732
+ if np.any(~np.isfinite(block_transition)):
733
+ block_transition = _clean_matrix(block_transition, 'loading', default_nan=0.0, default_inf=0.99)
734
+ block_transition, _ = _apply_ar_clipping(block_transition, config)
735
+ A_new[t_start:t_end, t_start:t_end] = block_transition
736
+ Q_new[t_start:t_end, t_start:t_end] = block_innovation_cov
737
+ V_0_block = _clean_matrix(vsmooth[t_start:t_end, t_start:t_end, 0], 'covariance', default_nan=0.0)
738
+ V_0_block, _ = _ensure_positive_definite(V_0_block, min_eigenval, warn_reg)
739
+ V_0_new[t_start:t_end, t_start:t_end] = V_0_block
740
+
741
+ idio_start_idx = int(np.sum(r) * ppC)
742
+ n_idio = int(np.sum(i_idio))
743
+ i_subset = slice(idio_start_idx, idio_start_idx + n_idio)
744
+ i_subset_slice = slice(i_subset.start, i_subset.stop)
745
+ Z_idio = Zsmooth[i_subset_slice, 1:]
746
+ n_idio_actual = Z_idio.shape[0]
747
+ expected_idio_current_sq = np.sum(Z_idio**2, axis=1)
748
+ vsmooth_idio_block = vsmooth[i_subset_slice, :, :][:, i_subset_slice, 1:]
749
+ vsmooth_idio_sum = np.sum(vsmooth_idio_block, axis=2)
750
+ vsmooth_idio_diag = np.diag(vsmooth_idio_sum[:n_idio_actual, :n_idio_actual]) if vsmooth_idio_sum.ndim == 2 else np.zeros(n_idio_actual)
751
+ expected_idio_current_sq = expected_idio_current_sq + vsmooth_idio_diag
752
+ Z_idio_lag = Zsmooth[i_subset_slice, :-1]
753
+ expected_idio_lag_sq = np.sum(Z_idio_lag**2, axis=1)
754
+ vsmooth_lag_block = vsmooth[i_subset_slice, :, :][:, i_subset_slice, :-1]
755
+ vsmooth_lag_sum = np.sum(vsmooth_lag_block, axis=2)
756
+ vsmooth_lag_diag = np.diag(vsmooth_lag_sum[:n_idio_actual, :n_idio_actual]) if vsmooth_lag_sum.ndim == 2 else np.zeros(n_idio_actual)
757
+ expected_idio_lag_sq = expected_idio_lag_sq + vsmooth_lag_diag
758
+ min_cols = min(Z_idio.shape[1], Z_idio_lag.shape[1])
759
+ expected_idio_cross = np.sum(Z_idio[:, :min_cols] * Z_idio_lag[:, :min_cols], axis=1)
760
+ vvsmooth_block = vvsmooth[i_subset_slice, :, :][:, i_subset_slice, :]
761
+ vvsmooth_sum = np.sum(vvsmooth_block, axis=2)
762
+ vvsmooth_diag = np.diag(vvsmooth_sum[:n_idio_actual, :n_idio_actual]) if vvsmooth_sum.ndim == 2 else np.zeros(n_idio_actual)
763
+ expected_idio_cross = expected_idio_cross + vvsmooth_diag
764
+ ar_coeffs_diag, _ = _estimate_ar_coefficient(expected_idio_cross, expected_idio_lag_sq, vsmooth_sum=vsmooth_lag_diag)
765
+ block_transition_idio = np.diag(ar_coeffs_diag)
766
+ innovation_cov_diag = (np.maximum(expected_idio_current_sq, 0.0) - ar_coeffs_diag * expected_idio_cross) / T
767
+ innovation_cov_diag = np.maximum(innovation_cov_diag, 1e-8)
768
+ block_innovation_cov_idio = np.diag(innovation_cov_diag)
769
+ i_subset_size = i_subset.stop - i_subset.start
770
+ if n_idio_actual == i_subset_size:
771
+ A_new[i_subset, i_subset] = block_transition_idio
772
+ Q_new[i_subset, i_subset] = block_innovation_cov_idio
773
+ elif n_idio_actual < i_subset_size:
774
+ A_new[i_subset.start:i_subset.start + n_idio_actual, i_subset.start:i_subset.start + n_idio_actual] = block_transition_idio
775
+ Q_new[i_subset.start:i_subset.start + n_idio_actual, i_subset.start:i_subset.start + n_idio_actual] = block_innovation_cov_idio
776
+ else:
777
+ A_new[i_subset, i_subset] = block_transition_idio[:i_subset_size, :i_subset_size]
778
+ Q_new[i_subset, i_subset] = block_innovation_cov_idio[:i_subset_size, :i_subset_size]
779
+ vsmooth_sub = vsmooth[i_subset_slice, :, :][:, i_subset_slice, 0]
780
+ vsmooth_diag = np.diag(vsmooth_sub[:n_idio_actual, :n_idio_actual]) if vsmooth_sub.ndim == 2 else np.zeros(n_idio_actual)
781
+ for idx in range(min(n_idio_actual, i_subset_size)):
782
+ V_0_new[i_subset.start + idx, i_subset.start + idx] = vsmooth_diag[idx] if idx < len(vsmooth_diag) else 0.0
783
+
784
+ Z_0 = Zsmooth[0, :].copy()
785
+ nanY = np.isnan(y)
786
+ y_clean = y.copy()
787
+ y_clean[nanY] = 0
788
+ bl = np.unique(blocks, axis=0)
789
+ n_bl = bl.shape[0]
790
+ bl_idx_same_freq = None
791
+ bl_idx_slower_freq = None
792
+ R_con_list = []
793
+ for i in range(num_blocks):
794
+ r_i = int(r[i])
795
+ bl_col_clock_freq = np.repeat(bl[:, i:i+1], r_i, axis=1)
796
+ bl_col_clock_freq = np.hstack([bl_col_clock_freq, np.zeros((n_bl, r_i * (ppC - 1)))])
797
+ bl_col_slower_freq = np.repeat(bl[:, i:i+1], r_i * ppC, axis=1)
798
+ if bl_idx_same_freq is None:
799
+ bl_idx_same_freq = bl_col_clock_freq
800
+ bl_idx_slower_freq = bl_col_slower_freq
801
+ else:
802
+ bl_idx_same_freq = np.hstack([bl_idx_same_freq, bl_col_clock_freq])
803
+ bl_idx_slower_freq = np.hstack([bl_idx_slower_freq, bl_col_slower_freq])
804
+ if R_mat is not None:
805
+ R_con_list.append(np.kron(R_mat, np.eye(r_i)))
806
+ if bl_idx_same_freq is not None:
807
+ bl_idx_same_freq = bl_idx_same_freq.astype(bool)
808
+ bl_idx_slower_freq = bl_idx_slower_freq.astype(bool)
809
+ else:
810
+ bl_idx_same_freq = np.array([]).reshape(n_bl, 0).astype(bool)
811
+ bl_idx_slower_freq = np.array([]).reshape(n_bl, 0).astype(bool)
812
+ R_con = block_diag(*R_con_list) if len(R_con_list) > 0 else np.array([])
813
+ q_con = np.zeros((np.sum(r.astype(int)) * R_mat.shape[0], 1)) if (R_mat is not None and q is not None) else np.array([])
814
+
815
+ i_idio_same = i_idio
816
+ n_idio_same = int(np.sum(i_idio_same))
817
+ c_i_idio = np.cumsum(i_idio.astype(int))
818
+ C_new = C.copy()
819
+ for i in range(n_bl):
820
+ bl_i = bl[i, :]
821
+ rs = int(np.sum(r[bl_i.astype(bool)]))
822
+ idx_i = np.where((blocks == bl_i).all(axis=1))[0]
823
+ freq_groups = group_series_by_frequency(idx_i, frequencies, clock)
824
+ idx_clock_freq = freq_groups.get(clock, np.array([], dtype=int))
825
+ n_i = len(idx_clock_freq)
826
+ if n_i == 0:
827
+ continue
828
+ bl_idx_same_freq_i = np.where(bl_idx_same_freq[i, :])[0]
829
+ if len(bl_idx_same_freq_i) == 0:
830
+ continue
831
+ rs_actual = len(bl_idx_same_freq_i)
832
+ if rs_actual != rs:
833
+ rs = rs_actual
834
+ denom_size = n_i * rs
835
+ denom = np.zeros((denom_size, denom_size))
836
+ nom = np.zeros((n_i, rs))
837
+ i_idio_i = i_idio_same[idx_clock_freq]
838
+ i_idio_ii = np.cumsum(i_idio.astype(int))[idx_clock_freq]
839
+ i_idio_ii = i_idio_ii[i_idio_i.astype(bool)]
840
+ for t in range(T):
841
+ nan_mask = ~nanY[idx_clock_freq, t]
842
+ Wt = np.diag(nan_mask.astype(float))
843
+ if t + 1 < Zsmooth.shape[0]:
844
+ Z_block_same_freq_row = Zsmooth[t + 1, bl_idx_same_freq_i]
845
+ ZZZ = Z_block_same_freq_row.reshape(-1, 1) @ Z_block_same_freq_row.reshape(1, -1)
846
+ else:
847
+ ZZZ = np.zeros((rs, rs))
848
+ if t + 1 < vsmooth.shape[2]:
849
+ V_block_same_freq = vsmooth[np.ix_(bl_idx_same_freq_i, bl_idx_same_freq_i, [t + 1])]
850
+ V_block_same_freq = V_block_same_freq[:, :, 0] if V_block_same_freq.ndim == 3 else V_block_same_freq
851
+ if V_block_same_freq.shape != (rs, rs):
852
+ V_block_same_freq = np.zeros((rs, rs))
853
+ else:
854
+ V_block_same_freq = np.zeros((rs, rs))
855
+ expected_shape = (denom_size, denom_size)
856
+ try:
857
+ kron_result = np.kron(ZZZ + V_block_same_freq, Wt)
858
+ if kron_result.shape == expected_shape:
859
+ denom += kron_result
860
+ except _NUMERICAL_EXCEPTIONS:
861
+ pass
862
+ if t + 1 < Zsmooth.shape[0]:
863
+ y_vec = y_clean[idx_clock_freq, t].reshape(-1, 1)
864
+ Z_vec_row = Zsmooth[t + 1, bl_idx_same_freq_i].reshape(1, -1)
865
+ y_term = y_vec @ Z_vec_row
866
+ else:
867
+ y_term = np.zeros((len(idx_clock_freq), rs_actual))
868
+ if len(i_idio_ii) > 0 and t + 1 < Zsmooth.shape[0]:
869
+ idio_idx = (idio_start_idx + i_idio_ii).astype(int)
870
+ if idio_idx.max() < Zsmooth.shape[1]:
871
+ idio_Z_col = Zsmooth[t + 1, idio_idx].reshape(-1, 1)
872
+ idio_Z_outer = idio_Z_col @ Z_vec_row
873
+ if t + 1 < vsmooth.shape[2]:
874
+ idio_V = vsmooth[np.ix_(idio_idx, bl_idx_same_freq_i, [t + 1])]
875
+ idio_V = idio_V[:, :, 0] if idio_V.ndim == 3 else idio_V
876
+ else:
877
+ idio_V = np.zeros((len(i_idio_ii), rs_actual))
878
+ idio_term = Wt[:, i_idio_i.astype(bool)] @ (idio_Z_outer + idio_V)
879
+ else:
880
+ idio_term = np.zeros((len(idx_clock_freq), rs_actual))
881
+ else:
882
+ idio_term = np.zeros((len(idx_clock_freq), rs_actual))
883
+ nom += y_term - idio_term
884
+ try:
885
+ scale_factor = _get_config_param(config, "regularization_scale", 1e-5)
886
+ warn_reg = _get_config_param(config, "warn_on_regularization", True)
887
+ reg_param, _ = _compute_regularization_param(denom, scale_factor, warn_reg)
888
+ denom_reg = denom + np.eye(denom.shape[0]) * reg_param
889
+ vec_C = inv(denom_reg) @ nom.flatten()
890
+ C_update = vec_C.reshape(n_i, rs)
891
+ C_update = _clean_matrix(C_update, 'loading', default_nan=0.0, default_inf=0.0)
892
+ C_scale = np.std(C_update[C_update != 0]) if np.any(C_update != 0) else 1.0
893
+ C_max = max(10.0, C_scale * 5)
894
+ C_update = np.clip(C_update, -C_max, C_max)
895
+ for ii, row_idx in enumerate(idx_clock_freq):
896
+ for jj, col_idx in enumerate(bl_idx_same_freq_i):
897
+ C_new[row_idx, col_idx] = C_update[ii, jj]
898
+ except _NUMERICAL_EXCEPTIONS:
899
+ pass
900
+ for freq, idx_iFreq in freq_groups.items():
901
+ if freq == clock:
902
+ continue
903
+ tent_weights = tent_weights_dict.get(freq) if (tent_weights_dict and freq in tent_weights_dict) else get_tent_weights_for_pair(freq, clock)
904
+ if tent_weights is None:
905
+ clock_h = FREQUENCY_HIERARCHY.get(clock, 3)
906
+ freq_h = FREQUENCY_HIERARCHY.get(freq, 3)
907
+ n_periods_est = freq_h - clock_h + 1
908
+ if 0 < n_periods_est <= 12:
909
+ tent_weights = generate_tent_weights(n_periods_est, 'symmetric')
910
+ _logger.warning(f"em_step: generated symmetric tent weights for '{freq}'")
911
+ else:
912
+ raise ValueError(f"em_step: cannot determine tent weights for '{freq}'")
913
+ pC_freq = len(tent_weights)
914
+ rs_full = rs * pC_freq
915
+ R_mat_freq, q_freq = generate_R_mat(tent_weights)
916
+ R_con_i = np.kron(R_mat_freq, np.eye(int(rs)))
917
+ q_con_i = np.kron(q_freq, np.zeros(int(rs)))
918
+ if i < bl_idx_slower_freq.shape[0]:
919
+ bl_idx_slower_freq_i = np.where(bl_idx_slower_freq[i, :])[0]
920
+ if len(bl_idx_slower_freq_i) >= rs_full:
921
+ bl_idx_slower_freq_i = bl_idx_slower_freq_i[:rs_full]
922
+ elif len(bl_idx_slower_freq_i) > 0:
923
+ bl_idx_slower_freq_i = np.pad(bl_idx_slower_freq_i, (0, rs_full - len(bl_idx_slower_freq_i)), mode='edge')
924
+ else:
925
+ continue
926
+ else:
927
+ continue
928
+ if R_con_i.size > 0:
929
+ no_c = ~np.any(R_con_i, axis=1)
930
+ R_con_i = R_con_i[~no_c, :]
931
+ q_con_i = q_con_i[~no_c]
932
+ for j in idx_iFreq:
933
+ rps_actual = len(bl_idx_slower_freq_i) if len(bl_idx_slower_freq_i) > 0 else rs_full
934
+ denom = np.zeros((rps_actual, rps_actual))
935
+ nom = np.zeros((1, rps_actual))
936
+ idx_j_slower = sum(1 for k in idx_iFreq if k < j and (frequencies is None or k >= len(frequencies) or frequencies[k] == freq))
937
+ for t in range(T):
938
+ nan_val = ~nanY[j, t]
939
+ Wt = np.array([[float(nan_val)]]) if np.isscalar(nan_val) else np.diag(nan_val.astype(float))
940
+ if len(bl_idx_slower_freq_i) == 0:
941
+ continue
942
+ valid_bl_idx = bl_idx_slower_freq_i[bl_idx_slower_freq_i < Zsmooth.shape[1]]
943
+ if len(valid_bl_idx) == 0:
944
+ continue
945
+ if t + 1 < Zsmooth.shape[0]:
946
+ Z_row = Zsmooth[t + 1, valid_bl_idx]
947
+ Z_col = Z_row.reshape(-1, 1)
948
+ ZZZ = Z_col @ Z_row.reshape(1, -1)
949
+ valid_vs_idx = valid_bl_idx[valid_bl_idx < vsmooth.shape[0]]
950
+ if len(valid_vs_idx) > 0:
951
+ V_block = vsmooth[np.ix_(valid_vs_idx, valid_vs_idx, [t + 1])]
952
+ V_block = V_block[:, :, 0] if V_block.ndim == 3 else V_block
953
+ if V_block.shape != ZZZ.shape:
954
+ min_size = min(V_block.shape[0], ZZZ.shape[0])
955
+ V_block = V_block[:min_size, :min_size]
956
+ ZZZ = ZZZ[:min_size, :min_size]
957
+ else:
958
+ V_block = np.zeros_like(ZZZ)
959
+ else:
960
+ Z_row = np.zeros(rps_actual)
961
+ Z_col = np.zeros((rps_actual, 1))
962
+ ZZZ = np.zeros((rps_actual, rps_actual))
963
+ V_block = np.zeros((rps_actual, rps_actual))
964
+ if Wt.shape == (1, 1):
965
+ denom += (ZZZ + V_block) * Wt[0, 0]
966
+ else:
967
+ denom += np.kron(ZZZ + V_block, Wt)
968
+ nom += y_clean[j, t] * Z_row.reshape(1, -1)
969
+ try:
970
+ scale_factor = _get_config_param(config, "regularization_scale", 1e-5)
971
+ warn_reg = _get_config_param(config, "warn_on_regularization", True)
972
+ reg_param, _ = _compute_regularization_param(denom, scale_factor, warn_reg)
973
+ denom_reg = denom + np.eye(denom.shape[0]) * reg_param
974
+ C_i = inv(denom_reg) @ nom.T
975
+ C_i = _clean_matrix(C_i, 'loading', default_nan=0.0, default_inf=0.0)
976
+ C_scale = np.std(C_i[C_i != 0]) if np.any(C_i != 0) else 1.0
977
+ C_max = max(10.0, C_scale * 5)
978
+ C_i = np.clip(C_i, -C_max, C_max)
979
+ if len(bl_idx_slower_freq_i) > 0:
980
+ C_update = C_i.flatten()[:len(bl_idx_slower_freq_i)]
981
+ for k, col_idx in enumerate(bl_idx_slower_freq_i):
982
+ C_new[j, col_idx] = C_update[k]
983
+ except _NUMERICAL_EXCEPTIONS:
984
+ pass
985
+
986
+ R_diag = np.zeros(n)
987
+ n_obs_per_series = np.zeros(n, dtype=int)
988
+ for t in range(T):
989
+ Z_t = Zsmooth[t + 1, :].reshape(-1, 1)
990
+ vsmooth_t = vsmooth[:, :, t + 1]
991
+ y_pred = (C_new @ Z_t).flatten()
992
+ for i in range(n):
993
+ if np.isnan(y[i, t]):
994
+ continue
995
+ n_obs_per_series[i] += 1
996
+ resid_sq = (y[i, t] - y_pred[i]) ** 2
997
+ C_i = C_new[i, :].reshape(1, -1)
998
+ var_factor = (C_i @ vsmooth_t @ C_i.T)[0, 0]
999
+ R_diag[i] += resid_sq + var_factor
1000
+ n_obs_per_series = np.maximum(n_obs_per_series, 1)
1001
+ R_diag = R_diag / n_obs_per_series
1002
+ mean_var = np.mean(R_diag[R_diag > 0]) if np.any(R_diag > 0) else 1e-4
1003
+ min_var = np.maximum(mean_var * 1e-6, 1e-8)
1004
+ R_diag = np.maximum(R_diag, min_var)
1005
+ valid_mask = np.isfinite(R_diag) & (R_diag > 0)
1006
+ if np.any(valid_mask):
1007
+ median_var = np.median(R_diag[valid_mask])
1008
+ R_diag = np.where(valid_mask, R_diag, median_var)
1009
+ else:
1010
+ R_diag.fill(1e-4)
1011
+ R_new = np.diag(R_diag)
1012
+ Q_new = _clean_matrix(Q_new, 'covariance', default_nan=0.0)
1013
+ min_eigenval = _get_config_param(config, "min_eigenvalue", 1e-8)
1014
+ warn_reg = _get_config_param(config, "warn_on_regularization", True)
1015
+ max_eigenval = _get_config_param(config, "max_eigenvalue", 1e6)
1016
+ Q_new, _ = _ensure_positive_definite(Q_new, min_eigenval, warn_reg)
1017
+ Q_new = _cap_max_eigenvalue(Q_new, max_eigenval=max_eigenval)
1018
+ V_0_new = _clean_matrix(V_0_new, 'covariance', default_nan=0.0)
1019
+ V_0_new, _ = _ensure_positive_definite(V_0_new, min_eigenval, warn_reg)
1020
+ return C_new, R_new, A_new, Q_new, Z_0, V_0_new, loglik
1021
+
1022
+