dfm-python 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dfm_python/kalman.py ADDED
@@ -0,0 +1,455 @@
1
+ """Kalman filter and fixed-interval smoother implementation."""
2
+
3
+ import numpy as np
4
+ from scipy.linalg import inv, pinv
5
+ from typing import Tuple, Dict
6
+ from dataclasses import dataclass
7
+ import logging
8
+
9
+ # Set up logger (optional - only if logging is enabled)
10
+ _logger = logging.getLogger(__name__)
11
+ if not _logger.handlers:
12
+ # Only configure if logging is enabled (avoid overhead if disabled)
13
+ root_logger = logging.getLogger()
14
+ if root_logger.level <= logging.WARNING:
15
+ _logger.setLevel(logging.WARNING)
16
+ handler = logging.StreamHandler()
17
+ handler.setFormatter(logging.Formatter(
18
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
19
+ datefmt='%Y-%m-%d %H:%M:%S'
20
+ ))
21
+ _logger.addHandler(handler)
22
+
23
+
24
+ @dataclass
25
+ class KalmanFilterState:
26
+ """Kalman filter state structure.
27
+
28
+ This dataclass stores the complete state of the Kalman filter after forward
29
+ and backward passes, including prior/posterior estimates and covariances.
30
+
31
+ Attributes
32
+ ----------
33
+ Zm : np.ndarray
34
+ Prior (predicted) factor state estimates, shape (m x nobs).
35
+ Zm[:, t] is the predicted state at time t given observations up to t-1.
36
+ Vm : np.ndarray
37
+ Prior covariance matrices, shape (m x m x nobs).
38
+ Vm[:, :, t] is the covariance of Zm[:, t].
39
+ ZmU : np.ndarray
40
+ Posterior (updated) factor state estimates, shape (m x (nobs+1)).
41
+ ZmU[:, t] is the updated state at time t given observations up to t.
42
+ Includes initial state at t=0.
43
+ VmU : np.ndarray
44
+ Posterior covariance matrices, shape (m x m x (nobs+1)).
45
+ VmU[:, :, t] is the covariance of ZmU[:, t].
46
+ loglik : float
47
+ Log-likelihood of the data under the current model parameters.
48
+ Computed as sum of log-likelihoods at each time step.
49
+ k_t : np.ndarray
50
+ Kalman gain matrix, shape (m x k) where k is number of observed series.
51
+ Used to update state estimates with new observations.
52
+ """
53
+ Zm: np.ndarray # Prior/predicted factor state (m x nobs)
54
+ Vm: np.ndarray # Prior covariance (m x m x nobs)
55
+ ZmU: np.ndarray # Posterior/updated state (m x (nobs+1))
56
+ VmU: np.ndarray # Posterior covariance (m x m x (nobs+1))
57
+ loglik: float # Log-likelihood
58
+ k_t: np.ndarray # Kalman gain
59
+
60
+
61
+ def miss_data(y: np.ndarray, C: np.ndarray, R: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
62
+ """Handle missing data by removing NaN observations from the Kalman filter equations.
63
+
64
+ This function implements the standard approach to missing data in Kalman filtering:
65
+ observations with NaN values are removed from the observation vector, observation
66
+ matrix, and covariance matrix. A selection matrix L is returned to restore standard
67
+ dimensions if needed.
68
+
69
+ Parameters
70
+ ----------
71
+ y : np.ndarray
72
+ Vector of observations at time t, shape (k,) where k is number of series.
73
+ Missing values should be NaN.
74
+ C : np.ndarray
75
+ Observation/loading matrix, shape (k x m) where m is state dimension.
76
+ Each row corresponds to a series in y.
77
+ R : np.ndarray
78
+ Covariance matrix for observation residuals, shape (k x k).
79
+ Typically diagonal (idiosyncratic variances).
80
+
81
+ Returns
82
+ -------
83
+ y_clean : np.ndarray
84
+ Reduced observation vector with NaN values removed, shape (k_obs,)
85
+ where k_obs is number of non-missing observations.
86
+ C_clean : np.ndarray
87
+ Reduced observation matrix, shape (k_obs x m).
88
+ Rows corresponding to missing observations are removed.
89
+ R_clean : np.ndarray
90
+ Reduced covariance matrix, shape (k_obs x k_obs).
91
+ Rows and columns corresponding to missing observations are removed.
92
+ L : np.ndarray
93
+ Selection matrix, shape (k x k_obs), used to restore standard dimensions.
94
+ L @ y_clean gives y with zeros for missing values.
95
+
96
+ Notes
97
+ -----
98
+ This function is called at each time step in the Kalman filter to handle
99
+ missing observations. The selection matrix L allows reconstruction of the
100
+ full-dimensional vectors if needed for downstream processing.
101
+
102
+ Examples
103
+ --------
104
+ >>> y = np.array([1.0, np.nan, 3.0])
105
+ >>> C = np.array([[1, 0], [0, 1], [1, 1]])
106
+ >>> R = np.eye(3)
107
+ >>> y_clean, C_clean, R_clean, L = miss_data(y, C, R)
108
+ >>> # y_clean = [1.0, 3.0], C_clean has 2 rows, R_clean is 2x2
109
+ """
110
+ # Returns True for nonmissing series
111
+ ix = ~np.isnan(y)
112
+
113
+ # Index for columns with nonmissing variables
114
+ e = np.eye(len(y))
115
+ L = e[:, ix]
116
+
117
+ # Remove missing series
118
+ y = y[ix]
119
+
120
+ # Remove missing series from observation matrix
121
+ C = C[ix, :]
122
+
123
+ # Remove missing series from covariance matrix
124
+ R = R[np.ix_(ix, ix)]
125
+
126
+ return y, C, R, L
127
+
128
+
129
+ def skf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
130
+ R: np.ndarray, Z_0: np.ndarray, V_0: np.ndarray) -> KalmanFilterState:
131
+ """Apply Kalman filter (forward pass).
132
+
133
+ Parameters:
134
+ -----------
135
+ Y : np.ndarray
136
+ Input data (k x nobs), where k = number of series, nobs = time periods
137
+ A : np.ndarray
138
+ Transition matrix (m x m)
139
+ C : np.ndarray
140
+ Observation matrix (k x m)
141
+ Q : np.ndarray
142
+ Covariance for transition equation residuals (m x m)
143
+ R : np.ndarray
144
+ Covariance for observation matrix residuals (k x k)
145
+ Z_0 : np.ndarray
146
+ Initial state vector (m,)
147
+ V_0 : np.ndarray
148
+ Initial state covariance matrix (m x m)
149
+
150
+ Returns:
151
+ --------
152
+ KalmanFilterState
153
+ Filter state with prior and posterior estimates
154
+ """
155
+ # Dimensions
156
+ k, nobs = Y.shape # k series, nobs time periods
157
+ m = C.shape[1] # m factors
158
+
159
+ # Initialize output
160
+ Zm = np.full((m, nobs), np.nan) # Z_t | t-1 (prior)
161
+ Vm = np.full((m, m, nobs), np.nan) # V_t | t-1 (prior)
162
+ ZmU = np.full((m, nobs + 1), np.nan) # Z_t | t (posterior/updated)
163
+ VmU = np.full((m, m, nobs + 1), np.nan) # V_t | t (posterior/updated)
164
+ loglik = 0.0
165
+
166
+ # Set initial values
167
+ Zu = Z_0.copy() # Z_0|0 (In loop, Zu gives Z_t | t)
168
+ Vu = V_0.copy() # V_0|0 (In loop, Vu gives V_t | t)
169
+
170
+ # Store initial values
171
+ ZmU[:, 0] = Zu
172
+ VmU[:, :, 0] = Vu
173
+
174
+ # Kalman filter procedure
175
+ for t in range(nobs):
176
+ # Calculate prior distribution
177
+ # Use transition equation to create prior estimate for factor
178
+ # i.e. Z = Z_t|t-1
179
+ # Check for NaN/Inf in inputs
180
+ if np.any(np.isnan(Zu)) or np.any(np.isinf(Zu)):
181
+ _logger.warning(f"skf: Zu contains NaN/Inf at t={t}, resetting to zeros")
182
+ Zu = np.zeros_like(Zu)
183
+
184
+ Z = A @ Zu
185
+
186
+ # Check for NaN/Inf in Z
187
+ if np.any(np.isnan(Z)) or np.any(np.isinf(Z)):
188
+ _logger.warning(f"skf: Z contains NaN/Inf at t={t}, using previous Zu")
189
+ Z = Zu.copy()
190
+
191
+ # Prior covariance matrix of Z (i.e. V = V_t|t-1)
192
+ # Var(Z) = Var(A*Z + u_t) = Var(A*Z) + Var(u) = A*Vu*A' + Q
193
+ V = A @ Vu @ A.T + Q
194
+ V = 0.5 * (V + V.T) # Trick to make symmetric
195
+
196
+ # Numerical stability: ensure V is positive semi-definite
197
+ # Add small regularization if needed
198
+ try:
199
+ eigenvals = np.linalg.eigvals(V)
200
+ min_eigenval = np.min(eigenvals)
201
+ if min_eigenval < 1e-8:
202
+ # Add regularization to ensure positive semi-definiteness
203
+ V = V + np.eye(V.shape[0]) * (1e-8 - min_eigenval)
204
+ V = 0.5 * (V + V.T) # Re-symmetrize
205
+ except (np.linalg.LinAlgError, ValueError):
206
+ # Eigendecomposition failed - add small regularization
207
+ V = V + np.eye(V.shape[0]) * 1e-8
208
+ V = 0.5 * (V + V.T)
209
+
210
+ # Check for NaN/Inf
211
+ if np.any(np.isnan(V)) or np.any(np.isinf(V)):
212
+ # Fallback: use previous covariance with regularization
213
+ V = Vu + np.eye(V.shape[0]) * 1e-6
214
+ V = 0.5 * (V + V.T)
215
+
216
+ # Calculate posterior distribution
217
+ # Remove missing series: These are removed from Y, C, and R
218
+ Y_t, C_t, R_t, _ = miss_data(Y[:, t], C, R)
219
+
220
+ # Check if y_t contains no data
221
+ if len(Y_t) == 0:
222
+ Zu = Z
223
+ Vu = V
224
+ else:
225
+ # Steps for variance and population regression coefficients:
226
+ # Var(c_t*Z_t + e_t) = c_t Var(Z) c_t' + Var(e) = c_t*V*c_t' + R
227
+ VC = V @ C_t.T
228
+
229
+ # Compute innovation covariance F = C_t @ V @ C_t.T + R_t
230
+ F = C_t @ VC + R_t
231
+ F = 0.5 * (F + F.T) # Ensure symmetry
232
+
233
+ # Numerical stability: regularize F if needed
234
+ try:
235
+ F_eigenvals = np.linalg.eigvals(F)
236
+ min_F_eigenval = np.min(F_eigenvals)
237
+ if min_F_eigenval < 1e-8:
238
+ F = F + np.eye(F.shape[0]) * (1e-8 - min_F_eigenval)
239
+ F = 0.5 * (F + F.T)
240
+ except (np.linalg.LinAlgError, ValueError):
241
+ F = F + np.eye(F.shape[0]) * 1e-8
242
+ F = 0.5 * (F + F.T)
243
+
244
+ # Check for NaN/Inf before inversion
245
+ if np.any(np.isnan(F)) or np.any(np.isinf(F)):
246
+ # Fallback: use identity with large variance
247
+ F = np.eye(F.shape[0]) * 1e6
248
+ _logger.warning(f"skf: F matrix contains NaN/Inf at t={t}, using fallback")
249
+
250
+ try:
251
+ iF = inv(F)
252
+ except (np.linalg.LinAlgError, ValueError) as e:
253
+ # Matrix inversion failed - use pseudo-inverse with regularization
254
+ F_reg = F + np.eye(F.shape[0]) * 1e-6
255
+ iF = pinv(F_reg)
256
+ _logger.warning(f"skf: F inversion failed at t={t}, using pinv: {type(e).__name__}")
257
+
258
+ # Matrix of population regression coefficients (Kalman gain)
259
+ VCF = VC @ iF
260
+
261
+ # Difference between actual and predicted observation matrix values
262
+ innov = Y_t - C_t @ Z
263
+
264
+ # Check for NaN/Inf in innovation
265
+ if np.any(np.isnan(innov)) or np.any(np.isinf(innov)):
266
+ _logger.warning(f"skf: Innovation contains NaN/Inf at t={t}, skipping update")
267
+ Zu = Z
268
+ Vu = V
269
+ else:
270
+ # Update estimate of factor values (posterior)
271
+ Zu = Z + VCF @ innov
272
+
273
+ # Clean NaN/Inf only (remove excessive clipping during iterations)
274
+ if np.any(~np.isfinite(Zu)):
275
+ Zu = np.nan_to_num(Zu, nan=0.0, posinf=0.0, neginf=0.0)
276
+
277
+ # Update covariance matrix (posterior) for time t
278
+ Vu = V - VCF @ VC.T
279
+ Vu = 0.5 * (Vu + Vu.T) # Approximation trick to make symmetric
280
+
281
+ # Clean NaN/Inf only (keep regularization for PSD)
282
+ if np.any(~np.isfinite(Vu)):
283
+ Vu = np.nan_to_num(Vu, nan=1e-8, posinf=1e6, neginf=1e-8)
284
+
285
+ # Ensure Vu is positive semi-definite
286
+ try:
287
+ Vu_eigenvals = np.linalg.eigvals(Vu)
288
+ min_Vu_eigenval = np.min(Vu_eigenvals)
289
+ if min_Vu_eigenval < 1e-8:
290
+ Vu = Vu + np.eye(Vu.shape[0]) * (1e-8 - min_Vu_eigenval)
291
+ Vu = 0.5 * (Vu + Vu.T)
292
+ except (np.linalg.LinAlgError, ValueError):
293
+ Vu = Vu + np.eye(Vu.shape[0]) * 1e-8
294
+ Vu = 0.5 * (Vu + Vu.T)
295
+
296
+ # Check for NaN/Inf in Vu
297
+ if np.any(np.isnan(Vu)) or np.any(np.isinf(Vu)):
298
+ _logger.warning(f"skf: Vu contains NaN/Inf at t={t}, using V as fallback")
299
+ Vu = V.copy()
300
+
301
+ # Update log-likelihood (with safeguards)
302
+ try:
303
+ det_iF = np.linalg.det(iF)
304
+ if det_iF > 0 and np.isfinite(det_iF):
305
+ log_det = np.log(det_iF)
306
+ innov_term = innov.T @ iF @ innov
307
+ if np.isfinite(innov_term):
308
+ loglik += 0.5 * (log_det - innov_term)
309
+ else:
310
+ _logger.debug(f"skf: innov_term not finite at t={t}, skipping loglik update")
311
+ else:
312
+ _logger.debug(f"skf: det(iF) <= 0 or not finite at t={t}, skipping loglik update")
313
+ except (np.linalg.LinAlgError, ValueError, OverflowError):
314
+ _logger.debug(f"skf: Log-likelihood calculation failed at t={t}")
315
+
316
+ # Store output
317
+ # Store covariance and observation values for t (priors)
318
+ Zm[:, t] = Z
319
+ Vm[:, :, t] = V
320
+
321
+ # Store covariance and state values for t (posteriors)
322
+ # i.e. Zu = Z_t|t & Vu = V_t|t
323
+ ZmU[:, t + 1] = Zu
324
+ VmU[:, :, t + 1] = Vu
325
+
326
+ # Store Kalman gain k_t (from final iteration)
327
+ if len(Y_t) == 0:
328
+ k_t = np.zeros((m, m))
329
+ else:
330
+ k_t = VCF @ C_t
331
+
332
+ return KalmanFilterState(Zm=Zm, Vm=Vm, ZmU=ZmU, VmU=VmU, loglik=loglik, k_t=k_t)
333
+
334
+
335
+ def fis(A: np.ndarray, S: KalmanFilterState) -> KalmanFilterState:
336
+ """Apply fixed-interval smoother (backward pass).
337
+
338
+ Parameters:
339
+ -----------
340
+ A : np.ndarray
341
+ Transition matrix (m x m)
342
+ S : KalmanFilterState
343
+ State from Kalman filter (SKF)
344
+
345
+ Returns:
346
+ --------
347
+ KalmanFilterState
348
+ State with smoothed estimates added (ZmT, VmT, VmT_1)
349
+ """
350
+ m, nobs = S.Zm.shape
351
+
352
+ # Initialize output matrices
353
+ ZmT = np.zeros((m, nobs + 1))
354
+ VmT = np.zeros((m, m, nobs + 1))
355
+
356
+ # Fill the final period of ZmT, VmT with SKF posterior values
357
+ ZmT[:, nobs] = S.ZmU[:, nobs]
358
+ VmT[:, :, nobs] = S.VmU[:, :, nobs]
359
+
360
+ # Initialize VmT_1 lag 1 covariance matrix for final period
361
+ VmT_1 = np.zeros((m, m, nobs))
362
+ VmT_1[:, :, nobs - 1] = (np.eye(m) - S.k_t) @ A @ S.VmU[:, :, nobs - 1]
363
+
364
+ # Used for recursion process
365
+ J_2 = S.VmU[:, :, nobs - 1] @ A.T @ pinv(S.Vm[:, :, nobs - 1])
366
+
367
+ # Run smoothing algorithm
368
+ # Loop through time reverse-chronologically (starting at final period nobs-1)
369
+ for t in range(nobs - 1, -1, -1):
370
+ # Store posterior and prior factor covariance values
371
+ VmU = S.VmU[:, :, t]
372
+ Vm1 = S.Vm[:, :, t]
373
+
374
+ # Store previous period smoothed factor covariance and lag-1 covariance
375
+ V_T = VmT[:, :, t + 1]
376
+ V_T1 = VmT_1[:, :, t] if t < nobs - 1 else np.zeros((m, m))
377
+
378
+ J_1 = J_2
379
+
380
+ # Update smoothed factor estimate
381
+ ZmT[:, t] = S.ZmU[:, t] + J_1 @ (ZmT[:, t + 1] - A @ S.ZmU[:, t])
382
+
383
+ # Clean NaN/Inf only (remove excessive clipping)
384
+ if np.any(~np.isfinite(ZmT[:, t])):
385
+ ZmT[:, t] = np.nan_to_num(ZmT[:, t], nan=0.0, posinf=0.0, neginf=0.0)
386
+
387
+ # Update smoothed factor covariance matrix
388
+ VmT[:, :, t] = VmU + J_1 @ (V_T - Vm1) @ J_1.T
389
+ VmT[:, :, t] = 0.5 * (VmT[:, :, t] + VmT[:, :, t].T) # Ensure symmetry
390
+
391
+ # Clean NaN/Inf and ensure PSD (keep only critical regularization)
392
+ if np.any(~np.isfinite(VmT[:, :, t])):
393
+ VmT[:, :, t] = np.nan_to_num(VmT[:, :, t], nan=1e-8, posinf=1e6, neginf=1e-8)
394
+
395
+ if t > 0:
396
+ # Update weight
397
+ J_2 = S.VmU[:, :, t - 1] @ A.T @ pinv(S.Vm[:, :, t - 1])
398
+
399
+ # Update lag 1 factor covariance matrix
400
+ VmT_1[:, :, t - 1] = VmU @ J_2.T + J_1 @ (V_T1 - A @ VmU) @ J_2.T
401
+
402
+ # Add smoothed estimates as attributes
403
+ S.ZmT = ZmT
404
+ S.VmT = VmT
405
+ S.VmT_1 = VmT_1
406
+
407
+ return S
408
+
409
+
410
+ def run_kf(Y: np.ndarray, A: np.ndarray, C: np.ndarray, Q: np.ndarray,
411
+ R: np.ndarray, Z_0: np.ndarray, V_0: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
412
+ """Apply Kalman filter and fixed-interval smoother.
413
+
414
+ Parameters:
415
+ -----------
416
+ Y : np.ndarray
417
+ Input data (k x nobs)
418
+ A : np.ndarray
419
+ Transition matrix (m x m)
420
+ C : np.ndarray
421
+ Observation matrix (k x m)
422
+ Q : np.ndarray
423
+ Covariance for transition residuals (m x m)
424
+ R : np.ndarray
425
+ Covariance for observation residuals (k x k)
426
+ Z_0 : np.ndarray
427
+ Initial state (m,)
428
+ V_0 : np.ndarray
429
+ Initial covariance (m x m)
430
+
431
+ Returns:
432
+ --------
433
+ zsmooth : np.ndarray
434
+ Smoothed factor estimates (m x (nobs+1)), zsmooth[:, t+1] = Z_t|T
435
+ Vsmooth : np.ndarray
436
+ Smoothed factor covariance (m x m x (nobs+1)), Vsmooth[:, :, t+1] = Cov(Z_t|T)
437
+ VVsmooth : np.ndarray
438
+ Lag 1 factor covariance (m x m x nobs), Cov(Z_t, Z_t-1|T)
439
+ loglik : float
440
+ Log-likelihood
441
+ """
442
+ # Kalman filter
443
+ S = skf(Y, A, C, Q, R, Z_0, V_0)
444
+
445
+ # Fixed-interval smoother
446
+ S = fis(A, S)
447
+
448
+ # Organize output
449
+ zsmooth = S.ZmT
450
+ Vsmooth = S.VmT
451
+ VVsmooth = S.VmT_1
452
+ loglik = S.loglik
453
+
454
+ return zsmooth, Vsmooth, VVsmooth, loglik
455
+