dfm-python 0.1.0__py3-none-any.whl

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